fix: list indent buttons
This commit is contained in:
1 parent
87b10b0059
commit
8e8b93bccf
5 files changed
+134
-58
No files matched your search
@@ -12,6 +12,7 @@
|
||||
import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
|
||||
import type { Orientation } from '../storage/pageOrientation';
|
||||
import { DEFAULT_HF_DISTANCES, clampHfDistance, type HfDistances } from '../storage/headerFooter';
|
||||
import { listContext } from './indent';
|
||||
|
||||
let { editor, tick, showFormattingMarks = $bindable(), pageMargins = $bindable(DEFAULT_MARGINS), pageOrientation = $bindable<Orientation>('portrait'), hfDistances = $bindable(DEFAULT_HF_DISTANCES), hfActive = null, onEditZone, onDebugDump }:
|
||||
{ editor: Editor | null; tick: number; showFormattingMarks: boolean; pageMargins?: PageMargins; pageOrientation?: Orientation; hfDistances?: HfDistances; hfActive?: 'header' | 'footer' | null; onEditZone?: (zone: 'header' | 'footer') => void; onDebugDump?: () => void } = $props();
|
||||
@@ -650,45 +651,14 @@
|
||||
}
|
||||
|
||||
// --- Indent (Einzug) ---
|
||||
// Resolve the selection's list context: whether the cursor is in a list, whether
|
||||
// the whole list is targeted (cursor in the first item, or the selection spans
|
||||
// every item), and the innermost list's current indent (cm).
|
||||
function listContext(): { inList: boolean; wholeList: boolean; listIndent: number } {
|
||||
if (!editor) return { inList: false, wholeList: false, listIndent: 0 };
|
||||
const { empty } = editor.state.selection;
|
||||
const head = editor.state.selection.$from;
|
||||
const tail = editor.state.selection.$to;
|
||||
let liDepth = -1;
|
||||
for (let d = head.depth; d > 0; d--) {
|
||||
if (head.node(d).type.name === 'listItem') { liDepth = d; break; }
|
||||
}
|
||||
if (liDepth < 0) return { inList: false, wholeList: false, listIndent: 0 };
|
||||
const listDepth = liDepth - 1;
|
||||
const list = head.node(listDepth);
|
||||
const listIndent = typeof list.attrs.indent === 'number' ? list.attrs.indent : 0;
|
||||
const fromFirst = head.index(listDepth) === 0;
|
||||
// Guard: a selection ending outside the list has no index at listDepth.
|
||||
const toLast = tail.depth >= listDepth && tail.node(listDepth) === list
|
||||
&& tail.index(listDepth) === list.childCount - 1;
|
||||
const wholeList = fromFirst && (empty || toLast);
|
||||
return { inList: true, wholeList, listIndent };
|
||||
}
|
||||
|
||||
// Word/LibreOffice indent: with the whole list targeted, shift the list as a
|
||||
// block; in a non-first item, change that item's nesting level; outside a list,
|
||||
// step the paragraph's left indent (indent.ts).
|
||||
// In a list, step the list point one level via indentListForward/Backward (the same
|
||||
// commands the Tab keymap uses — see indent.ts); outside a list, step the paragraph's
|
||||
// left indent.
|
||||
function changeIndent(dir: 1 | -1) {
|
||||
if (!editor) return;
|
||||
const c = editor.chain().focus();
|
||||
const ctx = listContext();
|
||||
if (ctx.inList) {
|
||||
if (ctx.wholeList) {
|
||||
if (dir === 1) c.indentListMore().run();
|
||||
else if (ctx.listIndent > 0) c.indentListLess().run();
|
||||
else c.liftListItem('listItem').run();
|
||||
} else {
|
||||
(dir === 1 ? c.sinkListItem('listItem') : c.liftListItem('listItem')).run();
|
||||
}
|
||||
if (listContext(editor.state).inList) {
|
||||
(dir === 1 ? c.indentListForward() : c.indentListBackward()).run();
|
||||
} else {
|
||||
(dir === 1 ? c.indentMore() : c.indentLess()).run();
|
||||
}
|
||||
|
||||
@@ -18,10 +18,10 @@ function buildDecorations(doc: PmNode): DecorationSet {
|
||||
if (!node.isText) return;
|
||||
const text = node.text ?? '';
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text.charCodeAt(i) === 32) {
|
||||
const from = pos + i;
|
||||
decos.push(Decoration.inline(from, from + 1, { class: 'pm-space-mark' }));
|
||||
}
|
||||
const code = text.charCodeAt(i);
|
||||
if (code !== 32 && code !== 9) continue;
|
||||
const from = pos + i;
|
||||
decos.push(Decoration.inline(from, from + 1, { class: code === 9 ? 'pm-tab-mark' : 'pm-space-mark' }));
|
||||
}
|
||||
});
|
||||
return decos.length ? DecorationSet.create(doc, decos) : DecorationSet.empty;
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
import { Extension, type CommandProps } from '@tiptap/core';
|
||||
import type { Node as PMNode } from '@tiptap/pm/model';
|
||||
import type { EditorState } from '@tiptap/pm/state';
|
||||
|
||||
// Left indent stored in cm so it round-trips 1:1 to ODF's fo:margin-left.
|
||||
// On a paragraph/heading it maps to odf-kit's ParagraphOptions.indentLeft; on a
|
||||
// bulletList/orderedList it shifts the whole list (the indent is added to the
|
||||
// list style's level margins on export — see export/odt.ts applyListIndents).
|
||||
// Renders as an inline margin-left. List *items* indent by nesting
|
||||
// (sinkListItem/liftListItem); this list attr shifts the list as a block.
|
||||
// Left indent in cm: maps to fo:margin-left on paragraphs/headings; on lists it shifts
|
||||
// the whole list block (list items nest instead via sinkListItem/liftListItem).
|
||||
|
||||
export const INDENT_STEP_CM = 1.25; // LibreOffice's default indent increment
|
||||
export const INDENT_MAX_CM = 12; // keep the indent inside the text column
|
||||
|
||||
export type ListIndentContext = { inList: boolean; wholeList: boolean; listIndent: number; nested: boolean };
|
||||
|
||||
// Shared by the Tab keymap and toolbar indent buttons so both apply identical rules.
|
||||
export function listContext(state: EditorState): ListIndentContext {
|
||||
const { empty } = state.selection;
|
||||
const head = state.selection.$from;
|
||||
const tail = state.selection.$to;
|
||||
let liDepth = -1;
|
||||
for (let d = head.depth; d > 0; d--) {
|
||||
if (head.node(d).type.name === 'listItem') { liDepth = d; break; }
|
||||
}
|
||||
if (liDepth < 0) return { inList: false, wholeList: false, listIndent: 0, nested: false };
|
||||
const listDepth = liDepth - 1;
|
||||
const list = head.node(listDepth);
|
||||
const listIndent = typeof list.attrs.indent === 'number' ? list.attrs.indent : 0;
|
||||
const fromFirst = head.index(listDepth) === 0;
|
||||
// Guard: a selection ending outside the list has no index at listDepth.
|
||||
const toLast = tail.depth >= listDepth && tail.node(listDepth) === list
|
||||
&& tail.index(listDepth) === list.childCount - 1;
|
||||
const wholeList = fromFirst && (empty || toLast);
|
||||
const nested = listDepth > 0 && head.node(listDepth - 1).type.name === 'listItem';
|
||||
return { inList: true, wholeList, listIndent, nested };
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
indent: {
|
||||
@@ -18,6 +39,8 @@ declare module '@tiptap/core' {
|
||||
indentLess: () => ReturnType;
|
||||
indentListMore: () => ReturnType;
|
||||
indentListLess: () => ReturnType;
|
||||
indentListForward: () => ReturnType;
|
||||
indentListBackward: () => ReturnType;
|
||||
unsetIndent: () => ReturnType;
|
||||
};
|
||||
}
|
||||
@@ -36,6 +59,10 @@ function parseIndent(value: string | null): number | null {
|
||||
export const Indent = Extension.create({
|
||||
name: 'indent',
|
||||
|
||||
// >100 so Tab/Shift-Tab beat ListItem's built-in sink/lift — TipTap priority-sorts
|
||||
// after reversing extension order, so equal priority would let ListItem win.
|
||||
priority: 1000,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
types: ['paragraph', 'heading'] as string[],
|
||||
@@ -113,8 +140,47 @@ export const Indent = Extension.create({
|
||||
indentLess: () => step(-INDENT_STEP_CM),
|
||||
indentListMore: () => stepList(INDENT_STEP_CM),
|
||||
indentListLess: () => stepList(-INDENT_STEP_CM),
|
||||
indentListForward: () => ({ state, commands }) => {
|
||||
const ctx = listContext(state);
|
||||
if (!ctx.inList) return false;
|
||||
return ctx.wholeList ? commands.indentListMore() : commands.sinkListItem('listItem');
|
||||
},
|
||||
indentListBackward: () => ({ state, commands }) => {
|
||||
const ctx = listContext(state);
|
||||
if (!ctx.inList) return false;
|
||||
if (ctx.listIndent > 0) return commands.indentListLess();
|
||||
if (ctx.nested) return commands.liftListItem('listItem');
|
||||
return false;
|
||||
},
|
||||
unsetIndent: () => ({ commands }) =>
|
||||
[...types, ...listTypes].map((type) => commands.resetAttributes(type, 'indent')).some((r) => r),
|
||||
};
|
||||
},
|
||||
|
||||
// In a list: Tab/Shift-Tab call indentListForward/Backward.
|
||||
// Outside a list: Tab inserts \t, Shift-Tab steps paragraph indent.
|
||||
// In a table cell: returns false so Table's own Tab navigates between cells.
|
||||
addKeyboardShortcuts() {
|
||||
const inCell = () => this.editor.isActive('tableCell') || this.editor.isActive('tableHeader');
|
||||
return {
|
||||
Tab: () => {
|
||||
if (inCell()) return false;
|
||||
if (listContext(this.editor.state).inList) {
|
||||
this.editor.commands.indentListForward();
|
||||
return true;
|
||||
}
|
||||
this.editor.commands.insertContent('\t');
|
||||
return true;
|
||||
},
|
||||
'Shift-Tab': () => {
|
||||
if (inCell()) return false;
|
||||
if (listContext(this.editor.state).inList) {
|
||||
this.editor.commands.indentListBackward();
|
||||
return true;
|
||||
}
|
||||
this.editor.commands.indentLess();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
+28
-11
@@ -41,14 +41,18 @@ const DEFAULT_LINE_HEIGHT = 1; // must match line-height multiplier default in
|
||||
const SEG = '';
|
||||
|
||||
// Sentinel for hardBreak nodes: replaceHardBreaks turns them into text so every
|
||||
// odf-kit path (plain paragraphs, list items, cells) carries them; applyLineBreaks
|
||||
// odf-kit path (plain paragraphs, list items, cells) carries them; applyInlineSentinels
|
||||
// swaps the char for <text:line-break/> in content.xml. U+E001 (SEG is U+E000).
|
||||
const LBR = '';
|
||||
|
||||
// Sentinel wrapping the page-count digits in header/footer runs; applyHfPostProcess
|
||||
// Sentinel wrapping page-count digits in header/footer runs; applyHfPostProcess
|
||||
// rewrites it to <text:page-count> in styles.xml. U+E002 (SEG/LBR are E000/E001).
|
||||
const PGC = '';
|
||||
|
||||
// Sentinel for tab chars: \t collapses to a space in ODF, so replaceTabs swaps
|
||||
// each tab for this before odf-kit serializes; applyInlineSentinels → <text:tab/>. U+E003.
|
||||
const TAB = '';
|
||||
|
||||
// Automatic list styles minted for in-cell lists (see applyCellBlocks). odf-kit's
|
||||
// CellBuilder is run-based, so a list inside a cell can't reference a list style it
|
||||
// generates for top-level lists — we inject our own, mirroring its buildListStyle.
|
||||
@@ -112,7 +116,7 @@ function injectCustomTypes(node: TiptapNode, inContainer = false): TiptapNode {
|
||||
}
|
||||
|
||||
// Turn hardBreak nodes into LBR-sentinel text nodes so they ride through every
|
||||
// odf-kit serialization path as plain run text; applyLineBreaks rewrites the char
|
||||
// odf-kit serialization path as plain run text; applyInlineSentinels rewrites the char
|
||||
// to <text:line-break/> in content.xml afterwards.
|
||||
function replaceHardBreaks(node: TiptapNode): TiptapNode {
|
||||
if (!node.content?.length) return node;
|
||||
@@ -122,6 +126,17 @@ function replaceHardBreaks(node: TiptapNode): TiptapNode {
|
||||
};
|
||||
}
|
||||
|
||||
// Swap each tab character in run text for the TAB sentinel so it survives odf-kit
|
||||
// serialization (a literal \t would collapse to a space); applyInlineSentinels
|
||||
// rewrites it to <text:tab/> in content.xml afterwards.
|
||||
function replaceTabs(node: TiptapNode): TiptapNode {
|
||||
if (node.type === 'text' && node.text?.includes('\t')) {
|
||||
return { ...node, text: node.text.split('\t').join(TAB) };
|
||||
}
|
||||
if (!node.content?.length) return node;
|
||||
return { ...node, content: node.content.map(replaceTabs) };
|
||||
}
|
||||
|
||||
// Mirror odf-kit's normalizeLineHeight (content.js): a number is a multiplier
|
||||
// (1.5 → "150%"); a string with a unit passes through ("18pt" → "18pt").
|
||||
function normalizeLineHeight(lh: number | string): string {
|
||||
@@ -909,17 +924,19 @@ function collapseRunWhitespace(odtBytes: Uint8Array): Uint8Array {
|
||||
return rezipOdt(files);
|
||||
}
|
||||
|
||||
// Rewrite the LBR sentinels replaceHardBreaks planted into real <text:line-break/>
|
||||
// elements (valid both as bare paragraph text and inside <text:span>).
|
||||
function applyLineBreaks(odtBytes: Uint8Array): Uint8Array {
|
||||
// Rewrite the inline sentinels planted before serialization into real ODF elements:
|
||||
// LBR (from replaceHardBreaks) → <text:line-break/>, TAB (from replaceTabs) →
|
||||
// <text:tab/>. Both are valid as bare paragraph text and inside <text:span>.
|
||||
function applyInlineSentinels(odtBytes: Uint8Array): Uint8Array {
|
||||
const files = unzipSync(odtBytes);
|
||||
const contentBytes = files['content.xml'];
|
||||
if (!contentBytes) return odtBytes;
|
||||
|
||||
const content = strFromU8(contentBytes);
|
||||
if (!content.includes(LBR)) return odtBytes;
|
||||
let content = strFromU8(contentBytes);
|
||||
if (!content.includes(LBR) && !content.includes(TAB)) return odtBytes;
|
||||
|
||||
files['content.xml'] = strToU8(content.split(LBR).join('<text:line-break/>'));
|
||||
content = content.split(LBR).join('<text:line-break/>').split(TAB).join('<text:tab/>');
|
||||
files['content.xml'] = strToU8(content);
|
||||
return rezipOdt(files);
|
||||
}
|
||||
|
||||
@@ -936,7 +953,7 @@ export type HfExport = {
|
||||
|
||||
// The full document → .odt pipeline, DOM-free; returns the .odt bytes.
|
||||
export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null): Promise<Uint8Array> {
|
||||
const raw = replaceHardBreaks(docJson);
|
||||
const raw = replaceTabs(replaceHardBreaks(docJson));
|
||||
const headerPara = hf && !hfIsEmpty(hf.header) ? (hf.header!.content![0] as TiptapNode) : null;
|
||||
const footerPara = hf && !hfIsEmpty(hf.footer) ? (hf.footer!.content![0] as TiptapNode) : null;
|
||||
// Distance from the page edge to the header (top) / footer (bottom). Becomes the
|
||||
@@ -1048,7 +1065,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
|
||||
const styledRows = applyTableRowHeights(styledCells, rowHeights);
|
||||
|
||||
const cleaned = collapseRunWhitespace(styledRows);
|
||||
const withBreaks = applyLineBreaks(cleaned);
|
||||
const withBreaks = applyInlineSentinels(cleaned);
|
||||
const withStyles = rewriteStylesXml(withBreaks, language ?? null);
|
||||
return applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist);
|
||||
}
|
||||
|
||||
+24
-1
@@ -147,6 +147,11 @@
|
||||
margin-bottom: 0.212cm;
|
||||
}
|
||||
|
||||
/* Tab stops every 1.25cm, matching ODF's default tab interval. */
|
||||
.paper .tiptap {
|
||||
tab-size: 1.25cm;
|
||||
}
|
||||
|
||||
.paper .tiptap h1,
|
||||
.paper .tiptap h2,
|
||||
.paper .tiptap h3 {
|
||||
@@ -378,6 +383,23 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Tab marks: a → arrow overlaid at the start of the tab's width (the tab char
|
||||
itself supplies the gap), mirroring Word/LibreOffice. */
|
||||
.paper.show-formatting-marks .tiptap .pm-tab-mark {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.paper.show-formatting-marks .tiptap .pm-tab-mark::before {
|
||||
content: '→';
|
||||
position: absolute;
|
||||
left: 0.1em;
|
||||
top: 0;
|
||||
color: var(--color-primary);
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Spell-check: red wavy underline under misspelled words (drawn by spellCheck.ts).
|
||||
skip-ink:none keeps the wave continuous under descenders. */
|
||||
.tiptap .pm-spell-error {
|
||||
@@ -432,7 +454,8 @@
|
||||
.paper.show-formatting-marks .tiptap h2::after,
|
||||
.paper.show-formatting-marks .tiptap h3::after,
|
||||
.paper.show-formatting-marks .tiptap li > p::after,
|
||||
.paper.show-formatting-marks .tiptap .pm-space-mark::before {
|
||||
.paper.show-formatting-marks .tiptap .pm-space-mark::before,
|
||||
.paper.show-formatting-marks .tiptap .pm-tab-mark::before {
|
||||
content: none;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user