From d8a779690a5a416cd5d934d90510ce02f46af1a0 Mon Sep 17 00:00:00 2001 From: dani-polani Date: Wed, 6 May 2026 02:50:51 +0300 Subject: [PATCH 01/12] multiple lines --- TODO.md | 36 +- .../src/lib/components/editor/Editor.svelte | 66 +-- .../components/editor/GlossInputRow.svelte | 42 -- .../src/lib/components/editor/LineCard.svelte | 305 +++++++++++ .../components/editor/SentenceField.svelte | 75 --- .../lib/components/editor/TokenChip.svelte | 15 +- .../preview/AlignmentPreview.svelte | 69 +-- .../components/preview/AlignmentSvg.svelte | 141 +++-- .../lib/components/preview/GlossRow.svelte | 42 -- .../preview/PreviewFontLoader.svelte | 42 +- .../lib/components/preview/TokenRow.svelte | 11 +- .../lib/components/preview/TokenView.svelte | 83 ++- .../components/settings/AppearanceTab.svelte | 70 +-- .../lib/components/settings/ColorsTab.svelte | 2 +- .../lib/components/settings/FontsTab.svelte | 246 +++------ .../components/settings/LinguisticsTab.svelte | 52 +- .../components/settings/SettingsPanel.svelte | 8 +- .../components/share/CopyLinkButton.svelte | 4 +- .../lib/components/share/ExportMenu.svelte | 54 +- .../lib/components/share/ShareDialog.svelte | 4 +- .../lib/components/share/ShareQuickRow.svelte | 6 +- bitext/src/lib/domain/alignment.ts | 94 ++-- bitext/src/lib/domain/lines-helpers.ts | 103 ++++ bitext/src/lib/domain/link-geometry.ts | 26 +- bitext/src/lib/domain/link-graph.ts | 90 ++-- bitext/src/lib/domain/tokens.ts | 30 +- bitext/src/lib/export/svg.ts | 87 ++-- bitext/src/lib/fonts/custom-fonts.ts | 11 +- bitext/src/lib/fonts/ensure-document-fonts.ts | 25 +- bitext/src/lib/fonts/inline-fonts.ts | 47 +- bitext/src/lib/fonts/text-to-paths.ts | 17 +- bitext/src/lib/fonts/visualization-font.ts | 60 +-- bitext/src/lib/seo/og-svg.ts | 34 +- bitext/src/lib/serialization/compact-v2.ts | 43 +- bitext/src/lib/serialization/compact-v3.ts | 258 ++++++++++ bitext/src/lib/serialization/decode.ts | 42 +- bitext/src/lib/serialization/encode.ts | 6 +- .../lib/serialization/migrate-v1-v2.test.ts | 32 ++ bitext/src/lib/serialization/schema.ts | 486 ++++++++++++++---- .../serialization-roundtrip.test.ts | 298 +++++------ bitext/src/lib/state/layoutExport.svelte.ts | 17 +- bitext/src/lib/state/project.svelte.ts | 367 ++++++++----- bitext/src/lib/state/selection.svelte.ts | 82 ++- bitext/src/lib/state/settings.svelte.ts | 16 +- bitext/src/routes/+page.svelte | 191 +++++-- docs/v2-manual-qa.md | 15 + 46 files changed, 2281 insertions(+), 1569 deletions(-) delete mode 100644 bitext/src/lib/components/editor/GlossInputRow.svelte create mode 100644 bitext/src/lib/components/editor/LineCard.svelte delete mode 100644 bitext/src/lib/components/editor/SentenceField.svelte delete mode 100644 bitext/src/lib/components/preview/GlossRow.svelte create mode 100644 bitext/src/lib/domain/lines-helpers.ts create mode 100644 bitext/src/lib/serialization/compact-v3.ts create mode 100644 bitext/src/lib/serialization/migrate-v1-v2.test.ts create mode 100644 docs/v2-manual-qa.md diff --git a/TODO.md b/TODO.md index 625cdbc..6e7dd92 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,8 @@ +# Version 1.0.0 + - [x] Sort out glossing functionality - [x] Improve sharing visuals -- [ ] Check SEO +- [x] Check SEO - [x] Highlighting tokens when selecting (hovering) and when selected (with current color, which will be the link color) - [x] Make sure instructions are clear and concise, up to date, and complete. - [x] Add better examples - with complex links and advanced tokenization. @@ -9,3 +11,35 @@ - [x] Dependency CVE check (`npm audit` / CI) - [x] Add QR code export method - [-] Add QR code to visualization exports (small in the corner - only site link; `siteLandingQrDataUrl` + `siteQrPngDataUri` in svg — wiring disabled in ExportMenu) - out of scope for now + +# Version 2 + +Includes bugs and feature requests from the public. + +Feature requests - high priority: +- [ ] Add ability to add more than 2 lines +- [ ] Improve support for longer sentences - currently non-svg export is low resolution when font is small +- [ ] add special separator to combine words into a single token - it will be connected with 1 line in the visualization but still be written with a space or spaces +- [ ] add ability to optionally tokenize punctuation as separate tokens +- [ ] add transcription line support (probably can be solved by adding more than 2 lines) + +Usability improvements - high priority: +- [ ] Parameter card or other view should move to be next to the editor - currently on small screens you have to scroll back and forth between the editor and the parameters + +Bug fixes - high priority: +- [ ] Reportedly ligatures in custom fonts are not working in the export (but fine in preview) - investigate and fix +- [ ] When color palette is depleted, it should cycle through the colors - currently uses the last color + +Advanced features - medium priority: +- [ ] Ability to create custom color palettes +- [ ] Maybe parameter-line connection should be reworked to be more flexible - each line should have all the parameters configured separately. + +General interface improvements - medium priority: +- [ ] Interface languages - add pages for some major languages +- [ ] Make interface more compact to accomodate more features +- [ ] Probably add full screen mode for the preview - so that the user would be able to see it all and screenshot if needed - this will partially help if we won't be able to solve ligature problems + +Considerations: +- If we support multiple lines with independent parameters, we can deprecate separate gloss row and configuration - it will be just a single new line with the glosses. Then, the user would be able to add transcription and other annotations in the same manner. +- In case of adding multiple lines, additional lines after the first 2 should be optional. +- The ultimate fix for pdf export would be to use external resource like gotenberg. We can set up a server with it, but preferably this is to be avoided since it will add costs to support it. diff --git a/bitext/src/lib/components/editor/Editor.svelte b/bitext/src/lib/components/editor/Editor.svelte index a97cc24..377b99b 100644 --- a/bitext/src/lib/components/editor/Editor.svelte +++ b/bitext/src/lib/components/editor/Editor.svelte @@ -1,9 +1,8 @@ @@ -32,47 +31,26 @@

