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.
This commit is contained in:
1 parent
92d641f72f
commit
ad38994c65
12 files changed
+418
-40
No files matched your search
+3
-1
@@ -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**
|
||||
|
||||
+8
-1
@@ -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}
|
||||
|
||||
@@ -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 @@
|
||||
</script>
|
||||
|
||||
<div class="editor" bind:this={editorContainer} onwheel={onWheel} oncontextmenu={openContextMenu} role="none">
|
||||
<!-- Hidden while a header/footer zone is active: those have no tab stops, so the
|
||||
ruler would edit the body paragraph behind the user's back. -->
|
||||
{#if showRuler && scaledWidth && !hfActive}
|
||||
<Ruler {editor} {tick} zoom={appliedZoom} width={scaledWidth} margins={pageMargins} />
|
||||
{/if}
|
||||
<!-- Reserves the scaled scroll footprint; the transform on .paper reserves none.
|
||||
Before the first measure (size 0) it's left unsized so .paper isn't clipped. -->
|
||||
<div class="paper-scaler" style={scaledWidth ? `width: ${scaledWidth}px; height: ${scaledHeight}px;` : ''}>
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
<script lang="ts">
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { PX_PER_CM, type PageMargins } from '../storage/pageMargins';
|
||||
import { activeTabStops, type TabAlign, type TabStop } from '../editor/extensions/tabStops';
|
||||
import { t } from '../i18n/i18n.svelte';
|
||||
|
||||
// Horizontal ruler: the tab stops and indents of the block the cursor is in.
|
||||
// Positions are cm from the left text margin, the attr's own origin.
|
||||
let { editor, tick, zoom, width, margins }: {
|
||||
editor: Editor | null;
|
||||
tick: number;
|
||||
zoom: number;
|
||||
width: number; // the page's SCALED width in px (.paper-scaler's)
|
||||
margins: PageMargins;
|
||||
} = $props();
|
||||
|
||||
const TYPES: TabAlign[] = ['left', 'center', 'right', 'decimal'];
|
||||
const SNAP_CM = 0.05;
|
||||
const DRAG_OFF_PX = 18; // pulled this far off the ruler = drop the stop
|
||||
|
||||
let type = $state<TabAlign>('left');
|
||||
let strip: HTMLDivElement;
|
||||
|
||||
const scale = $derived(zoom / 100);
|
||||
const pageCm = $derived(scale > 0 ? width / scale / PX_PER_CM : 0);
|
||||
// The block under the cursor; `tick` re-evaluates it on every transaction.
|
||||
const info = $derived(tick >= 0 && editor ? activeTabStops(editor.state) : null);
|
||||
|
||||
// cm from the left text margin → px within the strip, and back.
|
||||
const px = (cm: number) => (margins.left + cm) * PX_PER_CM * scale;
|
||||
const cmAt = (clientX: number) =>
|
||||
Math.round(((clientX - strip.getBoundingClientRect().left) / scale / PX_PER_CM - margins.left) / SNAP_CM) * SNAP_CM;
|
||||
|
||||
const textCm = $derived(Math.max(0, pageCm - margins.left - margins.right));
|
||||
const ticks = $derived(Array.from({ length: Math.ceil(pageCm) + 1 }, (_, i) => i - Math.floor(margins.left)));
|
||||
|
||||
// Marker glyphs in a shared 10×10 box: L / ⊥ / ⅃, plus ⊥ with a dot for decimal. The
|
||||
// stem is the stop's own position, so it stays centred in the box and the foot's
|
||||
// direction carries the type.
|
||||
const MARK: Record<TabAlign, string> = {
|
||||
left: 'M5,1 V9 M4.2,9 H9.5',
|
||||
center: 'M5,1 V9 M0.5,9 H9.5',
|
||||
right: 'M5,1 V9 M0.5,9 H5.8',
|
||||
decimal: 'M5,1 V9 M0.5,9 H9.5',
|
||||
};
|
||||
|
||||
type Drag =
|
||||
| { kind: 'stop'; index: number }
|
||||
| { kind: 'indent' }
|
||||
| { kind: 'first' };
|
||||
let drag = $state<{ target: Drag; cm: number; off: boolean } | null>(null);
|
||||
|
||||
function apply(stops: TabStop[]) {
|
||||
editor?.chain().focus().setTabStops(stops).run();
|
||||
}
|
||||
|
||||
function start(event: PointerEvent, target: Drag) {
|
||||
if (!editor || !info || event.button !== 0) return;
|
||||
event.preventDefault(); // keep the caret (and thus the target block) where it is
|
||||
event.stopPropagation();
|
||||
strip.setPointerCapture(event.pointerId);
|
||||
drag = { target, cm: cmAt(event.clientX), off: false };
|
||||
}
|
||||
|
||||
function move(event: PointerEvent) {
|
||||
if (!drag) return;
|
||||
const rect = strip.getBoundingClientRect();
|
||||
const off = event.clientY < rect.top - DRAG_OFF_PX || event.clientY > rect.bottom + DRAG_OFF_PX;
|
||||
drag = { ...drag, cm: cmAt(event.clientX), off: drag.target.kind === 'stop' && off };
|
||||
}
|
||||
|
||||
function end(event: PointerEvent) {
|
||||
if (!drag || !info) return;
|
||||
const { target, cm, off } = drag;
|
||||
drag = null;
|
||||
strip.releasePointerCapture(event.pointerId);
|
||||
if (target.kind === 'stop') {
|
||||
const rest = info.stops.filter((_, i) => i !== target.index);
|
||||
apply(off ? rest : [...rest, { pos: cm, align: info.stops[target.index].align }]);
|
||||
} else if (target.kind === 'indent') {
|
||||
// Moves the whole block: the first line keeps its offset from the indent.
|
||||
editor?.chain().focus().setIndent(Math.max(0, cm)).run();
|
||||
} else {
|
||||
editor?.chain().focus().setIndentFirst(cm - info.indent).run();
|
||||
}
|
||||
}
|
||||
|
||||
// A click on empty ruler adds a stop of the selected type.
|
||||
function addStop(event: PointerEvent) {
|
||||
if (!editor || !info || event.button !== 0) return;
|
||||
const cm = cmAt(event.clientX);
|
||||
if (cm <= 0 || cm >= textCm) return;
|
||||
event.preventDefault();
|
||||
apply([...info.stops, { pos: cm, align: type }]);
|
||||
}
|
||||
|
||||
const preview = $derived(drag && !drag.off ? drag.cm : null);
|
||||
|
||||
// Is this the marker being dragged, and where does it draw? While dragging it
|
||||
// follows the pointer, otherwise it sits at its stored position.
|
||||
const dragging = (t: Drag) => !!drag && drag.target.kind === t.kind
|
||||
&& (t.kind !== 'stop' || (drag.target as { index: number }).index === t.index);
|
||||
const at = (t: Drag, stored: number) => (dragging(t) && preview != null ? preview : stored);
|
||||
</script>
|
||||
|
||||
<div class="ruler" style="width: {width}px">
|
||||
<button
|
||||
class="tab-type"
|
||||
onpointerdown={(e) => e.preventDefault()}
|
||||
onclick={() => (type = TYPES[(TYPES.indexOf(type) + 1) % TYPES.length])}
|
||||
title={t().ruler.tabType[type]}
|
||||
>
|
||||
<svg viewBox="0 0 10 10"><path d={MARK[type]} /><circle cx="7.8" cy="6.6" r="1.1" class:hide={type !== 'decimal'} /></svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="strip"
|
||||
bind:this={strip}
|
||||
onpointerdown={addStop}
|
||||
onpointermove={move}
|
||||
onpointerup={end}
|
||||
onpointercancel={end}
|
||||
role="presentation"
|
||||
>
|
||||
<div class="margin" style="left: 0; width: {px(0)}px"></div>
|
||||
<div class="margin" style="left: {px(textCm)}px"></div>
|
||||
|
||||
{#each ticks as cm (cm)}
|
||||
{#if px(cm) >= 0 && px(cm) <= width}
|
||||
<div class="tick" style="left: {px(cm)}px">
|
||||
{#if cm > 0 && cm < textCm}<span>{cm}</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if info}
|
||||
{#each info.stops as stop, i (i)}
|
||||
<div
|
||||
class="stop"
|
||||
class:dragging={dragging({ kind: 'stop', index: i })}
|
||||
style="left: {px(at({ kind: 'stop', index: i }, stop.pos))}px"
|
||||
onpointerdown={(e) => start(e, { kind: 'stop', index: i })}
|
||||
role="presentation"
|
||||
title={t().ruler.tabType[stop.align]}
|
||||
>
|
||||
<svg viewBox="0 0 10 10"><path d={MARK[stop.align]} /><circle cx="7.8" cy="6.6" r="1.1" class:hide={stop.align !== 'decimal'} /></svg>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div
|
||||
class="indent first"
|
||||
style="left: {px(at({ kind: 'first' }, info.indent + info.indentFirst))}px"
|
||||
onpointerdown={(e) => start(e, { kind: 'first' })}
|
||||
role="presentation"
|
||||
title={t().ruler.firstLineIndent}
|
||||
></div>
|
||||
<div
|
||||
class="indent left"
|
||||
style="left: {px(at({ kind: 'indent' }, info.indent))}px"
|
||||
onpointerdown={(e) => start(e, { kind: 'indent' })}
|
||||
role="presentation"
|
||||
title={t().ruler.leftIndent}
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ruler {
|
||||
/* Sticky against .editor's content box, which already clears the toolbar island.
|
||||
The negative margin eats most of that padding (it was spacing the page, not a
|
||||
ruler); `top` matches it, or the bar would jump down that far once it scrolls. */
|
||||
position: sticky;
|
||||
top: -1.125rem;
|
||||
z-index: 5;
|
||||
margin: -1.125rem auto 0.5rem;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
user-select: none;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.tab-type {
|
||||
position: absolute;
|
||||
left: -28px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* The bar is chrome, not page: --color-surface separates it from the editor
|
||||
background in every theme (which --color-bg, being that background, did not). */
|
||||
.strip {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
background: var(--color-surface);
|
||||
/* --color-border is a hairline meant for white-on-white panels; against the
|
||||
editor background the bar needs its own outline to read as one object. */
|
||||
border: 1px solid color-mix(in srgb, var(--color-text) 25%, transparent);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* The margins are shaded, the printable column left plain. A currentColor tint
|
||||
darkens a light bar and lightens a dark one, so one rule fits every theme. */
|
||||
.margin {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background: currentColor;
|
||||
opacity: 0.18;
|
||||
}
|
||||
|
||||
.tick {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 0;
|
||||
transform: translateX(-0.5px);
|
||||
}
|
||||
|
||||
/* The mark itself sits along the top edge, clear of the tab markers below. */
|
||||
.tick::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 4px;
|
||||
border-left: 1px solid currentColor;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.tick span {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top:50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
color: var(--color-text);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.stop {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
margin-left: -5.5px;
|
||||
cursor: ew-resize;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.stop.dragging {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Block, or the svg sits on the text baseline: it hung 3px below its box and the
|
||||
strip's overflow clipped the glyph's foot, leaving every type a bare stem. */
|
||||
svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.6;
|
||||
}
|
||||
|
||||
circle {
|
||||
fill: currentColor;
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Indent handles: first line points down from the top edge, the block's left
|
||||
indent points up from the bottom edge. */
|
||||
.indent {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin-left: -5px;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.indent.first {
|
||||
top: 0;
|
||||
border-top: 6px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.indent.left {
|
||||
bottom: 0;
|
||||
border-bottom: 6px solid var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -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<Orientation>('portrait'), pageFormat = $bindable<PageFormat>('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<Orientation>('portrait'), pageFormat = $bindable<PageFormat>('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 @@
|
||||
<path d="M11 2.25v11.5M7.5 2.25v11.5M11 2.25H6.75a2.75 2.75 0 0 0 0 5.5H7.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class:active={showRuler}
|
||||
onclick={() => (showRuler = !showRuler)}
|
||||
title={t().ruler.show}
|
||||
aria-pressed={showRuler}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<rect x="1.75" y="5.25" width="12.5" height="5.5" rx="1" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M4.75 5.25v2M7 5.25v3M9.25 5.25v2M11.5 5.25v3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-separator"></div>
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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<TabAlign, string> = { left: 'l', center: 'c', right: 'r', decimal: 'd' };
|
||||
const ALIGN: Record<string, TabAlign> = { l: 'left', c: 'center', r: 'right', d: 'decimal' };
|
||||
|
||||
@@ -67,7 +64,7 @@ declare module '@tiptap/core' {
|
||||
const tabStopsKey = new PluginKey<DecorationSet>('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
|
||||
|
||||
@@ -2292,9 +2292,8 @@ function collapseRunWhitespace(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>.
|
||||
// Also completes a decimal tab stop, which odf-kit can only write half of.
|
||||
// LBR → <text:line-break/>, TAB → <text:tab/> (both valid as bare paragraph text and
|
||||
// inside <text:span>), and complete the decimal stop odf-kit only half-writes.
|
||||
const CHAR_TAB_STOP = 'style:type="char"';
|
||||
|
||||
function applyInlineSentinels(odtBytes: Uint8Array): Uint8Array {
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user