Skip to content

Commit f5a1baa

Browse files
committed
updated ui
1 parent b1aa5a8 commit f5a1baa

9 files changed

Lines changed: 497 additions & 54 deletions

File tree

bitext/src/lib/components/editor/LineCard.svelte

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
<script lang="ts">
2+
import { TrashBinOutline } from 'flowbite-svelte-icons';
23
import type { LineV2 } from '$lib/serialization/schema.js';
34
import { projectStore } from '$lib/state/project.svelte.js';
45
import { ButtonGroup, Input, InputAddon } from 'flowbite-svelte';
56
7+
const addonClass = 'border-gray-300! bg-gray-50! px-2! dark:border-gray-600! dark:bg-gray-700!';
8+
69
let {
710
line,
811
index
@@ -31,6 +34,25 @@
3134
function toggleLineDir() {
3235
projectStore.updateLineStyle(line.id, { rtl: !line.rtl });
3336
}
37+
38+
function lineHasAnyConnection(): boolean {
39+
const connections = projectStore.connections;
40+
return connections.some(
41+
(c) => c.upperTokenId.startsWith(`${line.id}-`) || c.lowerTokenId.startsWith(`${line.id}-`)
42+
);
43+
}
44+
45+
function confirmRemove(): boolean {
46+
if (!lineHasAnyConnection()) return true;
47+
return typeof window !== 'undefined'
48+
? window.confirm('This line has connections. Removing it will delete those links. Continue?')
49+
: true;
50+
}
51+
52+
function removeThisLine() {
53+
if (!confirmRemove()) return;
54+
projectStore.removeLine(line.id);
55+
}
3456
</script>
3557

3658
<div
@@ -56,8 +78,8 @@
5678
</span>
5779
</div>
5880

81+
<label class="sr-only" for="line-{line.id}">Line {index + 1} text</label>
5982
<ButtonGroup class="w-full min-w-32 flex-1 basis-48 sm:basis-auto">
60-
<label class="sr-only" for="line-{line.id}">Line {index + 1} text</label>
6183
<Input
6284
id="line-{line.id}"
6385
type="text"
@@ -68,9 +90,7 @@
6890
oninput={(e) =>
6991
projectStore.setLineText(line.id, (e.currentTarget as HTMLInputElement).value)}
7092
/>
71-
<InputAddon
72-
class="rounded-e-none! border-gray-300! bg-gray-50! px-2! dark:border-gray-600! dark:bg-gray-700!"
73-
>
93+
<InputAddon class={addonClass}>
7494
<button
7595
type="button"
7696
class="min-w-9 cursor-pointer select-none border-0 bg-transparent p-0 text-center text-[10px] font-medium tracking-wide text-gray-600 uppercase hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1 dark:text-gray-400 dark:hover:text-gray-100 dark:focus-visible:ring-primary-400 dark:focus-visible:ring-offset-gray-800"
@@ -82,5 +102,17 @@
82102
{line.rtl ? 'RTL' : 'LTR'}
83103
</button>
84104
</InputAddon>
105+
<InputAddon class="{addonClass} rounded-e-none!">
106+
<button
107+
type="button"
108+
class="flex cursor-pointer items-center justify-center border-0 bg-transparent p-0 text-gray-500 hover:text-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-gray-500 dark:text-gray-400 dark:hover:text-red-400 dark:focus-visible:ring-primary-400 dark:focus-visible:ring-offset-gray-800 dark:disabled:hover:text-gray-400"
109+
title="Remove line"
110+
aria-label="Remove line"
111+
disabled={projectStore.lines.length <= 2}
112+
onclick={removeThisLine}
113+
>
114+
<TrashBinOutline class="h-4 w-4 shrink-0" aria-hidden="true" />
115+
</button>
116+
</InputAddon>
85117
</ButtonGroup>
86118
</div>

bitext/src/lib/components/share/ShareDialog.svelte

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
<script lang="ts">
2+
import { browser } from '$app/environment';
23
import { Button, Modal } from 'flowbite-svelte';
34
import { ALIGNER_SITE_HOST } from '$lib/brand.js';
45
import { encodeState } from '$lib/serialization/encode.js';
5-
import { SCHEMA_VERSION, type AppStateV2 } from '$lib/serialization/schema.js';
6+
import {
7+
SCHEMA_VERSION,
8+
defaultVisualSettingsV2,
9+
type AppStateV2,
10+
type VisualSettingsV2
11+
} from '$lib/serialization/schema.js';
612
import { projectStore } from '$lib/state/project.svelte.js';
713
import { settingsStore } from '$lib/state/settings.svelte.js';
814
import { getShareUrl } from '$lib/share/url.js';
@@ -13,6 +19,7 @@
1319
let qrSrc = $state<string | null>(null);
1420
let qrErr = $state<string | null>(null);
1521
let qrLoading = $state(false);
22+
let dataObjectCopied = $state(false);
1623
1724
/** Called from parent via `bind:this` */
1825
export function open() {
@@ -85,6 +92,49 @@
8592
a.download = 'alignment-share-qr.png';
8693
a.click();
8794
}
95+
96+
function visualSettingsDiff(): Partial<VisualSettingsV2> | undefined {
97+
const cur = settingsStore.settings;
98+
const d = defaultVisualSettingsV2();
99+
const patch: Partial<VisualSettingsV2> = {};
100+
(keysOfVisualSettings() as (keyof VisualSettingsV2)[]).forEach((k) => {
101+
if (cur[k] !== d[k]) (patch as Record<string, unknown>)[k] = cur[k];
102+
});
103+
return Object.keys(patch).length ? patch : undefined;
104+
}
105+
106+
function keysOfVisualSettings(): (keyof VisualSettingsV2)[] {
107+
return Object.keys(defaultVisualSettingsV2()) as (keyof VisualSettingsV2)[];
108+
}
109+
110+
/** JSON shaped like `ExampleEntry` in `src/lib/state/examples.ts` (placeholders for id/label). */
111+
function buildExampleDataObject(): Record<string, unknown> {
112+
const snap = projectStore.getSnapshot();
113+
const out: Record<string, unknown> = {
114+
format: 'bitext-example-candidate-v1',
115+
id: '<ExampleId>',
116+
label: '<Example label>',
117+
lines: snap.lines.map((l) => ({ ...l, font: { ...l.font } })),
118+
connections: snap.connections.map((c) => [c.upperTokenId, c.lowerTokenId])
119+
};
120+
if (snap.pairControls.length) {
121+
out.pairControls = snap.pairControls.map((p) => ({ ...p }));
122+
}
123+
if (snap.linePairGaps.length) {
124+
out.linePairGaps = snap.linePairGaps.map((g) => ({ ...g }));
125+
}
126+
const diff = visualSettingsDiff();
127+
if (diff) out.settings = diff;
128+
return out;
129+
}
130+
131+
async function copyDataObject() {
132+
if (!browser) return;
133+
const text = JSON.stringify(buildExampleDataObject(), null, '\t');
134+
await navigator.clipboard.writeText(text);
135+
dataObjectCopied = true;
136+
setTimeout(() => (dataObjectCopied = false), 2000);
137+
}
88138
</script>
89139

90140
<Modal bind:open={modalOpen} title="Share" size="md">
@@ -127,6 +177,14 @@
127177
Download QR (PNG)
128178
</Button>
129179
{/if}
180+
<Button
181+
color="alternative"
182+
size="sm"
183+
class="shrink-0 border border-gray-200 bg-gray-50 text-gray-500 hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-800/80 dark:text-gray-400 dark:hover:bg-gray-800"
184+
onclick={copyDataObject}
185+
>
186+
{dataObjectCopied ? 'Copied!' : 'Data object'}
187+
</Button>
130188
</div>
131189
</div>
132190
</Modal>

bitext/src/lib/serialization/schema.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export const MAX_TEXT_SIZE_PX = 64;
3535
export const DEFAULT_WORD_GAP_PX = 14;
3636
export const MIN_WORD_GAP_PX = 0;
3737
export const MAX_WORD_GAP_PX = 56;
38-
export const DEFAULT_TOKEN_SPLIT_CHARS = '.-';
38+
export const DEFAULT_TOKEN_SPLIT_CHARS = '.-|';
3939
/** Default join character for new projects; omits from compact when equal. */
4040
export const DEFAULT_TOKEN_MERGE_CHAR = '+';
4141

bitext/src/lib/state/examples.ts

Lines changed: 123 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1-
import { DEFAULT_WORD_GAP_PX, type LineV2 } from '$lib/serialization/schema.js';
1+
import {
2+
DEFAULT_WORD_GAP_PX,
3+
type LinePairGapV2,
4+
type LineV2,
5+
type PairControlV2,
6+
type VisualSettingsV2
7+
} from '$lib/serialization/schema.js';
28

3-
export type ExampleId = 'simple' | 'transcription' | 'rtl' | 'cjk';
9+
export type ExampleId = 'simple' | 'glosses' | 'rtl' | 'tagalog' | 'cjk';
410

511
/** Token id pair `[upperLineId-index, lowerLineId-index]` connected after the snapshot loads. */
612
export type ExampleConnection = readonly [string, string];
@@ -9,6 +15,16 @@ export interface ExampleEntry {
915
id: ExampleId;
1016
label: string;
1117
lines: LineV2[];
18+
/** Per-pair connector visibility (e.g., hide connectors between text and its tightly-stacked gloss row). */
19+
pairControls?: PairControlV2[];
20+
/** Per-pair vertical gaps (px); omit a pair to use the default. */
21+
linePairGaps?: LinePairGapV2[];
22+
/**
23+
* Tokenizer / visual setting overrides applied while the example is loaded.
24+
* Token-related fields are reset to defaults first, so customizations from a previous
25+
* example never leak across loads.
26+
*/
27+
settings?: Partial<VisualSettingsV2>;
1228
connections: ExampleConnection[];
1329
}
1430

@@ -37,10 +53,12 @@ const noto = (
3753

3854
/**
3955
* Curated, opinionated set of preset alignments shown in the “Load example” dropdown.
40-
* Each entry is a self-contained project: lines + connections to draw between them.
56+
* Each entry is a self-contained project: lines, optional pair controls / gaps / settings,
57+
* plus the connections to draw between them.
4158
*
42-
* Connection ids must reference `lineId-tokenIndex` after whitespace tokenization with the
43-
* default visual settings; do not rely on user-customized split/merge characters here.
59+
* Connection ids reference `lineId-tokenIndex` after tokenization with the example’s
60+
* effective settings (the loader resets token settings to defaults before applying
61+
* `example.settings`).
4462
*/
4563
export const EXAMPLES: readonly ExampleEntry[] = [
4664
{
@@ -54,61 +72,123 @@ export const EXAMPLES: readonly ExampleEntry[] = [
5472
]
5573
},
5674
{
57-
id: 'transcription',
58-
label: 'Turkish with IPA (3 lines)',
75+
// Turkish interlinear: morpheme glosses → IPA → segmented text → free translation.
76+
// Only `|` splits tokens here so glosses can show a literal hyphen between stem and tag
77+
// (`garden|-|LOC` → garden, -, LOC) while Turkish/IPA use plain `bahçe|de` / `bahtʃe|de`.
78+
id: 'glosses',
79+
label: 'Turkish interlinear (IPA + glosses)',
5980
lines: [
60-
inter('Merhaba dünya', 's', 34),
61-
noto('meɾˈhaba dyzˈnja', 'ipa', 'Noto Sans', 28),
62-
inter('Hello world', 't', 34)
81+
inter('child garden|-|LOC play|-|PROG', 'gl', 22),
82+
noto('tʃodʒuk bahtʃe|de ojnu|joɾ', 'ipa', 'Noto Sans', 26),
83+
inter('Çocuk bahçe|de oynu|yor', 's', 36),
84+
inter('The child is+playing in the garden', 't', 30)
85+
],
86+
settings: { tokenSplitChars: '|' },
87+
// Top three rows form one interlinear block: tight vertical spacing, no link lines.
88+
// The free translation sits at a normal distance below, with full link drawing.
89+
pairControls: [
90+
{ upperLineId: 'gl', lowerLineId: 'ipa', showConnectors: false },
91+
{ upperLineId: 'ipa', lowerLineId: 's', showConnectors: false }
92+
],
93+
linePairGaps: [
94+
{ upperLineId: 'gl', lowerLineId: 'ipa', gapPx: 16 },
95+
{ upperLineId: 'ipa', lowerLineId: 's', gapPx: 16 }
6396
],
6497
connections: [
65-
['s-0', 'ipa-0'],
66-
['s-1', 'ipa-1'],
67-
['ipa-0', 't-0'],
68-
['ipa-1', 't-1']
98+
// Interlinear: seven gloss tokens vs five IPA/Turkish tokens; hyphen-only gloss
99+
// tokens have no counterpart in orthography/IPA.
100+
['gl-0', 'ipa-0'],
101+
['gl-1', 'ipa-1'],
102+
['gl-3', 'ipa-2'],
103+
['gl-4', 'ipa-3'],
104+
['gl-6', 'ipa-4'],
105+
['ipa-0', 's-0'],
106+
['ipa-1', 's-1'],
107+
['ipa-2', 's-2'],
108+
['ipa-3', 's-3'],
109+
['ipa-4', 's-4'],
110+
// Segmented Turkish ↔ free translation. English uses the default merge char (`+`)
111+
// so “is playing” is one alignment token while still displaying as two words.
112+
['s-0', 't-1'], // Çocuk → child
113+
['s-1', 't-5'], // bahçe → garden
114+
['s-2', 't-3'], // de → in
115+
['s-3', 't-2'], // oynu → is+playing
116+
['s-4', 't-2'] // yor → is+playing
117+
// `t-0` (The) and `t-4` (the) intentionally unaligned: English-only definiteness.
69118
]
70119
},
71120
{
121+
// Hebrew → Arabic → English. Two right-to-left scripts compared against an LTR
122+
// translation. Hebrew writes the preposition bound to the noun (`בבית`); we mark the
123+
// morpheme boundary with `-` in the editor so it splits under the default tokenizer.
72124
id: 'rtl',
73-
label: 'Hebrew + Arabic + English (RTL, merged ב+בית / في+البيت)',
125+
label: 'Hebrew + Arabic + English (right-to-left)',
126+
lines: [
127+
noto('אני גר ב-בית גדול', 'he', 'Noto Sans Hebrew', 36, true),
128+
noto('أنا أسكن في بيت كبير', 'ar', 'Noto Sans Arabic', 36, true),
129+
inter('I live in a big house', 'en', 30)
130+
],
131+
connections: [
132+
// Hebrew (5 tokens after the `-` split) ↔ Arabic (5 tokens). Both put the
133+
// adjective after the noun, so the rows are parallel — no crossings.
134+
['he-0', 'ar-0'], // אני ↔ أنا
135+
['he-1', 'ar-1'], // גר ↔ أسكن
136+
['he-2', 'ar-2'], // ב ↔ في
137+
['he-3', 'ar-3'], // בית ↔ بيت
138+
['he-4', 'ar-4'], // גדול ↔ كبير
139+
// Arabic ↔ English. Adjective-noun order flips, so the last two links cross.
140+
['ar-0', 'en-0'], // أنا ↔ I
141+
['ar-1', 'en-1'], // أسكن ↔ live
142+
['ar-2', 'en-2'], // في ↔ in
143+
['ar-3', 'en-5'], // بيت ↔ house (crossing)
144+
['ar-4', 'en-4'] // كبير ↔ big (crossing)
145+
// `en-3` (a) intentionally unaligned: Hebrew/Arabic have no indefinite article.
146+
]
147+
},
148+
{
149+
// Tagalog compounds often contain hyphens that should remain inside a word rather than
150+
// becoming alignment boundaries. This example disables `-` as a split character while
151+
// keeping the predicate-initial Tagalog sentence aligned to a natural English translation.
152+
id: 'tagalog',
153+
label: 'Tagalog compounds (keep hyphens)',
74154
lines: [
75-
// `+` is the default merge character: bound morphemes stay one alignment token but show
76-
// with a space in the preview (e.g. Hebrew inseparable preposition + noun vs. English
77-
// “at home” as two words).
78-
noto('אני אוהב ב+בית', 'he', 'Noto Sans Hebrew', 34, true),
79-
noto('أنا أحب في+البيت', 'ar', 'Noto Sans Arabic', 34, true),
80-
inter('I love at home', 'en', 32)
155+
noto('Maganda ang bahay-kubo sa tabing-ilog', 'tl', 'Noto Sans', 34),
156+
inter('The nipa+hut by the river is beautiful', 'en', 30)
81157
],
158+
settings: { tokenSplitChars: '.' },
82159
connections: [
83-
['he-0', 'ar-0'],
84-
['he-1', 'ar-1'],
85-
['he-2', 'ar-2'],
86-
['ar-0', 'en-0'],
87-
['ar-1', 'en-1'],
88-
['ar-2', 'en-2'],
89-
['ar-2', 'en-3']
160+
['tl-0', 'en-6'], // Maganda → beautiful
161+
['tl-1', 'en-0'], // ang → The
162+
['tl-2', 'en-1'], // bahay-kubo → nipa+hut
163+
['tl-3', 'en-2'], // sa → by
164+
['tl-4', 'en-4'] // tabing-ilog → river
90165
]
91166
},
92167
{
168+
// Japanese (SOV) ↔ Chinese (SVO) ↔ English (SVO). Putting two related East-Asian
169+
// languages side by side highlights how the verb travels in alignment, while CJK
170+
// scripts share most content morphemes (今日/今天, 本/书, 読/读).
171+
// Word boundaries are inserted with spaces because neither script uses them
172+
// natively — the alignment tool needs explicit token boundaries to draw links.
93173
id: 'cjk',
94-
label: 'Japanese + Chinese + English',
174+
label: 'Japanese + Chinese + English (SOV ↔ SVO)',
95175
lines: [
96-
// Word/phrase boundaries marked with spaces — CJK scripts have no native word
97-
// separators, and an alignment tool needs explicit token boundaries to draw links.
98-
// Horizontal Japanese and Chinese are laid out LTR here (standard typography).
99-
noto('私は 本を 読む', 'ja', 'Noto Sans JP', 34),
100-
noto('我 读 书', 'zh', 'Noto Sans SC', 34),
101-
inter('I read books', 'en', 32)
176+
noto('今日 私は 本を 読みました', 'ja', 'Noto Sans JP', 34),
177+
noto('今天 我 读了 书', 'zh', 'Noto Sans SC', 34),
178+
inter('Today I read a book', 'en', 30)
102179
],
103-
// Japanese is SOV (verb last) while Chinese/English are SVO — verb/object swap shows
104-
// up as crossing connectors between the Japanese and Chinese rows.
105180
connections: [
106-
['ja-0', 'zh-0'],
107-
['ja-1', 'zh-2'],
108-
['ja-2', 'zh-1'],
109-
['zh-0', 'en-0'],
110-
['zh-1', 'en-1'],
111-
['zh-2', 'en-2']
181+
// Japanese ↔ Chinese: the object precedes the verb in Japanese (本を 読みました)
182+
// but follows it in Chinese (读了 书) — the swap shows up as a clean crossing.
183+
['ja-0', 'zh-0'], // 今日 ↔ 今天
184+
['ja-1', 'zh-1'], // 私は ↔ 我
185+
['ja-2', 'zh-3'], // 本を ↔ 书 (crossing)
186+
['ja-3', 'zh-2'], // 読みました ↔ 读了 (crossing)
187+
// Chinese ↔ English: parallel SVO. English “a” has no Chinese counterpart.
188+
['zh-0', 'en-0'], // 今天 ↔ Today
189+
['zh-1', 'en-1'], // 我 ↔ I
190+
['zh-2', 'en-2'], // 读了 ↔ read
191+
['zh-3', 'en-4'] // 书 ↔ book
112192
]
113193
}
114194
] as const;

0 commit comments

Comments
 (0)