feat: one reading of the review view for every layer that draws it
storage/markup holds the display mode (all, simple, none, original) plus, per kind, whether it shows and where its cards go; markupView folds that into what the page acts on. Bars, leader and both panes ask reviewItems instead of the document, the page takes the hiding attributes, and trackChanges drops its author decoration where the mode shows a finished text.
This commit is contained in:
1 parent
f338aec89c
commit
6d5db7135b
11 files changed
+307
-18
No files matched your search
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { cmToPx, type PageMargins } from '../storage/pageMargins';
|
||||
import { commentRanges, commentIdAt } from '../editor/extensions/comment';
|
||||
import { revisions, revisionIdAt, authorColorIndex, REVISION_AUTHOR_COLORS } from '../editor/extensions/trackChanges';
|
||||
import { commentIdAt } from '../editor/extensions/comment';
|
||||
import { revisionIdAt, authorColorIndex, REVISION_AUTHOR_COLORS } from '../editor/extensions/trackChanges';
|
||||
import { visibleCommentRanges, visibleRevisions } from './reviewItems';
|
||||
import { markupView } from '../storage/markup.svelte';
|
||||
|
||||
// The changed-lines bar both word processors draw in the margin: a stroke beside every
|
||||
// line a recorded change or a comment covers, so a pane entry can be found in the text.
|
||||
@@ -33,10 +35,12 @@
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
// Re-measure on every edit, every selection move and each pagination settle.
|
||||
// Re-measure on every edit, every selection move, each pagination settle — and when
|
||||
// the display mode takes a kind off the page or brings it back.
|
||||
void tick;
|
||||
void pageBoxes;
|
||||
void pageMargins;
|
||||
void markupView();
|
||||
schedule();
|
||||
});
|
||||
|
||||
@@ -51,10 +55,10 @@
|
||||
// Every marked range with the colour its bar takes: a revision in its author's colour,
|
||||
// a comment in the comment amber. A resolved comment is handled and gets none.
|
||||
function marked(state: Editor['state']): { from: number; to: number; color: string; active: boolean }[] {
|
||||
const revs = revisions(state.doc);
|
||||
const revs = visibleRevisions(state.doc);
|
||||
const order = authorColorIndex(revs);
|
||||
const activeRev = revisionIdAt(state, revs);
|
||||
const comments = commentRanges(state.doc);
|
||||
const comments = visibleCommentRanges(state.doc);
|
||||
const activeComment = commentIdAt(state, comments);
|
||||
return [
|
||||
...revs.map((r) => ({
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { commentIdAt, commentRanges } from '../editor/extensions/comment';
|
||||
import { revisionIdAt, revisions, authorColorIndex, REVISION_AUTHOR_COLORS } from '../editor/extensions/trackChanges';
|
||||
import { commentIdAt } from '../editor/extensions/comment';
|
||||
import { revisionIdAt, authorColorIndex, REVISION_AUTHOR_COLORS } from '../editor/extensions/trackChanges';
|
||||
import { visibleCommentRanges, visibleRevisions } from './reviewItems';
|
||||
import { markupView } from '../storage/markup.svelte';
|
||||
|
||||
// The dashed leader both word processors draw from an annotated range to its balloon,
|
||||
// here from the text to the reviewing pane's card. Only for the one the caret sits in:
|
||||
@@ -23,6 +25,7 @@
|
||||
// Deferred to the frame, so the pane's own card class and scroll are in the DOM.
|
||||
$effect(() => {
|
||||
void tick;
|
||||
void markupView();
|
||||
schedule();
|
||||
});
|
||||
|
||||
@@ -75,14 +78,14 @@
|
||||
};
|
||||
|
||||
const state = view.state;
|
||||
const ranges = commentRanges(state.doc);
|
||||
const ranges = visibleCommentRanges(state.doc);
|
||||
const cid = commentIdAt(state, ranges);
|
||||
if (cid) {
|
||||
// The last range of the comment: its end is the anchor both products point from.
|
||||
const range = ranges.filter((c) => c.id === cid).pop();
|
||||
if (range) add(range.to, document.querySelector('.comments-pane li.active'), COMMENT_COLOR);
|
||||
}
|
||||
const list = revisions(state.doc);
|
||||
const list = visibleRevisions(state.doc);
|
||||
const rid = revisionIdAt(state, list);
|
||||
if (rid) {
|
||||
const range = list.filter((r) => r.id === rid).pop();
|
||||
|
||||
@@ -28,6 +28,7 @@ import PageSheetLayer from './PageSheetLayer.svelte';
|
||||
import LineNumberLayer from './LineNumberLayer.svelte';
|
||||
import FoldMarkLayer from './FoldMarkLayer.svelte';
|
||||
import ChangeBarLayer from './ChangeBarLayer.svelte';
|
||||
import { markupView } from '../storage/markup.svelte';
|
||||
import { DEFAULT_LINE_NUMBERING, type LineNumbering } from '../storage/lineNumbering';
|
||||
import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
|
||||
import Ruler from './Ruler.svelte';
|
||||
@@ -375,6 +376,20 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
|
||||
});
|
||||
});
|
||||
|
||||
// The display mode takes text off the page (a deletion, or an insertion in the
|
||||
// original view), so the page positions under it are stale.
|
||||
let markup = $derived(markupView());
|
||||
$effect(() => {
|
||||
markup;
|
||||
const ed = editor;
|
||||
if (!ed) return;
|
||||
requestAnimationFrame(() => {
|
||||
if (ed.view.dom.isConnected) {
|
||||
ed.view.dispatch(ed.state.tr.setMeta('addToHistory', false).setMeta(FORCE_PAGE_RECALC, true));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Bundled fonts (Liberation Serif, Carlito, …) load with font-display:swap, so text
|
||||
// can reflow after pagination first runs (e.g. imported content in a not-yet-loaded
|
||||
// font). Re-paginate when fonts finish so page positions and the HF band settle.
|
||||
@@ -1450,7 +1465,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
|
||||
{/snippet}
|
||||
|
||||
{#snippet paper(i: number, offsetTop: number, offsetLeft: number)}
|
||||
<div bind:this={papers[i]} class="paper" style:position={offsetTop || offsetLeft ? 'absolute' : null} style:top={offsetTop ? `${offsetTop}px` : null} style:left={offsetLeft ? `${offsetLeft}px` : null} data-spacing-model={spacingModel} class:show-formatting-marks={showFormattingMarks} class:hf-editing={hfActive} class:settling style="transform: scale({appliedZoom / 100});{pageDecor.background ? ` --color-page-bg: ${pageDecor.background};` : ''}">
|
||||
<div bind:this={papers[i]} class="paper" data-hide-deletions={markup.hideDeletions ? '' : null} data-hide-insertions={markup.hideInsertions ? '' : null} data-plain-markup={markup.plainRevisions ? '' : null} data-hide-comments={markup.comments ? null : ''} style:position={offsetTop || offsetLeft ? 'absolute' : null} style:top={offsetTop ? `${offsetTop}px` : null} style:left={offsetLeft ? `${offsetLeft}px` : null} data-spacing-model={spacingModel} class:show-formatting-marks={showFormattingMarks} class:hf-editing={hfActive} class:settling style="transform: scale({appliedZoom / 100});{pageDecor.background ? ` --color-page-bg: ${pageDecor.background};` : ''}">
|
||||
<!-- Dedicated mount point that TipTap fully owns — keeping it free of Svelte
|
||||
content avoids Svelte and ProseMirror fighting over the same parent's DOM. -->
|
||||
<div bind:this={hosts[i]} class="tiptap-host" data-split-pane={i > 0 ? '' : null} dir={pageRtl ? 'rtl' : null} lang={documentLanguage === NO_LANGUAGE ? null : documentLanguage} style:hyphens={hyphenate ? 'auto' : null}></div>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import type { Node as PMNode } from '@tiptap/pm/model';
|
||||
import { TextSelection } from '@tiptap/pm/state';
|
||||
import { comments, commentRanges, type CommentRange } from '../editor/extensions/comment';
|
||||
import { revisions, type Revision } from '../editor/extensions/trackChanges';
|
||||
import { markupView } from '../storage/markup.svelte';
|
||||
|
||||
// What the review views may show, in one place: bars, balloons, leader line and both
|
||||
// panes ask here instead of each carrying its own reading of the markup settings.
|
||||
// A resolved comment is not this rule's business — every caller handles it as it did.
|
||||
|
||||
export function visibleComments(doc: PMNode): CommentRange[] {
|
||||
return markupView().comments ? comments(doc) : [];
|
||||
}
|
||||
|
||||
export function visibleCommentRanges(doc: PMNode): CommentRange[] {
|
||||
return markupView().comments ? commentRanges(doc) : [];
|
||||
}
|
||||
|
||||
export function visibleRevisions(doc: PMNode): Revision[] {
|
||||
return markupView().changes ? revisions(doc) : [];
|
||||
}
|
||||
|
||||
/** Select a marked range and scroll it into view — what a click on any of its cards does. */
|
||||
export function selectRange(editor: Editor, from: number, to: number): void {
|
||||
const { state, view } = editor;
|
||||
view.dispatch(state.tr.setSelection(TextSelection.create(state.doc, from, to)).scrollIntoView());
|
||||
view.focus();
|
||||
}
|
||||
|
||||
/** The entry after the caret, wrapping at the end — Word's Next in both review groups. */
|
||||
export function step<T extends { from: number; to: number }>(
|
||||
list: T[], pos: number, dir: 1 | -1,
|
||||
): T | null {
|
||||
if (!list.length) return null;
|
||||
const sorted = [...list].sort((a, b) => a.from - b.from);
|
||||
const hit = dir === 1
|
||||
? sorted.find((r) => r.from > pos)
|
||||
: [...sorted].reverse().find((r) => r.from < pos);
|
||||
return hit ?? (dir === 1 ? sorted[0] : sorted[sorted.length - 1]);
|
||||
}
|
||||
@@ -75,6 +75,7 @@ import { HEADING_LEVELS, MAX_HEADING_LEVEL } from '../export/odt';
|
||||
import { styleSheet } from '../styles/sheet.svelte';
|
||||
import { noteSettings } from '../storage/notes.svelte';
|
||||
import { recordChanges } from '../storage/trackChanges.svelte';
|
||||
import { markupView } from '../storage/markup.svelte';
|
||||
import { loadDocProperties } from '../storage/docProperties';
|
||||
import { Insertion, Deletion, TrackChanges } from './extensions/trackChanges';
|
||||
|
||||
@@ -240,7 +241,11 @@ export const extensions = [
|
||||
// marked. Round-trips to ODF <text:tracked-changes> and DOCX w:ins/w:del.
|
||||
Insertion,
|
||||
Deletion,
|
||||
TrackChanges.configure({ recording: recordChanges, author: () => loadDocProperties().author.trim() }),
|
||||
TrackChanges.configure({
|
||||
recording: recordChanges,
|
||||
author: () => loadDocProperties().author.trim(),
|
||||
plain: () => markupView().plainRevisions,
|
||||
}),
|
||||
AutoCorrect,
|
||||
WordCompletion,
|
||||
// Named blocks of text kept beside the documents; F3 expands a typed shortcut.
|
||||
|
||||
@@ -31,7 +31,7 @@ Five topics are large enough to have their own deep-dive — read the file befor
|
||||
- **`listStyle.ts`** — the named list style (`listStyleName` on the outermost list; nested lists inherit) and the walk resolving every list's effective marker (`data-eff-list-style`, `data-bullet`+`--bullet`, indent, label alignment) from node attr > style level > depth cycle — the style level decides the depth's kind, so a number level numbers a `<ul>` (counter-offset via `counter-reset`, since `<ul>` ignores `start`); `setListStyle` assigns (clearing the subtree's direct marker attrs and retyping each list to its level's kind), `listStyleNameAt` feeds the dropdowns — see `src/lib/styles/CLAUDE.md`.
|
||||
- **`link.ts`** — hyperlink mark; owns `Mod-k`, which fires the event `Toolbar.svelte` listens for to open the link dialog. A `#name` href targets a bookmark: `Editor.svelte`'s `handleClick` scrolls to it instead of opening a tab. The `plain` attr (`data-plain`, `editor.css`) suppresses the editor's own blue: Word paints a link through its **Hyperlink character style**, which arrives as an ordinary mark, so every DOCX link is plain and one carrying no style is drawn like the text around it — while LibreOffice paints every ODF `text:a` styled or not (probed), so those never are. The DOCX exporter paints `#0563C1` only on a non-plain link, which is how a round trip tells its own links from a file's.
|
||||
- **`comment.ts`** — an annotation on a range of text, a mark like `bookmark`: the whole comment (author, date, body, resolved) rides its attrs, so two runs of one comment merge back together and no second store is needed. `commentRanges(doc)` walks every range (an id split by a paragraph boundary yields several, which is why the commands walk all of them), `comments(doc)` keeps one per id for `CommentsPane.svelte`. A point comment — LibreOffice's, made with nothing selected, written with no `office:name` and no end element — attaches on import to the run before it, else the one after: the editor's comment is a mark and needs a range. A comment carries its **thread** in the same attrs (`replies`, one entry per answer, appended by `replyToComment`): ODF gives an answer its own `office:annotation` naming its comment in `loext:parent-name` (probed) and Word its own `w:comment` tied by `w15:paraIdParent` in commentsExtended.xml, so both importers fold the answers back into the comment they belong to instead of listing them beside it. `commentIdAt(state)` is the comment the selection lies within, which a plugin tints heavier (`pm-comment-active`, skipping a resolved one) and `CommentsPane.svelte`, `ChangeBarLayer.svelte` and `ConnectorLayer.svelte` each mark their own half of. It compares **ranges**, not the marks at the caret: the mark is non-inclusive, so `$pos.marks()` is empty at either end of it — and a click on the pane's card selects exactly that range, which would then match nothing.
|
||||
- **`trackChanges.ts`** — the `insertion`/`deletion` marks plus the recording plugin. `appendTransaction` marks whatever a transaction added, and `adjacentInsertion` reuses the id of the run just before it when the author matches, so a typed sentence is one change and not one per keystroke. A delete never reaches the document: `filterTransaction` rejects it and dispatches a mark pass instead (the author's own pending insertion is removed for real, as both word processors do). A **replacement** — typing or pasting over a selection — goes the same way (`replacedRange`), the new content landing after the struck-out text it replaces; recorded as the insertion alone, its other half was simply dropped and a reject lost the original. Every transaction of our own carries the `RECORDING` meta — accept/reject included, or its `tr.delete` would be read as a fresh user deletion and marked up again. **Loading a document carries it too** (`App.svelte`'s `loadContent`, both for a new document and for an import): a `setContent` is one transaction that inserts the whole file, so without the meta an opened document arrives wholly marked as the reader's own insertion, underlined in the author colour. Both word processors record what is typed after an open, never the file. `revisions(doc)` merges adjacent runs of one id, so an export writes one change per id. The **author's colour** is a decoration, not the mark: a mark cannot know the document's other authors, so a plugin walks the revisions, hands out `REVISION_AUTHOR_COLORS` (LibreOffice's own author palette) in order of first appearance and paints `pm-rev-ins`/`-del` + `pm-rev-a<n>`. That span nests *inside* the mark's, so it carries the underline/strikethrough too — drawn by the ancestor it would take the fallback colour. Cached on the doc; the walk is the whole document and `decorations` runs on every view update. `acceptRevision(id)`/`rejectRevision(id)` apply every range of one change, which is what `RevisionsPane.svelte` lists as one row. `revisionIdAt(state)` is the change the selection lies within — the row the pane marks and the margin bar `ChangeBarLayer.svelte` draws heavier — compared by range for the same reason `commentIdAt` is.
|
||||
- **`trackChanges.ts`** — the `insertion`/`deletion` marks plus the recording plugin. `appendTransaction` marks whatever a transaction added, and `adjacentInsertion` reuses the id of the run just before it when the author matches, so a typed sentence is one change and not one per keystroke. A delete never reaches the document: `filterTransaction` rejects it and dispatches a mark pass instead (the author's own pending insertion is removed for real, as both word processors do). A **replacement** — typing or pasting over a selection — goes the same way (`replacedRange`), the new content landing after the struck-out text it replaces; recorded as the insertion alone, its other half was simply dropped and a reject lost the original. Every transaction of our own carries the `RECORDING` meta — accept/reject included, or its `tr.delete` would be read as a fresh user deletion and marked up again. **Loading a document carries it too** (`App.svelte`'s `loadContent`, both for a new document and for an import): a `setContent` is one transaction that inserts the whole file, so without the meta an opened document arrives wholly marked as the reader's own insertion, underlined in the author colour. Both word processors record what is typed after an open, never the file. `revisions(doc)` merges adjacent runs of one id, so an export writes one change per id. The **author's colour** is a decoration, not the mark: a mark cannot know the document's other authors, so a plugin walks the revisions, hands out `REVISION_AUTHOR_COLORS` (LibreOffice's own author palette) in order of first appearance and paints `pm-rev-ins`/`-del` + `pm-rev-a<n>`. That span nests *inside* the mark's, so it carries the underline/strikethrough too — drawn by the ancestor it would take the fallback colour. Cached on the doc; the walk is the whole document and `decorations` runs on every view update. The `plain` option (from `storage/markup`) drops the decoration entirely where the display mode shows a finished text — and it is part of the cache key, or the set built before the switch would stand. `acceptRevision(id)`/`rejectRevision(id)` apply every range of one change, which is what `RevisionsPane.svelte` lists as one row. `revisionIdAt(state)` is the change the selection lies within — the row the pane marks and the margin bar `ChangeBarLayer.svelte` draws heavier — compared by range for the same reason `commentIdAt` is.
|
||||
- **`placeholderField.ts`** — a template placeholder, an inline atom drawn gray as ‹label›: a click selects the whole field (NodeSelection) and typing replaces it; Tab/Shift-Tab from a selected field jump to the next/previous one. Round-trips to ODF `<text:placeholder>` and a tagged DOCX `w:sdt` content control.
|
||||
- **`caption.ts`** — a caption is an ordinary paragraph in the `Caption` style (LibreOffice's, probed: 10pt italic, 0.212cm above and below) holding one `sequenceField` atom, its running number. `sequenceFields(doc)` counts one counter per category in document order; the cached `number` attr is what an export writes, and an `appendTransaction` (off the undo stack) plus an `onCreate` pass keep it true — a file's own cached numbers are unreadable wherever the format is not arabic. The counter's name differs per format: ODF `Illustration`/`Table`, Word `Figure`/`Table`, and `ODF_SEQ_CATEGORY` reads both back. The caption paragraph is **boxed to the frame it captions** (`captionPlacement`), so it stands under the picture instead of at the margin: LibreOffice frames picture and caption together, and a loose paragraph carries that over as `indent`/`indentRight` — the frame's own span in the column, measured (`frameSpanCm`), since that span comes from the wrap, the offset and the column, not from one attr. The indent is also what lifts the caption clear of a **side-wrapped** frame: a line with no room beside a float drops below it. It is centred in that box only under a frame that is itself centred; a block with no frame at all has only its own alignment to pass on. `getFrameDebug` reports the same two spans for the debug dump. `CaptionDialog.svelte` (References tab) owns the insert.
|
||||
- **`ruby.ts`** — a reading printed over its base (LibreOffice's Format ▸ Asian Phonetic Guide, Word's Phonetic Guide). An inline **atom** carrying both halves, because that is what both formats write: one element holding a base and a reading, edited as a unit (`RubyDialog.svelte`). Rendered as a real `<ruby>`, so the browser sets it. Alignment and position are both products' defaults and are not offered.
|
||||
|
||||
@@ -159,11 +159,11 @@ function apply(state: EditorState, tr: Transaction, list: Revision[], accept: bo
|
||||
tr.setMeta(RECORDING, true);
|
||||
}
|
||||
|
||||
export const TrackChanges = Extension.create<{ recording: () => boolean; author: () => string }>({
|
||||
export const TrackChanges = Extension.create<{ recording: () => boolean; author: () => string; plain: () => boolean }>({
|
||||
name: 'trackChanges',
|
||||
|
||||
addOptions() {
|
||||
return { recording: () => false, author: () => '' };
|
||||
return { recording: () => false, author: () => '', plain: () => false };
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
@@ -190,19 +190,22 @@ export const TrackChanges = Extension.create<{ recording: () => boolean; author:
|
||||
// The mark cannot know the document's other authors, so the colour is a decoration:
|
||||
// one class per palette slot, keyed by where the author first appears. Cached on the
|
||||
// doc — the walk is the whole document, and this runs on every view update.
|
||||
let cache: { doc: PMNode; set: DecorationSet } | null = null;
|
||||
let cache: { doc: PMNode; plain: boolean; set: DecorationSet } | null = null;
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
decorations(state) {
|
||||
if (cache?.doc === state.doc) return cache.set;
|
||||
const list = revisions(state.doc);
|
||||
// `plain` is in the key: a display mode changes what this draws without
|
||||
// touching the document, and the cached set would otherwise stand.
|
||||
const plain = options.plain();
|
||||
if (cache?.doc === state.doc && cache.plain === plain) return cache.set;
|
||||
const list = plain ? [] : revisions(state.doc);
|
||||
const order = authorColorIndex(list);
|
||||
const set = DecorationSet.create(state.doc, list.map((r) =>
|
||||
Decoration.inline(r.from, r.to, {
|
||||
class: `pm-rev-${r.kind === 'insertion' ? 'ins' : 'del'} pm-rev-a${order.get(r.author) ?? 0}`,
|
||||
})));
|
||||
cache = { doc: state.doc, set };
|
||||
cache = { doc: state.doc, plain, set };
|
||||
return set;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
- **`trackChanges.svelte.ts`** — whether the **document** records revisions (`edentext-record-changes`, the key removed when off, as it is by default in both word processors). It travels with the file — ODF `text:track-changes` on the change registry, Word `w:trackRevisions` in settings.xml (both probed against soffice) — so an import adopts the file's flag and a new document starts off; only the open document's state lives under the key. A reactive singleton because the extension reads it on every transaction while the Review tab flips it; the recorded changes themselves live in the document, on the marks.
|
||||
- **`lineNumbering.ts`** — numbers in the left margin (`edentext-line-numbering`, the key removed when off, as it is by default in both word processors). ODF keeps one `<text:linenumbering-configuration>` in `office:styles`, Word a `<w:lnNumType>` per `w:sectPr` (probed). `LineNumberLayer.svelte` measures the lines: CSS exposes no line boxes, so a Range per block is taken and its distinct client-rect tops are the lines.
|
||||
- **`printMarkup.svelte.ts`** — whether a printout carries the review markup (`edentext-print-markup`, the key removed at its **on** default, so it is the off state that is stored — the switch both word processors offer). A reactive singleton like `trackChanges.svelte.ts`: the Review tab flips it, all three export paths read it. What it governs lives in `export/reviewPrint.ts`.
|
||||
- **`markup.svelte.ts`** — the whole review view. The mode (`edentext-markup-mode`, absent at `all`) is Word's four steps — *all* (every change marked up), *simple* and *none* (the final text, the second without bars, cards or lists), *original* (the text before the changes). Per kind it holds whether it shows (`edentext-markup-comments`, `edentext-markup-changes`) and **where** its cards go (`edentext-markup-comment-place`, `edentext-markup-change-place`, absent at `balloons`) — beside the line or in the pane, which is why no separate pane flag exists any more. The two are tied: picking a mode carries both kinds with it, and switching a kind on lifts a mode that hides everything back to *all*, so no button is left claiming what the page does not show. `markupView()` folds it into what the page and the layers act on, read once instead of re-derived at every call site; `markupAttrs()` is the same view as `.paper` attributes, for the print path that builds its page from scratch. Both formats keep the choice out of the file — it is a view, like zoom.
|
||||
- **`foldMarks.ts`** — fold + punch marks in the left margin (`edentext-fold-marks`, the key removed when off; neither word processor prints any by default). The DIN 5008 positions (87/192mm folds, 148.5mm punch) are this module's constants; `FoldMarkLayer.svelte` draws them per page — the folds on **both** edges (both hands find the crease), the punch mark only left, where the pages are filed. They export as named lines (`EdenFoldMark*`) in the master page's header — the one place that repeats on every page, where LibreOffice's own DIN templates keep them — ODF as page-relative `<draw:line>`s, DOCX as VML `<v:line>`s; both importers read the names back into the flag and never into the zone's content.
|
||||
- **`pageOrientation.ts`** — `'portrait' | 'landscape'`. `applyOrientationVars` sets `--user-page-{width,height}`; landscape swaps the A4 dimensions (matching odf-kit's automatic swap).
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// How much markup the page shows (Word's "Display for review") and, per kind, whether it
|
||||
// shows at all and where its cards go. Reactive singletons like printMarkup.svelte.ts:
|
||||
// the Review tab flips them, the page CSS, the review layers and both panes read them.
|
||||
|
||||
export type MarkupMode = 'all' | 'simple' | 'none' | 'original';
|
||||
/** Beside the line the annotation belongs to, or listed in the pane next to the page. */
|
||||
export type MarkupPlace = 'balloons' | 'pane';
|
||||
|
||||
const MODE_KEY = 'edentext-markup-mode';
|
||||
const COMMENTS_KEY = 'edentext-markup-comments';
|
||||
const CHANGES_KEY = 'edentext-markup-changes';
|
||||
const COMMENT_PLACE_KEY = 'edentext-markup-comment-place';
|
||||
const CHANGE_PLACE_KEY = 'edentext-markup-change-place';
|
||||
|
||||
export const MARKUP_MODES: MarkupMode[] = ['all', 'simple', 'none', 'original'];
|
||||
export const MARKUP_PLACES: MarkupPlace[] = ['balloons', 'pane'];
|
||||
|
||||
const storedMode = localStorage.getItem(MODE_KEY) as MarkupMode | null;
|
||||
const place = (key: string): MarkupPlace => (localStorage.getItem(key) === 'pane' ? 'pane' : 'balloons');
|
||||
|
||||
let mode = $state<MarkupMode>(storedMode && MARKUP_MODES.includes(storedMode) ? storedMode : 'all');
|
||||
let comments = $state(localStorage.getItem(COMMENTS_KEY) !== 'false');
|
||||
let changes = $state(localStorage.getItem(CHANGES_KEY) !== 'false');
|
||||
let commentAt = $state<MarkupPlace>(place(COMMENT_PLACE_KEY));
|
||||
let changeAt = $state<MarkupPlace>(place(CHANGE_PLACE_KEY));
|
||||
|
||||
/** Whether a mode has anything to show — the two that read as a finished text have not. */
|
||||
const marks = (m: MarkupMode) => m === 'all' || m === 'simple';
|
||||
|
||||
function store(key: string, value: string, isDefault: boolean): void {
|
||||
if (isDefault) localStorage.removeItem(key);
|
||||
else localStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
export function markupMode(): MarkupMode { return mode; }
|
||||
|
||||
// The mode is the coarse switch over both kinds, so it carries them with it: picking a
|
||||
// view that shows nothing leaves no button behind that claims otherwise.
|
||||
export function setMarkupMode(m: MarkupMode): void {
|
||||
mode = m;
|
||||
setShowComments(marks(m));
|
||||
setShowChanges(marks(m));
|
||||
store(MODE_KEY, m, m === 'all');
|
||||
}
|
||||
|
||||
export function showComments(): boolean { return comments; }
|
||||
|
||||
export function setShowComments(on: boolean): void {
|
||||
comments = on;
|
||||
store(COMMENTS_KEY, 'false', on);
|
||||
if (on) liftMode();
|
||||
}
|
||||
|
||||
export function showChanges(): boolean { return changes; }
|
||||
|
||||
export function setShowChanges(on: boolean): void {
|
||||
changes = on;
|
||||
store(CHANGES_KEY, 'false', on);
|
||||
if (on) liftMode();
|
||||
}
|
||||
|
||||
// Asking for a kind in a view that hides everything means asking for the full view, as
|
||||
// Word's Show comments does in No markup.
|
||||
function liftMode(): void {
|
||||
if (marks(mode)) return;
|
||||
mode = 'all';
|
||||
localStorage.removeItem(MODE_KEY);
|
||||
}
|
||||
|
||||
export function commentPlace(): MarkupPlace { return commentAt; }
|
||||
|
||||
export function setCommentPlace(p: MarkupPlace): void {
|
||||
commentAt = p;
|
||||
store(COMMENT_PLACE_KEY, p, p === 'balloons');
|
||||
}
|
||||
|
||||
export function changePlace(): MarkupPlace { return changeAt; }
|
||||
|
||||
export function setChangePlace(p: MarkupPlace): void {
|
||||
changeAt = p;
|
||||
store(CHANGE_PLACE_KEY, p, p === 'balloons');
|
||||
}
|
||||
|
||||
// Through markupView, so a stored flag can never outvote the mode that hides its kind.
|
||||
export const commentsInMargin = (): boolean => markupView().comments && commentAt === 'balloons';
|
||||
export const changesInMargin = (): boolean => markupView().changes && changeAt === 'balloons';
|
||||
export const commentsInPane = (): boolean => markupView().comments && commentAt === 'pane';
|
||||
export const changesInPane = (): boolean => markupView().changes && changeAt === 'pane';
|
||||
|
||||
/** What the mode and the two kinds come to: the one truth for the layers and the page. */
|
||||
export type MarkupView = {
|
||||
/** The deleted text is out — the text as it would read. */
|
||||
hideDeletions: boolean;
|
||||
/** The inserted text is out — the text as it read before. */
|
||||
hideInsertions: boolean;
|
||||
/** No underline, strike or author colour on what is left. */
|
||||
plainRevisions: boolean;
|
||||
comments: boolean;
|
||||
changes: boolean;
|
||||
};
|
||||
|
||||
export function markupView(): MarkupView {
|
||||
// Only the full view leaves a change marked up; the other three show a finished text.
|
||||
const marked = mode === 'all' && changes;
|
||||
return {
|
||||
hideDeletions: mode !== 'original' && !marked,
|
||||
hideInsertions: mode === 'original',
|
||||
plainRevisions: !marked,
|
||||
comments: comments && marks(mode),
|
||||
changes: changes && marks(mode),
|
||||
};
|
||||
}
|
||||
|
||||
/** The same view as attributes, for a page the print paths build from scratch. */
|
||||
export function markupAttrs(): string {
|
||||
const v = markupView();
|
||||
return [
|
||||
v.hideDeletions && 'data-hide-deletions',
|
||||
v.hideInsertions && 'data-hide-insertions',
|
||||
v.plainRevisions && 'data-plain-markup',
|
||||
!v.comments && 'data-hide-comments',
|
||||
].filter(Boolean).map((a) => ` ${a}`).join('');
|
||||
}
|
||||
@@ -1083,6 +1083,22 @@
|
||||
.tiptap .pm-rev-a7 { color: #35556b; }
|
||||
.tiptap .pm-rev-a8 { color: #d17600; }
|
||||
|
||||
/* Word's display modes (storage/markup.svelte.ts). The document keeps every change —
|
||||
this only takes it off the page, so a final text or the original one can be read. */
|
||||
.paper[data-hide-deletions] .tiptap .pm-deletion,
|
||||
.paper[data-hide-insertions] .tiptap .pm-insertion { display: none; }
|
||||
|
||||
/* What is left reads as plain text: the decoration that carries underline, strike and
|
||||
author colour is off in these modes too (trackChanges.ts), the mark's tint is not. */
|
||||
.paper[data-plain-markup] .tiptap .pm-insertion,
|
||||
.paper[data-plain-markup] .tiptap .pm-deletion { color: inherit; }
|
||||
|
||||
.paper[data-hide-comments] .tiptap .pm-comment,
|
||||
.paper[data-hide-comments] .tiptap .pm-comment-active {
|
||||
background: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Print styles: proper A4 pagination */
|
||||
@media print {
|
||||
.editor {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
markupView, markupMode, setMarkupMode, setShowChanges, setShowComments, markupAttrs,
|
||||
showChanges, showComments, commentsInMargin, commentsInPane, setCommentPlace,
|
||||
} from '../../src/lib/storage/markup.svelte';
|
||||
|
||||
// Word's two questions crossed: the display mode decides how much of a change the page
|
||||
// shows, the two checks which kind shows at all.
|
||||
|
||||
describe('the markup view', () => {
|
||||
beforeEach(() => {
|
||||
setMarkupMode('all');
|
||||
setShowChanges(true);
|
||||
setShowComments(true);
|
||||
});
|
||||
|
||||
it('shows every change and comment in the full view', () => {
|
||||
expect(markupView()).toEqual({
|
||||
hideDeletions: false, hideInsertions: false, plainRevisions: false,
|
||||
comments: true, changes: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the final text under simple and no markup', () => {
|
||||
for (const mode of ['simple', 'none'] as const) {
|
||||
setMarkupMode(mode);
|
||||
const v = markupView();
|
||||
expect([v.hideDeletions, v.hideInsertions, v.plainRevisions]).toEqual([true, false, true]);
|
||||
}
|
||||
});
|
||||
|
||||
it('drops bars, balloons and lists where nothing is marked up', () => {
|
||||
setMarkupMode('simple');
|
||||
expect(markupView()).toMatchObject({ comments: true, changes: true });
|
||||
setMarkupMode('none');
|
||||
expect(markupView()).toMatchObject({ comments: false, changes: false });
|
||||
setMarkupMode('original');
|
||||
expect(markupView()).toMatchObject({ comments: false, changes: false });
|
||||
});
|
||||
|
||||
it('takes the inserted text off the page in the original view', () => {
|
||||
setMarkupMode('original');
|
||||
expect(markupView()).toMatchObject({ hideInsertions: true, hideDeletions: false, plainRevisions: true });
|
||||
});
|
||||
|
||||
it('carries both kinds with the mode, and a kind lifts a mode that hides it', () => {
|
||||
setMarkupMode('none');
|
||||
expect([showComments(), showChanges()]).toEqual([false, false]);
|
||||
setMarkupMode('all');
|
||||
expect([showComments(), showChanges()]).toEqual([true, true]);
|
||||
setMarkupMode('original');
|
||||
setShowComments(true);
|
||||
expect(markupMode()).toBe('all');
|
||||
});
|
||||
|
||||
it('reads an unchecked kind as its own hiding, as the mode does', () => {
|
||||
setShowChanges(false);
|
||||
expect(markupView()).toMatchObject({ hideDeletions: true, plainRevisions: true, changes: false });
|
||||
setShowChanges(true);
|
||||
setShowComments(false);
|
||||
expect(markupView()).toMatchObject({ comments: false, changes: true, hideDeletions: false });
|
||||
});
|
||||
|
||||
it('puts a kind where its own switch says, and nowhere while it is hidden', () => {
|
||||
expect([commentsInMargin(), commentsInPane()]).toEqual([true, false]);
|
||||
setCommentPlace('pane');
|
||||
expect([commentsInMargin(), commentsInPane()]).toEqual([false, true]);
|
||||
setMarkupMode('none');
|
||||
expect([commentsInMargin(), commentsInPane()]).toEqual([false, false]);
|
||||
setCommentPlace('balloons');
|
||||
});
|
||||
|
||||
it('writes no attribute for the view that shows everything', () => {
|
||||
expect(markupAttrs()).toBe('');
|
||||
setMarkupMode('none');
|
||||
expect(markupAttrs()).toBe(' data-hide-deletions data-plain-markup data-hide-comments');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user