feat: introduce formatting marks

This commit is contained in:
Steffen Becker committed 2026-05-28 17:39:34 +02:00
1 parent 416a405d0f
commit e03b1d04ec
8 files changed
+210 -7

No files matched your search

+1
View File
@@ -35,6 +35,7 @@
- font size selection
- line spacing button
- text alignment
- show formatting marks
### Not yet implemented
- ODT import / open existing file
+8 -3
View File
@@ -5,7 +5,7 @@
import ToolbarExpanded from './lib/editor/ToolbarExpanded.svelte';
import { exportToOdt } from './lib/export/odt';
import { getPageBreakDebug } from './lib/editor/pageBreaks';
import { loadTheme, saveTheme, applyTheme, loadToolbarExpanded, saveToolbarExpanded, type ThemeMode } from './lib/storage/theme';
import { loadTheme, saveTheme, applyTheme, loadToolbarExpanded, saveToolbarExpanded, loadFormattingMarks, saveFormattingMarks, type ThemeMode } from './lib/storage/theme';
let editor: Editor | null = $state(null);
let tick: number = $state(0);
@@ -15,8 +15,13 @@
let themeMode: ThemeMode = $state(loadTheme());
let themeOpen = $state(false);
let toolbarExpanded = $state(loadToolbarExpanded());
let showFormattingMarks = $state(loadFormattingMarks());
let zoom = $state(Math.max(50, Math.min(200, parseInt(localStorage.getItem('odf-editor-zoom') ?? '100', 10))));
$effect(() => {
saveFormattingMarks(showFormattingMarks);
});
function setZoom(value: number) {
zoom = Math.max(50, Math.min(200, value));
localStorage.setItem('odf-editor-zoom', String(zoom));
@@ -145,9 +150,9 @@
</div>
</header>
{#if toolbarExpanded}
<ToolbarExpanded {editor} {tick} />
<ToolbarExpanded {editor} {tick} bind:showFormattingMarks />
{/if}
<EditorComponent bind:editor bind:tick bind:currentPage bind:numPages {zoom} />
<EditorComponent bind:editor bind:tick bind:currentPage bind:numPages {zoom} {showFormattingMarks} />
<footer class="statusbar">
<span>Page {currentPage} of {numPages}</span>
<div class="zoom-controls">
+3 -3
View File
@@ -9,8 +9,8 @@
const DEFAULT_EDITOR_FONT = 'Georgia'; // must match ToolbarExpanded.svelte
let { editor = $bindable(), tick = $bindable(0), currentPage = $bindable(1), numPages = $bindable(1), zoom = 100 }: {
editor: Editor | null; tick: number; currentPage: number; numPages: number; zoom: number;
let { editor = $bindable(), tick = $bindable(0), currentPage = $bindable(1), numPages = $bindable(1), zoom = 100, showFormattingMarks = false }: {
editor: Editor | null; tick: number; currentPage: number; numPages: number; zoom: number; showFormattingMarks?: boolean;
} = $props();
let element: HTMLDivElement;
@@ -119,5 +119,5 @@
</script>
<div class="editor" bind:this={editorContainer}>
<div bind:this={element} class="paper" style="zoom: {zoom / 100}"></div>
<div bind:this={element} class="paper" class:show-formatting-marks={showFormattingMarks} style="zoom: {zoom / 100}"></div>
</div>
+16 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
let { editor, tick }: { editor: Editor | null; tick: number } = $props();
let { editor, tick, showFormattingMarks = $bindable() }: { editor: Editor | null; tick: number; showFormattingMarks: boolean } = $props();
type AlignValue = 'left' | 'center' | 'right' | 'justify';
@@ -459,6 +459,21 @@
<div class="toolbar-separator"></div>
<div class="toolbar-group">
<button
class:active={showFormattingMarks}
onclick={() => (showFormattingMarks = !showFormattingMarks)}
title="Formatting marks"
aria-pressed={showFormattingMarks}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<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>
</div>
<div class="toolbar-separator"></div>
<div class="toolbar-group">
<button disabled title="Insert table (coming soon)">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
+2
View File
@@ -15,6 +15,7 @@ import Placeholder from '@tiptap/extension-placeholder';
import TextAlign from '@tiptap/extension-text-align';
import { PageBreaks } from './pageBreaks';
import { LineHeight } from './lineHeight';
import { FormattingMarks } from './formattingMarks';
export const extensions = [
Document,
@@ -40,4 +41,5 @@ export const extensions = [
defaultAlignment: 'left',
}),
PageBreaks,
FormattingMarks,
];
+95
View File
@@ -0,0 +1,95 @@
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import type { EditorState, Transaction } from '@tiptap/pm/state';
import type { Node as PmNode } from '@tiptap/pm/model';
const formattingMarksKey = new PluginKey<DecorationSet>('formattingMarks');
const FORCE_REBUILD_META = 'formattingMarks/forceRebuild';
function isEnabled(domRoot: HTMLElement | null): boolean {
return !!domRoot?.closest('.paper')?.classList.contains('show-formatting-marks');
}
function buildDecorations(doc: PmNode): DecorationSet {
const decos: Decoration[] = [];
doc.descendants((node, pos) => {
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' }));
}
}
});
return decos.length ? DecorationSet.create(doc, decos) : DecorationSet.empty;
}
export const FormattingMarks = Extension.create({
name: 'formattingMarks',
addProseMirrorPlugins() {
return [
new Plugin<DecorationSet>({
key: formattingMarksKey,
state: {
init: () => DecorationSet.empty,
apply(tr: Transaction, old: DecorationSet, _oldState: EditorState, newState: EditorState) {
const force = tr.getMeta(FORCE_REBUILD_META) === true;
// Resolve enabled-state via the DOM class on .paper. The plugin's
// `view` hook below installs a MutationObserver that dispatches a
// FORCE_REBUILD meta whenever that class toggles, so this check
// always reflects the current UI state.
// During init (no view yet) we can't read DOM, so default to keeping
// the previous set mapped through.
if (typeof document === 'undefined') {
return tr.docChanged ? old.map(tr.mapping, tr.doc) : old;
}
const enabled = document.querySelector('.paper.show-formatting-marks') !== null;
if (!enabled) return DecorationSet.empty;
if (force || tr.docChanged) return buildDecorations(newState.doc);
return old;
},
},
props: {
decorations(state) {
return formattingMarksKey.getState(state);
},
},
view(editorView) {
const paper = editorView.dom.closest('.paper') as HTMLElement | null;
let observer: MutationObserver | null = null;
const triggerRebuild = () => {
editorView.dispatch(editorView.state.tr.setMeta(FORCE_REBUILD_META, true));
};
if (paper) {
observer = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'attributes' && m.attributeName === 'class') {
triggerRebuild();
break;
}
}
});
observer.observe(paper, { attributes: true, attributeFilter: ['class'] });
}
// Initial pass: if the class is already on at mount-time, render now.
if (isEnabled(editorView.dom)) {
queueMicrotask(triggerRebuild);
}
return {
destroy() {
observer?.disconnect();
},
};
},
}),
];
},
});
+9
View File
@@ -2,6 +2,7 @@ export type ThemeMode = 'light' | 'dark' | 'auto';
const THEME_KEY = 'odf-editor-theme';
const TOOLBAR_KEY = 'odf-editor-toolbar-expanded';
const FORMATTING_MARKS_KEY = 'odf-editor-formatting-marks';
export function loadTheme(): ThemeMode {
const saved = localStorage.getItem(THEME_KEY);
@@ -21,6 +22,14 @@ export function saveToolbarExpanded(expanded: boolean): void {
localStorage.setItem(TOOLBAR_KEY, String(expanded));
}
export function loadFormattingMarks(): boolean {
return localStorage.getItem(FORMATTING_MARKS_KEY) === 'true';
}
export function saveFormattingMarks(enabled: boolean): void {
localStorage.setItem(FORMATTING_MARKS_KEY, String(enabled));
}
function resolveMode(mode: ThemeMode): 'light' | 'dark' {
if (mode === 'auto') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+76
View File
@@ -156,6 +156,73 @@
height: 0;
}
/* Formatting marks (Toggle in extended toolbar).
Zero-width inline-block with visible overflow: the ¶ glyph renders just
past the last character without consuming line width — so right/center/
justify alignment of the actual text is preserved. The leading NBSP in
the content gives a small visual gap from the text. */
.paper.show-formatting-marks .tiptap p::after,
.paper.show-formatting-marks .tiptap h1::after,
.paper.show-formatting-marks .tiptap h2::after,
.paper.show-formatting-marks .tiptap h3::after {
content: '\00A0¶';
display: inline-block;
width: 0;
overflow: visible;
white-space: pre;
color: var(--color-primary);
opacity: 0.5;
font-family: var(--font-sans);
font-weight: 400;
user-select: none;
pointer-events: none;
}
.paper.show-formatting-marks .tiptap li > p::after {
content: '\00A0¶';
}
/* Empty block fix: when a paragraph/heading/li-paragraph contains only the
ProseMirror trailing-break BR, the inline ::after would otherwise render
on a second line below the BR, doubling the block's height and shifting
the page-break calculation. Pull ::after out of flow via absolute positioning. */
.paper.show-formatting-marks .tiptap p:has(> br.ProseMirror-trailingBreak:only-child),
.paper.show-formatting-marks .tiptap h1:has(> br.ProseMirror-trailingBreak:only-child),
.paper.show-formatting-marks .tiptap h2:has(> br.ProseMirror-trailingBreak:only-child),
.paper.show-formatting-marks .tiptap h3:has(> br.ProseMirror-trailingBreak:only-child),
.paper.show-formatting-marks .tiptap li > p:has(> br.ProseMirror-trailingBreak:only-child) {
position: relative;
}
.paper.show-formatting-marks .tiptap p:has(> br.ProseMirror-trailingBreak:only-child)::after,
.paper.show-formatting-marks .tiptap h1:has(> br.ProseMirror-trailingBreak:only-child)::after,
.paper.show-formatting-marks .tiptap h2:has(> br.ProseMirror-trailingBreak:only-child)::after,
.paper.show-formatting-marks .tiptap h3:has(> br.ProseMirror-trailingBreak:only-child)::after,
.paper.show-formatting-marks .tiptap li > p:has(> br.ProseMirror-trailingBreak:only-child)::after {
position: absolute;
top: 0;
left: 0;
margin-left: 0;
}
.paper.show-formatting-marks .tiptap .pm-space-mark {
position: relative;
}
.paper.show-formatting-marks .tiptap .pm-space-mark::before {
content: '·';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-primary);
opacity: 0.55;
font-weight: 700;
pointer-events: none;
user-select: none;
}
/* Print styles: proper A4 pagination */
@media print {
.editor {
@@ -185,4 +252,13 @@
size: A4;
margin: 25.4mm;
}
.paper.show-formatting-marks .tiptap p::after,
.paper.show-formatting-marks .tiptap h1::after,
.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 {
content: none;
}
}