- Edit the sentences here. To link words, click a word in the preview below, then click the - matching word on the other line — the connector will appear. You can link a word to multiple - words on the other side. Click a connector to remove it. Click a selected word again to deselect - it. + Each row is a line of text with its own font and size. In the preview, click a word, then click + a word on an adjacent line to connect them. Connectors only run between neighboring + lines. Click a connector to remove it. Click the same word again to deselect.

-
-
- projectStore.setSourceText(v)} - /> -
-
- projectStore.setTargetText(v)} - /> -
+ {#each projectStore.lines as line, i (line.id)} + + {/each} +
+ + {#if projectStore.lines.length >= MAX_LINES} +

+ Soft limit: {MAX_LINES} lines — consider simplifying for shorter share links. +

+ {/if}
- {#if settingsStore.settings.showGloss} -
-
-

Source glosses

- projectStore.setSourceGloss(id, v)} - /> -
-
-

Target glosses

- projectStore.setTargetGloss(id, v)} - /> -
-
- {/if} diff --git a/bitext/src/lib/components/editor/GlossInputRow.svelte b/bitext/src/lib/components/editor/GlossInputRow.svelte deleted file mode 100644 index c26d465..0000000 --- a/bitext/src/lib/components/editor/GlossInputRow.svelte +++ /dev/null @@ -1,42 +0,0 @@ - - -
- {#each tokens as t (t.id)} -
- - onGloss(t.id, (e.currentTarget as HTMLInputElement).value)} - /> -
- {/each} -
diff --git a/bitext/src/lib/components/editor/LineCard.svelte b/bitext/src/lib/components/editor/LineCard.svelte new file mode 100644 index 0000000..bd01c6f --- /dev/null +++ b/bitext/src/lib/components/editor/LineCard.svelte @@ -0,0 +1,305 @@ + + +
+
+ + + Line {index + 1} + + + + +
+ + + + +
+
+ + +
+ {#if line.font.source === 'google'} +
+ + +
+ {:else} +
+ + + +
+ {/if} +
+ + + projectStore.updateLineStyle(line.id, { + textSizePx: Number((e.currentTarget as HTMLInputElement).value) + })} + /> +
+
+ +
+ {#each tokens as t (t.id)} + + {/each} +
+ + {#if nextLine} +
+ +
+ {/if} +
diff --git a/bitext/src/lib/components/editor/SentenceField.svelte b/bitext/src/lib/components/editor/SentenceField.svelte deleted file mode 100644 index fbdcefc..0000000 --- a/bitext/src/lib/components/editor/SentenceField.svelte +++ /dev/null @@ -1,75 +0,0 @@ - - -
- - -
- {#each tokens as t (t.id)} - - {/each} -
-
diff --git a/bitext/src/lib/components/editor/TokenChip.svelte b/bitext/src/lib/components/editor/TokenChip.svelte index 0467745..2ed5ea9 100644 --- a/bitext/src/lib/components/editor/TokenChip.svelte +++ b/bitext/src/lib/components/editor/TokenChip.svelte @@ -1,12 +1,10 @@ @@ -44,45 +30,22 @@
{/if}
- {#if showSourceGloss} + {#each projectStore.lines as line, li (line.id)}
- -
- {/if} -
- -
-
- -
- {#if showTargetGloss} -
- +
- {/if} + {/each}
- +
diff --git a/bitext/src/lib/components/preview/AlignmentSvg.svelte b/bitext/src/lib/components/preview/AlignmentSvg.svelte index 29582bc..e577a1f 100644 --- a/bitext/src/lib/components/preview/AlignmentSvg.svelte +++ b/bitext/src/lib/components/preview/AlignmentSvg.svelte @@ -1,7 +1,8 @@ - - - diff --git a/bitext/src/lib/components/preview/PreviewFontLoader.svelte b/bitext/src/lib/components/preview/PreviewFontLoader.svelte index 33a2578..d2e4304 100644 --- a/bitext/src/lib/components/preview/PreviewFontLoader.svelte +++ b/bitext/src/lib/components/preview/PreviewFontLoader.svelte @@ -1,46 +1,32 @@ diff --git a/bitext/src/lib/components/preview/TokenRow.svelte b/bitext/src/lib/components/preview/TokenRow.svelte index 75a27e1..4ba125a 100644 --- a/bitext/src/lib/components/preview/TokenRow.svelte +++ b/bitext/src/lib/components/preview/TokenRow.svelte @@ -5,12 +5,14 @@ let { tokens, - side, + lineId, + textSizePx, showNumbers, interactive = false }: { tokens: Token[]; - side: 'source' | 'target'; + lineId: string; + textSizePx: number; showNumbers: boolean; interactive?: boolean; } = $props(); @@ -18,7 +20,7 @@ const gap = $derived(settingsStore.settings.gapWordPx); -
+
{#each tokens as t, i (t.id)} {@const nextTok = tokens[i + 1]} import type { Token } from '$lib/domain/tokens.js'; - import { pendingAlignmentColor, primaryLinkForToken } from '$lib/domain/alignment.js'; + import { pendingAlignmentColor, primaryConnectionForToken } from '$lib/domain/alignment.js'; import { settingsStore } from '$lib/state/settings.svelte.js'; import { projectStore } from '$lib/state/project.svelte.js'; import { selectionStore } from '$lib/state/selection.svelte.js'; let { token, - side, + lineId, + textSizePx, showNumber, index, interactive = false, @@ -15,75 +16,59 @@ joinTightEnd = false }: { token: Token; - side: 'source' | 'target'; + lineId: string; + textSizePx: number; showNumber: boolean; index: number; interactive?: boolean; - /** Strip inline padding on the start when `token.joinLeft` (alt. separator split). */ joinTightStart?: boolean; - /** Strip inline padding on the end when the next token is joined to this one. */ joinTightEnd?: boolean; } = $props(); let hovering = $state(false); - const sz = $derived( - side === 'source' - ? settingsStore.settings.sourceTextSizePx - : settingsStore.settings.targetTextSizePx - ); const palette = $derived(settingsStore.settings.palette); - - const links = $derived(projectStore.links); - const link = $derived.by(() => primaryLinkForToken(links, token.id)); + const connections = $derived(projectStore.connections); + const lineIds = $derived(projectStore.lines.map((l) => l.id)); + const pending = $derived(selectionStore.pending); + const conn = $derived.by(() => primaryConnectionForToken(connections, token.id)); const textColor = $derived.by(() => { - if (!settingsStore.settings.colorTokensByLink || !link?.color) return null; - return link.color; + if (!settingsStore.settings.colorTokensByLink || !conn?.color) return null; + return conn.color; }); - const selectedSource = $derived(selectionStore.selectedSource); - const selectedTarget = $derived(selectionStore.selectedTarget); - - const isSelected = $derived.by(() => - side === 'source' ? selectedSource.has(token.id) : selectedTarget.has(token.id) - ); + const isPinned = $derived(pending != null && pending.tokenId === token.id); const accentColor = $derived.by(() => { if (!interactive) return null; - const src = [...selectedSource]; - const tgt = [...selectedTarget]; - - if (isSelected) { - return pendingAlignmentColor(links, src, tgt, palette); + if (isPinned) { + return pendingAlignmentColor(connections, [token.id], [], palette); } if (!hovering) return null; - - if (src.length && tgt.length === 0 && side === 'target') { - return pendingAlignmentColor(links, src, [token.id], palette); - } - if (tgt.length && src.length === 0 && side === 'source') { - return pendingAlignmentColor(links, [token.id], tgt, palette); - } - if (src.length === 0 && tgt.length === 0) { - return side === 'source' - ? pendingAlignmentColor(links, [token.id], [], palette) - : pendingAlignmentColor(links, [], [token.id], palette); + const pend = pending; + if (!pend) { + return pendingAlignmentColor(connections, [token.id], [], palette); } - if (src.length && side === 'source') { - return pendingAlignmentColor(links, [token.id], [], palette); + if (pend.lineId === lineId) { + return pendingAlignmentColor(connections, [token.id], [], palette); } - if (tgt.length && side === 'target') { - return pendingAlignmentColor(links, [], [token.id], palette); + const ip = lineIds.indexOf(pend.lineId); + const it = lineIds.indexOf(lineId); + if (ip < 0 || it < 0) return null; + if (Math.abs(ip - it) !== 1) { + return pendingAlignmentColor(connections, [token.id], [], palette); } - return null; + const upperTok = ip < it ? pend.tokenId : token.id; + const lowerTok = ip < it ? token.id : pend.tokenId; + return pendingAlignmentColor(connections, [upperTok], [lowerTok], palette); }); const displayColor = $derived(accentColor ?? textColor ?? undefined); function onClick() { if (!interactive) return; - selectionStore.previewTokenClick(side, token.id); + selectionStore.previewTokenClick(lineId, token.id); } @@ -94,11 +79,11 @@ class:token-view--join-before={joinTightStart} class:token-view--join-after={joinTightEnd} class:token-view--colored={textColor && !accentColor} - class:token-view--accent-sel={accentColor !== null && isSelected} - class:token-view--accent-hover={accentColor !== null && !isSelected} + class:token-view--accent-sel={accentColor !== null && isPinned} + class:token-view--accent-hover={accentColor !== null && !isPinned} data-token-id={token.id} - data-side={side} - style:font-size="{sz}px" + data-line={lineId} + style:font-size="{textSizePx}px" style:--token-accent={accentColor ?? 'transparent'} style:color={displayColor} onclick={onClick} @@ -121,8 +106,8 @@ class:token-view--join-after={joinTightEnd} class:token-view--colored={textColor} data-token-id={token.id} - data-side={side} - style:font-size="{sz}px" + data-line={lineId} + style:font-size="{textSizePx}px" style:color={textColor ?? undefined} > {#if showNumber} diff --git a/bitext/src/lib/components/settings/AppearanceTab.svelte b/bitext/src/lib/components/settings/AppearanceTab.svelte index fbafc61..159f2f9 100644 --- a/bitext/src/lib/components/settings/AppearanceTab.svelte +++ b/bitext/src/lib/components/settings/AppearanceTab.svelte @@ -1,12 +1,7 @@
-
- - -
- settingsStore.patch({ - sourceFontSource: (e.currentTarget as HTMLSelectElement).value as 'google' | 'custom' - })} - > - - - +
+ +
- {#if s.sourceFontSource === 'google'} -
- - + {#if status} +
+ {status}
- {:else} -
- - onCustomFile('source', e)} - /> -
- {#if s.sourceCustomFontName} -
-

- Loaded: {s.sourceCustomFontName} -

-
- {/if} {/if} - -
-

Target line

-
-
- - -
- {#if s.targetFontSource === 'google'} -
- - -
- {:else} -
- - onCustomFile('target', e)} - /> -
- {#if s.targetCustomFontName} -
-

- Loaded: {s.targetCustomFontName} -

-
+ {/if} - {/if} - -
-

Gloss line

-

- Interlinear glosses (preview, editor, export). Independent of the source and target script - fonts. -

-
-
- -
- {#if s.glossFontSource === 'google'} -
- - -
- {:else} -
- - onCustomFile('gloss', e)} - /> -
- {#if s.glossCustomFontName} -
-

- Loaded: {s.glossCustomFontName} -

-
- {/if} - {/if}
diff --git a/bitext/src/lib/components/settings/LinguisticsTab.svelte b/bitext/src/lib/components/settings/LinguisticsTab.svelte index e8abb53..37535f0 100644 --- a/bitext/src/lib/components/settings/LinguisticsTab.svelte +++ b/bitext/src/lib/components/settings/LinguisticsTab.svelte @@ -1,13 +1,9 @@
-
-
-
-

- Interlinear gloss row -

-

- Source glosses above the source line, target glosses below the target line (when filled) -

-
- -
-
-
- -

- Distance between each gloss row and its sentence. Default ≈ 1.5× gloss line height. -

- - settingsStore.patch({ - glossLineGapPx: Number((e.currentTarget as HTMLInputElement).value) - })} - /> -
diff --git a/bitext/src/lib/components/settings/SettingsPanel.svelte b/bitext/src/lib/components/settings/SettingsPanel.svelte index 9e3d1cc..536f11b 100644 --- a/bitext/src/lib/components/settings/SettingsPanel.svelte +++ b/bitext/src/lib/components/settings/SettingsPanel.svelte @@ -42,13 +42,13 @@
- + {#snippet titleSlot()} - Linguistics + Tokens {/snippet} @@ -58,7 +58,7 @@ {#snippet titleSlot()} - + Fonts diff --git a/bitext/src/lib/components/share/CopyLinkButton.svelte b/bitext/src/lib/components/share/CopyLinkButton.svelte index d62e329..8d73ed1 100644 --- a/bitext/src/lib/components/share/CopyLinkButton.svelte +++ b/bitext/src/lib/components/share/CopyLinkButton.svelte @@ -1,14 +1,14 @@ @@ -103,31 +123,70 @@
-
-

- Word-by-word translation visualizer -

-

- See exactly which word matches which across two translated sentences. Link single words or - short phrases, add an optional interlinear gloss, and export a clean image for lessons, posts, - or conlang notes. -
- Created by - Dani. See other - tools for linguistics and conlanging. -

+
+
+
+

+ Word-by-word translation visualizer +

+
+
+ + +
+
+

+ Created by + Dani. See other + tools for linguistics and conlanging. +

+
+
+

+ See exactly which word matches which across translated lines. Stack multiple lines (e.g. + source, IPA, target), link adjacent rows, and export a clean image for lessons, posts, or + conlang notes. +

+
+
@@ -146,24 +205,68 @@ {#if selectionStore.showLinkHint()}

- Click a word on the other line to create the link. + {#if selectionStore.adjacencyHint} + Only adjacent lines can be linked — choose a word directly above or + below. + {:else} + Click a word on an adjacent line to create the link. + {/if}

{/if}
- +
+ + +
+ {#if previewExpand} + + {/if}
diff --git a/docs/v2-manual-qa.md b/docs/v2-manual-qa.md new file mode 100644 index 0000000..1ae73d3 --- /dev/null +++ b/docs/v2-manual-qa.md @@ -0,0 +1,15 @@ +# v2 manual QA checklist + +Quick pass after multi-line / adjacent-pair changes. + +1. **Lines**: Add 5 lines, edit text, set different fonts/sizes per line (Google + custom if available). +2. **Links**: Create a chain of connections across all adjacent pairs; verify colors/concepts match palette rules. +3. **Pair control**: Turn off “Show connectors with line below” for one pair; paths hide, token colors still follow concept. +4. **Reorder**: Use Up/Down and drag-and-drop; invalid cross-line connections drop; pair controls stay valid. +5. **Delete middle line**: Confirm connections prune; app stays stable. +6. **Export**: PNG, PDF, SVG, HTML — open SVG in a clean viewer; text and paths look correct. +7. **Share**: Copy link, open in a private/incognito tab; state loads (compact v3). +8. **Legacy URL**: Open an old v2-in-URL (pre–v3 wire) share link if you have one; data should migrate sensibly. +9. **Fullscreen**: Expand preview, resize, press Escape; layout remeasures after close. + +Record failures with browser + rough steps. From 213313bd6bd36dd17dab5017b47bf142e992858d Mon Sep 17 00:00:00 2001 From: dani-polani Date: Wed, 6 May 2026 03:31:54 +0300 Subject: [PATCH 02/12] new editor --- .../src/lib/components/editor/Editor.svelte | 16 -- .../components/editor/LineEditModal.svelte | 137 +++++++++ .../editor/LineSettingsPopover.svelte | 269 ++++++++++++++++++ .../preview/AlignmentPreview.svelte | 67 ++++- .../components/preview/AlignmentSvg.svelte | 49 +++- .../preview/LineReorderButtons.svelte | 39 +++ .../preview/LineTrailingActions.svelte | 44 +++ bitext/src/lib/domain/lines-helpers.ts | 34 +++ bitext/src/lib/serialization/schema.ts | 3 + bitext/src/lib/state/editorUi.svelte.ts | 15 + bitext/src/lib/state/layoutExport.svelte.ts | 13 + bitext/src/lib/state/project.svelte.ts | 10 +- bitext/src/routes/+page.svelte | 44 ++- 13 files changed, 696 insertions(+), 44 deletions(-) create mode 100644 bitext/src/lib/components/editor/LineEditModal.svelte create mode 100644 bitext/src/lib/components/editor/LineSettingsPopover.svelte create mode 100644 bitext/src/lib/components/preview/LineReorderButtons.svelte create mode 100644 bitext/src/lib/components/preview/LineTrailingActions.svelte create mode 100644 bitext/src/lib/state/editorUi.svelte.ts diff --git a/bitext/src/lib/components/editor/Editor.svelte b/bitext/src/lib/components/editor/Editor.svelte index 377b99b..28c9596 100644 --- a/bitext/src/lib/components/editor/Editor.svelte +++ b/bitext/src/lib/components/editor/Editor.svelte @@ -13,22 +13,6 @@ > Editor -
- - -

Each row is a line of text with its own font and size. In the preview, click a word, then click diff --git a/bitext/src/lib/components/editor/LineEditModal.svelte b/bitext/src/lib/components/editor/LineEditModal.svelte new file mode 100644 index 0000000..b484680 --- /dev/null +++ b/bitext/src/lib/components/editor/LineEditModal.svelte @@ -0,0 +1,137 @@ + + += 0 ? `Edit line ${lineIndex + 1}` : 'Edit line'} + size="lg" +> + {#if currentLine} +

+ Whitespace splits words. Extra split characters (from Linguistics in settings) also split + within the line (currently + {settingsStore.settings.tokenSplitChars || DEFAULT_TOKEN_SPLIT_CHARS}). Below is a live preview of tokens. +

+ + +
+ {#each tokens as t (t.id)} + + {/each} +
+ {:else} +

No line selected.

+ {/if} + + {#snippet footer()} +
+ +
+ {/snippet} + diff --git a/bitext/src/lib/components/editor/LineSettingsPopover.svelte b/bitext/src/lib/components/editor/LineSettingsPopover.svelte new file mode 100644 index 0000000..f32eaf6 --- /dev/null +++ b/bitext/src/lib/components/editor/LineSettingsPopover.svelte @@ -0,0 +1,269 @@ + + + +
+

+ {popoverTitle} +

+
+ +
+ +
+
+ + +
+ {#if line.font.source === 'google'} +
+ + +
+ {:else} +
+ + + +
+ {/if} +
+ + + projectStore.updateLineStyle(line.id, { + textSizePx: Number((e.currentTarget as HTMLInputElement).value) + })} + /> +
+
+ +
Preview tokens
+
+ {#each tokens as t (t.id)} + + {/each} +
+ + {#if nextLine} +
+ +
+ {/if} +
+
diff --git a/bitext/src/lib/components/preview/AlignmentPreview.svelte b/bitext/src/lib/components/preview/AlignmentPreview.svelte index 647d47e..825253c 100644 --- a/bitext/src/lib/components/preview/AlignmentPreview.svelte +++ b/bitext/src/lib/components/preview/AlignmentPreview.svelte @@ -2,15 +2,31 @@ import TokenRow from './TokenRow.svelte'; import AlignmentSvg from './AlignmentSvg.svelte'; import PreviewFontLoader from './PreviewFontLoader.svelte'; + import LineReorderButtons from './LineReorderButtons.svelte'; + import LineTrailingActions from './LineTrailingActions.svelte'; import { projectStore } from '$lib/state/project.svelte.js'; import { settingsStore } from '$lib/state/settings.svelte.js'; + import { selectionStore } from '$lib/state/selection.svelte.js'; + import { lineIsLinkTargetWhilePending } from '$lib/domain/lines-helpers.js'; import { resolveLineFontCss } from '$lib/fonts/visualization-font.js'; + import { MAX_LINES } from '$lib/serialization/schema.js'; + + let { + instancePrefix = 'preview-default', + writesExportLayout = true + }: { + /** Unique prefix for gear `id` / Popover anchor when several previews are mounted. */ + instancePrefix?: string; + /** Only one instance should push layout into `layoutExportStore` (export / PNG). */ + writesExportLayout?: boolean; + } = $props(); let rootEl = $state(null); const gapLine = $derived(settingsStore.settings.gapLinePx); const bg = $derived(settingsStore.settings.background); const connections = $derived(projectStore.connections); + const lineIds = $derived(projectStore.lines.map((l) => l.id)); @@ -30,22 +46,57 @@
{/if}
+
+ +
{#each projectStore.lines as line, li (line.id)} + {@const gearDomId = `${instancePrefix}-line-gear-${line.id}`} + {@const pending = selectionStore.pending} + {@const rowDimmed = + pending != null && !lineIsLinkTargetWhilePending(lineIds, pending.lineId, line.id)}
- +
+ +
+
{/each} +
+ +
- +
diff --git a/bitext/src/lib/components/preview/AlignmentSvg.svelte b/bitext/src/lib/components/preview/AlignmentSvg.svelte index e577a1f..d9046da 100644 --- a/bitext/src/lib/components/preview/AlignmentSvg.svelte +++ b/bitext/src/lib/components/preview/AlignmentSvg.svelte @@ -2,21 +2,37 @@ import { browser } from '$app/environment'; import LinkPath from './LinkPath.svelte'; import type { Connection } from '$lib/domain/alignment.js'; - import { canonicalPair, showConnectorsForPair, tokenLineId } from '$lib/domain/lines-helpers.js'; + import { + canonicalPair, + connectionIsActiveForPendingSelection, + showConnectorsForPair, + tokenLineId + } from '$lib/domain/lines-helpers.js'; import { linkEndpoints, linkPathD } from '$lib/domain/link-geometry.js'; import { projectStore } from '$lib/state/project.svelte.js'; import { settingsStore } from '$lib/state/settings.svelte.js'; import { linkHover } from '$lib/state/linkHover.svelte.js'; import { layoutExportStore, type TokenLayout } from '$lib/state/layoutExport.svelte.js'; + import { selectionStore } from '$lib/state/selection.svelte.js'; let { rootEl, - connections + connections, + writesExportLayout = true }: { rootEl: HTMLElement | null; connections: Connection[]; + /** When false, this preview only draws links locally (avoids clobbering export layout). */ + writesExportLayout?: boolean; } = $props(); + let displayTokenLayout = $state>({}); + + const lineOrder = $derived(projectStore.lines.map((l) => l.id)); + + /** Opacity multiplier for connectors not usable while picking the second token (0 = hidden is wrong; use low alpha). */ + const PENDING_DIM_FACTOR = 0.22; + function shouldDrawPath(conn: Connection): boolean { const lineOrder = projectStore.lines.map((l) => l.id); const pair = canonicalPair( @@ -74,13 +90,16 @@ linkPaths.push({ linkId: conn.id, color, d }); } - layoutExportStore.setSnapshot({ - width: w, - height: h, - tokenLayout, - linkPaths, - lineRowY - }); + displayTokenLayout = tokenLayout; + if (writesExportLayout) { + layoutExportStore.setSnapshot({ + width: w, + height: h, + tokenLayout, + linkPaths, + lineRowY + }); + } } $effect(() => { @@ -96,6 +115,7 @@ void projectStore.lines; void settingsStore.settings.gapLinePx; void settingsStore.settings.gapWordPx; + void writesExportLayout; function remeasure() { requestAnimationFrame(() => { requestAnimationFrame(() => measure()); @@ -135,20 +155,25 @@ {#each connections as conn (conn.id)} {#if shouldDrawPath(conn)} - {@const p1 = layoutExportStore.tokenLayout[conn.upperTokenId]} - {@const p2 = layoutExportStore.tokenLayout[conn.lowerTokenId]} + {@const p1 = displayTokenLayout[conn.upperTokenId]} + {@const p2 = displayTokenLayout[conn.lowerTokenId]} {#if p1 && p2} {@const pts = linkEndpoints(p1, p2)} {@const d = linkPathD(pts.x1, pts.y1, pts.x2, pts.y2, settingsStore.settings.lineStyle)} {@const col = conn.color ?? '#94a3b8'} {@const hi = linkHover.id === conn.id} + {@const pend = selectionStore.pending} + {@const activeForPending = + pend == null || connectionIsActiveForPendingSelection(lineOrder, conn, pend.lineId)} + {@const baseOp = settingsStore.settings.lineOpacity} + {@const pathOpacity = hi ? 1 : activeForPending ? baseOp : baseOp * PENDING_DIM_FACTOR} { linkHover.id = id; diff --git a/bitext/src/lib/components/preview/LineReorderButtons.svelte b/bitext/src/lib/components/preview/LineReorderButtons.svelte new file mode 100644 index 0000000..fed8d19 --- /dev/null +++ b/bitext/src/lib/components/preview/LineReorderButtons.svelte @@ -0,0 +1,39 @@ + + +
+ + +
diff --git a/bitext/src/lib/components/preview/LineTrailingActions.svelte b/bitext/src/lib/components/preview/LineTrailingActions.svelte new file mode 100644 index 0000000..d1b262e --- /dev/null +++ b/bitext/src/lib/components/preview/LineTrailingActions.svelte @@ -0,0 +1,44 @@ + + +
+ + + +
diff --git a/bitext/src/lib/domain/lines-helpers.ts b/bitext/src/lib/domain/lines-helpers.ts index 4cf64a4..d78ebce 100644 --- a/bitext/src/lib/domain/lines-helpers.ts +++ b/bitext/src/lib/domain/lines-helpers.ts @@ -91,6 +91,40 @@ export function tokenLineId(tokenId: string): string { return i > 0 ? tokenId.slice(0, i) : tokenId; } +/** + * While a token is pinned for linking, these are the line ids the user may click on + * (that line and its vertical neighbors in the stack). + */ +export function lineIsLinkTargetWhilePending( + lineIds: string[], + pendingLineId: string, + rowLineId: string +): boolean { + const i = lineIds.indexOf(pendingLineId); + if (i < 0) return false; + if (rowLineId === pendingLineId) return true; + if (i > 0 && rowLineId === lineIds[i - 1]) return true; + if (i + 1 < lineIds.length && rowLineId === lineIds[i + 1]) return true; + return false; +} + +/** + * While a token is selected for linking (`pendingLineId`), only connectors on the pair(s) + * that include that line and a vertically adjacent line are "active"; others are dimmed in the UI. + */ +export function connectionIsActiveForPendingSelection( + lineIds: string[], + conn: Connection, + pendingLineId: string +): boolean { + const a = tokenLineId(conn.upperTokenId); + const b = tokenLineId(conn.lowerTokenId); + return ( + lineIsLinkTargetWhilePending(lineIds, pendingLineId, a) && + lineIsLinkTargetWhilePending(lineIds, pendingLineId, b) + ); +} + /** True when `upper` is directly above `lower` in the current line stack. */ export function isStackedAdjacentPair( lines: LineV2[], diff --git a/bitext/src/lib/serialization/schema.ts b/bitext/src/lib/serialization/schema.ts index 2743bcf..5156976 100644 --- a/bitext/src/lib/serialization/schema.ts +++ b/bitext/src/lib/serialization/schema.ts @@ -12,6 +12,9 @@ export const SCHEMA_VERSION_V1 = 1 as const; export const MAX_LINES = 8; +/** Initial text for lines created with “Add line” (prompts editing; user can replace). */ +export const NEW_LINE_HINT_TEXT = 'Type your text here'; + export type LineStyle = 'straight' | 'curved'; export type BackgroundMode = 'light' | 'dark' | 'image'; export type UiTheme = 'light' | 'dark'; diff --git a/bitext/src/lib/state/editorUi.svelte.ts b/bitext/src/lib/state/editorUi.svelte.ts new file mode 100644 index 0000000..da22aea --- /dev/null +++ b/bitext/src/lib/state/editorUi.svelte.ts @@ -0,0 +1,15 @@ +/** UI-only state for inline preview editing (text modal). */ + +class EditorUiStore { + editingLineId = $state(null); + + openEditLine(lineId: string) { + this.editingLineId = lineId; + } + + closeEditLine() { + this.editingLineId = null; + } +} + +export const editorUiStore = new EditorUiStore(); diff --git a/bitext/src/lib/state/layoutExport.svelte.ts b/bitext/src/lib/state/layoutExport.svelte.ts index aba65a9..9465a07 100644 --- a/bitext/src/lib/state/layoutExport.svelte.ts +++ b/bitext/src/lib/state/layoutExport.svelte.ts @@ -22,6 +22,19 @@ class LayoutExportStore { this.layoutRemeasureTick++; } + /** After popovers, flex reflow, or toggling connectors — measure on the next paint frames. */ + requestRemeasureAfterLayout() { + if (typeof window === 'undefined') { + this.layoutRemeasureTick++; + return; + } + requestAnimationFrame(() => { + requestAnimationFrame(() => { + this.layoutRemeasureTick++; + }); + }); + } + setSnapshot(s: { width: number; height: number; diff --git a/bitext/src/lib/state/project.svelte.ts b/bitext/src/lib/state/project.svelte.ts index b7c1af1..8d8ac70 100644 --- a/bitext/src/lib/state/project.svelte.ts +++ b/bitext/src/lib/state/project.svelte.ts @@ -19,10 +19,12 @@ import type { Token } from '$lib/domain/tokens.js'; import { defaultProjectSnapshotV2, MAX_LINES, + NEW_LINE_HINT_TEXT, type LineV2, type PairControlV2, type ProjectSnapshotV2 } from '$lib/serialization/schema.js'; +import { layoutExportStore } from '$lib/state/layoutExport.svelte.js'; import { settingsStore } from '$lib/state/settings.svelte.js'; function newLineId(): string { @@ -98,6 +100,7 @@ class ProjectStore { const rest = this.pairControls.filter((p) => !(p.upperLineId === u && p.lowerLineId === lo)); this.pairControls = [...rest, { upperLineId: u, lowerLineId: lo, showConnectors: false }]; } + layoutExportStore.requestRemeasureAfterLayout(); } pairShowsConnectors(upperLineId: string, lowerLineId: string): boolean { @@ -112,7 +115,7 @@ class ProjectStore { const id = newLineId(); const newLine: LineV2 = { id, - rawText: '', + rawText: NEW_LINE_HINT_TEXT, font: { family: 'Inter', source: 'google' }, textSizePx: 36 }; @@ -121,9 +124,10 @@ class ProjectStore { ? this.lines.length : Math.max(0, Math.min(this.lines.length, insertIndex)); this.lines = [...this.lines.slice(0, idx), newLine, ...this.lines.slice(idx)]; - this.tokensByLineId = { ...this.tokensByLineId, [id]: [] }; + this.syncAllTokens(); this.prunePairControls(); this.pruneInvalidConnections(); + layoutExportStore.requestRemeasureAfterLayout(); } removeLine(lineId: string) { @@ -157,6 +161,7 @@ class ProjectStore { this.lines = next; this.prunePairControls(); this.pruneInvalidConnections(); + layoutExportStore.requestRemeasureAfterLayout(); } /** Move `lineId` to zero-based index `newIndex` in the line stack (for drag-and-drop). */ @@ -169,6 +174,7 @@ class ProjectStore { this.lines = [...rest.slice(0, j), line, ...rest.slice(j)]; this.prunePairControls(); this.pruneInvalidConnections(); + layoutExportStore.requestRemeasureAfterLayout(); } addConnection(upperTokenId: string, lowerTokenId: string, palette: PaletteName) { diff --git a/bitext/src/routes/+page.svelte b/bitext/src/routes/+page.svelte index ee33441..447e801 100644 --- a/bitext/src/routes/+page.svelte +++ b/bitext/src/routes/+page.svelte @@ -2,6 +2,7 @@ import { browser } from '$app/environment'; import { page } from '$app/state'; import Editor from '$lib/components/editor/Editor.svelte'; + import LineEditModal from '$lib/components/editor/LineEditModal.svelte'; import AlignmentPreview from '$lib/components/preview/AlignmentPreview.svelte'; import SettingsPanel from '$lib/components/settings/SettingsPanel.svelte'; import ExportCard from '$lib/components/settings/ExportCard.svelte'; @@ -24,6 +25,7 @@ let hydrated = $state(false); let previewExpand = $state(false); + let showClassicEditor = $state(false); $effect(() => { if (hydrated) return; @@ -191,9 +193,11 @@
-
- -
+ {#if showClassicEditor} +
+ +
+ {/if}
@@ -203,6 +207,22 @@ > Preview +
+ + +
{#if selectionStore.showLinkHint()}

{#if selectionStore.adjacencyHint} @@ -215,6 +235,16 @@ {/if}

+
- + {#if previewExpand}
@@ -358,6 +388,8 @@
+ +