diff --git a/src/lib/editor/ToolbarExpanded.svelte b/src/lib/editor/ToolbarExpanded.svelte index c7f4fc9..b7850ba 100644 --- a/src/lib/editor/ToolbarExpanded.svelte +++ b/src/lib/editor/ToolbarExpanded.svelte @@ -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('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(); } diff --git a/src/lib/editor/formattingMarks.ts b/src/lib/editor/formattingMarks.ts index 201f3a0..4d7fc45 100644 --- a/src/lib/editor/formattingMarks.ts +++ b/src/lib/editor/formattingMarks.ts @@ -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; diff --git a/src/lib/editor/indent.ts b/src/lib/editor/indent.ts index 8f013a0..cbe8ae6 100644 --- a/src/lib/editor/indent.ts +++ b/src/lib/editor/indent.ts @@ -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 { 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; + }, + }; + }, }); diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index f409c40..4f2fde9 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -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 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 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 → . 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 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 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 -// elements (valid both as bare paragraph text and inside ). -function applyLineBreaks(odtBytes: Uint8Array): Uint8Array { +// Rewrite the inline sentinels planted before serialization into real ODF elements: +// LBR (from replaceHardBreaks) → , TAB (from replaceTabs) → +// . Both are valid as bare paragraph text and inside . +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('')); + content = content.split(LBR).join('').split(TAB).join(''); + 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 { - 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); } diff --git a/src/styles/editor.css b/src/styles/editor.css index fdc9507..b90bef2 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -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; } }