feat: introduce formatting marks
This commit is contained in:
1 parent
416a405d0f
commit
e03b1d04ec
8 files changed
+210
-7
No files matched your search
@@ -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
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user