feat: AutoText keeps a block of text under a shortcut
LibreOffice's Tools ▸ AutoText, in the ribbon where Word keeps its Quick Parts: store the selection under a name and a shortcut, insert it from the library or by typing the shortcut and pressing F3 — LibreOffice's key for it. An entry is HTML parsed back through the schema, so it keeps its marks and blocks; the library is app-level, as it is in both products, so no file carries it.
This commit is contained in:
1 parent
ee801967f6
commit
405b175e49
16 files changed
+384
-4
No files matched your search
+2
-1
@@ -57,6 +57,7 @@
|
||||
- Paragraph borders and shading: per-side borders with presets (all / single edges / none), width and color, plus a background fill, on paragraphs and headings; typing `---`, `___` or `===` on a line of its own turns it into a rule line (Word/LibreOffice AutoCorrect). Round-trips to ODF `fo:border-*`/`fo:background-color` and DOCX `w:pBdr`/`w:shd`
|
||||
- AutoCorrect while typing (LibreOffice's Tools ▸ AutoCorrect Options, one checkbox per rule): typographic quotes per document language (`"…"` / `„…“`), its dash matrix (`A - B` → `A – B`, `A--B` → `A—B`), its replacement table (`-->` → `→`, `(C)` → `©`, `...` → `…`), capitalize the first letter of a sentence — with an abbreviation exception list — and TWo INitial CApitals. URL recognition and auto-list ride the same switches
|
||||
- Word completion (the same dialog's own section, as LibreOffice keeps it): every word of at least eight letters is remembered as it is typed, and typing its first three letters offers the rest in grey after the caret — Enter takes the offer, Esc drops it. It is offered while typing only, so an Enter meant to split a paragraph never completes a word instead, and the offer stays outside the document until it is accepted. Minimum length, an appended space and clearing the collected words are all settable
|
||||
- AutoText (LibreOffice's Tools ▸ AutoText, Word's Insert ▸ Quick Parts): keep a selected block of text under a name and a shortcut, then insert it from the library or by typing the shortcut and pressing F3 — LibreOffice's own key. An entry keeps its formatting, and the library belongs to the app rather than to a document, as it does in both products
|
||||
- Automatic hyphenation for the whole document (Layout ▸ Hyphenation): the browser hyphenates in the document's own language, which shortens a justified paragraph the way LibreOffice does. Round-trips as ODF `fo:hyphenate` on the base style — where LibreOffice keeps it, in its *text* properties — and Word's `w:autoHyphenation`
|
||||
- Manual line breaks (Shift+Enter)
|
||||
|
||||
@@ -161,7 +162,7 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-14.
|
||||
- Linked / chained text frames
|
||||
- Find & Replace by font, size or colour — the weight/slant/underline and paragraph-style half is implemented, but LibreOffice's Format dialog is the whole character dialog
|
||||
- Multi-document management: one document is open at a time, so there is no window list and no side-by-side compare
|
||||
- Thesaurus, grammar check, AutoText / building blocks
|
||||
- Thesaurus and grammar check
|
||||
- Vertical writing modes (ODF `tb-rl`) and Asian typography (ruby)
|
||||
- Password-protected ODT/DOCX; digital signatures
|
||||
- Split view: two views of one document, scrolled independently
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
import NavigatorPane from './lib/components/NavigatorPane.svelte';
|
||||
import { OPEN_COMMENT_EVENT } from './lib/editor/extensions/comment';
|
||||
import AutoCorrectDialog from './lib/components/AutoCorrectDialog.svelte';
|
||||
import AutoTextDialog from './lib/components/AutoTextDialog.svelte';
|
||||
import StyleManagerDialog from './lib/components/StyleManagerDialog.svelte';
|
||||
import NoteOptionsDialog from './lib/components/NoteOptionsDialog.svelte';
|
||||
import { t } from './lib/i18n/i18n.svelte';
|
||||
@@ -173,6 +174,7 @@
|
||||
let pageDecor: PageDecor = $state(loadPageDecor());
|
||||
let lineNumbering: LineNumbering = $state(loadLineNumbering());
|
||||
let autoCorrectOpen = $state(false);
|
||||
let autoTextOpen = $state(false);
|
||||
let commentsOpen = $state(false);
|
||||
let revisionsOpen = $state(false);
|
||||
let navigatorOpen = $state(false);
|
||||
@@ -1044,6 +1046,7 @@
|
||||
onAbout={() => (aboutOpen = true)}
|
||||
onDocProperties={() => (docPropsOpen = true)}
|
||||
onAutoCorrect={() => (autoCorrectOpen = true)}
|
||||
onAutoText={() => (autoTextOpen = true)}
|
||||
onNewComment={addComment}
|
||||
{commentsOpen}
|
||||
onToggleComments={() => (commentsOpen = !commentsOpen)}
|
||||
@@ -1235,6 +1238,9 @@
|
||||
<button class="theme-option" onclick={() => { themeOpen = false; autoCorrectOpen = true; }} role="menuitem">
|
||||
<span>{t().ribbon.autoCorrect}</span>
|
||||
</button>
|
||||
<button class="theme-option" onclick={() => { themeOpen = false; autoTextOpen = true; }} role="menuitem">
|
||||
<span>{t().autoText.title}</span>
|
||||
</button>
|
||||
<div class="theme-heading">{t().ribbon.chrome.title}</div>
|
||||
<button
|
||||
class="theme-option"
|
||||
@@ -1427,6 +1433,7 @@
|
||||
|
||||
<AboutDialog bind:open={aboutOpen} />
|
||||
<AutoCorrectDialog bind:open={autoCorrectOpen} />
|
||||
<AutoTextDialog bind:open={autoTextOpen} editor={activeEditor} />
|
||||
<DocPropertiesDialog bind:open={docPropsOpen} props={docProps} onApply={(p) => { docProps = p; saveDocProperties(p); }} />
|
||||
<!-- One instance for every entry point (styles gallery, insert-table menu): the
|
||||
callers only say which family to land on. -->
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<script lang="ts">
|
||||
import { getHTMLFromFragment, type Editor } from '@tiptap/core';
|
||||
import { autoTextEntries, putAutoText, removeAutoText } from '../storage/autoText.svelte';
|
||||
import { t } from '../i18n/i18n.svelte';
|
||||
|
||||
// LibreOffice's Tools ▸ AutoText / Word's Quick Parts: the library, plus "new from
|
||||
// selection". Inserting one puts it where the caret is.
|
||||
let { open = $bindable(false), editor }: { open?: boolean; editor: Editor | null } = $props();
|
||||
|
||||
let dialogEl = $state<HTMLDialogElement | null>(null);
|
||||
let name = $state('');
|
||||
let shortcut = $state('');
|
||||
|
||||
// Re-read on each open: the dialog stays mounted, and a modal freezes the selection
|
||||
// it was opened on.
|
||||
const hasSelection = $derived(open && !!editor && !editor.state.selection.empty);
|
||||
|
||||
$effect(() => {
|
||||
const el = dialogEl;
|
||||
if (!el) return;
|
||||
if (open && !el.open) el.showModal();
|
||||
else if (!open && el.open) el.close();
|
||||
});
|
||||
|
||||
function add() {
|
||||
const slice = editor?.state.selection.content();
|
||||
if (!editor || !slice || !name.trim()) return;
|
||||
const html = getHTMLFromFragment(slice.content, editor.schema);
|
||||
putAutoText({ name: name.trim(), shortcut: shortcut.trim(), html });
|
||||
name = '';
|
||||
shortcut = '';
|
||||
}
|
||||
|
||||
function insert(html: string) {
|
||||
editor?.chain().focus().insertContent(html).run();
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog
|
||||
bind:this={dialogEl}
|
||||
onclose={() => (open = false)}
|
||||
onclick={(e) => e.target === dialogEl && (open = false)}
|
||||
aria-label={t().autoText.title}
|
||||
>
|
||||
<div class="body">
|
||||
<h2>{t().autoText.title}</h2>
|
||||
|
||||
{#if autoTextEntries().length}
|
||||
<ul>
|
||||
{#each autoTextEntries() as entry (entry.name)}
|
||||
<li>
|
||||
<button class="entry" onclick={() => insert(entry.html)} title={t().autoText.insertEntry}>
|
||||
<span class="entry-name">{entry.name}</span>
|
||||
{#if entry.shortcut}<span class="entry-key">{entry.shortcut}</span>{/if}
|
||||
</button>
|
||||
<button class="small" onclick={() => removeAutoText(entry.name)} title={t().common.remove}>×</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="hint">{t().autoText.f3Hint}</p>
|
||||
{:else}
|
||||
<p class="hint">{t().autoText.empty}</p>
|
||||
{/if}
|
||||
|
||||
<h3>{t().autoText.newEntry}</h3>
|
||||
<div class="new-row">
|
||||
<input bind:value={name} placeholder={t().autoText.name} disabled={!hasSelection} />
|
||||
<input class="key" bind:value={shortcut} placeholder={t().autoText.shortcut} disabled={!hasSelection} />
|
||||
<button class="small" onclick={add} disabled={!hasSelection || !name.trim()}>{t().common.insert}</button>
|
||||
</div>
|
||||
{#if !hasSelection}<p class="hint">{t().autoText.needsSelection}</p>{/if}
|
||||
|
||||
<div class="actions">
|
||||
<span class="spacer"></span>
|
||||
<button class="primary" onclick={() => (open = false)}>{t().common.close}</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
dialog {
|
||||
/* The global reset zeroes every margin, which also takes the auto centring a
|
||||
modal <dialog> gets by default. */
|
||||
margin: auto;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
dialog::backdrop { background: rgba(0, 0, 0, 0.35); }
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 420px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: 18px 20px 16px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
h2 { font-size: 1rem; }
|
||||
h3 { font-size: 0.85rem; padding-top: 8px; border-top: 1px solid var(--color-border); }
|
||||
.hint { color: var(--color-text-muted); }
|
||||
|
||||
ul { display: flex; flex-direction: column; gap: 4px; list-style: none; }
|
||||
li { display: flex; align-items: center; gap: 6px; }
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
padding: 4px 8px;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.entry:hover { background: var(--color-btn-hover); }
|
||||
.entry-name { flex: 1; }
|
||||
.entry-key { color: var(--color-text-muted); font-family: var(--font-mono, monospace); }
|
||||
|
||||
.new-row { display: flex; gap: 6px; }
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
padding: 4px 6px;
|
||||
font: inherit;
|
||||
}
|
||||
.key { flex: 0 0 6rem; }
|
||||
|
||||
.actions { display: flex; align-items: center; gap: 8px; padding-top: 6px; }
|
||||
.spacer { flex: 1; }
|
||||
|
||||
button.small, .actions button {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
padding: 4px 10px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover:not(:disabled) { background: var(--color-btn-hover); }
|
||||
button:disabled { opacity: 0.5; cursor: default; }
|
||||
.actions .primary {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -81,6 +81,13 @@ and built the same way: one row per change **id** (a paragraph boundary splits o
|
||||
into several ranges), in the author's own colour, click to select the text, accept or
|
||||
reject in place through `acceptRevision`/`rejectRevision`. Opened from the Review tab.
|
||||
|
||||
## AutoText (`AutoTextDialog.svelte`, `editor/extensions/autoText.ts`)
|
||||
|
||||
LibreOffice's Tools ▸ AutoText, reachable where Word puts it too (the ribbon's Insert ▸
|
||||
Text group, the classic chrome's Tools menu): the library, a click to insert, and "new
|
||||
from selection" — the selected slice serialized to HTML. `hasSelection` is derived off
|
||||
`open` because the dialog stays mounted and a modal freezes the selection it opened on.
|
||||
|
||||
## Navigator (`NavigatorPane.svelte`, `editor/extensions/outline.ts`)
|
||||
|
||||
LibreOffice's Navigator and Word's Navigation pane: the outline, click to jump, and the
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
onSelectTheme,
|
||||
docxBusy = false,
|
||||
pdfBusy = false,
|
||||
onNew, onOpen, onSave, onSaveAs, onSaveDocx, onSaveTemplate, onExportPdf, onPrintPdf, onPrint, onAbout, onDocProperties, onAutoCorrect, onNewComment, commentsOpen = false, onToggleComments, revisionsOpen = false, onToggleRevisions,
|
||||
onNew, onOpen, onSave, onSaveAs, onSaveDocx, onSaveTemplate, onExportPdf, onPrintPdf, onPrint, onAbout, onDocProperties, onAutoCorrect, onAutoText, onNewComment, commentsOpen = false, onToggleComments, revisionsOpen = false, onToggleRevisions,
|
||||
navigatorOpen = false, onToggleNavigator,
|
||||
recentFiles = [], onOpenRecent, onForgetRecent,
|
||||
}: {
|
||||
@@ -113,6 +113,7 @@
|
||||
onAbout?: () => void;
|
||||
onDocProperties?: () => void;
|
||||
onAutoCorrect?: () => void;
|
||||
onAutoText?: () => void;
|
||||
onNewComment?: () => void;
|
||||
commentsOpen?: boolean;
|
||||
onToggleComments?: () => void;
|
||||
@@ -332,7 +333,7 @@
|
||||
{#if tab === 'home'}
|
||||
<HomeTab {editor} {tick} bind:showFormattingMarks {onManageStyles} {onFind} onParagraphDialog={() => (paragraphDialogOpen = true)} />
|
||||
{:else if tab === 'insert'}
|
||||
<InsertTab {editor} {tick} {hfActive} {pageMargins} {pageOrientation} {pageFormat} {onEditZone} {onManageTableStyles} />
|
||||
<InsertTab {editor} {tick} {hfActive} {pageMargins} {pageOrientation} {pageFormat} {onEditZone} {onManageTableStyles} {onAutoText} />
|
||||
{:else if tab === 'layout'}
|
||||
<LayoutTab {editor} {tick} {hfActive} bind:pageMargins bind:pageOrientation bind:pageFormat bind:extraHfSections bind:hyphenate bind:pageNumbering bind:pageDecor bind:lineNumbering onParagraphDialog={() => (paragraphDialogOpen = true)} />
|
||||
{:else if tab === 'references'}
|
||||
|
||||
@@ -137,6 +137,8 @@ export const STROKED = {
|
||||
footer: ['M3.5 1.75h9v12.5h-9z', 'M5.4 4h5.2M5.4 6.1h5.2M5.4 8.2h3.1'],
|
||||
pageNumber: ['M3.25 1.75h9.5v12.5h-9.5z', 'M6.9 6.4 8 5.3v5.2', 'M6.75 10.5h2.5'],
|
||||
pageBreak: ['M2 2.5h12M2 13.5h12', 'M8 5.5v5.2', 'M6.2 8.9 8 10.7l1.8-1.8'],
|
||||
// A page with one saved block picked out of its text: the stored entry.
|
||||
quickParts: ['M3 1.75h10v12.5H3z', 'M5.2 4.4h5.6v3H5.2z', 'M5.2 9.9h5.6M5.2 12h3.4'],
|
||||
} as const;
|
||||
|
||||
export const FILLED = {
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
let {
|
||||
editor, tick, hfActive = null, pageMargins, pageOrientation, pageFormat,
|
||||
onEditZone, onManageTableStyles,
|
||||
onEditZone, onManageTableStyles, onAutoText,
|
||||
}: {
|
||||
editor: Editor | null;
|
||||
tick: number;
|
||||
@@ -34,6 +34,7 @@
|
||||
pageFormat: PageFormat;
|
||||
onEditZone?: (zone: HfZone) => void;
|
||||
onManageTableStyles?: (family: StyleFamily) => void;
|
||||
onAutoText?: () => void;
|
||||
} = $props();
|
||||
|
||||
let hasSelection = $derived(tick >= 0 && !!editor && !editor.state.selection.empty);
|
||||
@@ -255,6 +256,14 @@
|
||||
<div class="ribbon-sep"></div>
|
||||
|
||||
<RibbonGroup label={t().ribbon.groups.text}>
|
||||
<RibbonButton
|
||||
variant="big"
|
||||
icon="quickParts"
|
||||
label={t().autoText.title}
|
||||
title={t().autoText.title}
|
||||
disabled={!editor || !onAutoText}
|
||||
onclick={() => onAutoText?.()}
|
||||
/>
|
||||
<div class="rb-captioned" use:captionClicks>
|
||||
<DateTimePicker
|
||||
bind:open={dateOpen}
|
||||
|
||||
@@ -67,6 +67,7 @@ import { Note, NoteRef, NoteSection, Notes } from './extensions/notes';
|
||||
import { Shortcuts } from './extensions/shortcuts';
|
||||
import { AutoCorrect } from './extensions/autoCorrect';
|
||||
import { WordCompletion } from './extensions/wordCompletion';
|
||||
import { AutoText } from './extensions/autoText';
|
||||
import { HEADING_LEVELS, MAX_HEADING_LEVEL } from '../export/odt';
|
||||
import { styleSheet } from '../styles/sheet.svelte';
|
||||
import { noteSettings } from '../storage/notes.svelte';
|
||||
@@ -222,6 +223,8 @@ export const extensions = [
|
||||
TrackChanges.configure({ recording: recordChanges, author: () => loadDocProperties().author.trim() }),
|
||||
AutoCorrect,
|
||||
WordCompletion,
|
||||
// Named blocks of text kept beside the documents; F3 expands a typed shortcut.
|
||||
AutoText,
|
||||
SearchReplace.configure({ sheet: styleSheet }),
|
||||
// Word/LibreOffice key bindings that aren't TipTap defaults; body:true adds the
|
||||
// ones the header/footer schema has no commands for (headings, line spacing, fields).
|
||||
|
||||
@@ -44,6 +44,7 @@ Five topics are large enough to have their own deep-dive — read the file befor
|
||||
- **`pageField.ts`** — inline atoms `pageNumber`/`pageCount`/`chapterField`, rendered as `<span data-page-field>`; `HeaderFooterLayer.svelte` patches each span's text to the real per-page value. `chapterField` is the running head (ODF `text:chapter`, Word `STYLEREF`): its `level` is the outline level to show, `text` the file's cached name, and the page's own chapter is the last heading at or above that level to have started (`Editor.svelte` collects the heading→page map on each pagination pass, only when a zone shows one).
|
||||
- **`autoCorrect.ts`** — AutoCorrect while typing (LibreOffice's Tools ▸ AutoCorrect Options): typographic quotes per document language, its dash matrix, its replacement table, sentence capitalization and TWo INitials. One `handleTextInput` plugin, listed **last** in `extensions.ts` so every input rule (list markers, the rule line) still fires first; `autoCorrectFix(before, opts, lang)` is pure — the whole table is `tests/unit/autocorrect.test.ts`. URL recognition and auto-list are TipTap's own (`Link autolink` — gated by the same options — and the list input rules).
|
||||
- **`wordCompletion.ts`** — LibreOffice's Word Completion (its AutoCorrect dialog's own tab): every word of at least `minLength` letters is collected as it is finished, and typing its first three letters offers the rest. Drawn as a **widget decoration** after the caret, not LibreOffice's tooltip and not its selected-text insert — the offer is outside the document until Enter takes it, so nothing has to be undone when it is not. Only while **typing**: the offer is dropped on a bare caret move, or an Enter meant to split the paragraph would complete a word instead. `priority: 1000`, so its `handleKeyDown` sees Enter ahead of the keymaps. The list and its settings live in `storage/wordCompletion.svelte.ts`, the collection and matching rules in `utils/wordCompletion.ts`.
|
||||
- **`autoText.ts`** — LibreOffice's AutoText / Word's Quick Parts: a named block of text, inserted from the library (`AutoTextDialog.svelte`) or by typing its shortcut and pressing **F3**, which is LibreOffice's key for it. An entry is stored as HTML and parsed back through the schema, so it keeps its marks and blocks and survives a schema that has grown. The library belongs to the app, not to a document (`storage/autoText.svelte.ts`) — as it does in both products, which keep it in a template of their own; nothing about it reaches a saved file.
|
||||
- **`shortcuts.ts`** — see `src/lib/editor/CLAUDE.md`.
|
||||
|
||||
`extensions.ts` deliberately keeps TipTap's `resizable: false` (its columnResizing plugin stays
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Extension, type Editor } from '@tiptap/core';
|
||||
import { wordBefore } from '../../utils/wordCompletion';
|
||||
import { entryForShortcut, type AutoTextEntry } from '../../storage/autoText';
|
||||
import { autoTextEntries } from '../../storage/autoText.svelte';
|
||||
|
||||
// LibreOffice's AutoText: a named block of text, inserted by name or by typing its
|
||||
// shortcut and pressing F3. The entries live in storage/autoText.svelte.ts — they
|
||||
// belong to the app, not to a document, exactly as they do in both products.
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
autoText: {
|
||||
insertAutoText: (entry: AutoTextEntry) => ReturnType;
|
||||
expandAutoText: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** The shortcut typed at the caret and the range it occupies, if it names an entry. */
|
||||
function shortcutAt(editor: Editor): { entry: AutoTextEntry; from: number; to: number } | null {
|
||||
const { empty, $from } = editor.state.selection;
|
||||
if (!empty || !$from.parent.isTextblock) return null;
|
||||
const word = wordBefore($from.parent.textBetween(0, $from.parentOffset, undefined, ' '));
|
||||
const entry = word ? entryForShortcut(autoTextEntries(), word) : null;
|
||||
return entry ? { entry, from: $from.pos - word.length, to: $from.pos } : null;
|
||||
}
|
||||
|
||||
export const AutoText = Extension.create({
|
||||
name: 'autoText',
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertAutoText: (entry) => ({ commands }) => commands.insertContent(entry.html),
|
||||
expandAutoText: () => ({ editor, chain }) => {
|
||||
const hit = shortcutAt(editor);
|
||||
if (!hit) return false;
|
||||
return chain().focus().deleteRange({ from: hit.from, to: hit.to }).insertContent(hit.entry.html).run();
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return { F3: () => this.editor.commands.expandAutoText() };
|
||||
},
|
||||
});
|
||||
@@ -40,6 +40,16 @@ const de: Messages = {
|
||||
urls: '',
|
||||
},
|
||||
},
|
||||
autoText: {
|
||||
title: 'AutoText',
|
||||
empty: 'Noch keine Einträge. Text markieren und unten hinzufügen.',
|
||||
newEntry: 'Neu aus Auswahl',
|
||||
name: 'Name',
|
||||
shortcut: 'Kürzel',
|
||||
insertEntry: 'Diesen Eintrag einfügen',
|
||||
needsSelection: 'Zuerst den zu sichernden Text markieren',
|
||||
f3Hint: 'Kürzel tippen und F3 drücken, um den Eintrag einzufügen.',
|
||||
},
|
||||
wordCompletion: {
|
||||
title: 'Wortergänzung',
|
||||
enabled: 'Wörter während der Eingabe ergänzen',
|
||||
|
||||
@@ -41,6 +41,16 @@ const en = {
|
||||
urls: '',
|
||||
},
|
||||
},
|
||||
autoText: {
|
||||
title: 'AutoText',
|
||||
empty: 'No entries yet. Select some text and add it below.',
|
||||
newEntry: 'New from selection',
|
||||
name: 'Name',
|
||||
shortcut: 'Shortcut',
|
||||
insertEntry: 'Insert this entry',
|
||||
needsSelection: 'Select the text to keep first',
|
||||
f3Hint: 'Type a shortcut and press F3 to insert its entry.',
|
||||
},
|
||||
wordCompletion: {
|
||||
title: 'Word completion',
|
||||
enabled: 'Complete words while typing',
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
- **`pageNumbering.ts`** — the page-number field's format and start value (`edentext-page-numbering`, absent at the 1/1 default). `HeaderFooterLayer.svelte` formats each page through `formatOrdinal`; the page **count** stays decimal, as the field both word processors write does. ODF keeps the format on the page layout and the start on the first paragraph's own style (`style:page-number` — probed, ODF has no document-level start); Word keeps both in the first section's `w:pgNumType`. A **later section** can restart the count (`HfSet.pageNumberStart`): ODF writes `style:page-number` on the same paragraph that switches master page, Word a `w:pgNumType w:start` — and that section is then exported as a page-starting break, since both word processors ignore a start on a continuous one (probed: LibreOffice drops it).
|
||||
- **`hyphenation.ts`** — automatic hyphenation for the whole document (`edentext-hyphenation`, off by default as in both word processors). `Editor.svelte` puts `hyphens: auto` and the document `lang` on the body host — the browser needs the language to pick its patterns. ODF keeps it as `fo:hyphenate` in the **text** properties of the base paragraph style (probed: in paragraph-properties LibreOffice ignores it and drops it on the next save), Word as `w:autoHyphenation` in settings.xml. A **paragraph** can only opt *out* (`noHyphenation`, pageBreak.ts): Word has no per-paragraph "on", and both importers drop the flag under a document that does not hyphenate, where it says what is already true.
|
||||
- **`wordCompletion.ts`** / **`.svelte.ts`** — the word-completion settings *and* the collected words (`edentext-word-completion`), LibreOffice's defaults: on, no appended space, words from eight letters, 1000 of them. The list outlives the document that filled it, as LibreOffice's profile copy does; a reactive singleton, since the extension both reads it and adds to it on every keystroke while the AutoCorrect dialog edits it.
|
||||
- **`autoText.ts`** / **`.svelte.ts`** — the AutoText library (`edentext-autotext`, the key removed when empty): name, shortcut and the entry's HTML. App-level, not document-level, so no exporter looks at it.
|
||||
- **`tableOptions.svelte.ts`** — table number recognition (`edentext-number-recognition`, the key removed when off, as it is by default in LibreOffice). A reactive singleton like `trackChanges.svelte.ts`: the extension reads it on every transaction, both table surfaces flip it. It also holds the **number locale** — the document's language, which is what LibreOffice reads and writes a cell's number in — pushed by `App.svelte`'s language effect; the exporters take that locale from their own `language` argument instead, so they stay store-free. See `docs/architecture/tables.md`.
|
||||
- **`pageDecor.ts`** — the page's own decoration (`edentext-page-decor`, the key removed when all three are off): background colour, a border around the text area at `paddingCm` from it, and a text watermark (LibreOffice's Format ▸ Watermark, field for field). All three ride the ODF page layout — the watermark as a `fontwork-plain-text` `<draw:custom-shape>` named `PowerPlusWaterMarkObject` in the **master page's header**, the only place LibreOffice reads one from (probed: hung off the master page directly it is dropped, and an undeclared `xmlns:draw` in styles.xml drops it too). Word keeps the background on `w:document` (plus `w:displayBackgroundShape`), the border in `w:sectPr` **after `w:pgMar`** — the child order is fixed, and ahead of the header references Word's reader loses those — and the watermark as the VML `_x0000_t136` shape, whose definition has to travel or LibreOffice cannot resolve it. `PageDecorLayer.svelte` draws border and watermark per page; the background is a `--color-page-bg` override on `.paper`.
|
||||
- **`trackChanges.svelte.ts`** — whether the editor records revisions (`edentext-record-changes`, the key removed when off, as it is by default in both word processors). 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.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// The AutoText library as a reactive singleton (like autoCorrect.svelte.ts): the
|
||||
// extension reads it on F3, the dialog adds to and removes from it.
|
||||
|
||||
import { loadAutoText, saveAutoText, type AutoTextEntry } from './autoText';
|
||||
|
||||
let current = $state<AutoTextEntry[]>(loadAutoText());
|
||||
|
||||
export function autoTextEntries(): AutoTextEntry[] {
|
||||
return current;
|
||||
}
|
||||
|
||||
export function setAutoTextEntries(next: AutoTextEntry[]): void {
|
||||
current = next;
|
||||
saveAutoText(current);
|
||||
}
|
||||
|
||||
/** Added, or replaced where an entry of that name is already there. */
|
||||
export function putAutoText(entry: AutoTextEntry): void {
|
||||
setAutoTextEntries([...current.filter((e) => e.name !== entry.name), entry]);
|
||||
}
|
||||
|
||||
export function removeAutoText(name: string): void {
|
||||
setAutoTextEntries(current.filter((e) => e.name !== name));
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// LibreOffice's Tools ▸ AutoText / Word's Insert ▸ Quick Parts: named blocks of text
|
||||
// kept beside the documents, not in one. Both products store them in a template of
|
||||
// their own (`.bau`, a `.dotx`); here they live in localStorage, so they outlive the
|
||||
// document that made them.
|
||||
|
||||
export type AutoTextEntry = {
|
||||
name: string;
|
||||
/** Typed in the text, then F3 — LibreOffice's shortcut, case-insensitive. */
|
||||
shortcut: string;
|
||||
/** The entry's content as HTML, parsed back through the schema on insert. */
|
||||
html: string;
|
||||
};
|
||||
|
||||
const KEY = 'edentext-autotext';
|
||||
|
||||
export function loadAutoText(): AutoTextEntry[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
const data = raw ? (JSON.parse(raw) as AutoTextEntry[]) : [];
|
||||
return Array.isArray(data)
|
||||
? data.filter((e) => e && typeof e.name === 'string' && typeof e.html === 'string')
|
||||
.map((e) => ({ name: e.name, shortcut: String(e.shortcut ?? ''), html: e.html }))
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveAutoText(list: AutoTextEntry[]): void {
|
||||
if (list.length) localStorage.setItem(KEY, JSON.stringify(list));
|
||||
else localStorage.removeItem(KEY);
|
||||
}
|
||||
|
||||
/** The entry a typed shortcut names, matched as LibreOffice matches it: ignoring case. */
|
||||
export function entryForShortcut(list: AutoTextEntry[], word: string): AutoTextEntry | null {
|
||||
const key = word.trim().toLowerCase();
|
||||
return key ? list.find((e) => e.shortcut.toLowerCase() === key) ?? null : null;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// LibreOffice's AutoText: a stored block of text, inserted by name or by typing its
|
||||
// shortcut and pressing F3. The entries belong to the app, not to a document.
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { Editor } from '@tiptap/core';
|
||||
import Document from '@tiptap/extension-document';
|
||||
import Paragraph from '@tiptap/extension-paragraph';
|
||||
import Text from '@tiptap/extension-text';
|
||||
import Bold from '@tiptap/extension-bold';
|
||||
import { AutoText } from '../../src/lib/editor/extensions/autoText';
|
||||
import { entryForShortcut } from '../../src/lib/storage/autoText';
|
||||
import { setAutoTextEntries } from '../../src/lib/storage/autoText.svelte';
|
||||
|
||||
const entry = { name: 'Greeting', shortcut: 'mfg', html: '<p>Yours, <strong>Ada</strong></p>' };
|
||||
|
||||
describe('the shortcut', () => {
|
||||
it('matches ignoring case, and only whole', () => {
|
||||
expect(entryForShortcut([entry], 'MFG')?.name).toBe('Greeting');
|
||||
expect(entryForShortcut([entry], 'mf')).toBeNull();
|
||||
expect(entryForShortcut([entry], '')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('expanding in the editor', () => {
|
||||
beforeEach(() => setAutoTextEntries([entry]));
|
||||
|
||||
function makeEditor(content: string) {
|
||||
const el = document.createElement('div');
|
||||
document.body.appendChild(el);
|
||||
return new Editor({ element: el, extensions: [Document, Paragraph, Text, Bold, AutoText], content });
|
||||
}
|
||||
|
||||
it('replaces the typed shortcut with the entry, marks and all', () => {
|
||||
const e = makeEditor('<p>Bye. mfg</p>');
|
||||
e.commands.setTextSelection(e.state.doc.content.size - 1);
|
||||
expect(e.commands.expandAutoText()).toBe(true);
|
||||
expect(e.state.doc.textContent).toBe('Bye. Yours, Ada');
|
||||
expect(e.state.doc.lastChild!.lastChild!.marks[0]?.type.name).toBe('bold');
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it('does nothing where the word at the caret names no entry', () => {
|
||||
const e = makeEditor('<p>Bye. mfx</p>');
|
||||
e.commands.setTextSelection(e.state.doc.content.size - 1);
|
||||
expect(e.commands.expandAutoText()).toBe(false);
|
||||
expect(e.state.doc.textContent).toBe('Bye. mfx');
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it('inserts an entry the library names, wherever the caret is', () => {
|
||||
const e = makeEditor('<p>Bye.</p>');
|
||||
e.commands.setTextSelection(e.state.doc.content.size - 1);
|
||||
expect(e.commands.insertAutoText(entry)).toBe(true);
|
||||
expect(e.state.doc.textContent).toBe('Bye.Yours, Ada');
|
||||
e.destroy();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user