@sutrata/editor 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -0
- package/dist/ai/generated-providers.json +1 -1
- package/package.json +30 -3
- package/scripts/copy-assets.mjs +0 -16
- package/scripts/generate-providers.mjs +0 -127
- package/src/ai/OnboardingDialog.tsx +0 -390
- package/src/ai/VoiceConfirmDialog.tsx +0 -137
- package/src/ai/VoiceToolbar.tsx +0 -321
- package/src/ai/ai-client.ts +0 -596
- package/src/ai/commands.ts +0 -164
- package/src/ai/generated-providers.json +0 -3696
- package/src/ai/prompts.ts +0 -62
- package/src/ai/voice-service.ts +0 -96
- package/src/autocomplete/AutocompletePlugin.ts +0 -21
- package/src/autocomplete/suggestions.ts +0 -147
- package/src/context/DocumentContext.tsx +0 -648
- package/src/desktop-api.d.ts +0 -40
- package/src/editor/EditorView.tsx +0 -82
- package/src/editor/SelectionMenu.tsx +0 -152
- package/src/editor/attr-pencil-plugin.ts +0 -172
- package/src/editor/character-decoration-plugin.ts +0 -104
- package/src/editor/editor-bus.ts +0 -51
- package/src/editor/find-highlight-plugin.ts +0 -68
- package/src/editor/frontmatter-field-view.ts +0 -52
- package/src/editor/frontmatter-field.ts +0 -115
- package/src/editor/grapheme-cursor.ts +0 -99
- package/src/editor/ime-plugin.ts +0 -85
- package/src/editor/insert-helper.ts +0 -43
- package/src/editor/keymap.ts +0 -124
- package/src/editor/language-class-plugin.ts +0 -91
- package/src/editor/plugins.ts +0 -26
- package/src/editor/prosemirror-to-sutra.ts +0 -192
- package/src/editor/scene-heading-view.ts +0 -147
- package/src/editor/schema.ts +0 -156
- package/src/editor/sutra-to-prosemirror.ts +0 -156
- package/src/extensions/storage-adapter.ts +0 -65
- package/src/extensions/types.ts +0 -60
- package/src/file/docx-exporter.ts +0 -585
- package/src/file/docx-importer.ts +0 -313
- package/src/file/print-paginator.ts +0 -301
- package/src/file/templates.ts +0 -73
- package/src/file/workflow-reports.ts +0 -820
- package/src/findreplace/FindReplace.tsx +0 -242
- package/src/findreplace/find-engine.ts +0 -97
- package/src/findreplace/scroll-to-match.ts +0 -89
- package/src/fonts/tofu-detect.ts +0 -53
- package/src/i18n/LanguageContext.tsx +0 -207
- package/src/i18n/language-detect.ts +0 -73
- package/src/i18n/useTranslation.tsx +0 -74
- package/src/index.ts +0 -27
- package/src/locales/bn.json +0 -143
- package/src/locales/en.json +0 -143
- package/src/locales/gu.json +0 -143
- package/src/locales/hi.json +0 -143
- package/src/locales/kn.json +0 -143
- package/src/locales/ml.json +0 -143
- package/src/locales/mr.json +0 -143
- package/src/locales/or.json +0 -143
- package/src/locales/pa.json +0 -143
- package/src/locales/si.json +0 -143
- package/src/locales/ta.json +0 -143
- package/src/locales/te.json +0 -143
- package/src/navigator/SceneNavigator.tsx +0 -844
- package/src/navigator/StatisticsDialog.tsx +0 -249
- package/src/navigator/scene-list.ts +0 -84
- package/src/settings/SettingsDialog.tsx +0 -303
- package/src/shell/AppBar.tsx +0 -354
- package/src/shell/AppShell.tsx +0 -71
- package/src/shell/ConfirmDialog.tsx +0 -71
- package/src/shell/ElementToolbar.tsx +0 -175
- package/src/shell/LanguageStatus.tsx +0 -101
- package/src/shell/StatusBar.tsx +0 -135
- package/src/shell/SutrataLogo.tsx +0 -89
- package/src/shell/Toast.tsx +0 -46
- package/src/shell/ToolbarButton.tsx +0 -30
- package/src/shell/VersionHistory.tsx +0 -212
- package/src/shell/Watermark.tsx +0 -9
- package/src/shell/fonts/fonts.css +0 -1133
- package/src/shell/icons.tsx +0 -335
- package/src/shell/toolbar-actions.ts +0 -59
- package/src/source/SourceView.tsx +0 -111
- package/src/source/find-highlight-source.ts +0 -57
- package/src/source/sutra-lang.ts +0 -121
- package/src/spellcheck/dictionaries.ts +0 -21
- package/src/spellcheck/hunspell.ts +0 -110
- package/src/spellcheck/spellcheck-plugin.ts +0 -83
- package/src/styles/StyleDialog.tsx +0 -148
- package/src/styles/builtin-styles.ts +0 -139
- package/src/styles/css-adapter.ts +0 -76
- package/src/styles/docx-adapter.ts +0 -109
- package/src/styles/embed-fonts.ts +0 -81
- package/src/styles/pagination.ts +0 -27
- package/src/styles/print-adapter.ts +0 -288
- package/src/styles/registry.ts +0 -20
- package/src/styles/screenplay.css +0 -3006
- package/src/styles/script-ranges.ts +0 -17
- package/src/styles/types.ts +0 -104
- package/src/styles/validate.ts +0 -114
- package/src/templates/templates.ts +0 -194
- package/src/titlepage/ExportDialog.tsx +0 -494
- package/src/titlepage/TitlePageDialog.tsx +0 -312
- package/src/titlepage/frontmatter-form.ts +0 -280
- package/src/titlepage/languages.ts +0 -37
- package/src/types.ts +0 -7
- package/tests/AppBar.test.tsx +0 -35
- package/tests/AppShell.test.tsx +0 -22
- package/tests/ElementToolbar.test.tsx +0 -36
- package/tests/LanguageStatus.test.tsx +0 -129
- package/tests/SelectionMenu.test.tsx +0 -71
- package/tests/ToolbarButton.test.tsx +0 -24
- package/tests/ai-onboarding.test.ts +0 -102
- package/tests/ai-status.test.tsx +0 -72
- package/tests/ai-structured.test.ts +0 -94
- package/tests/autocomplete/AutocompletePlugin.test.ts +0 -20
- package/tests/autocomplete/suggestions.test.ts +0 -106
- package/tests/document-context.test.tsx +0 -565
- package/tests/docx-importer.test.ts +0 -160
- package/tests/editor/character-decoration-plugin.test.ts +0 -101
- package/tests/editor/character-extension.test.ts +0 -44
- package/tests/editor/grapheme-cursor.test.ts +0 -118
- package/tests/editor/ime-plugin.test.ts +0 -72
- package/tests/editor/inline-note-bridge.test.ts +0 -32
- package/tests/editor/language-class-plugin.test.ts +0 -265
- package/tests/editor/title-page.test.ts +0 -124
- package/tests/embed-fonts.test.ts +0 -17
- package/tests/exporters.test.ts +0 -365
- package/tests/findreplace/FindReplace.test.tsx +0 -391
- package/tests/findreplace/find-engine.test.ts +0 -86
- package/tests/findreplace/scroll-to-match.test.ts +0 -50
- package/tests/fonts/tofu-detect.test.ts +0 -122
- package/tests/i18n/LanguageContext.test.tsx +0 -146
- package/tests/i18n/language-detect.test.ts +0 -54
- package/tests/i18n/locale-parity.test.ts +0 -25
- package/tests/i18n/useTranslation.test.tsx +0 -45
- package/tests/keymap.test.ts +0 -18
- package/tests/navigator/SceneNavigator.test.tsx +0 -177
- package/tests/navigator/StatisticsDialog.test.tsx +0 -63
- package/tests/navigator/scene-list.test.ts +0 -71
- package/tests/print-paginator.test.ts +0 -82
- package/tests/shell/VersionHistory.test.tsx +0 -129
- package/tests/spellcheck/hunspell.test.ts +0 -82
- package/tests/spellcheck/spellcheck-plugin.test.ts +0 -56
- package/tests/styles.test.ts +0 -315
- package/tests/templates/templates.test.ts +0 -83
- package/tests/titlepage/TitlePageDialog.test.tsx +0 -90
- package/tests/titlepage/apply-title-page.test.ts +0 -61
- package/tests/titlepage/frontmatter-form.test.ts +0 -115
- package/tests/toolbar-actions.test.ts +0 -93
- package/tests/voice-commands.test.ts +0 -58
- package/tsconfig.json +0 -17
- package/vitest.config.ts +0 -11
- package/vitest.setup.ts +0 -65
|
@@ -1,312 +0,0 @@
|
|
|
1
|
-
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
-
import type { ReactNode } from 'react'
|
|
3
|
-
import { useDocument } from '../context/DocumentContext'
|
|
4
|
-
import { useTranslation } from '../i18n/useTranslation'
|
|
5
|
-
import { allStyles, resolveStyle } from '../styles/registry'
|
|
6
|
-
import { getEditorView } from '../editor/editor-bus'
|
|
7
|
-
import { applyTitlePageText } from '../editor/frontmatter-field'
|
|
8
|
-
import { readTitlePage, writeTitlePage, MANAGED_KEYS } from './frontmatter-form'
|
|
9
|
-
import type { TitlePageForm, PageSize } from './frontmatter-form'
|
|
10
|
-
import { languageOptions } from './languages'
|
|
11
|
-
|
|
12
|
-
export { OPEN_TITLE_PAGE_EVENT, openTitlePageForm } from '../editor/editor-bus'
|
|
13
|
-
|
|
14
|
-
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
|
|
15
|
-
|
|
16
|
-
const TABS = ['story', 'credits', 'draft', 'contact', 'document', 'other'] as const
|
|
17
|
-
type Tab = typeof TABS[number]
|
|
18
|
-
|
|
19
|
-
function wordCount(s: string): number {
|
|
20
|
-
return s.split(/\s+/).filter(Boolean).length
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
interface Props {
|
|
24
|
-
onClose: () => void
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function TitlePageDialog({ onClose }: Props) {
|
|
28
|
-
const { ast, text, setText, mode, customStyles, showToast } = useDocument()
|
|
29
|
-
const { t } = useTranslation()
|
|
30
|
-
const data = ast.frontmatter?.data as Record<string, unknown> | undefined
|
|
31
|
-
const initial = useMemo(() => readTitlePage(data), []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
32
|
-
const [form, setForm] = useState<TitlePageForm>(initial)
|
|
33
|
-
const firstFieldRef = useRef<HTMLInputElement>(null)
|
|
34
|
-
const [tab, setTab] = useState<Tab>('story')
|
|
35
|
-
const tabRefs = useRef<Partial<Record<Tab, HTMLButtonElement | null>>>({})
|
|
36
|
-
|
|
37
|
-
/** Arrow keys move between tabs (vertical tab list; left/right also work). */
|
|
38
|
-
function onTabKey(e: React.KeyboardEvent, current: Tab) {
|
|
39
|
-
const i = TABS.indexOf(current)
|
|
40
|
-
let next: Tab | undefined
|
|
41
|
-
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') next = TABS[(i + 1) % TABS.length]
|
|
42
|
-
else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') next = TABS[(i - 1 + TABS.length) % TABS.length]
|
|
43
|
-
else if (e.key === 'Home') next = TABS[0]
|
|
44
|
-
else if (e.key === 'End') next = TABS[TABS.length - 1]
|
|
45
|
-
if (!next) return
|
|
46
|
-
e.preventDefault()
|
|
47
|
-
setTab(next)
|
|
48
|
-
tabRefs.current[next]?.focus()
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const set = <K extends keyof TitlePageForm>(key: K, value: TitlePageForm[K]) =>
|
|
52
|
-
setForm(f => ({ ...f, [key]: value }))
|
|
53
|
-
|
|
54
|
-
useEffect(() => {
|
|
55
|
-
firstFieldRef.current?.focus()
|
|
56
|
-
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
|
57
|
-
document.addEventListener('keydown', onKey)
|
|
58
|
-
return () => document.removeEventListener('keydown', onKey)
|
|
59
|
-
}, [onClose])
|
|
60
|
-
|
|
61
|
-
const langs = languageOptions([
|
|
62
|
-
form.lang, ...form.langSecondary, ...form.altTitles.map(a => a.lang),
|
|
63
|
-
])
|
|
64
|
-
const langLabel = (code: string) => langs.find(l => l.code === code)?.label ?? code
|
|
65
|
-
const styles = allStyles(customStyles)
|
|
66
|
-
const defaultStyleName = resolveStyle(undefined, customStyles).name
|
|
67
|
-
const logWords = wordCount(form.logline)
|
|
68
|
-
|
|
69
|
-
// Frontmatter keys the form neither manages nor shows as a custom row: kept as is.
|
|
70
|
-
const preservedCount = data
|
|
71
|
-
? Object.keys(data).filter(k =>
|
|
72
|
-
!(MANAGED_KEYS as readonly string[]).includes(k) &&
|
|
73
|
-
!initial.custom.some(c => c.key === k)).length
|
|
74
|
-
: 0
|
|
75
|
-
|
|
76
|
-
function save() {
|
|
77
|
-
const current = readTitlePage(ast.frontmatter?.data as Record<string, unknown> | undefined)
|
|
78
|
-
const next = writeTitlePage(text, current, form)
|
|
79
|
-
if (next !== text) {
|
|
80
|
-
applyTitlePageText(mode === 'formatted' ? getEditorView() : null, next, setText)
|
|
81
|
-
showToast(t('titlePage.saved'), 'success')
|
|
82
|
-
}
|
|
83
|
-
onClose()
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
return (
|
|
87
|
-
<div
|
|
88
|
-
className="cs-export-overlay"
|
|
89
|
-
role="presentation"
|
|
90
|
-
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
|
91
|
-
>
|
|
92
|
-
<div className="cs-export-dialog cs-tp-dialog" role="dialog" aria-modal="true" aria-labelledby="cs-tp-title">
|
|
93
|
-
<div className="cs-panel-header">
|
|
94
|
-
<span id="cs-tp-title" className="cs-panel-title">{t('titlePage.title')}</span>
|
|
95
|
-
<button className="cs-panel-close" onClick={onClose} aria-label={t('titlePage.cancel')}>×</button>
|
|
96
|
-
</div>
|
|
97
|
-
|
|
98
|
-
<div className="cs-tp-body">
|
|
99
|
-
<div className="cs-tp-tabs" role="tablist" aria-orientation="vertical" aria-label={t('titlePage.title')}>
|
|
100
|
-
{TABS.map(id => (
|
|
101
|
-
<button
|
|
102
|
-
key={id}
|
|
103
|
-
ref={el => { tabRefs.current[id] = el }}
|
|
104
|
-
type="button"
|
|
105
|
-
role="tab"
|
|
106
|
-
id={`cs-tp-tab-${id}`}
|
|
107
|
-
aria-selected={tab === id}
|
|
108
|
-
aria-controls={`cs-tp-panel-${id}`}
|
|
109
|
-
tabIndex={tab === id ? 0 : -1}
|
|
110
|
-
className={`cs-tp-tab${tab === id ? ' cs-tp-tab-active' : ''}`}
|
|
111
|
-
onClick={() => setTab(id)}
|
|
112
|
-
onKeyDown={e => onTabKey(e, id)}
|
|
113
|
-
>
|
|
114
|
-
{t(`titlePage.section.${id}`)}
|
|
115
|
-
</button>
|
|
116
|
-
))}
|
|
117
|
-
</div>
|
|
118
|
-
<form
|
|
119
|
-
className="cs-tp-form"
|
|
120
|
-
onSubmit={e => { e.preventDefault(); save() }}
|
|
121
|
-
>
|
|
122
|
-
<Panel id="story" active={tab}>
|
|
123
|
-
<Field label={t('titlePage.field.title')} htmlFor="cs-tp-f-title">
|
|
124
|
-
<input id="cs-tp-f-title" ref={firstFieldRef} className="cs-tp-input" value={form.title}
|
|
125
|
-
onChange={e => set('title', e.target.value)} />
|
|
126
|
-
</Field>
|
|
127
|
-
{form.altTitles.map((alt, i) => (
|
|
128
|
-
<div className="cs-tp-row" key={i}>
|
|
129
|
-
<select aria-label={t('titlePage.field.titleLanguage')} className="cs-settings-select cs-tp-lang-select"
|
|
130
|
-
value={alt.lang}
|
|
131
|
-
onChange={e => set('altTitles', form.altTitles.map((a, j) => j === i ? { ...a, lang: e.target.value } : a))}>
|
|
132
|
-
<option value="">{t('titlePage.chooseLanguage')}</option>
|
|
133
|
-
{langs.map(l => <option key={l.code} value={l.code}>{l.label}</option>)}
|
|
134
|
-
</select>
|
|
135
|
-
<input aria-label={t('titlePage.field.titleIn')} className="cs-tp-input" value={alt.text}
|
|
136
|
-
onChange={e => set('altTitles', form.altTitles.map((a, j) => j === i ? { ...a, text: e.target.value } : a))} />
|
|
137
|
-
<button type="button" className="cs-tp-remove" aria-label={t('titlePage.remove')}
|
|
138
|
-
onClick={() => set('altTitles', form.altTitles.filter((_, j) => j !== i))}>×</button>
|
|
139
|
-
</div>
|
|
140
|
-
))}
|
|
141
|
-
<button type="button" className="cs-tp-add"
|
|
142
|
-
onClick={() => set('altTitles', [...form.altTitles, { lang: '', text: '' }])}>
|
|
143
|
-
+ {t('titlePage.addTitle')}
|
|
144
|
-
</button>
|
|
145
|
-
<Field label={t('titlePage.field.logline')} htmlFor="cs-tp-f-logline"
|
|
146
|
-
hint={<span className={logWords > 50 ? 'cs-tp-count-bad' : logWords > 30 ? 'cs-tp-count-warn' : 'cs-tp-count'}>
|
|
147
|
-
{t('titlePage.words').replace('{n}', String(logWords))}
|
|
148
|
-
</span>}>
|
|
149
|
-
<textarea id="cs-tp-f-logline" className="cs-tp-input" rows={2} value={form.logline}
|
|
150
|
-
onChange={e => set('logline', e.target.value)} />
|
|
151
|
-
</Field>
|
|
152
|
-
</Panel>
|
|
153
|
-
|
|
154
|
-
<Panel id="credits" active={tab}>
|
|
155
|
-
<TextField id="author" label={t('titlePage.field.author')} value={form.author} onChange={v => set('author', v)} />
|
|
156
|
-
<TextField id="credit" label={t('titlePage.field.credit')} placeholder="Written by" value={form.credit} onChange={v => set('credit', v)} />
|
|
157
|
-
<TextField id="source" label={t('titlePage.field.source')} value={form.source} onChange={v => set('source', v)} />
|
|
158
|
-
<TextField id="story" label={t('titlePage.field.story')} value={form.story} onChange={v => set('story', v)} />
|
|
159
|
-
<TextField id="screenplay" label={t('titlePage.field.screenplay')} value={form.screenplay} onChange={v => set('screenplay', v)} />
|
|
160
|
-
<TextField id="dialogue" label={t('titlePage.field.dialogue')} value={form.dialogue} onChange={v => set('dialogue', v)} />
|
|
161
|
-
</Panel>
|
|
162
|
-
|
|
163
|
-
<Panel id="draft" active={tab}>
|
|
164
|
-
<TextField id="draft" label={t('titlePage.field.draft')} placeholder="First Draft" value={form.draft} onChange={v => set('draft', v)} />
|
|
165
|
-
<TextField id="revision" label={t('titlePage.field.revision')} value={form.revision} onChange={v => set('revision', v)} />
|
|
166
|
-
<Field label={t('titlePage.field.date')} htmlFor="cs-tp-f-date">
|
|
167
|
-
<DateInput id="cs-tp-f-date" value={form.date} onChange={v => set('date', v)}
|
|
168
|
-
usePickerLabel={t('titlePage.usePicker')} useTextLabel={t('titlePage.useText')} />
|
|
169
|
-
</Field>
|
|
170
|
-
</Panel>
|
|
171
|
-
|
|
172
|
-
<Panel id="contact" active={tab}>
|
|
173
|
-
<Field label={t('titlePage.field.contact')} htmlFor="cs-tp-f-contact">
|
|
174
|
-
<textarea id="cs-tp-f-contact" className="cs-tp-input" rows={3} value={form.contact}
|
|
175
|
-
onChange={e => set('contact', e.target.value)} />
|
|
176
|
-
</Field>
|
|
177
|
-
<TextField id="copyright" label={t('titlePage.field.copyright')} placeholder="© 2026 …" value={form.copyright} onChange={v => set('copyright', v)} />
|
|
178
|
-
</Panel>
|
|
179
|
-
|
|
180
|
-
<Panel id="document" active={tab}>
|
|
181
|
-
<Field label={t('titlePage.field.lang')} htmlFor="cs-tp-f-lang">
|
|
182
|
-
<select id="cs-tp-f-lang" className="cs-settings-select" value={form.lang}
|
|
183
|
-
onChange={e => set('lang', e.target.value)}>
|
|
184
|
-
<option value="">{t('titlePage.notSet')}</option>
|
|
185
|
-
{langs.map(l => <option key={l.code} value={l.code}>{l.label}</option>)}
|
|
186
|
-
</select>
|
|
187
|
-
</Field>
|
|
188
|
-
<Field label={t('titlePage.field.langSecondary')} htmlFor="cs-tp-f-lang2">
|
|
189
|
-
<div className="cs-tp-chips">
|
|
190
|
-
{form.langSecondary.map(code => (
|
|
191
|
-
<span className="cs-tp-chip" key={code}>
|
|
192
|
-
{langLabel(code)}
|
|
193
|
-
<button type="button" aria-label={`${t('titlePage.remove')} ${langLabel(code)}`}
|
|
194
|
-
onClick={() => set('langSecondary', form.langSecondary.filter(c => c !== code))}>×</button>
|
|
195
|
-
</span>
|
|
196
|
-
))}
|
|
197
|
-
<select id="cs-tp-f-lang2" className="cs-settings-select" value=""
|
|
198
|
-
onChange={e => {
|
|
199
|
-
const code = e.target.value
|
|
200
|
-
if (code && !form.langSecondary.includes(code)) set('langSecondary', [...form.langSecondary, code])
|
|
201
|
-
}}>
|
|
202
|
-
<option value="">{t('titlePage.addLanguage')}</option>
|
|
203
|
-
{langs.filter(l => l.code !== form.lang && !form.langSecondary.includes(l.code))
|
|
204
|
-
.map(l => <option key={l.code} value={l.code}>{l.label}</option>)}
|
|
205
|
-
</select>
|
|
206
|
-
</div>
|
|
207
|
-
</Field>
|
|
208
|
-
<Field label={t('titlePage.field.page')} htmlFor="cs-tp-f-page">
|
|
209
|
-
<select id="cs-tp-f-page" className="cs-settings-select" value={form.page}
|
|
210
|
-
onChange={e => set('page', e.target.value as PageSize)}>
|
|
211
|
-
<option value="">{t('titlePage.pageDefault')}</option>
|
|
212
|
-
<option value="A4">A4</option>
|
|
213
|
-
<option value="Letter">US Letter</option>
|
|
214
|
-
</select>
|
|
215
|
-
</Field>
|
|
216
|
-
<Field label={t('titlePage.field.style')} htmlFor="cs-tp-f-style">
|
|
217
|
-
<select id="cs-tp-f-style" className="cs-settings-select" value={form.style}
|
|
218
|
-
onChange={e => set('style', e.target.value)}>
|
|
219
|
-
<option value="">{t('titlePage.styleDefault').replace('{name}', defaultStyleName)}</option>
|
|
220
|
-
{styles.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
|
221
|
-
{form.style && !styles.some(s => s.id === form.style) && (
|
|
222
|
-
<option value={form.style}>{form.style}</option>
|
|
223
|
-
)}
|
|
224
|
-
</select>
|
|
225
|
-
</Field>
|
|
226
|
-
<TextField id="watermark" label={t('titlePage.field.watermark')} placeholder="CONFIDENTIAL" value={form.watermark} onChange={v => set('watermark', v)} />
|
|
227
|
-
</Panel>
|
|
228
|
-
|
|
229
|
-
<Panel id="other" active={tab}>
|
|
230
|
-
{form.custom.map((c, i) => (
|
|
231
|
-
<div className="cs-tp-row" key={i}>
|
|
232
|
-
<input aria-label={t('titlePage.field.key')} className="cs-tp-input cs-tp-key" value={c.key}
|
|
233
|
-
placeholder="key"
|
|
234
|
-
onChange={e => set('custom', form.custom.map((x, j) => j === i ? { ...x, key: e.target.value } : x))} />
|
|
235
|
-
<input aria-label={t('titlePage.field.value')} className="cs-tp-input" value={c.value}
|
|
236
|
-
onChange={e => set('custom', form.custom.map((x, j) => j === i ? { ...x, value: e.target.value } : x))} />
|
|
237
|
-
<button type="button" className="cs-tp-remove" aria-label={t('titlePage.remove')}
|
|
238
|
-
onClick={() => set('custom', form.custom.filter((_, j) => j !== i))}>×</button>
|
|
239
|
-
</div>
|
|
240
|
-
))}
|
|
241
|
-
<button type="button" className="cs-tp-add"
|
|
242
|
-
onClick={() => set('custom', [...form.custom, { key: '', value: '' }])}>
|
|
243
|
-
+ {t('titlePage.addField')}
|
|
244
|
-
</button>
|
|
245
|
-
{preservedCount > 0 && (
|
|
246
|
-
<p className="cs-tp-note">{t('titlePage.preserved').replace('{n}', String(preservedCount))}</p>
|
|
247
|
-
)}
|
|
248
|
-
</Panel>
|
|
249
|
-
</form>
|
|
250
|
-
</div>
|
|
251
|
-
|
|
252
|
-
<div className="cs-export-actions cs-tp-actions">
|
|
253
|
-
<button type="button" className="cs-confirm-btn-cancel" onClick={onClose}>{t('titlePage.cancel')}</button>
|
|
254
|
-
<button type="button" className="cs-confirm-btn-primary" onClick={save}>{t('titlePage.save')}</button>
|
|
255
|
-
</div>
|
|
256
|
-
</div>
|
|
257
|
-
</div>
|
|
258
|
-
)
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function Panel({ id, active, children }: { id: Tab; active: Tab; children: ReactNode }) {
|
|
262
|
-
return (
|
|
263
|
-
<div
|
|
264
|
-
role="tabpanel"
|
|
265
|
-
id={`cs-tp-panel-${id}`}
|
|
266
|
-
aria-labelledby={`cs-tp-tab-${id}`}
|
|
267
|
-
className="cs-tp-panel"
|
|
268
|
-
hidden={active !== id}
|
|
269
|
-
>
|
|
270
|
-
{children}
|
|
271
|
-
</div>
|
|
272
|
-
)
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function Field({ label, htmlFor, hint, children }: { label: string; htmlFor: string; hint?: ReactNode; children: ReactNode }) {
|
|
276
|
-
return (
|
|
277
|
-
<div className="cs-tp-field">
|
|
278
|
-
<label className="cs-tp-label" htmlFor={htmlFor}>{label}{hint ? <> {hint}</> : null}</label>
|
|
279
|
-
{children}
|
|
280
|
-
</div>
|
|
281
|
-
)
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
function TextField({ id, label, value, placeholder, onChange }: {
|
|
285
|
-
id: string; label: string; value: string; placeholder?: string; onChange: (v: string) => void
|
|
286
|
-
}) {
|
|
287
|
-
return (
|
|
288
|
-
<Field label={label} htmlFor={`cs-tp-f-${id}`}>
|
|
289
|
-
<input id={`cs-tp-f-${id}`} className="cs-tp-input" value={value} placeholder={placeholder}
|
|
290
|
-
onChange={e => onChange(e.target.value)} />
|
|
291
|
-
</Field>
|
|
292
|
-
)
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/** Date picker for ISO dates (YYYY-MM-DD). A date already written as free text (e.g.
|
|
296
|
-
* "June 2026") stays editable as text, with a switch to the picker. */
|
|
297
|
-
function DateInput({ id, value, onChange, usePickerLabel, useTextLabel }: {
|
|
298
|
-
id: string; value: string; onChange: (v: string) => void; usePickerLabel: string; useTextLabel: string
|
|
299
|
-
}) {
|
|
300
|
-
const [asText, setAsText] = useState(value !== '' && !ISO_DATE.test(value))
|
|
301
|
-
return (
|
|
302
|
-
<div className="cs-tp-date">
|
|
303
|
-
{asText
|
|
304
|
-
? <input id={id} className="cs-tp-input" value={value} onChange={e => onChange(e.target.value)} />
|
|
305
|
-
: <input id={id} type="date" className="cs-tp-input" value={ISO_DATE.test(value) ? value : ''}
|
|
306
|
-
onChange={e => onChange(e.target.value)} />}
|
|
307
|
-
<button type="button" className="cs-tp-link" onClick={() => setAsText(!asText)}>
|
|
308
|
-
{asText ? usePickerLabel : useTextLabel}
|
|
309
|
-
</button>
|
|
310
|
-
</div>
|
|
311
|
-
)
|
|
312
|
-
}
|
|
@@ -1,280 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Title page form ⇄ Sutra frontmatter.
|
|
3
|
-
*
|
|
4
|
-
* `readTitlePage` turns the parsed frontmatter into form state. `writeTitlePage` writes the
|
|
5
|
-
* form back into the Sutra text by editing only the frontmatter lines of keys whose
|
|
6
|
-
* value actually changed. Every other line (unknown keys, nested maps the form does not
|
|
7
|
-
* manage, comments, ordering, formatting) is kept byte for byte, so opening the form and
|
|
8
|
-
* saving without edits leaves the file unchanged.
|
|
9
|
-
*
|
|
10
|
-
* Values are written in the subset the parser's minimal YAML reader understands: plain
|
|
11
|
-
* scalars, `key: |` block scalars, one level of nested map (multilingual `title:`), and
|
|
12
|
-
* `[a, b]` flow lists (kept as strings by the parser and split by readers).
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
export type PageSize = '' | 'A4' | 'Letter'
|
|
16
|
-
|
|
17
|
-
export interface AltTitle { lang: string; text: string }
|
|
18
|
-
export interface CustomField { key: string; value: string }
|
|
19
|
-
|
|
20
|
-
export interface TitlePageForm {
|
|
21
|
-
// Title & story
|
|
22
|
-
title: string
|
|
23
|
-
altTitles: AltTitle[]
|
|
24
|
-
logline: string
|
|
25
|
-
// Credits
|
|
26
|
-
author: string
|
|
27
|
-
credit: string
|
|
28
|
-
source: string
|
|
29
|
-
story: string
|
|
30
|
-
screenplay: string
|
|
31
|
-
dialogue: string
|
|
32
|
-
// Draft
|
|
33
|
-
draft: string
|
|
34
|
-
revision: string
|
|
35
|
-
date: string
|
|
36
|
-
// Contact & rights
|
|
37
|
-
contact: string
|
|
38
|
-
copyright: string
|
|
39
|
-
// Document settings
|
|
40
|
-
lang: string
|
|
41
|
-
langSecondary: string[]
|
|
42
|
-
page: PageSize
|
|
43
|
-
style: string
|
|
44
|
-
watermark: string
|
|
45
|
-
// Simple custom keys, editable as rows
|
|
46
|
-
custom: CustomField[]
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Keys the form edits directly (in the order new keys are appended). */
|
|
50
|
-
export const MANAGED_KEYS = [
|
|
51
|
-
'title', 'logline', 'author', 'credit', 'source', 'story', 'screenplay', 'dialogue',
|
|
52
|
-
'draft', 'revision', 'date', 'contact', 'copyright',
|
|
53
|
-
'lang', 'lang-secondary', 'page', 'style', 'watermark',
|
|
54
|
-
] as const
|
|
55
|
-
|
|
56
|
-
type ManagedKey = typeof MANAGED_KEYS[number]
|
|
57
|
-
|
|
58
|
-
/** Keys kept but never shown as editable custom rows. */
|
|
59
|
-
const HIDDEN_KEYS = new Set(['format'])
|
|
60
|
-
|
|
61
|
-
const KEY_RE = /^([A-Za-z0-9_-]+):(.*)$/
|
|
62
|
-
|
|
63
|
-
export function emptyForm(): TitlePageForm {
|
|
64
|
-
return {
|
|
65
|
-
title: '', altTitles: [], logline: '',
|
|
66
|
-
author: '', credit: '', source: '', story: '', screenplay: '', dialogue: '',
|
|
67
|
-
draft: '', revision: '', date: '',
|
|
68
|
-
contact: '', copyright: '',
|
|
69
|
-
lang: '', langSecondary: [], page: '', style: '', watermark: '',
|
|
70
|
-
custom: [],
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function str(v: unknown): string {
|
|
75
|
-
return typeof v === 'string' ? v : v === undefined || v === null ? '' : String(v)
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Split a list value: `[a, b]`, `a, b`, or an array. */
|
|
79
|
-
export function splitList(v: unknown): string[] {
|
|
80
|
-
if (Array.isArray(v)) return v.map(x => str(x).trim()).filter(Boolean)
|
|
81
|
-
return str(v).replace(/^\s*\[|\]\s*$/g, '').split(',')
|
|
82
|
-
.map(x => x.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function normalizePage(v: string): PageSize {
|
|
86
|
-
const p = v.trim().toLowerCase()
|
|
87
|
-
if (p === 'a4') return 'A4'
|
|
88
|
-
if (p === 'letter' || p === 'us-letter' || p === 'usletter') return 'Letter'
|
|
89
|
-
return ''
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** Build form state from parsed frontmatter data. */
|
|
93
|
-
export function readTitlePage(data: Record<string, unknown> | undefined): TitlePageForm {
|
|
94
|
-
const f = emptyForm()
|
|
95
|
-
if (!data) return f
|
|
96
|
-
f.lang = str(data['lang']).trim()
|
|
97
|
-
|
|
98
|
-
const title = data['title']
|
|
99
|
-
if (title && typeof title === 'object' && !Array.isArray(title)) {
|
|
100
|
-
const map = title as Record<string, unknown>
|
|
101
|
-
const keys = Object.keys(map)
|
|
102
|
-
const primary = keys.includes(f.lang) ? f.lang : keys[0]
|
|
103
|
-
if (primary !== undefined) f.title = str(map[primary])
|
|
104
|
-
f.altTitles = keys.filter(k => k !== primary).map(k => ({ lang: k, text: str(map[k]) }))
|
|
105
|
-
// Remember which language key held the main title.
|
|
106
|
-
if (primary !== undefined && !f.lang) f.lang = primary
|
|
107
|
-
} else {
|
|
108
|
-
f.title = str(title)
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
f.logline = str(data['logline'])
|
|
112
|
-
f.author = str(data['author'])
|
|
113
|
-
f.credit = str(data['credit'])
|
|
114
|
-
f.source = str(data['source'])
|
|
115
|
-
f.story = str(data['story'])
|
|
116
|
-
f.screenplay = str(data['screenplay'])
|
|
117
|
-
f.dialogue = str(data['dialogue'])
|
|
118
|
-
f.draft = str(data['draft'])
|
|
119
|
-
f.revision = str(data['revision'])
|
|
120
|
-
f.date = str(data['date'])
|
|
121
|
-
f.contact = str(data['contact'])
|
|
122
|
-
f.copyright = str(data['copyright'])
|
|
123
|
-
f.langSecondary = splitList(data['lang-secondary'])
|
|
124
|
-
f.page = normalizePage(str(data['page']))
|
|
125
|
-
f.style = str(data['style'])
|
|
126
|
-
f.watermark = str(data['watermark'])
|
|
127
|
-
|
|
128
|
-
const managed = new Set<string>(MANAGED_KEYS)
|
|
129
|
-
for (const [key, value] of Object.entries(data)) {
|
|
130
|
-
if (managed.has(key) || HIDDEN_KEYS.has(key)) continue
|
|
131
|
-
if (typeof value === 'string' && !value.includes('\n')) f.custom.push({ key, value })
|
|
132
|
-
}
|
|
133
|
-
return f
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// ── Writing ─────────────────────────────────────────────────────────────────────────
|
|
137
|
-
|
|
138
|
-
function scalarLines(key: string, value: string): string[] | null {
|
|
139
|
-
const v = value.replace(/\s+$/g, '')
|
|
140
|
-
if (v.trim() === '') return null
|
|
141
|
-
if (v.includes('\n')) {
|
|
142
|
-
return [`${key}: |`, ...v.split('\n').map(l => (l ? ` ${l}` : ''))]
|
|
143
|
-
}
|
|
144
|
-
return [`${key}: ${v.trim()}`]
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function oneLine(v: string): string {
|
|
148
|
-
return v.replace(/\s*\n\s*/g, ' ').trim()
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** Lines for a managed key, or null to remove it. */
|
|
152
|
-
function managedLines(key: ManagedKey, f: TitlePageForm): string[] | null {
|
|
153
|
-
switch (key) {
|
|
154
|
-
case 'title': {
|
|
155
|
-
const alts = f.altTitles.filter(a => a.lang.trim() && a.text.trim())
|
|
156
|
-
if (alts.length === 0) return scalarLines('title', oneLine(f.title))
|
|
157
|
-
const primaryKey = f.lang.trim() || 'und'
|
|
158
|
-
const lines = ['title:']
|
|
159
|
-
if (f.title.trim()) lines.push(` ${primaryKey}: ${oneLine(f.title)}`)
|
|
160
|
-
for (const a of alts) if (a.lang.trim() !== primaryKey) lines.push(` ${a.lang.trim()}: ${oneLine(a.text)}`)
|
|
161
|
-
return lines
|
|
162
|
-
}
|
|
163
|
-
case 'lang-secondary': {
|
|
164
|
-
const list = f.langSecondary.map(l => l.trim()).filter(Boolean)
|
|
165
|
-
return list.length ? [`lang-secondary: [${list.join(', ')}]`] : null
|
|
166
|
-
}
|
|
167
|
-
case 'page': return f.page ? [`page: ${f.page}`] : null
|
|
168
|
-
case 'logline': return scalarLines('logline', oneLine(f.logline))
|
|
169
|
-
case 'contact': return scalarLines('contact', f.contact)
|
|
170
|
-
default: {
|
|
171
|
-
const field = ({
|
|
172
|
-
author: f.author, credit: f.credit, source: f.source, story: f.story,
|
|
173
|
-
screenplay: f.screenplay, dialogue: f.dialogue, draft: f.draft, revision: f.revision,
|
|
174
|
-
date: f.date, copyright: f.copyright, lang: f.lang, style: f.style, watermark: f.watermark,
|
|
175
|
-
} as Record<string, string>)[key] ?? ''
|
|
176
|
-
return scalarLines(key, oneLine(field))
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/** Semantic value of a managed key, for change detection. */
|
|
182
|
-
function managedValue(key: ManagedKey, f: TitlePageForm): string {
|
|
183
|
-
return JSON.stringify(managedLines(key, f))
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
interface Entry { key: string | null; lines: string[] }
|
|
187
|
-
|
|
188
|
-
function splitEntries(yamlLines: string[]): Entry[] {
|
|
189
|
-
const entries: Entry[] = []
|
|
190
|
-
for (const line of yamlLines) {
|
|
191
|
-
const m = KEY_RE.exec(line)
|
|
192
|
-
if (m) entries.push({ key: m[1]!, lines: [line] })
|
|
193
|
-
else if (entries.length > 0) entries[entries.length - 1]!.lines.push(line)
|
|
194
|
-
else entries.push({ key: null, lines: [line] })
|
|
195
|
-
}
|
|
196
|
-
return entries
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Write `next` into `text`'s frontmatter. `original` is the form as read from the same
|
|
201
|
-
* text; only keys whose value differs between the two are rewritten.
|
|
202
|
-
*/
|
|
203
|
-
export function writeTitlePage(text: string, original: TitlePageForm, next: TitlePageForm): string {
|
|
204
|
-
const eol = text.includes('\r\n') ? '\r\n' : '\n'
|
|
205
|
-
const lines = text.split(/\r?\n/)
|
|
206
|
-
|
|
207
|
-
let yamlLines: string[] = []
|
|
208
|
-
let bodyLines = lines
|
|
209
|
-
let hadFrontmatter = false
|
|
210
|
-
if (lines[0] === '---') {
|
|
211
|
-
const close = lines.indexOf('---', 1)
|
|
212
|
-
if (close > 0) {
|
|
213
|
-
hadFrontmatter = true
|
|
214
|
-
yamlLines = lines.slice(1, close)
|
|
215
|
-
bodyLines = lines.slice(close + 1)
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
const entries = splitEntries(yamlLines)
|
|
220
|
-
const present = new Set(entries.map(e => e.key).filter((k): k is string => k !== null))
|
|
221
|
-
|
|
222
|
-
// Managed keys that changed → their new lines (null = remove).
|
|
223
|
-
const changes = new Map<string, string[] | null>()
|
|
224
|
-
for (const key of MANAGED_KEYS) {
|
|
225
|
-
if (managedValue(key, original) !== managedValue(key, next)) changes.set(key, managedLines(key, next))
|
|
226
|
-
}
|
|
227
|
-
// Primary language key of a multilingual title follows `lang`.
|
|
228
|
-
if (next.altTitles.some(a => a.text.trim()) && original.lang !== next.lang) {
|
|
229
|
-
changes.set('title', managedLines('title', next))
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// Custom rows: edited, removed, or added.
|
|
233
|
-
const origCustom = new Map(original.custom.map(c => [c.key, c.value]))
|
|
234
|
-
const nextCustom = new Map<string, string>()
|
|
235
|
-
for (const c of next.custom) {
|
|
236
|
-
const k = c.key.trim()
|
|
237
|
-
if (!/^[A-Za-z0-9_-]+$/.test(k) || (MANAGED_KEYS as readonly string[]).includes(k)) continue
|
|
238
|
-
nextCustom.set(k, c.value)
|
|
239
|
-
}
|
|
240
|
-
for (const [k, v] of origCustom) {
|
|
241
|
-
if (!nextCustom.has(k)) changes.set(k, null)
|
|
242
|
-
else if (nextCustom.get(k) !== v) changes.set(k, scalarLines(k, oneLine(nextCustom.get(k)!)))
|
|
243
|
-
}
|
|
244
|
-
for (const [k, v] of nextCustom) {
|
|
245
|
-
if (!origCustom.has(k)) changes.set(k, scalarLines(k, oneLine(v)))
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
if (changes.size === 0) return text
|
|
249
|
-
|
|
250
|
-
const out: string[] = []
|
|
251
|
-
for (const e of entries) {
|
|
252
|
-
if (e.key !== null && changes.has(e.key)) {
|
|
253
|
-
const replacement = changes.get(e.key)
|
|
254
|
-
if (replacement) out.push(...replacement)
|
|
255
|
-
changes.delete(e.key)
|
|
256
|
-
} else {
|
|
257
|
-
out.push(...e.lines)
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
// Keys not present before: managed keys in canonical order, then custom keys.
|
|
261
|
-
for (const key of MANAGED_KEYS) {
|
|
262
|
-
if (!present.has(key) && changes.has(key)) {
|
|
263
|
-
const add = changes.get(key)
|
|
264
|
-
if (add) out.push(...add)
|
|
265
|
-
changes.delete(key)
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
for (const [, add] of changes) if (add) out.push(...add)
|
|
269
|
-
|
|
270
|
-
const hasContent = out.some(l => l.trim() !== '')
|
|
271
|
-
if (!hasContent) {
|
|
272
|
-
// Everything removed: drop the frontmatter block and the blank line after it.
|
|
273
|
-
const rest = hadFrontmatter && bodyLines[0] === '' ? bodyLines.slice(1) : bodyLines
|
|
274
|
-
return rest.join(eol)
|
|
275
|
-
}
|
|
276
|
-
if (!hadFrontmatter) {
|
|
277
|
-
return ['---', ...out, '---', '', ...lines].join(eol)
|
|
278
|
-
}
|
|
279
|
-
return ['---', ...out, '---', ...bodyLines].join(eol)
|
|
280
|
-
}
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
/** Languages offered for a document's `lang` / `lang-secondary` (BCP-47 primary tags).
|
|
2
|
-
* Indian languages first, then others commonly mixed into Indian scripts. A code found in
|
|
3
|
-
* a file but missing here is still shown (see `languageOptions`). */
|
|
4
|
-
export interface LanguageOption { code: string; label: string }
|
|
5
|
-
|
|
6
|
-
export const DOCUMENT_LANGUAGES: LanguageOption[] = [
|
|
7
|
-
{ code: 'hi', label: 'Hindi — हिन्दी' },
|
|
8
|
-
{ code: 'ta', label: 'Tamil — தமிழ்' },
|
|
9
|
-
{ code: 'te', label: 'Telugu — తెలుగు' },
|
|
10
|
-
{ code: 'ml', label: 'Malayalam — മലയാളം' },
|
|
11
|
-
{ code: 'kn', label: 'Kannada — ಕನ್ನಡ' },
|
|
12
|
-
{ code: 'bn', label: 'Bengali — বাংলা' },
|
|
13
|
-
{ code: 'mr', label: 'Marathi — मराठी' },
|
|
14
|
-
{ code: 'gu', label: 'Gujarati — ગુજરાતી' },
|
|
15
|
-
{ code: 'pa', label: 'Punjabi — ਪੰਜਾਬੀ' },
|
|
16
|
-
{ code: 'or', label: 'Odia — ଓଡ଼ିଆ' },
|
|
17
|
-
{ code: 'as', label: 'Assamese — অসমীয়া' },
|
|
18
|
-
{ code: 'ur', label: 'Urdu — اردو' },
|
|
19
|
-
{ code: 'si', label: 'Sinhala — සිංහල' },
|
|
20
|
-
{ code: 'sa', label: 'Sanskrit — संस्कृतम्' },
|
|
21
|
-
{ code: 'ne', label: 'Nepali — नेपाली' },
|
|
22
|
-
{ code: 'kok', label: 'Konkani — कोंकणी' },
|
|
23
|
-
{ code: 'mai', label: 'Maithili — मैथिली' },
|
|
24
|
-
{ code: 'bho', label: 'Bhojpuri — भोजपुरी' },
|
|
25
|
-
{ code: 'tcy', label: 'Tulu — ತುಳು' },
|
|
26
|
-
{ code: 'en', label: 'English' },
|
|
27
|
-
{ code: 'ar', label: 'Arabic — العربية' },
|
|
28
|
-
{ code: 'fr', label: 'French — Français' },
|
|
29
|
-
{ code: 'es', label: 'Spanish — Español' },
|
|
30
|
-
]
|
|
31
|
-
|
|
32
|
-
/** The list plus any codes already in the document that it does not contain. */
|
|
33
|
-
export function languageOptions(inUse: string[]): LanguageOption[] {
|
|
34
|
-
const known = new Set(DOCUMENT_LANGUAGES.map(l => l.code))
|
|
35
|
-
const extra = inUse.filter(c => c && !known.has(c)).map(c => ({ code: c, label: c }))
|
|
36
|
-
return [...DOCUMENT_LANGUAGES, ...extra]
|
|
37
|
-
}
|
package/src/types.ts
DELETED
package/tests/AppBar.test.tsx
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
|
-
import { DocumentProvider } from '../src/context/DocumentContext'
|
|
4
|
-
import { TranslationProvider } from '../src/i18n/useTranslation'
|
|
5
|
-
import { AppBar } from '../src/shell/AppBar'
|
|
6
|
-
|
|
7
|
-
function renderAppBar() {
|
|
8
|
-
return render(
|
|
9
|
-
<TranslationProvider>
|
|
10
|
-
<DocumentProvider>
|
|
11
|
-
<AppBar />
|
|
12
|
-
</DocumentProvider>
|
|
13
|
-
</TranslationProvider>
|
|
14
|
-
)
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
describe('AppBar', () => {
|
|
18
|
-
it('shows the mode toggle pointing to the NEXT state (Source while formatted)', () => {
|
|
19
|
-
renderAppBar()
|
|
20
|
-
expect(screen.getByRole('button', { name: /switch to source/i })).toBeInTheDocument()
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
it('flips the mode toggle label on click', () => {
|
|
24
|
-
renderAppBar()
|
|
25
|
-
fireEvent.click(screen.getByRole('button', { name: /switch to source/i }))
|
|
26
|
-
expect(screen.getByRole('button', { name: /switch to formatted/i })).toBeInTheDocument()
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
it('toggles ribbon visibility', () => {
|
|
30
|
-
renderAppBar()
|
|
31
|
-
const btn = screen.getByRole('button', { name: /toolbar/i })
|
|
32
|
-
fireEvent.click(btn)
|
|
33
|
-
expect(btn).toBeInTheDocument()
|
|
34
|
-
})
|
|
35
|
-
})
|