From ad38994c65ce4fd6e92cfe833aeeb406421adb39 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Thu, 6 Aug 2026 22:08:26 +0000 Subject: [PATCH] feat: add ruler for tab stops and indents Click the strip to place a stop of the selected type, drag to move it, drag it off to drop it; the two triangles set the paragraph's first-line and left indent. Sticky inside the scroll area, so it clears the toolbar island and stays aligned with the page as it scrolls or zooms. --- CHANGELOG.md | 4 +- src/App.svelte | 9 +- src/lib/components/Editor.svelte | 10 +- src/lib/components/Ruler.svelte | 306 ++++++++++++++++++++++ src/lib/components/ToolbarExpanded.svelte | 15 +- src/lib/editor/extensions/indent.ts | 50 ++-- src/lib/editor/extensions/tabStops.ts | 21 +- src/lib/export/odt.ts | 5 +- src/lib/i18n/locales/de.ts | 11 + src/lib/i18n/locales/en.ts | 11 + src/lib/import/docxStyles.ts | 6 +- src/lib/storage/theme.ts | 10 + 12 files changed, 418 insertions(+), 40 deletions(-) create mode 100644 src/lib/components/Ruler.svelte diff --git a/CHANGELOG.md b/CHANGELOG.md index d2ccb8b..b31d46d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,7 +49,8 @@ - Word-style nesting defaults for ordered lists: an indented level numbers 1. → a. → i. (repeating) instead of restarting at "1." everywhere; explicit styles per level still override. Plus legal/outline numbering (1., 1.1., 1.2.1. …) as a list type — rendered via CSS counters, round-trips to ODF `text:display-levels` and DOCX `%1.%2.` lvlText chains - Nested ordered levels advance the cycle relative to the level above and inherit its suffix (a level-1 `a)` gives `i)` then `1)`); the numbering style chosen for a level is reused when you nest into it again - Customizable bullet symbols per list level (Word-style picker on the bullet-list split button: • ◦ ▪ ❖ ➢ ⇨ ✓ – >); round-trips to ODF `text:bullet-char` and DOCX `w:lvlText`, and the DOCX/ODT import maps Wingdings/Symbol bullets (arrows, diamonds, checkmarks …) to their Unicode equivalents instead of flattening them to plain dots. Symbols Liberation Serif lacks render from a bundled 2 KB DejaVu Sans subset (`EdenSymbols.woff2`), so markers look compact and identical on every platform instead of a stretched OS fallback -- Tab stops (real tab character) and manual line breaks (Shift+Enter) +- Tab stops per paragraph: left, centre, right and decimal, honoured in the rendered text (CSS only has a fixed tab grid, so each tab is measured and placed); a hanging indent implies a stop at the text position. Set them on the ruler; round-trips to ODF `style:tab-stops` and DOCX `w:tabs` +- Manual line breaks (Shift+Enter) **Insert** - Tables: insert via size picker, Word-style row/column drag-resize, add / delete rows & columns, delete table, cell borders, merge cells and split cells (N×M, Word/LibreOffice-style), cell background shading, header row / first column toggles, named table styles (see Styles); a table splits cleanly across page boundaries @@ -68,6 +69,7 @@ - Multi-column (newspaper) layout: 1–3 columns with adjustable gap, applied to the whole document (no selection) or to selected paragraphs, Word-style — text fills column 1 to the page bottom, then column 2, and flows across pages with mid-paragraph line breaks; a section followed by other content balances its columns. Round-trips to ODF `text:section`/`style:columns` and DOCX continuous sections with `w:cols` - Headers & footers: page-number / page-count fields, configurable edge distances, and Word-style variants — different first page and different odd & even pages (edit a page's zone directly; blank first/even zones supported). Press Enter to add blank lines that grow a zone into the page while body text reflows so the page break sits above the footer / below the header, never overlapping. Inline images/logos can be inserted in a zone. Round-trips to ODF (`style:header-first`/`-left`, `draw:frame`) and DOCX (`w:titlePg`/`w:evenAndOddHeaders`, `ImageRun`) - Zoom (20–300 %) +- Horizontal ruler above the page: click to place a tab stop of the selected type, drag to move it, drag it off to remove it; two markers set the paragraph's first-line and left indent. Toggle in the extended toolbar - Show formatting marks (spaces, tabs, paragraph marks) **Editing aids** diff --git a/src/App.svelte b/src/App.svelte index 6430c16..afac012 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -20,7 +20,7 @@ import { resetHistoryLog } from './lib/utils/historyLog.svelte'; import { countText, type TextStats } from './lib/utils/wordCount'; import { clampZoom, wheelZoomFactor, MIN_ZOOM, MAX_ZOOM } from './lib/utils/zoom'; - import { loadTheme, saveTheme, applyTheme, loadToolbarExpanded, saveToolbarExpanded, loadFormattingMarks, saveFormattingMarks, type ThemeMode } from './lib/storage/theme'; + import { loadTheme, saveTheme, applyTheme, loadToolbarExpanded, saveToolbarExpanded, loadFormattingMarks, saveFormattingMarks, loadRuler, saveRuler, type ThemeMode } from './lib/storage/theme'; import { loadPageMargins, savePageMargins, DEFAULT_MARGINS, type PageMargins } from './lib/storage/pageMargins'; import { loadOrientation, saveOrientation, type Orientation } from './lib/storage/pageOrientation'; import { loadPageFormat, savePageFormat, type PageFormat } from './lib/storage/pageFormat'; @@ -113,6 +113,7 @@ let themeOpen = $state(false); let toolbarExpanded = $state(loadToolbarExpanded()); let showFormattingMarks = $state(loadFormattingMarks()); + let showRuler = $state(loadRuler()); let zoom = $state(clampZoom(parseInt(localStorage.getItem('odf-editor-zoom') ?? '100', 10))); let pageMargins: PageMargins = $state(loadPageMargins()); let pageOrientation: Orientation = $state(loadOrientation()); @@ -153,6 +154,10 @@ saveFormattingMarks(showFormattingMarks); }); + $effect(() => { + saveRuler(showRuler); + }); + $effect(() => { savePageMargins(pageMargins); }); @@ -977,6 +982,7 @@ editor={activeEditor} tick={activeTick} bind:showFormattingMarks + bind:showRuler bind:pageMargins bind:pageOrientation bind:pageFormat @@ -1015,6 +1021,7 @@ {zoom} onZoom={setZoom} {showFormattingMarks} + {showRuler} {pageMargins} orientation={pageOrientation} {pageFormat} diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index f9e104c..9f36949 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -16,6 +16,7 @@ import { NodeSelection, TextSelection } from '@tiptap/pm/state'; import ContextMenu from './ContextMenu.svelte'; import HeaderFooterLayer from './HeaderFooterLayer.svelte'; + import Ruler from './Ruler.svelte'; import { saveDocument, loadDocument } from '../storage/autosave'; import { applyMarginVars, cmToPx, DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins'; import { type Orientation } from '../storage/pageOrientation'; @@ -34,7 +35,7 @@ let { editor = $bindable(), tick = $bindable(0), currentPage = $bindable(1), numPages = $bindable(1), - zoom = 100, onZoom, showFormattingMarks = false, pageMargins = DEFAULT_MARGINS, orientation = 'portrait', + zoom = 100, onZoom, showFormattingMarks = false, showRuler = true, pageMargins = DEFAULT_MARGINS, orientation = 'portrait', pageFormat = 'A4', headerDoc = $bindable(null), footerDoc = $bindable(null), hfDistances = DEFAULT_HF_DISTANCES, headerFirstDoc = $bindable(null), footerFirstDoc = $bindable(null), differentFirstPage = false, @@ -43,7 +44,7 @@ }: { editor: Editor | null; tick: number; currentPage: number; numPages: number; zoom: number; onZoom?: (zoom: number) => void; - showFormattingMarks?: boolean; pageMargins?: PageMargins; orientation?: Orientation; pageFormat?: PageFormat; + showFormattingMarks?: boolean; showRuler?: boolean; pageMargins?: PageMargins; orientation?: Orientation; pageFormat?: PageFormat; headerDoc?: HfDoc; footerDoc?: HfDoc; hfDistances?: HfDistances; headerFirstDoc?: HfDoc; footerFirstDoc?: HfDoc; differentFirstPage?: boolean; headerEvenDoc?: HfDoc; footerEvenDoc?: HfDoc; differentOddEven?: boolean; @@ -777,6 +778,11 @@
+ + {#if showRuler && scaledWidth && !hfActive} + + {/if}
diff --git a/src/lib/components/Ruler.svelte b/src/lib/components/Ruler.svelte new file mode 100644 index 0000000..3831865 --- /dev/null +++ b/src/lib/components/Ruler.svelte @@ -0,0 +1,306 @@ + + +
+ + + +
+ + diff --git a/src/lib/components/ToolbarExpanded.svelte b/src/lib/components/ToolbarExpanded.svelte index 3cb42a0..3c2814a 100644 --- a/src/lib/components/ToolbarExpanded.svelte +++ b/src/lib/components/ToolbarExpanded.svelte @@ -24,8 +24,8 @@ import { t } from '../i18n/i18n.svelte'; import { shortcutHint, type ShortcutId } from '../editor/shortcuts'; - let { editor, tick, showFormattingMarks = $bindable(), pageMargins = $bindable(DEFAULT_MARGINS), pageOrientation = $bindable('portrait'), pageFormat = $bindable('A4'), hfDistances = $bindable(DEFAULT_HF_DISTANCES), differentFirstPage = $bindable(false), differentOddEven = $bindable(false), hfActive = null, onEditZone, onDebugDump, onManageTableStyles }: - { editor: Editor | null; tick: number; showFormattingMarks: boolean; pageMargins?: PageMargins; pageOrientation?: Orientation; pageFormat?: PageFormat; hfDistances?: HfDistances; differentFirstPage?: boolean; differentOddEven?: boolean; hfActive?: 'header' | 'footer' | null; onEditZone?: (zone: 'header' | 'footer') => void; onDebugDump?: () => void; onManageTableStyles?: () => void } = $props(); + let { editor, tick, showFormattingMarks = $bindable(), showRuler = $bindable(true), pageMargins = $bindable(DEFAULT_MARGINS), pageOrientation = $bindable('portrait'), pageFormat = $bindable('A4'), hfDistances = $bindable(DEFAULT_HF_DISTANCES), differentFirstPage = $bindable(false), differentOddEven = $bindable(false), hfActive = null, onEditZone, onDebugDump, onManageTableStyles }: + { editor: Editor | null; tick: number; showFormattingMarks: boolean; showRuler?: boolean; pageMargins?: PageMargins; pageOrientation?: Orientation; pageFormat?: PageFormat; hfDistances?: HfDistances; differentFirstPage?: boolean; differentOddEven?: boolean; hfActive?: 'header' | 'footer' | null; onEditZone?: (zone: 'header' | 'footer') => void; onDebugDump?: () => void; onManageTableStyles?: () => void } = $props(); const PAGE_FORMATS = Object.keys(PAGE_FORMAT_CM) as PageFormat[]; @@ -1455,6 +1455,17 @@ +
diff --git a/src/lib/editor/extensions/indent.ts b/src/lib/editor/extensions/indent.ts index 2cf98d1..6b5c266 100644 --- a/src/lib/editor/extensions/indent.ts +++ b/src/lib/editor/extensions/indent.ts @@ -89,6 +89,8 @@ declare module '@tiptap/core' { indent: { indentMore: () => ReturnType; indentLess: () => ReturnType; + setIndent: (cm: number) => ReturnType; + setIndentFirst: (cm: number) => ReturnType; indentListMore: () => ReturnType; indentListLess: () => ReturnType; indentListForward: () => ReturnType; @@ -102,6 +104,11 @@ function clampIndent(cm: number): number { return Math.round(Math.max(0, Math.min(INDENT_MAX_CM, cm)) * 100) / 100; } +// A first-line indent is signed (negative = Word's hanging indent). +function clampFirst(cm: number): number { + return Math.round(Math.max(-INDENT_MAX_CM, Math.min(INDENT_MAX_CM, cm)) * 100) / 100; +} + function parseIndent(value: string | null): number | null { if (value == null || value === '') return null; const n = parseFloat(value); @@ -169,25 +176,27 @@ export const Indent = Extension.create({ const types = this.options.types as string[]; const listTypes = this.options.listTypes as string[]; - // Step every paragraph/heading in the selection by delta cm, clamped to - // [0, MAX]. Per-node (relative) change, so a mixed selection keeps each - // block's offset; list-item paragraphs are skipped (lists indent by nesting). - const step = (delta: number) => ({ state, tr, dispatch }: CommandProps) => { - const { from, to } = state.selection; - let changed = false; - state.doc.nodesBetween(from, to, (node: PMNode, pos: number, parent: PMNode | null) => { - if (!types.includes(node.type.name)) return; - if (parent?.type.name === 'listItem') return; - const current = typeof node.attrs.indent === 'number' ? node.attrs.indent : 0; - const next = clampIndent(current + delta); - const value = next > 0 ? next : null; - if (value === (node.attrs.indent ?? null)) return; - tr.setNodeMarkup(pos, undefined, { ...node.attrs, indent: value }); - changed = true; - }); - if (changed && dispatch) dispatch(tr); - return changed; - }; + // Rewrite one indent attr on every paragraph/heading in the selection; 0 clears it. + // `next` gets the block's own value, so a relative step keeps each block's offset in + // a mixed selection. List-item paragraphs are skipped (lists indent by nesting). + const write = (attr: 'indent' | 'indentFirst', next: (current: number) => number) => + ({ state, tr, dispatch }: CommandProps) => { + const { from, to } = state.selection; + let changed = false; + state.doc.nodesBetween(from, to, (node: PMNode, pos: number, parent: PMNode | null) => { + if (!types.includes(node.type.name)) return; + if (parent?.type.name === 'listItem') return; + const current = typeof node.attrs[attr] === 'number' ? node.attrs[attr] : 0; + const value = next(current) || null; + if (value === (node.attrs[attr] ?? null)) return; + tr.setNodeMarkup(pos, undefined, { ...node.attrs, [attr]: value }); + changed = true; + }); + if (changed && dispatch) dispatch(tr); + return changed; + }; + + const step = (delta: number) => write('indent', (current) => clampIndent(current + delta)); // Step the innermost list ancestor's indent (shifts the whole list as a block). const stepList = (delta: number) => ({ state, tr, dispatch }: CommandProps) => { @@ -211,6 +220,9 @@ export const Indent = Extension.create({ return { indentMore: () => step(INDENT_STEP_CM), indentLess: () => step(-INDENT_STEP_CM), + // Absolute, as the ruler drags them; the buttons above step relatively. + setIndent: (cm: number) => write('indent', () => clampIndent(cm)), + setIndentFirst: (cm: number) => write('indentFirst', () => clampFirst(cm)), indentListMore: () => stepList(INDENT_STEP_CM), indentListLess: () => stepList(-INDENT_STEP_CM), indentListForward: () => ({ state, chain, commands }) => { diff --git a/src/lib/editor/extensions/tabStops.ts b/src/lib/editor/extensions/tabStops.ts index d93bfa4..98f8c50 100644 --- a/src/lib/editor/extensions/tabStops.ts +++ b/src/lib/editor/extensions/tabStops.ts @@ -12,9 +12,6 @@ import { PX_PER_CM } from '../../storage/pageMargins'; export type TabAlign = 'left' | 'center' | 'right' | 'decimal'; export type TabStop = { pos: number; align: TabAlign }; -// The default grid a tab falls back to (editor.css `tab-size`), also LibreOffice's. -export const DEFAULT_TAB_CM = 1.25; - const CODE: Record = { left: 'l', center: 'c', right: 'r', decimal: 'd' }; const ALIGN: Record = { l: 'left', c: 'center', r: 'right', d: 'decimal' }; @@ -67,7 +64,7 @@ declare module '@tiptap/core' { const tabStopsKey = new PluginKey('tabStops'); // A tab's advance is decided per line, so a block only needs measuring when it has -// stops of its own; a hanging indent implies one at the text position (Word). +// stops of its own; a hanging indent implies one at the text position. function stopsOf(node: PmNode): TabStop[] { const stops = parseTabStops(node.attrs.tabStops); const first = typeof node.attrs.indentFirst === 'number' ? node.attrs.indentFirst : 0; @@ -111,10 +108,13 @@ function measure(view: EditorView): TabWidth[] { for (let t = 0; t < tabs.length; t++) { const tabPos = tabs[t]; - const start = view.coordsAtPos(tabPos, 1); + // Side -1 measures at the end of the content BEFORE the tab, outside the tab's + // own span — so the margin this pass applies can't contaminate the next one's + // reading of where the pen stands. + const start = view.coordsAtPos(tabPos, -1); const xCm = (start.left - originX) / scale / PX_PER_CM; - // Word: custom stops replace the default grid to their left; past the last one - // the CSS grid already does the right thing, so that tab stays undecorated. + // Custom stops replace the default grid to their left; past the last one the + // CSS grid already does the right thing, so that tab stays undecorated. const stop = stops.find((s) => s.pos > xCm + 0.01); if (!stop) continue; let width = (stop.pos - xCm) * PX_PER_CM; @@ -123,7 +123,7 @@ function measure(view: EditorView): TabWidth[] { const segEnd = t + 1 < tabs.length ? tabs[t + 1] : blockEnd; const segStart = view.coordsAtPos(tabPos + 1, 1); const end = view.coordsAtPos(segEnd, -1); - // A segment that wraps can't satisfy the alignment — Word falls back to left. + // A segment that wraps can't satisfy the alignment, so it falls back to left. if (segEnd > tabPos + 1 && Math.abs(end.top - start.top) < 1) { let back = (end.left - segStart.left) / scale; if (stop.align === 'center') back /= 2; @@ -242,7 +242,10 @@ export const TabStops = Extension.create({ if (next === key) return; key = next; const decos = widths.map((w) => - Decoration.inline(w.pos, w.pos + 1, { style: `tab-size:0;margin-right:${w.width}px` }), + // margin-LEFT: the gap is the tab's own advance, so a caret placed after + // the tab has to sit behind it. As margin-right it stayed at the old x + // until the next keystroke moved it into the following text node. + Decoration.inline(w.pos, w.pos + 1, { style: `tab-size:0;margin-left:${w.width}px` }), ); // The advances change line breaking, so pagination has to re-measure. view.dispatch(view.state.tr diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index f938d48..feba5cf 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -2292,9 +2292,8 @@ function collapseRunWhitespace(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 . -// Also completes a decimal tab stop, which odf-kit can only write half of. +// LBR → , TAB → (both valid as bare paragraph text and +// inside ), and complete the decimal stop odf-kit only half-writes. const CHAR_TAB_STOP = 'style:type="char"'; function applyInlineSentinels(odtBytes: Uint8Array): Uint8Array { diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index 5de6de0..4e25a60 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -236,6 +236,17 @@ const de: Messages = { label: 'Rechtschreibsprache', noSpellCheck: 'Keine Rechtschreibprüfung', }, + ruler: { + show: 'Lineal', + firstLineIndent: 'Erstzeileneinzug', + leftIndent: 'Linker Einzug', + tabType: { + left: 'Linker Tabstopp', + center: 'Zentrierter Tabstopp', + right: 'Rechter Tabstopp', + decimal: 'Dezimaltabstopp', + }, + }, color: { automatic: 'Automatisch', moreColors: 'Weitere Farben…', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 98b9240..6283f1c 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -240,6 +240,17 @@ const en = { label: 'Spell-check language', noSpellCheck: 'No spell check', }, + ruler: { + show: 'Ruler', + firstLineIndent: 'First line indent', + leftIndent: 'Left indent', + tabType: { + left: 'Left tab', + center: 'Centre tab', + right: 'Right tab', + decimal: 'Decimal tab', + }, + }, color: { automatic: 'Automatic', moreColors: 'More colors…', diff --git a/src/lib/import/docxStyles.ts b/src/lib/import/docxStyles.ts index be2d839..2ddb056 100644 --- a/src/lib/import/docxStyles.ts +++ b/src/lib/import/docxStyles.ts @@ -306,9 +306,6 @@ export class DocxStyles { return this.styleAlign(this.basedOn.get(styleId) ?? null, seen); } - // Effective paragraph alignment from styles only (direct w:pPr/w:jc is read and wins in - // the caller): the pStyle's basedOn chain, else the default paragraph style, else - // docDefaults. Lets a paragraph inheriting justify from its style keep it. null = unset. // w:widowControl along the w:basedOn chain, then the default style, then docDefaults. // Absent everywhere is OOXML's implied "on". paragraphWidowControl(pStyleId: string | null | undefined): boolean { @@ -340,6 +337,9 @@ export class DocxStyles { return this.styleWidow(this.basedOn.get(styleId) ?? null, seen); } + // Effective paragraph alignment from styles only (direct w:pPr/w:jc is read and wins in + // the caller): the pStyle's basedOn chain, else the default paragraph style, else + // docDefaults. Lets a paragraph inheriting justify from its style keep it. null = unset. paragraphAlign(pStyleId: string | null | undefined): string | null { const own = this.styleAlign(pStyleId); if (own != null) return own; diff --git a/src/lib/storage/theme.ts b/src/lib/storage/theme.ts index be77ea5..074c0fd 100644 --- a/src/lib/storage/theme.ts +++ b/src/lib/storage/theme.ts @@ -3,6 +3,7 @@ export type ThemeMode = 'light' | 'dark' | 'allBlack' | 'auto'; const THEME_KEY = 'odf-editor-theme'; const TOOLBAR_KEY = 'odf-editor-toolbar-expanded'; const FORMATTING_MARKS_KEY = 'odf-editor-formatting-marks'; +const RULER_KEY = 'odf-editor-ruler'; export function loadTheme(): ThemeMode { const saved = localStorage.getItem(THEME_KEY); @@ -30,6 +31,15 @@ export function saveFormattingMarks(enabled: boolean): void { localStorage.setItem(FORMATTING_MARKS_KEY, String(enabled)); } +// The ruler is on unless it was switched off. +export function loadRuler(): boolean { + return localStorage.getItem(RULER_KEY) !== 'false'; +} + +export function saveRuler(enabled: boolean): void { + localStorage.setItem(RULER_KEY, String(enabled)); +} + function resolveMode(mode: ThemeMode): 'light' | 'dark' | 'allBlack' { if (mode === 'auto') { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';