feat: a reference finds the text it points at, and follows it
Bookmarks are a mark, not a point: both formats store a range, and only a range lets a REF recompute its text. A cross-reference caches its display like a Word field and resolves live on each pagination settle — the bookmark's text, or the page it renders on — keeping the cached value when the target is gone. Ctrl+click jumps to it, internal #name links too. ODF splices its sentinels into the run text so cells and custom paragraphs need no separate emitter; DOCX needs no post-pass at all.
This commit is contained in:
1 parent
46ae0211af
commit
78e35cef2c
22 files changed
+1131
-42
No files matched your search
@@ -0,0 +1,153 @@
|
||||
<script lang="ts">
|
||||
import { t } from '../i18n/i18n.svelte';
|
||||
// Popover to name a bookmark for the current selection, and to jump to or delete the
|
||||
// ones the document already has. The parent owns `open` and applies the name.
|
||||
let {
|
||||
open,
|
||||
names = [],
|
||||
initialName = '',
|
||||
onApply,
|
||||
onRemove,
|
||||
onGoTo,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
names?: string[];
|
||||
initialName?: string;
|
||||
onApply: (name: string) => void;
|
||||
onRemove: (name: string) => void;
|
||||
onGoTo: (name: string) => void;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
let name = $state('');
|
||||
let input = $state<HTMLInputElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
name = initialName;
|
||||
queueMicrotask(() => input?.focus());
|
||||
queueMicrotask(() => input?.select());
|
||||
}
|
||||
});
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); onApply(name); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); onClose(); }
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div class="bm-dialog" role="dialog" aria-label={t().bookmark.dialogLabel}>
|
||||
<input
|
||||
bind:this={input}
|
||||
bind:value={name}
|
||||
type="text"
|
||||
placeholder={t().bookmark.namePlaceholder}
|
||||
onkeydown={onKeydown}
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="bm-list-label">{t().bookmark.existing}</div>
|
||||
<div class="bm-list">
|
||||
{#each names as n (n)}
|
||||
<div class="bm-row">
|
||||
<button class="bm-name" onclick={() => onGoTo(n)} title={n}>{n}</button>
|
||||
<button class="bm-drop" onclick={() => onRemove(n)} aria-label={`${t().common.remove} ${n}`}>×</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="bm-empty">{t().bookmark.none}</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="bm-actions">
|
||||
<span class="bm-spacer"></span>
|
||||
<button class="bm-cancel" onclick={onClose}>{t().common.cancel}</button>
|
||||
<button class="bm-apply" onclick={() => onApply(name)} disabled={!name.trim()}>{t().common.apply}</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.bm-dialog {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 18rem;
|
||||
padding: 0.6rem;
|
||||
background: var(--color-toolbar-bg, #fff);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.bm-list-label { font-size: 0.72rem; opacity: 0.7; }
|
||||
|
||||
.bm-list {
|
||||
max-height: 9rem;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.bm-row { display: flex; align-items: center; }
|
||||
.bm-empty { padding: 0.35rem 0.5rem; font-size: 0.8rem; opacity: 0.6; }
|
||||
|
||||
.bm-name {
|
||||
flex: 1;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.8rem;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bm-drop {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #c0392b;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1;
|
||||
padding: 0 0.45rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bm-row:hover { background: var(--color-hover, rgba(0, 0, 0, 0.06)); }
|
||||
|
||||
.bm-actions { display: flex; align-items: center; gap: 0.4rem; }
|
||||
.bm-spacer { flex: 1; }
|
||||
|
||||
.bm-actions button {
|
||||
height: 1.8rem;
|
||||
padding: 0 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bm-actions button:hover:not(:disabled) { background: var(--color-hover, rgba(0, 0, 0, 0.06)); }
|
||||
.bm-apply { border-color: var(--color-accent, #1a56db) !important; color: var(--color-accent, #1a56db); }
|
||||
.bm-actions button:disabled { opacity: 0.5; cursor: default; }
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import { t } from '../i18n/i18n.svelte';
|
||||
// Popover to insert a reference to one of the document's bookmarks, showing either its
|
||||
// text or the page it sits on.
|
||||
let {
|
||||
open,
|
||||
names = [],
|
||||
onInsert,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
names?: string[];
|
||||
onInsert: (name: string, format: 'text' | 'page') => void;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
let name = $state('');
|
||||
let format = $state<'text' | 'page'>('text');
|
||||
let select = $state<HTMLSelectElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
name = names[0] ?? '';
|
||||
queueMicrotask(() => select?.focus());
|
||||
}
|
||||
});
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); if (name) onInsert(name, format); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); onClose(); }
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- tabindex so the whole popover can own Enter/Escape, wherever focus sits inside it -->
|
||||
<div class="xr-dialog" role="dialog" tabindex="-1" aria-label={t().bookmark.crossRefLabel} onkeydown={onKeydown}>
|
||||
<select bind:this={select} bind:value={name} size={Math.min(6, Math.max(2, names.length))}>
|
||||
{#each names as n (n)}
|
||||
<option value={n}>{n}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<label><input type="radio" bind:group={format} value="text" />{t().bookmark.formatText}</label>
|
||||
<label><input type="radio" bind:group={format} value="page" />{t().bookmark.formatPage}</label>
|
||||
<div class="xr-actions">
|
||||
<span class="xr-spacer"></span>
|
||||
<button class="xr-cancel" onclick={onClose}>{t().common.cancel}</button>
|
||||
<button class="xr-apply" onclick={() => onInsert(name, format)} disabled={!name}>{t().common.insert}</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.xr-dialog {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
width: 18rem;
|
||||
padding: 0.6rem;
|
||||
background: var(--color-toolbar-bg, #fff);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
select {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
label { display: flex; align-items: center; gap: 0.4rem; font-size: 0.8rem; }
|
||||
|
||||
.xr-actions { display: flex; align-items: center; gap: 0.4rem; }
|
||||
.xr-spacer { flex: 1; }
|
||||
|
||||
.xr-actions button {
|
||||
height: 1.8rem;
|
||||
padding: 0 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xr-actions button:hover:not(:disabled) { background: var(--color-hover, rgba(0, 0, 0, 0.06)); }
|
||||
.xr-apply { border-color: var(--color-accent, #1a56db) !important; color: var(--color-accent, #1a56db); }
|
||||
.xr-actions button:disabled { opacity: 0.5; cursor: default; }
|
||||
</style>
|
||||
@@ -24,6 +24,7 @@
|
||||
import { applyPageSizeVars, type PageFormat } from '../storage/pageFormat';
|
||||
import { DEFAULT_HF_DISTANCES, hfIsEmpty, type HfDoc, type HfZone, type HfDistances, type HfSet } from '../storage/headerFooter';
|
||||
import { FORCE_PAGE_RECALC, type TableBreakBand } from '../editor/extensions/pageBreaks';
|
||||
import { findBookmark } from '../editor/extensions/bookmark';
|
||||
import { recordTransaction, resetHistoryLog } from '../utils/historyLog.svelte';
|
||||
import { wheelZoomFactor } from '../utils/zoom';
|
||||
import { styleCss } from '../styles/styleSheet';
|
||||
@@ -705,11 +706,18 @@
|
||||
// they don't double up.
|
||||
attributes: { spellcheck: 'false' },
|
||||
// Ctrl/Cmd+click opens a hyperlink (a plain click just places the cursor).
|
||||
handleClick: (_view, _pos, event) => {
|
||||
handleClick: (view, _pos, event) => {
|
||||
if (!(event.metaKey || event.ctrlKey)) return false;
|
||||
const a = (event.target as HTMLElement | null)?.closest?.('a[href]') as HTMLAnchorElement | null;
|
||||
const href = a?.getAttribute('href');
|
||||
if (!href) return false;
|
||||
// An internal href targets a bookmark in this document, not a URL.
|
||||
if (href.startsWith('#')) {
|
||||
const found = findBookmark(view.state.doc, href.slice(1));
|
||||
if (!found) return true;
|
||||
editor?.chain().focus().setTextSelection({ from: found.from, to: found.to }).scrollIntoView().run();
|
||||
return true;
|
||||
}
|
||||
window.open(href, '_blank', 'noopener,noreferrer');
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -7,8 +7,12 @@
|
||||
import SpecialCharPicker from './SpecialCharPicker.svelte';
|
||||
import DateTimePicker from './DateTimePicker.svelte';
|
||||
import LinkDialog from './LinkDialog.svelte';
|
||||
import BookmarkDialog from './BookmarkDialog.svelte';
|
||||
import CrossRefDialog from './CrossRefDialog.svelte';
|
||||
import FormulaDialog from './FormulaDialog.svelte';
|
||||
import { OPEN_LINK_DIALOG_EVENT } from '../editor/extensions/link';
|
||||
import { OPEN_BOOKMARK_DIALOG_EVENT, bookmarkNames, findBookmark } from '../editor/extensions/bookmark';
|
||||
import { OPEN_CROSS_REF_DIALOG_EVENT } from '../editor/extensions/crossReference';
|
||||
import { EDIT_FORMULA_EVENT } from '../editor/extensions/formula';
|
||||
import {
|
||||
detectInstalledFonts,
|
||||
@@ -953,6 +957,63 @@
|
||||
return { destroy() { window.removeEventListener('mousedown', handler); } };
|
||||
}
|
||||
|
||||
// --- Bookmarks + cross-references (bookmark.ts / crossReference.ts) ---
|
||||
let bookmarkOpen = $state(false);
|
||||
let crossRefOpen = $state(false);
|
||||
let bmNames = $derived(tick >= 0 && editor && !hfActive ? bookmarkNames(editor.state.doc) : []);
|
||||
// A bookmark covers a range, so there has to be one selected.
|
||||
let hasSelection = $derived(tick >= 0 && !!editor && !editor.state.selection.empty);
|
||||
|
||||
function openBookmarkDialog() {
|
||||
if (!editor || hfActive || !hasSelection) return;
|
||||
bookmarkOpen = true;
|
||||
crossRefOpen = false;
|
||||
}
|
||||
|
||||
function applyBookmark(name: string) {
|
||||
bookmarkOpen = false;
|
||||
editor?.chain().focus().setBookmark(name).run();
|
||||
}
|
||||
|
||||
function goToBookmark(name: string) {
|
||||
const found = editor && findBookmark(editor.state.doc, name);
|
||||
if (!editor || !found) return;
|
||||
bookmarkOpen = false;
|
||||
editor.chain().focus().setTextSelection({ from: found.from, to: found.to }).scrollIntoView().run();
|
||||
}
|
||||
|
||||
function openCrossRefDialog() {
|
||||
if (!editor || hfActive || !bmNames.length) return;
|
||||
crossRefOpen = true;
|
||||
bookmarkOpen = false;
|
||||
}
|
||||
|
||||
function insertCrossRef(name: string, format: 'text' | 'page') {
|
||||
crossRefOpen = false;
|
||||
editor?.chain().focus().insertCrossRef({ name, format }).run();
|
||||
}
|
||||
|
||||
function bookmarkClickOutside(node: HTMLElement) {
|
||||
function handler(e: MouseEvent) {
|
||||
if (node.contains(e.target as Node)) return;
|
||||
bookmarkOpen = false;
|
||||
crossRefOpen = false;
|
||||
}
|
||||
window.addEventListener('mousedown', handler);
|
||||
return { destroy() { window.removeEventListener('mousedown', handler); } };
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const openBm = () => openBookmarkDialog();
|
||||
const openXr = () => openCrossRefDialog();
|
||||
window.addEventListener(OPEN_BOOKMARK_DIALOG_EVENT, openBm);
|
||||
window.addEventListener(OPEN_CROSS_REF_DIALOG_EVENT, openXr);
|
||||
return () => {
|
||||
window.removeEventListener(OPEN_BOOKMARK_DIALOG_EVENT, openBm);
|
||||
window.removeEventListener(OPEN_CROSS_REF_DIALOG_EVENT, openXr);
|
||||
};
|
||||
});
|
||||
|
||||
// Ctrl/Cmd+K (from the Link extension) opens the dialog.
|
||||
$effect(() => {
|
||||
const open = () => openLinkDialog();
|
||||
@@ -1689,6 +1750,47 @@
|
||||
onClose={() => (linkDialogOpen = false)}
|
||||
/>
|
||||
</div>
|
||||
<div class="link-wrap" use:bookmarkClickOutside>
|
||||
<button
|
||||
onclick={openBookmarkDialog}
|
||||
disabled={!!hfActive || !hasSelection}
|
||||
title={hfActive ? t().toolbarExpanded.bookmarkNotInHf : hasSelection ? t().toolbarExpanded.insertBookmark : t().toolbarExpanded.bookmarkNeedsSelection}
|
||||
aria-label={t().toolbarExpanded.insertBookmark}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={bookmarkOpen}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M4 2.5h8v11l-4-3-4 3v-11z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<BookmarkDialog
|
||||
open={bookmarkOpen}
|
||||
names={bmNames}
|
||||
onApply={applyBookmark}
|
||||
onRemove={(n) => editor?.chain().focus().removeBookmark(n).run()}
|
||||
onGoTo={goToBookmark}
|
||||
onClose={() => (bookmarkOpen = false)}
|
||||
/>
|
||||
<button
|
||||
onclick={openCrossRefDialog}
|
||||
disabled={!!hfActive || !bmNames.length}
|
||||
title={hfActive ? t().toolbarExpanded.bookmarkNotInHf : bmNames.length ? t().toolbarExpanded.insertCrossRef : t().toolbarExpanded.crossRefNeedsBookmark}
|
||||
aria-label={t().toolbarExpanded.insertCrossRef}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={crossRefOpen}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M2.5 4.5h5M2.5 8h5M2.5 11.5h3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
<path d="M9.5 8h4M11.5 6l2 2-2 2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<CrossRefDialog
|
||||
open={crossRefOpen}
|
||||
names={bmNames}
|
||||
onInsert={insertCrossRef}
|
||||
onClose={() => (crossRefOpen = false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-separator"></div>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { t } from '../i18n/i18n.svelte';
|
||||
import { withShortcut } from '../i18n/shortcut';
|
||||
import { shortcutHint } from './shortcuts';
|
||||
import { OPEN_LINK_DIALOG_EVENT } from './extensions/link';
|
||||
import { OPEN_BOOKMARK_DIALOG_EVENT, bookmarkNames } from './extensions/bookmark';
|
||||
import { OPEN_CROSS_REF_DIALOG_EVENT } from './extensions/crossReference';
|
||||
|
||||
// The right-click menu's contents, the usual text menu mapped onto this editor. Pure
|
||||
// data + closures — ContextMenu.svelte only renders it.
|
||||
@@ -90,6 +92,19 @@ export function buildContextMenu(editor: Editor, opts: { spell?: SpellSection }
|
||||
});
|
||||
}
|
||||
|
||||
entries.push({
|
||||
kind: 'item',
|
||||
label: m.insertBookmark,
|
||||
disabled: !hasSelection,
|
||||
run: () => window.dispatchEvent(new CustomEvent(OPEN_BOOKMARK_DIALOG_EVENT)),
|
||||
});
|
||||
entries.push({
|
||||
kind: 'item',
|
||||
label: m.insertCrossRef,
|
||||
disabled: !bookmarkNames(editor.state.doc).length,
|
||||
run: () => window.dispatchEvent(new CustomEvent(OPEN_CROSS_REF_DIALOG_EVENT)),
|
||||
});
|
||||
|
||||
entries.push(
|
||||
{ kind: 'sep' },
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ import Subscript from '@tiptap/extension-subscript';
|
||||
import Superscript from '@tiptap/extension-superscript';
|
||||
import Highlight from '@tiptap/extension-highlight';
|
||||
import { Link } from './extensions/link';
|
||||
import { Bookmark } from './extensions/bookmark';
|
||||
import { TextStyle, FontFamily, FontSize } from '@tiptap/extension-text-style';
|
||||
import { FontWeight } from './extensions/fontWeight';
|
||||
import { FontColor } from './extensions/fontColor';
|
||||
@@ -47,6 +48,7 @@ import { TableStyle } from './extensions/tableStyle';
|
||||
import { TrailingNode } from './extensions/trailingNode';
|
||||
import { Image } from './extensions/image';
|
||||
import { DateTimeField } from './extensions/dateTimeField';
|
||||
import { CrossReference } from './extensions/crossReference';
|
||||
import { Formula } from './extensions/formula';
|
||||
import { TextBox } from './extensions/textBox';
|
||||
import { Columns } from './extensions/columns';
|
||||
@@ -66,6 +68,9 @@ export const extensions = [
|
||||
UnderlineStyled,
|
||||
StrikeStyled,
|
||||
Link,
|
||||
// Named range of text, the target of a cross-reference or an internal link;
|
||||
// round-trips to ODF text:bookmark-start/-end and DOCX w:bookmarkStart/End.
|
||||
Bookmark,
|
||||
Subscript,
|
||||
Superscript,
|
||||
TextStyle,
|
||||
@@ -88,6 +93,9 @@ export const extensions = [
|
||||
// Inline date/time field (fixed or auto-updating); round-trips to ODF
|
||||
// <text:date>/<text:time> and DOCX DATE/TIME fields.
|
||||
DateTimeField,
|
||||
// Inline reference to a bookmark, showing its text or its page; kept live by its
|
||||
// node view. Round-trips to ODF <text:bookmark-ref> and DOCX REF/PAGEREF fields.
|
||||
CrossReference,
|
||||
// Mathematical formula; stores LaTeX, renders native MathML, round-trips to an ODF
|
||||
// embedded formula object and DOCX OMML. See docs/architecture/formulas.md.
|
||||
Formula,
|
||||
|
||||
@@ -12,7 +12,7 @@ Five topics are large enough to have their own deep-dive — read the file befor
|
||||
| `pageBreaks.ts`, `columns.ts`, `columnsFlow.ts` | `docs/architecture/pagination.md` |
|
||||
| `image.ts`, `textBox.ts` | `docs/architecture/frames.md` |
|
||||
| `table*.ts`, `tableStyle.ts` | `docs/architecture/tables.md` |
|
||||
| `textEffects.ts`, `indent.ts`, `tabStops.ts`, `listMarker.ts`, `paragraphBox.ts`, `dateTimeField.ts` | `docs/architecture/formatting.md` |
|
||||
| `textEffects.ts`, `indent.ts`, `tabStops.ts`, `listMarker.ts`, `paragraphBox.ts`, `dateTimeField.ts`, `bookmark.ts`, `crossReference.ts` | `docs/architecture/formatting.md` |
|
||||
| `formula.ts` | `docs/architecture/formulas.md` |
|
||||
| `paragraphStyle.ts`, `characterStyle.ts` | `src/lib/styles/CLAUDE.md` |
|
||||
|
||||
@@ -26,7 +26,8 @@ Five topics are large enough to have their own deep-dive — read the file befor
|
||||
- **`pageBreak.ts`** — the text-flow attrs of a paragraph/heading: `breakBefore: 'page'`, plus `keepNext`/`keepLines` (see `docs/architecture/pagination.md`). Owns `Mod-Enter`.
|
||||
- **`formattingMarks.ts`** (`FormattingMarks`) — decorations marking spaces (`·`) and tabs (`→`) when `.paper.show-formatting-marks` is set.
|
||||
- **`bulletList.ts`** / **`orderedList.ts`** — the TipTap lists plus a marker-type attr: `setBulletChar` (innermost list, `null` = default cycle) and the ordered numbering cycle (`utils/orderedListTypes.ts`, multilevel targets the outermost list).
|
||||
- **`link.ts`** — hyperlink mark; owns `Mod-k`, which fires the event `Toolbar.svelte` listens for to open the link dialog.
|
||||
- **`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.
|
||||
- **`bookmark.ts`** / **`crossReference.ts`** — a named range of text (a mark) and the live references to it (`REF`/`PAGEREF`). `ToolbarExpanded.svelte` owns both dialogs, as it does the link one.
|
||||
- **`searchReplace.ts`** — Find & Replace: decorations highlight all matches, commands navigate/replace. Touches the document only for actual replacements.
|
||||
- **`spellCheck.ts`** — decorations from `spell/controller.ts`; `spellErrorAt` feeds the context menu.
|
||||
- **`tableOfContents.ts`** — a generated TOC: block atom listing every heading (levels 1–5).
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Mark, mergeAttributes } from '@tiptap/core';
|
||||
import type { Node as PMNode, Mark as PMMark } from '@tiptap/pm/model';
|
||||
|
||||
// A bookmark: a named range of text, the target a cross-reference or an internal link
|
||||
// points at. A mark, not a point node — both formats store a range, and a REF field
|
||||
// needs that range to recompute its text. Round-trips to ODF text:bookmark-start/-end
|
||||
// and DOCX w:bookmarkStart/w:bookmarkEnd.
|
||||
|
||||
export type BookmarkRef = { name: string; from: number; to: number; text: string };
|
||||
|
||||
// Fired by the context menu; ToolbarExpanded owns the dialog (as it does for links).
|
||||
export const OPEN_BOOKMARK_DIALOG_EVENT = 'odf-open-bookmark-dialog';
|
||||
|
||||
// Bookmark names as Word writes them: letters, digits, underscore and dot, never
|
||||
// leading with a digit, 40 chars at most. Sanitizing here also keeps the ODF export's
|
||||
// sentinel pass free of XML escaping.
|
||||
export function sanitizeBookmarkName(raw: string): string {
|
||||
const cleaned = raw.trim().replace(/[^A-Za-z0-9_.]/g, '_').slice(0, 40);
|
||||
if (!cleaned) return '';
|
||||
return /^[0-9]/.test(cleaned) ? `_${cleaned}`.slice(0, 40) : cleaned;
|
||||
}
|
||||
|
||||
export function bookmarkNameOf(marks: readonly PMMark[]): string | null {
|
||||
const name = marks.find((m) => m.type.name === 'bookmark')?.attrs?.name;
|
||||
return typeof name === 'string' && name ? name : null;
|
||||
}
|
||||
|
||||
// Every bookmark range in document order. Adjacent text nodes sharing a name merge into
|
||||
// one range; a name that reappears later (a paste, or across a paragraph boundary) gets
|
||||
// its own entry and every lookup below takes the first.
|
||||
// ponytail: no cross-paragraph merge — the DOCX exporter can't write one either.
|
||||
export function bookmarks(doc: PMNode): BookmarkRef[] {
|
||||
const out: BookmarkRef[] = [];
|
||||
let last: BookmarkRef | null = null;
|
||||
doc.descendants((node, pos) => {
|
||||
if (!node.isText) return true;
|
||||
const name = bookmarkNameOf(node.marks);
|
||||
if (!name) { last = null; return false; }
|
||||
if (last && last.name === name && last.to === pos) {
|
||||
last.to = pos + node.nodeSize;
|
||||
last.text += node.text ?? '';
|
||||
return false;
|
||||
}
|
||||
last = { name, from: pos, to: pos + node.nodeSize, text: node.text ?? '' };
|
||||
out.push(last);
|
||||
return false;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export function findBookmark(doc: PMNode, name: string): BookmarkRef | null {
|
||||
return bookmarks(doc).find((b) => b.name === name) ?? null;
|
||||
}
|
||||
|
||||
// Unique names, document order — the pick list of both dialogs.
|
||||
export function bookmarkNames(doc: PMNode): string[] {
|
||||
return [...new Set(bookmarks(doc).map((b) => b.name))];
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
bookmark: {
|
||||
setBookmark: (name: string) => ReturnType;
|
||||
unsetBookmark: () => ReturnType;
|
||||
removeBookmark: (name: string) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const Bookmark = Mark.create({
|
||||
name: 'bookmark',
|
||||
// The range is fixed: typing at either end stays outside it, as it does in Word.
|
||||
inclusive: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
name: {
|
||||
default: '',
|
||||
parseHTML: (el) => el.getAttribute('data-bookmark') || '',
|
||||
renderHTML: (attrs) => ({ 'data-bookmark': String(attrs.name ?? '') }),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'span[data-bookmark]' }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['span', mergeAttributes(HTMLAttributes), 0];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setBookmark:
|
||||
(name) =>
|
||||
({ commands }) => {
|
||||
const clean = sanitizeBookmarkName(name);
|
||||
return clean ? commands.setMark(this.name, { name: clean }) : false;
|
||||
},
|
||||
unsetBookmark:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
commands.unsetMark(this.name),
|
||||
// Drop a bookmark by name from wherever it sits — the delete affordance in the
|
||||
// dialog, which works without moving the selection there first.
|
||||
removeBookmark:
|
||||
(name) =>
|
||||
({ tr, state, dispatch }) => {
|
||||
const ranges = bookmarks(state.doc).filter((b) => b.name === name);
|
||||
if (!ranges.length) return false;
|
||||
if (dispatch) {
|
||||
for (const r of ranges) tr.removeMark(r.from, r.to, state.schema.marks.bookmark);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import type { Node as PMNode } from '@tiptap/pm/model';
|
||||
import { findBookmark } from './bookmark';
|
||||
import { readVerticalMargins, pageOfElement } from './pageBreaks';
|
||||
|
||||
// A cross-reference: an inline atom showing either the text of a bookmark or the page it
|
||||
// sits on, kept live by the node view the way the TOC keeps its page numbers. Round-trips
|
||||
// to ODF text:bookmark-ref and DOCX REF/PAGEREF fields.
|
||||
|
||||
export type CrossRefFormat = 'text' | 'page';
|
||||
|
||||
// Fired by the context menu; ToolbarExpanded owns the dialog (as it does for links).
|
||||
export const OPEN_CROSS_REF_DIALOG_EVENT = 'odf-open-cross-ref-dialog';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
crossReference: {
|
||||
insertCrossRef: (opts: { name: string; format: CrossRefFormat }) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const CrossReference = Node.create({
|
||||
name: 'crossRef',
|
||||
group: 'inline',
|
||||
inline: true,
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
name: {
|
||||
default: '',
|
||||
parseHTML: (el) => el.getAttribute('data-cross-ref') || '',
|
||||
renderHTML: (attrs) => ({ 'data-cross-ref': String(attrs.name ?? '') }),
|
||||
},
|
||||
format: {
|
||||
default: 'text' as CrossRefFormat,
|
||||
parseHTML: (el) => (el.getAttribute('data-format') === 'page' ? 'page' : 'text'),
|
||||
renderHTML: (attrs) => ({ 'data-format': attrs.format === 'page' ? 'page' : 'text' }),
|
||||
},
|
||||
// The last resolved display text, persisted like a Word field's cached result: it
|
||||
// is what a reference to a deleted bookmark keeps showing.
|
||||
text: {
|
||||
default: '',
|
||||
parseHTML: (el) => el.getAttribute('data-text') ?? el.textContent ?? '',
|
||||
renderHTML: (attrs) => ({ 'data-text': String(attrs.text ?? '') }),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'span[data-cross-ref]' }];
|
||||
},
|
||||
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
return ['span', mergeAttributes(HTMLAttributes), String(node.attrs.text ?? '')];
|
||||
},
|
||||
|
||||
renderText({ node }) {
|
||||
return String(node.attrs.text ?? '');
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertCrossRef:
|
||||
({ name, format }) =>
|
||||
({ commands, state }) => {
|
||||
if (!name) return false;
|
||||
const found = findBookmark(state.doc, name);
|
||||
// Adopt the cursor's marks so the atom renders like the surrounding text.
|
||||
const marks = (state.storedMarks ?? state.selection.$to.marks())
|
||||
.filter((m) => m.type.name !== 'bookmark')
|
||||
.map((m) => ({ type: m.type.name, attrs: m.attrs }));
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: { name, format, text: format === 'page' ? '1' : (found?.text ?? name) },
|
||||
...(marks.length ? { marks } : {}),
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ editor, getPos }) => new CrossRefView(editor, getPos as () => number);
|
||||
},
|
||||
});
|
||||
|
||||
// Node view: repaints from the live bookmark on each pagination settle (pm-pagecount on
|
||||
// the .paper ancestor) and on doc change, writing the resolved text back to the attr —
|
||||
// guarded against a loop by comparing with what the node already holds.
|
||||
class CrossRefView {
|
||||
dom: HTMLElement;
|
||||
private editor: Editor;
|
||||
private getPos: () => number;
|
||||
private scheduled = false;
|
||||
private paper: HTMLElement | null = null;
|
||||
private onPageCount = () => this.schedule();
|
||||
|
||||
constructor(editor: Editor, getPos: () => number) {
|
||||
this.editor = editor;
|
||||
this.getPos = getPos;
|
||||
|
||||
this.dom = document.createElement('span');
|
||||
this.dom.className = 'cross-ref';
|
||||
this.dom.setAttribute('contenteditable', 'false');
|
||||
this.paint(this.node());
|
||||
this.dom.addEventListener('mousedown', (ev) => {
|
||||
if (!(ev.metaKey || ev.ctrlKey)) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.goTo();
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.paper = this.dom.closest('.paper') as HTMLElement | null;
|
||||
this.paper?.addEventListener('pm-pagecount', this.onPageCount);
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
private schedule(): void {
|
||||
if (this.scheduled) return;
|
||||
this.scheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this.scheduled = false;
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
private node(): PMNode | null {
|
||||
const pos = this.getPos();
|
||||
const node = typeof pos === 'number' ? this.editor.state.doc.nodeAt(pos) : null;
|
||||
return node?.type.name === 'crossRef' ? node : null;
|
||||
}
|
||||
|
||||
// The reference's text now: the bookmark's own text, or the page it renders on. A
|
||||
// bookmark that no longer exists leaves the cached text alone (Word shows its cached
|
||||
// result too until the field is updated).
|
||||
private resolve(node: PMNode): string {
|
||||
const found = findBookmark(this.editor.state.doc, String(node.attrs.name ?? ''));
|
||||
if (!found) return String(node.attrs.text ?? '');
|
||||
if (node.attrs.format !== 'page') return found.text;
|
||||
const at = this.editor.view.domAtPos(found.from).node;
|
||||
const el = (at.nodeType === 1 ? at : at.parentElement) as HTMLElement | null;
|
||||
if (!el) return String(node.attrs.text ?? '');
|
||||
const { cycle } = readVerticalMargins(this.editor.view.dom as HTMLElement);
|
||||
return String(pageOfElement(this.editor.view, el, cycle));
|
||||
}
|
||||
|
||||
// Mirror what renderHTML would emit, so the DOM reads the same with or without the view.
|
||||
private paint(node: PMNode | null, text = String(node?.attrs?.text ?? '')): void {
|
||||
this.dom.dataset.crossRef = String(node?.attrs?.name ?? '');
|
||||
this.dom.dataset.format = node?.attrs?.format === 'page' ? 'page' : 'text';
|
||||
this.dom.dataset.text = text;
|
||||
this.dom.textContent = text;
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
if (this.editor.isDestroyed || !this.dom.isConnected) return;
|
||||
const node = this.node();
|
||||
if (!node) return;
|
||||
const text = this.resolve(node);
|
||||
this.paint(node, text);
|
||||
if (text === node.attrs.text) return;
|
||||
const pos = this.getPos();
|
||||
if (typeof pos !== 'number') return;
|
||||
this.editor.view.dispatch(
|
||||
this.editor.state.tr.setNodeAttribute(pos, 'text', text).setMeta('addToHistory', false),
|
||||
);
|
||||
}
|
||||
|
||||
// Scroll the bookmark into view and drop the cursor into it.
|
||||
private goTo(): void {
|
||||
const node = this.node();
|
||||
const found = node && findBookmark(this.editor.state.doc, String(node.attrs.name ?? ''));
|
||||
if (!found) return;
|
||||
const at = this.editor.view.domAtPos(found.from).node;
|
||||
const el = (at.nodeType === 1 ? at : at.parentElement) as HTMLElement | null;
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
this.editor.chain().focus().setTextSelection({ from: found.from, to: found.to }).run();
|
||||
}
|
||||
|
||||
update(node: PMNode): boolean {
|
||||
if (node.type.name !== 'crossRef') return false;
|
||||
this.schedule();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Own only the modifier-click that navigates; a plain click still selects the atom so
|
||||
// it can be deleted.
|
||||
stopEvent(event: Event): boolean {
|
||||
const e = event as MouseEvent;
|
||||
return event.type.startsWith('mouse') && (e.metaKey || e.ctrlKey);
|
||||
}
|
||||
|
||||
ignoreMutation(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.paper?.removeEventListener('pm-pagecount', this.onPageCount);
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,20 @@ export function readVerticalMargins(dom: HTMLElement): VMargins {
|
||||
};
|
||||
}
|
||||
|
||||
// Rendered page of an element: its offsetTop within the editor DOM (summed up the
|
||||
// offsetParent chain, spacers included) divided by the page cycle. The TOC and every
|
||||
// cross-reference resolve their page numbers with it.
|
||||
export function pageOfElement(view: EditorView, el: HTMLElement, cycle: number): number {
|
||||
const tiptap = view.dom as HTMLElement;
|
||||
let top = 0;
|
||||
let n: HTMLElement | null = el;
|
||||
while (n && n !== tiptap) {
|
||||
top += n.offsetTop;
|
||||
n = n.offsetParent as HTMLElement | null;
|
||||
}
|
||||
return Math.max(1, Math.floor(top / cycle) + 1);
|
||||
}
|
||||
|
||||
function pageContentStart(page: number, marginTop: number, cycle: number): number {
|
||||
return (page - 1) * cycle + marginTop;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import type { Node as PMNode } from '@tiptap/pm/model';
|
||||
import { MAX_HEADING_LEVEL } from '../../export/odt';
|
||||
import { readVerticalMargins, pageOfElement } from './pageBreaks';
|
||||
|
||||
// A generated table of contents: a block atom listing every heading (levels 1–5) with its
|
||||
// live page number. The node view regenerates entries from the headings + pagination and
|
||||
@@ -9,10 +10,6 @@ import { MAX_HEADING_LEVEL } from '../../export/odt';
|
||||
|
||||
export type TocEntry = { text: string; level: number; page: number };
|
||||
|
||||
// Mirrors pageBreaks.ts PAGE_GAP + Editor.svelte getCycle(): the rendered page of a
|
||||
// heading is its offsetTop within .tiptap (spacers included) divided by the cycle.
|
||||
const PAGE_GAP = 20;
|
||||
const FALLBACK_PAGE_HEIGHT = 1123;
|
||||
// The heading above the entries. An imported TOC keeps the one its file used
|
||||
// ("Inhalt", "Sommaire", …); a freshly inserted one takes this.
|
||||
const TITLE = 'Table of Contents';
|
||||
@@ -129,25 +126,13 @@ class TocView {
|
||||
}
|
||||
|
||||
private cycle(): number {
|
||||
const tiptap = this.editor.view.dom as HTMLElement;
|
||||
const ph = parseFloat(getComputedStyle(tiptap).getPropertyValue('--user-page-height'));
|
||||
return (Number.isFinite(ph) ? ph : FALLBACK_PAGE_HEIGHT) + PAGE_GAP;
|
||||
return readVerticalMargins(this.editor.view.dom as HTMLElement).cycle;
|
||||
}
|
||||
|
||||
// Rendered page of a heading: sum offsetTop up the offsetParent chain to .tiptap (the
|
||||
// live DOM already includes pagination spacers, so this is the on-page position),
|
||||
// divided by the page cycle. Mirrors topWithin/getPageForY in pageBreaks.ts.
|
||||
private pageOf(pos: number, cycle: number): number {
|
||||
const tiptap = this.editor.view.dom as HTMLElement;
|
||||
const el = this.editor.view.nodeDOM(pos) as HTMLElement | null;
|
||||
if (!el || el.nodeType !== 1) return 1;
|
||||
let top = 0;
|
||||
let n: HTMLElement | null = el;
|
||||
while (n && n !== tiptap) {
|
||||
top += n.offsetTop;
|
||||
n = n.offsetParent as HTMLElement | null;
|
||||
}
|
||||
return Math.max(1, Math.floor(top / cycle) + 1);
|
||||
return pageOfElement(this.editor.view, el, cycle);
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
|
||||
@@ -24,6 +24,7 @@ Frames, tables and pagination have their own deep-dives in `docs/architecture/`.
|
||||
- **Tables:** column widths come from the editor's per-column weights (`tableColumnWidthsCm`, summing exactly to the text width, with `table:align="margins"`). **Table margins:** a table's `marginLeft`/`marginRight` shrink that width and are written by `applyTableMargins` into the table's automatic style (`style:width` + `fo:margin-left`/`-right`; odf-kit names table styles `Table1`, `Table2`, … in document order, matching the descriptor list `exportTable` fills). odf-kit serializes each cell to a single `<text:p>` of runs; `exportTable` emits all of a cell's content into that paragraph separated by a `SEG` sentinel and records a `CellBlock[]` descriptor, then `applyCellBlocks` splits on `SEG` and rebuilds real `<text:h>`/`<text:p>`/`<text:list>` (incl. nested lists) with minted automatic paragraph styles. **Merged cells:** `exportTable` passes each cell's `colspan`/`rowspan` as odf-kit `CellOptions.colSpan`/`rowSpan`; odf-kit emits `table:number-columns/rows-spanned` and auto-generates the `<table:covered-table-cell>` placeholders (which never match the `applyCellBlocks` cell regex, so `cellBlocks` stays one-per-real-cell aligned). Import already reads spans/covered cells in `convertTable`. **Cell shading:** a cell's `backgroundColor` attr is passed as `CellOptions.backgroundColor` (normalized), which odf-kit mints into the cell style as `fo:background-color`. **Cell borders:** the per-side border attrs (tableCellBorders.ts) are passed as `CellOptions.borderTop/Right/Bottom/Left` (`'none'` or `'<W>pt solid #RRGGBB'`) overriding the table-level default border per side.
|
||||
- **Text boxes / shapes** (`replaceTextBoxes`/`applyTextBoxes`): a top-level `textBox` is **hoisted** — `replaceTextBoxes` (after `replacePageBreaks`, before the inline passes) swaps it for `TBX`-sentinel marker paragraphs (`S{i}`…`E{i}`, U+E008) bracketing its child blocks at top level, so every existing pass (custom attrs, list styles, inline sentinels, images) serializes them unchanged and document-order-indexed passes stay aligned. `applyTextBoxes` (after `applyImages`) wraps each serialized S…E region into `<draw:frame><draw:text-box fo:min-height>` (plain box; `svg:height` also emitted for non-auto-grow consumers) or `<draw:custom-shape>` + preset `<draw:enhanced-geometry>` (roundRect/ellipse, static LibreOffice-style paths) inside a fresh anchor paragraph, minting a `TbxFr{n}` graphic style (fill/stroke/padding + the image wrap props; `draw:auto-grow-height="true"` for boxes, both auto-grows explicitly **false** for shapes or LibreOffice's autofit shrinks them to their text).
|
||||
- **Formulas** (`replaceFormulas`/`applyFormulas`): an `MTH`-sentinel run becomes an as-char `<draw:frame><draw:object xlink:href="./Formula{n}"/>` plus a `Formula{n}/content.xml` sub-document (MathML root) and its two manifest entries. **No `svg:width`/`svg:height`** — a sized frame makes LibreOffice scale the object instead of typesetting it. DOCX emits `<m:oMath>` (or `<m:oMathPara>` when displayed) from the same post-pack pass as the text boxes. See `docs/architecture/formulas.md`.
|
||||
- **Bookmarks & cross-references** (`replaceBookmarks`/`applyBookmarks`): the `BMS`/`BME`/`XRF` sentinels are spliced into the **run text** rather than emitted by a run builder, so they ride every odf-kit path (cells, custom paragraphs, lists) with no per-emitter change; one post-pass rewrites them to `<text:bookmark-start/>`/`<text:bookmark-end/>`/`<text:bookmark-ref>`. DOCX needs no pass at all — the `docx` package has `Bookmark`, `SimpleField` (`REF`/`PAGEREF`) and `InternalHyperlink`. See `docs/architecture/formatting.md`.
|
||||
- **`applyListItemStyles`** — per-item alignment/spacing/line-height for top-level lists (odf-kit's ListBuilder has no per-item options) via minted automatic styles.
|
||||
- **`applyListIndents`** — a top-level list's whole-list `indent` (cm) added to its `L#` list-style per-level `fo:margin-left`/`text:list-tab-stop-position` (label-alignment mode ignores the paragraph margin, so the shift must live in the list style). Import reverses it in `convertList` (level-1 margin minus the 1.27cm base).
|
||||
- **`applyNestedListTypes`** — odf-kit emits nested lists as bare `<text:list>` sharing the top-level list style, so a nested list of a different kind/format (e.g. ordered inside bullets) gets its own minted 6-level list style.
|
||||
@@ -43,4 +44,5 @@ Sentinel order matters — the passes assume it. Full list of sentinels in use:
|
||||
`LBR`/`TAB` (inline), `IMG`, `SEG` (cell blocks), `TBX` U+E008 (text boxes),
|
||||
U+E009 (columns), U+E00A (date/time fields), `STY` U+E00D (paragraph styles),
|
||||
`CST` U+E00E (character styles), `TEF` U+E00F (text effects), `PBX` (paragraph boxes), `SEC` U+E010
|
||||
(section breaks), `LEAD` U+E011 (a tab stop's leader, inside `style:type`), `MTH` U+E012 (formulas).
|
||||
(section breaks), `LEAD` U+E011 (a tab stop's leader, inside `style:type`), `MTH` U+E012 (formulas),
|
||||
`BMS`/`BME` U+E013/U+E014 (a bookmark's range), `XRF` U+E015 (cross-references).
|
||||
+42
-5
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
Document, Packer, Paragraph, TextRun, ImageRun, ExternalHyperlink, Tab,
|
||||
Document, Packer, Paragraph, TextRun, ImageRun, ExternalHyperlink, InternalHyperlink, Bookmark, Tab,
|
||||
TableOfContents,
|
||||
Table, TableRow, TableCell, Header, Footer, PageNumber, SimpleField,
|
||||
AlignmentType, LevelFormat, UnderlineType, BorderStyle, ShadingType,
|
||||
@@ -284,7 +284,7 @@ function textNodeToRuns(node: TiptapNode, force: TextProps): TextRun[] {
|
||||
return [new TextRun({ children, ...props })];
|
||||
}
|
||||
|
||||
type Inline = TextRun | ImageRun | ExternalHyperlink | SimpleField;
|
||||
type Inline = TextRun | ImageRun | ExternalHyperlink | InternalHyperlink | SimpleField | Bookmark;
|
||||
|
||||
// A date/time field. A fixed field is plain text (Word has no fixed-date field); an
|
||||
// auto field is a DATE/TIME field with the picture switch and a cached value.
|
||||
@@ -302,15 +302,52 @@ function dateTimeRun(node: TiptapNode): Inline {
|
||||
return new SimpleField(`${kind === 'time' ? 'TIME' : 'DATE'} \\@ "${docxPicture(fmt)}"`, text);
|
||||
}
|
||||
|
||||
// A cross-reference: a REF/PAGEREF field with the resolved text as its cached result,
|
||||
// so Word and LibreOffice show it before anyone updates fields.
|
||||
function crossRefField(node: TiptapNode): SimpleField {
|
||||
const a = node.attrs ?? {};
|
||||
const verb = a.format === 'page' ? 'PAGEREF' : 'REF';
|
||||
return new SimpleField(`${verb} ${String(a.name ?? '')} \\h`, String(a.text ?? ''));
|
||||
}
|
||||
|
||||
const bookmarkNameOf = (node: TiptapNode): string | null => {
|
||||
const name = node.marks?.find((m) => m.type === 'bookmark')?.attrs?.name;
|
||||
return typeof name === 'string' && name ? name : null;
|
||||
};
|
||||
|
||||
function inlineToRuns(content: TiptapNode[] = [], force: TextProps = {}): Inline[] {
|
||||
const out: Inline[] = [];
|
||||
// Consecutive nodes sharing a bookmark become one w:bookmarkStart/End pair.
|
||||
// ponytail: a range spanning paragraphs is emitted per paragraph — Word tolerates the
|
||||
// repeated name, and splitting it would need a second pass over the whole document.
|
||||
let open: { name: string; children: Inline[] } | null = null;
|
||||
const flush = () => {
|
||||
if (!open) return;
|
||||
out.push(new Bookmark({ id: open.name, children: open.children }));
|
||||
open = null;
|
||||
};
|
||||
const emit = (runs: Inline[], name: string | null) => {
|
||||
if (name !== (open?.name ?? null)) flush();
|
||||
if (!name) { out.push(...runs); return; }
|
||||
if (!open) open = { name, children: [] };
|
||||
open.children.push(...runs);
|
||||
};
|
||||
|
||||
for (const node of content) {
|
||||
const bookmark = bookmarkNameOf(node);
|
||||
if (node.type === 'text' && node.text) {
|
||||
const runs = textNodeToRuns(node, force);
|
||||
const href = node.marks?.find((m) => m.type === 'link')?.attrs?.href;
|
||||
if (href) out.push(new ExternalHyperlink({ link: String(href), children: runs }));
|
||||
else out.push(...runs);
|
||||
} else if (node.type === 'hardBreak') {
|
||||
const link = href ? String(href) : '';
|
||||
// An internal href targets a bookmark in this document, not a URL.
|
||||
if (link.startsWith('#')) emit([new InternalHyperlink({ anchor: link.slice(1), children: runs })], bookmark);
|
||||
else if (link) emit([new ExternalHyperlink({ link, children: runs })], bookmark);
|
||||
else emit(runs, bookmark);
|
||||
continue;
|
||||
}
|
||||
if (node.type === 'crossRef') { emit([crossRefField(node)], bookmark); continue; }
|
||||
flush();
|
||||
if (node.type === 'hardBreak') {
|
||||
// Carry the run's props so an empty line between two breaks keeps its font size.
|
||||
out.push(new TextRun({ break: 1, ...runPropsFromMarks(node.marks) }));
|
||||
} else if (node.type === 'image') {
|
||||
|
||||
+85
-2
@@ -147,6 +147,16 @@ const LEAD = '\uE011';
|
||||
// <draw:frame>/<draw:object> and writes the embedded formula object. U+E012.
|
||||
const MTH = '\uE012';
|
||||
|
||||
// Sentinels bracketing a bookmark's text (BMS<name>BMS \u2026 BME<name>BME), spliced into
|
||||
// the run text by replaceBookmarks so they ride every odf-kit path; applyBookmarks
|
||||
// rewrites them to <text:bookmark-start/>/<text:bookmark-end/>. U+E013/U+E014.
|
||||
const BMS = '\uE013';
|
||||
const BME = '\uE014';
|
||||
|
||||
// Sentinel wrapping a cross-reference's index and its display text
|
||||
// (XRF{i}XRF<text>XRF); applyBookmarks rewrites it to <text:bookmark-ref>. U+E015.
|
||||
const XRF = '\uE015';
|
||||
|
||||
const EXT_BY_MIME: Record<string, string> = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
@@ -502,6 +512,52 @@ function replaceDateTimeFields(node: TiptapNode, fields: DateTimeFieldExport[]):
|
||||
return { ...node, content };
|
||||
}
|
||||
|
||||
// One cross-reference, collected by replaceCrossRefs and emitted by applyBookmarks.
|
||||
type CrossRefExport = { name: string; format: 'text' | 'page' };
|
||||
|
||||
const bookmarkNameOf = (node: TiptapNode): string =>
|
||||
String(node.marks?.find((m) => m.type === 'bookmark')?.attrs?.name ?? '');
|
||||
|
||||
// Bracket each bookmark's text with the BMS/BME sentinels and replace every `crossRef`
|
||||
// node with an XRF-sentinel run. Splicing into the run text (rather than into a run
|
||||
// emitter) is what makes both ride every odf-kit path — cells and custom paragraphs
|
||||
// included. applyBookmarks resolves them after serialization.
|
||||
function replaceBookmarks(node: TiptapNode, refs: CrossRefExport[]): TiptapNode {
|
||||
if (!node.content?.length) return node;
|
||||
const content: TiptapNode[] = [];
|
||||
// Index of the run the open bookmark started on, so its end sentinel lands on the
|
||||
// last run of the range.
|
||||
let open = '';
|
||||
let lastOfRange = -1;
|
||||
const closeRange = () => {
|
||||
if (!open) return;
|
||||
const run = content[lastOfRange];
|
||||
content[lastOfRange] = { ...run, text: `${run.text ?? ''}${BME}${open}${BME}` };
|
||||
open = '';
|
||||
};
|
||||
for (const child of node.content) {
|
||||
if (child.type === 'crossRef') {
|
||||
closeRange();
|
||||
const a = child.attrs ?? {};
|
||||
refs.push({ name: String(a.name ?? ''), format: a.format === 'page' ? 'page' : 'text' });
|
||||
content.push({ type: 'text', text: `${XRF}${refs.length - 1}${XRF}${String(a.text ?? '')}${XRF}`, marks: child.marks });
|
||||
continue;
|
||||
}
|
||||
const name = child.type === 'text' ? bookmarkNameOf(child) : '';
|
||||
if (name !== open) closeRange();
|
||||
if (name && !open) {
|
||||
open = name;
|
||||
content.push({ ...child, text: `${BMS}${name}${BMS}${child.text ?? ''}` });
|
||||
lastOfRange = content.length - 1;
|
||||
continue;
|
||||
}
|
||||
content.push(name ? child : replaceBookmarks(child, refs));
|
||||
if (name) lastOfRange = content.length - 1;
|
||||
}
|
||||
closeRange();
|
||||
return { ...node, content };
|
||||
}
|
||||
|
||||
// One text box / shape, collected by replaceTextBoxes and emitted by applyTextBoxes.
|
||||
type ShapeKind = 'textbox' | 'roundRect' | 'ellipse';
|
||||
type TextBoxExport = {
|
||||
@@ -3091,6 +3147,31 @@ function tocXml(toc: TocExport, index: number): string {
|
||||
|
||||
// Resolve TOC sentinels: rewrite each marker <text:p>TOC{i}TOC</text:p> to its
|
||||
// <text:table-of-content> and mint the Contents_20_* paragraph styles it references.
|
||||
// BMS/BME/XRF sentinels → <text:bookmark-start/>, <text:bookmark-end/> and
|
||||
// <text:bookmark-ref>. All three are legal anywhere in paragraph content, so a plain
|
||||
// replace works wherever odf-kit put the run — inside a <text:span> included. Bookmark
|
||||
// names are sanitized on insert, so nothing here needs XML escaping.
|
||||
function applyBookmarks(odtBytes: Uint8Array, refs: CrossRefExport[]): Uint8Array {
|
||||
const files = unzipSync(odtBytes);
|
||||
const contentBytes = files['content.xml'];
|
||||
if (!contentBytes) return odtBytes;
|
||||
|
||||
let content = strFromU8(contentBytes);
|
||||
if (!content.includes(BMS) && !content.includes(XRF)) return odtBytes;
|
||||
|
||||
content = content
|
||||
.replace(new RegExp(`${BMS}([^${BMS}]*)${BMS}`, 'g'), (_m, name: string) => `<text:bookmark-start text:name="${name}"/>`)
|
||||
.replace(new RegExp(`${BME}([^${BME}]*)${BME}`, 'g'), (_m, name: string) => `<text:bookmark-end text:name="${name}"/>`)
|
||||
.replace(new RegExp(`${XRF}(\\d+)${XRF}([^${XRF}]*)${XRF}`, 'g'), (_m, idx: string, shown: string) => {
|
||||
const ref = refs[Number(idx)];
|
||||
if (!ref) return shown;
|
||||
return `<text:bookmark-ref text:reference-format="${ref.format}" text:ref-name="${ref.name}">${shown}</text:bookmark-ref>`;
|
||||
});
|
||||
|
||||
files['content.xml'] = strToU8(content);
|
||||
return rezipOdt(files);
|
||||
}
|
||||
|
||||
function applyToc(odtBytes: Uint8Array, tocs: TocExport[], contentWidthCm: number): Uint8Array {
|
||||
if (!tocs.length) return odtBytes;
|
||||
const files = unzipSync(odtBytes);
|
||||
@@ -3156,7 +3237,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
|
||||
const columns: ColumnsExport[] = [];
|
||||
const dateFields: DateTimeFieldExport[] = [];
|
||||
const formulas: FormulaExport[] = [];
|
||||
const sentinels = replaceFormulas(replaceDateTimeFields(replaceImages(replaceTabs(replaceHardBreaks(replaceSectionBreaks(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns)))), images), dateFields), formulas);
|
||||
const crossRefs: CrossRefExport[] = [];
|
||||
const sentinels = replaceBookmarks(replaceFormulas(replaceDateTimeFields(replaceImages(replaceTabs(replaceHardBreaks(replaceSectionBreaks(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns)))), images), dateFields), formulas), crossRefs);
|
||||
const raw = markTextEffects(bakeListCharStyles(sentinels, styles));
|
||||
let headerPara = hf && !hfIsEmpty(hf.header) ? (hf.header!.content![0] as TiptapNode) : null;
|
||||
let footerPara = hf && !hfIsEmpty(hf.footer) ? (hf.footer!.content![0] as TiptapNode) : null;
|
||||
@@ -3359,7 +3441,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
|
||||
const withFormulas = applyFormulas(withDateFields, formulas);
|
||||
const withTextBoxes = applyTextBoxes(withFormulas, textBoxes);
|
||||
const withColumns = applyColumns(withTextBoxes, columns);
|
||||
const withToc = applyToc(withColumns, tocs, contentWidthCm);
|
||||
const withBookmarks = applyBookmarks(withColumns, crossRefs);
|
||||
const withToc = applyToc(withBookmarks, tocs, contentWidthCm);
|
||||
const withPageBreaks = applyPageBreaks(withToc);
|
||||
const withEmptyFontSizes = applyEmptyLineFontSizes(withPageBreaks);
|
||||
const withParaBoxes = applyParagraphBoxes(withEmptyFontSizes);
|
||||
|
||||
@@ -9,6 +9,7 @@ const de: Messages = {
|
||||
cancel: 'Abbrechen',
|
||||
close: 'Schließen',
|
||||
apply: 'Übernehmen',
|
||||
insert: 'Einfügen',
|
||||
remove: 'Entfernen',
|
||||
},
|
||||
appearance: {
|
||||
@@ -201,6 +202,11 @@ const de: Messages = {
|
||||
tocNotInHf: 'Ein Inhaltsverzeichnis ist in Kopf-/Fußzeilen nicht verfügbar',
|
||||
insertLink: 'Link einfügen',
|
||||
linkNotInHf: 'Links sind in Kopf-/Fußzeilen nicht verfügbar',
|
||||
insertBookmark: 'Lesezeichen',
|
||||
bookmarkNeedsSelection: 'Markiere den Text, den das Lesezeichen umfassen soll',
|
||||
bookmarkNotInHf: 'Lesezeichen sind in Kopf-/Fußzeilen nicht verfügbar',
|
||||
insertCrossRef: 'Querverweis einfügen',
|
||||
crossRefNeedsBookmark: 'Erst ein Lesezeichen setzen — ein Querverweis zeigt darauf',
|
||||
clearFormatting: 'Formatierung entfernen',
|
||||
},
|
||||
image: {
|
||||
@@ -228,6 +234,16 @@ const de: Messages = {
|
||||
borderWidth: 'Rahmenbreite',
|
||||
pt: (w) => `${w} pt`,
|
||||
},
|
||||
bookmark: {
|
||||
dialogLabel: 'Lesezeichen',
|
||||
namePlaceholder: 'Name des Lesezeichens',
|
||||
existing: 'In diesem Dokument',
|
||||
none: 'Noch keine Lesezeichen',
|
||||
crossRefLabel: 'Querverweis einfügen',
|
||||
formatText: 'Text des Lesezeichens',
|
||||
formatPage: 'Seitenzahl',
|
||||
openHint: (mod) => `${mod}+Klick, um zum Lesezeichen zu springen`,
|
||||
},
|
||||
link: {
|
||||
dialogLabel: 'Link einfügen',
|
||||
openHint: (mod) => `${mod}+Klick, um den Link zu öffnen`,
|
||||
@@ -336,6 +352,8 @@ const de: Messages = {
|
||||
insertLink: 'Link…',
|
||||
editLink: 'Link bearbeiten…',
|
||||
removeLink: 'Link entfernen',
|
||||
insertBookmark: 'Lesezeichen…',
|
||||
insertCrossRef: 'Querverweis…',
|
||||
clearFormatting: 'Direkte Formatierung löschen',
|
||||
},
|
||||
about: {
|
||||
|
||||
@@ -10,6 +10,7 @@ const en = {
|
||||
cancel: 'Cancel',
|
||||
close: 'Close',
|
||||
apply: 'Apply',
|
||||
insert: 'Insert',
|
||||
remove: 'Remove',
|
||||
},
|
||||
appearance: {
|
||||
@@ -205,6 +206,11 @@ const en = {
|
||||
tocNotInHf: 'A table of contents is not available in headers/footers',
|
||||
insertLink: 'Insert link',
|
||||
linkNotInHf: 'Links are not available in headers/footers',
|
||||
insertBookmark: 'Bookmark',
|
||||
bookmarkNeedsSelection: 'Select the text the bookmark should cover',
|
||||
bookmarkNotInHf: 'Bookmarks are not available in headers/footers',
|
||||
insertCrossRef: 'Insert cross-reference',
|
||||
crossRefNeedsBookmark: 'Add a bookmark first — a cross-reference points at one',
|
||||
clearFormatting: 'Clear formatting',
|
||||
},
|
||||
image: {
|
||||
@@ -232,6 +238,16 @@ const en = {
|
||||
borderWidth: 'Border width',
|
||||
pt: (w: number) => `${w} pt`,
|
||||
},
|
||||
bookmark: {
|
||||
dialogLabel: 'Bookmark',
|
||||
namePlaceholder: 'Bookmark name',
|
||||
existing: 'In this document',
|
||||
none: 'No bookmarks yet',
|
||||
crossRefLabel: 'Insert cross-reference',
|
||||
formatText: 'Text of the bookmark',
|
||||
formatPage: 'Page number',
|
||||
openHint: (mod: string) => `${mod}+Click to jump to the bookmark`,
|
||||
},
|
||||
link: {
|
||||
dialogLabel: 'Insert link',
|
||||
openHint: (mod: string) => `${mod}+Click to open link`,
|
||||
@@ -340,6 +356,8 @@ const en = {
|
||||
insertLink: 'Link…',
|
||||
editLink: 'Edit Link…',
|
||||
removeLink: 'Remove Link',
|
||||
insertBookmark: 'Bookmark…',
|
||||
insertCrossRef: 'Cross-reference…',
|
||||
clearFormatting: 'Clear Formatting',
|
||||
},
|
||||
about: {
|
||||
|
||||
@@ -16,6 +16,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt(
|
||||
- **Charts (`import/chart.ts`, DOCX)**: `word/charts/chartN.xml` is parsed and **drawn as an SVG picture** of the frame's own size — series values from the `numCache` the producer left, colours from the named theme accent (`themeAccents`), title/axis titles/gridlines, and the value axis' own `c:scaling` bounds where the file fixes them. Bar (incl. stacked and `barDir="bar"`), line, area, scatter and pie; anything else keeps the placeholder. Read-only — the editor has no chart object, so a re-export carries the picture, not the chart. **ODF** charts go through the same drawing (`odfChartDataUrl`, `convertChartFrame`): `<chart:chart>` in the `draw:object`'s sub-document, series values read out of its own `local-table` by cell range, colours from the series' `draw:fill-color`, bounds from the y-axis style's `chart:maximum`/`-minimum`.
|
||||
- **Content controls** (`inlineChildren`, DOCX): a `w:sdt` inside a paragraph — Word wraps every citation and bibliography entry in one — is walked through to its `w:sdtContent`, or the runs inside it never reach the paragraph (the thesis fixture lost every `[7]`-style reference and broke its lines one page short).
|
||||
- **Formulas** (`convertFormulaFrame`): a `<draw:frame>` whose `<draw:object>` resolves to a sub-document with a `math:math` root (or holds one inline) → a `formula` node. Our own `<annotation encoding="application/x-tex">` is preferred; LibreOffice strips it, so the MathML is parsed back to LaTeX instead. **`display` comes from the paragraph** (`aloneInParagraph`), not the object — LibreOffice writes `display="block"` on every formula it re-saves. DOCX: `m:oMath`/`m:oMathPara` sit beside the `w:r` runs in their own namespace, so `convertInline` probes them ahead of its `namespaceURI !== W` guard (that guard silently dropped every Word formula). See `docs/architecture/formulas.md`.
|
||||
- **Bookmarks & cross-references**: `text:bookmark-start`/`-end` (ODF) and `w:bookmarkStart`/`End` (DOCX) become a `bookmark` **mark** on the text they cover; the open ranges live in `Ctx` because Word writes them beside paragraphs as well as beside runs, and a range may close in a later paragraph. A **point** bookmark has no range to mark and is dropped, `_GoBack` with it. `text:bookmark-ref` and `REF`/`PAGEREF` fields (the `fldChar` machine and `w:fldSimple` alike, including a field Word left unresolved) become `crossRef` nodes carrying the producer's cached text. A `w:hyperlink w:anchor` with no `r:id` is an internal link → `#name`. See `docs/architecture/formatting.md`.
|
||||
- **Graceful degradation** (collected as `warnings`, surfaced once): footnotes/comments dropped, hyperlinks flattened to text, nested tables flattened to paragraphs, non-`Pictures` drawings dropped, unreadable pictures skipped (each with a warning). Page size is normalized to A4. Floating/wrapped images are imported as left/right/top-bottom wrap (free x/y positions collapse to the nearest side; see `image.ts`).
|
||||
|
||||
`tests/roundtrip.test.ts` (export→import) and `tests/lo-roundtrip.test.ts` (export→LibreOffice re-save→import, needs `soffice`) verify the round trip; run with `npm test` / `npm run test:lo`.
|
||||
|
||||
+80
-7
@@ -58,6 +58,9 @@ type Ctx = {
|
||||
cellSpacing: ParaSpacing;
|
||||
// theme1.xml's accent1..6, the colours a chart series names instead of an sRGB.
|
||||
accents: string[];
|
||||
// Bookmarks open at this point of the walk (w:id → name). A range may start beside a
|
||||
// paragraph and end inside a later one, so the state outlives both walks.
|
||||
openBookmarks: Map<string, string>;
|
||||
};
|
||||
|
||||
// ---- units & editor defaults to suppress -----------------------------------
|
||||
@@ -139,7 +142,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
|
||||
const sectPr = fc(body, 'sectPr');
|
||||
const contentWidthCm = sectionContentWidthCm(sectPr);
|
||||
const leftMarginCm = twipToCm(intAttr(fc(sectPr, 'pgMar'), W, 'left') ?? 1440);
|
||||
const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map(), contentWidthCm, leftMarginCm, cellSpacing: {}, accents: themeAccents(themeDoc) };
|
||||
const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map(), contentWidthCm, leftMarginCm, cellSpacing: {}, accents: themeAccents(themeDoc), openBookmarks: new Map() };
|
||||
|
||||
// Mid-body sectPr paragraphs delimit sections; a section whose w:cols declares
|
||||
// more than one column becomes a columns node (the trailing group is described
|
||||
@@ -303,6 +306,33 @@ function tocHeading(content: Element | null): string | null {
|
||||
return text && text.length <= 60 ? text : null;
|
||||
}
|
||||
|
||||
// Word's own cursor bookkeeping, never a reference target.
|
||||
const BOOKMARK_SKIP = new Set(['_GoBack']);
|
||||
|
||||
// w:bookmarkStart/w:bookmarkEnd sit beside runs, beside paragraphs, or split across
|
||||
// both, so both walks feed the same open-range map. Returns true when the element was
|
||||
// one of them.
|
||||
function trackBookmark(el: Element, ctx: Ctx): boolean {
|
||||
if (el.localName === 'bookmarkStart') {
|
||||
const id = el.getAttributeNS(W, 'id');
|
||||
const name = el.getAttributeNS(W, 'name');
|
||||
if (id && name && !BOOKMARK_SKIP.has(name)) ctx.openBookmarks.set(id, name);
|
||||
return true;
|
||||
}
|
||||
if (el.localName === 'bookmarkEnd') {
|
||||
const id = el.getAttributeNS(W, 'id');
|
||||
if (id) ctx.openBookmarks.delete(id);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// A mark can hold one bookmark, so overlapping ranges collapse to the outermost.
|
||||
function openBookmarkMark(ctx: Ctx): Mark | null {
|
||||
const name = ctx.openBookmarks.values().next().value;
|
||||
return name ? { type: 'bookmark', attrs: { name } } : null;
|
||||
}
|
||||
|
||||
function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDefault = false): Node[] {
|
||||
const out: Node[] = [];
|
||||
const stack: { ilvl: number; numId: number; list: Node }[] = [];
|
||||
@@ -349,6 +379,7 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef
|
||||
|
||||
for (const el of children) {
|
||||
if (el.namespaceURI !== W) continue;
|
||||
if (trackBookmark(el, ctx)) continue;
|
||||
if (el.localName === 'p') {
|
||||
// A paragraph owned by a TOC field is skipped; the field emits one node.
|
||||
const startedInToc = tocState.tocDepth >= 0;
|
||||
@@ -917,11 +948,18 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
|
||||
// A recognized body date/time field: its cached result runs are dropped and a live
|
||||
// dateTimeField node is emitted in their place when the field ends.
|
||||
let fieldDateTime: Node | null = null;
|
||||
// Same for a REF/PAGEREF field: the cached result runs are collected as the node's
|
||||
// display text instead of being pushed.
|
||||
let fieldCrossRef: Node | null = null;
|
||||
let fieldCrossRefText = '';
|
||||
|
||||
const pushText = (text: string, marks: Mark[]) => {
|
||||
if (!text) return;
|
||||
// The one-paragraph header/footer schema has no bookmark mark.
|
||||
const bookmark = hfFields ? null : openBookmarkMark(ctx);
|
||||
const all = bookmark ? [...marks, bookmark] : marks;
|
||||
const node: Node = { type: 'text', text };
|
||||
if (marks.length) node.marks = marks;
|
||||
if (all.length) node.marks = all;
|
||||
out.push(node);
|
||||
};
|
||||
|
||||
@@ -957,7 +995,7 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
|
||||
const marks = runMarks(r, linkHref);
|
||||
// Hide a field's cached result: always for a hf field, and for a recognized body
|
||||
// date/time field (replaced by its live node).
|
||||
const skipResult = () => fieldMode === 'result' && (hfFields || !!fieldDateTime);
|
||||
const skipResult = () => fieldMode === 'result' && (hfFields || !!fieldDateTime || !!fieldCrossRef);
|
||||
|
||||
// Route a drawing/pict result: an image is inline, a text box a block node riding
|
||||
// ctx.pendingBlocks. The one-paragraph header/footer zone takes as-char images only —
|
||||
@@ -993,24 +1031,39 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
|
||||
switch (child.localName) {
|
||||
case 'fldChar': {
|
||||
const t = child.getAttributeNS(W, 'fldCharType');
|
||||
if (t === 'begin') { fieldMode = 'instr'; fieldInstr = ''; fieldDateTime = null; }
|
||||
if (t === 'begin') { fieldMode = 'instr'; fieldInstr = ''; fieldDateTime = null; fieldCrossRef = null; fieldCrossRefText = ''; }
|
||||
else if (t === 'separate') {
|
||||
fieldMode = 'result';
|
||||
if (!hfFields) {
|
||||
fieldDateTime = dateTimeFieldFromInstr(fieldInstr);
|
||||
fieldCrossRef = fieldDateTime ? null : crossRefFromInstr(fieldInstr);
|
||||
fieldCrossRefText = '';
|
||||
// Carry the field run's marks so the atom renders in the run's font.
|
||||
if (fieldDateTime && marks.length) fieldDateTime.marks = marks;
|
||||
const field = fieldDateTime ?? fieldCrossRef;
|
||||
if (field && marks.length) field.marks = marks;
|
||||
}
|
||||
} else if (t === 'end') {
|
||||
// A reference Word never resolved has no w:separate and so no cached result
|
||||
// (it shows nothing until the field is updated); the node view fills it in
|
||||
// when the bookmark is still there.
|
||||
if (!hfFields && !fieldDateTime && !fieldCrossRef) {
|
||||
fieldCrossRef = crossRefFromInstr(fieldInstr);
|
||||
if (fieldCrossRef && marks.length) fieldCrossRef.marks = marks;
|
||||
}
|
||||
if (fieldDateTime) out.push(fieldDateTime);
|
||||
else if (fieldCrossRef) out.push({ ...fieldCrossRef, attrs: { ...fieldCrossRef.attrs, text: fieldCrossRefText } });
|
||||
else emitField(out, fieldInstr, hfFields, marks);
|
||||
fieldMode = 'none';
|
||||
fieldDateTime = null;
|
||||
fieldCrossRef = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'instrText': if (fieldMode === 'instr') fieldInstr += child.textContent ?? ''; break;
|
||||
case 't': if (!skipResult()) pushText(child.textContent ?? '', marks); break;
|
||||
case 't':
|
||||
if (fieldCrossRef && fieldMode === 'result') fieldCrossRefText += child.textContent ?? '';
|
||||
else if (!skipResult()) pushText(child.textContent ?? '', marks);
|
||||
break;
|
||||
case 'tab': if (!skipResult()) pushText('\t', marks); break;
|
||||
case 'br': out.push(child.getAttributeNS(W, 'type') === 'page' ? { type: PB_MARKER } : hardBreakNode(marks)); break;
|
||||
case 'cr': out.push(hardBreakNode(marks)); break;
|
||||
@@ -1029,17 +1082,28 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
|
||||
continue;
|
||||
}
|
||||
if (el.namespaceURI !== W) continue;
|
||||
if (trackBookmark(el, ctx)) continue;
|
||||
switch (el.localName) {
|
||||
case 'r': handleRun(el); break;
|
||||
case 'hyperlink': {
|
||||
const rid = el.getAttributeNS(R, 'id');
|
||||
const href = rid ? ctx.rels.get(rid)?.target : undefined;
|
||||
// No relationship id: an internal link to a bookmark in this document.
|
||||
const anchor = el.getAttributeNS(W, 'anchor');
|
||||
const href = rid ? ctx.rels.get(rid)?.target : anchor ? `#${anchor}` : undefined;
|
||||
for (const r of fcAll(el, 'r')) handleRun(r, href);
|
||||
break;
|
||||
}
|
||||
case 'fldSimple': {
|
||||
const instr = el.getAttributeNS(W, 'instr') ?? '';
|
||||
if (hfFields) { const first = fcAll(el, 'r')[0]; emitField(out, instr, true, first ? runMarks(first) : []); break; }
|
||||
const xref = crossRefFromInstr(instr);
|
||||
if (xref) {
|
||||
const first = fcAll(el, 'r')[0];
|
||||
const m = first ? runMarks(first) : [];
|
||||
if (m.length) xref.marks = m;
|
||||
out.push({ ...xref, attrs: { ...xref.attrs, text: el.textContent ?? '' } });
|
||||
break;
|
||||
}
|
||||
const field = dateTimeFieldFromInstr(instr);
|
||||
if (field) {
|
||||
const first = fcAll(el, 'r')[0];
|
||||
@@ -1081,6 +1145,15 @@ function emitField(out: Node[], instr: string, hfFields: boolean, marks: Mark[]
|
||||
else if (/\bPAGE\b/.test(instr)) push('pageNumber');
|
||||
}
|
||||
|
||||
// A REF/PAGEREF field instruction → a cross-reference node; its cached result text is
|
||||
// filled in by the caller when the field closes. Every other field (SEQ, CITATION, …)
|
||||
// returns null and keeps showing the text the producer cached.
|
||||
function crossRefFromInstr(instr: string): Node | null {
|
||||
const m = /^\s*(PAGEREF|REF)\s+(\S+)/i.exec(instr);
|
||||
if (!m) return null;
|
||||
return { type: 'crossRef', attrs: { name: m[2], format: /^PAGEREF$/i.test(m[1]) ? 'page' : 'text', text: '' } };
|
||||
}
|
||||
|
||||
// A DATE/TIME field instruction with a picture switch we recognize → a live
|
||||
// dateTimeField node (always auto: Word writes a fixed date as plain text). Returns
|
||||
// null for an unknown picture so the caller keeps the field's shown text.
|
||||
|
||||
+38
-5
@@ -97,6 +97,9 @@ type Ctx = {
|
||||
contentWidthCm: number;
|
||||
// Master pages the body switches to, in order — one section each past the first.
|
||||
masterPages: string[];
|
||||
// Bookmark ranges open at this point of the walk, outermost first; a range may end in
|
||||
// a later paragraph than it started in, so the set outlives one convertInline call.
|
||||
openBookmarks: Set<string>;
|
||||
};
|
||||
|
||||
// Read a Pictures/ entry into a base64 data-URI; null when it's missing or in a format
|
||||
@@ -520,7 +523,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
|
||||
const contentWidthCm = geo
|
||||
? pageDimsCm(geo.format, geo.orientation).w - geo.margins.left - geo.margins.right
|
||||
: pageDimsCm('A4', 'portrait').w - 2 * 2.12;
|
||||
const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [], contentWidthCm, masterPages: [] };
|
||||
const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [], contentWidthCm, masterPages: [], openBookmarks: new Set() };
|
||||
let blocks = convertBlocks(Array.from(body.children), ctx, 'body');
|
||||
if (blocks.length === 0) blocks.push({ type: 'paragraph' });
|
||||
pairAlignedFrames(blocks, Math.floor(cmToPx(contentWidthCm)));
|
||||
@@ -1303,6 +1306,10 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: Bl
|
||||
marks.push({ type: 'charStyle', attrs: { name: display } });
|
||||
}
|
||||
if (linkHref) marks.push({ type: 'link', attrs: { href: linkHref } });
|
||||
// A mark can hold one bookmark, so overlapping ranges collapse to the outermost.
|
||||
// The one-paragraph header/footer schema has no bookmark mark.
|
||||
const bookmark = hfFields ? undefined : ctx.openBookmarks.values().next().value;
|
||||
if (bookmark) marks.push({ type: 'bookmark', attrs: { name: bookmark } });
|
||||
const node: Node = { type: 'text', text: clean };
|
||||
if (marks.length) node.marks = marks;
|
||||
out.push(node);
|
||||
@@ -1344,8 +1351,8 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: Bl
|
||||
continue;
|
||||
}
|
||||
case 'a': {
|
||||
// ODF hyperlink → link mark on the contained text (internal #bookmark
|
||||
// links are kept as-is though the editor has no bookmark targets).
|
||||
// ODF hyperlink → link mark on the contained text; an internal #bookmark
|
||||
// href is kept as-is and resolved against the document's bookmarks.
|
||||
const href = e.getAttributeNS(NS.xlink, 'href') ?? '';
|
||||
walk(e, props, href || linkHref);
|
||||
continue;
|
||||
@@ -1353,9 +1360,35 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: Bl
|
||||
case 'note':
|
||||
ctx.warnings.add('Footnotes and endnotes were removed');
|
||||
continue;
|
||||
case 'bookmark':
|
||||
case 'bookmark-start':
|
||||
case 'bookmark-end':
|
||||
case 'bookmark-end': {
|
||||
// A named range → a bookmark mark on the text it covers. A point bookmark
|
||||
// (<text:bookmark/>) has no range to mark, so it is dropped.
|
||||
const name = e.getAttributeNS(NS.text, 'name');
|
||||
if (name) {
|
||||
if (e.localName === 'bookmark-start') ctx.openBookmarks.add(name);
|
||||
else ctx.openBookmarks.delete(name);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
case 'bookmark-ref': {
|
||||
const name = e.getAttributeNS(NS.text, 'ref-name');
|
||||
const fmt = e.getAttributeNS(NS.text, 'reference-format');
|
||||
// Only the two formats the editor models; any other one (chapter, number,
|
||||
// …) keeps the value the file cached.
|
||||
// Same for the crossRef node: in a zone the reference stays its shown text.
|
||||
if (!hfFields && name && (fmt === 'text' || fmt === 'page')) {
|
||||
const field: Node = { type: 'crossRef', attrs: { name, format: fmt, text: e.textContent ?? '' } };
|
||||
const marks = marksFor(props, ctx.resolver, defaults);
|
||||
if (linkHref) marks.push({ type: 'link', attrs: { href: linkHref } });
|
||||
if (marks.length) field.marks = marks;
|
||||
out.push(field);
|
||||
continue;
|
||||
}
|
||||
if (e.textContent) pushText(e.textContent, props, linkHref);
|
||||
continue;
|
||||
}
|
||||
case 'bookmark':
|
||||
case 'soft-page-break':
|
||||
case 'change':
|
||||
case 'change-start':
|
||||
|
||||
Reference in new issue
Block a user