@sutrata/editor 0.1.0 → 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.
Files changed (152) hide show
  1. package/README.md +87 -0
  2. package/dist/ai/generated-providers.json +1 -1
  3. package/package.json +36 -9
  4. package/scripts/copy-assets.mjs +0 -16
  5. package/scripts/generate-providers.mjs +0 -127
  6. package/src/ai/OnboardingDialog.tsx +0 -390
  7. package/src/ai/VoiceConfirmDialog.tsx +0 -137
  8. package/src/ai/VoiceToolbar.tsx +0 -321
  9. package/src/ai/ai-client.ts +0 -596
  10. package/src/ai/commands.ts +0 -164
  11. package/src/ai/generated-providers.json +0 -3696
  12. package/src/ai/prompts.ts +0 -62
  13. package/src/ai/voice-service.ts +0 -96
  14. package/src/autocomplete/AutocompletePlugin.ts +0 -21
  15. package/src/autocomplete/suggestions.ts +0 -147
  16. package/src/context/DocumentContext.tsx +0 -648
  17. package/src/desktop-api.d.ts +0 -40
  18. package/src/editor/EditorView.tsx +0 -82
  19. package/src/editor/SelectionMenu.tsx +0 -152
  20. package/src/editor/attr-pencil-plugin.ts +0 -172
  21. package/src/editor/character-decoration-plugin.ts +0 -104
  22. package/src/editor/editor-bus.ts +0 -51
  23. package/src/editor/find-highlight-plugin.ts +0 -68
  24. package/src/editor/frontmatter-field-view.ts +0 -52
  25. package/src/editor/frontmatter-field.ts +0 -115
  26. package/src/editor/grapheme-cursor.ts +0 -99
  27. package/src/editor/ime-plugin.ts +0 -85
  28. package/src/editor/insert-helper.ts +0 -43
  29. package/src/editor/keymap.ts +0 -124
  30. package/src/editor/language-class-plugin.ts +0 -91
  31. package/src/editor/plugins.ts +0 -26
  32. package/src/editor/prosemirror-to-sutra.ts +0 -192
  33. package/src/editor/scene-heading-view.ts +0 -147
  34. package/src/editor/schema.ts +0 -156
  35. package/src/editor/sutra-to-prosemirror.ts +0 -156
  36. package/src/extensions/storage-adapter.ts +0 -65
  37. package/src/extensions/types.ts +0 -60
  38. package/src/file/docx-exporter.ts +0 -585
  39. package/src/file/docx-importer.ts +0 -313
  40. package/src/file/print-paginator.ts +0 -301
  41. package/src/file/templates.ts +0 -73
  42. package/src/file/workflow-reports.ts +0 -820
  43. package/src/findreplace/FindReplace.tsx +0 -242
  44. package/src/findreplace/find-engine.ts +0 -97
  45. package/src/findreplace/scroll-to-match.ts +0 -89
  46. package/src/fonts/tofu-detect.ts +0 -53
  47. package/src/i18n/LanguageContext.tsx +0 -207
  48. package/src/i18n/language-detect.ts +0 -73
  49. package/src/i18n/useTranslation.tsx +0 -74
  50. package/src/index.ts +0 -27
  51. package/src/locales/bn.json +0 -143
  52. package/src/locales/en.json +0 -143
  53. package/src/locales/gu.json +0 -143
  54. package/src/locales/hi.json +0 -143
  55. package/src/locales/kn.json +0 -143
  56. package/src/locales/ml.json +0 -143
  57. package/src/locales/mr.json +0 -143
  58. package/src/locales/or.json +0 -143
  59. package/src/locales/pa.json +0 -143
  60. package/src/locales/si.json +0 -143
  61. package/src/locales/ta.json +0 -143
  62. package/src/locales/te.json +0 -143
  63. package/src/navigator/SceneNavigator.tsx +0 -844
  64. package/src/navigator/StatisticsDialog.tsx +0 -249
  65. package/src/navigator/scene-list.ts +0 -84
  66. package/src/settings/SettingsDialog.tsx +0 -303
  67. package/src/shell/AppBar.tsx +0 -354
  68. package/src/shell/AppShell.tsx +0 -71
  69. package/src/shell/ConfirmDialog.tsx +0 -71
  70. package/src/shell/ElementToolbar.tsx +0 -175
  71. package/src/shell/LanguageStatus.tsx +0 -101
  72. package/src/shell/StatusBar.tsx +0 -135
  73. package/src/shell/SutrataLogo.tsx +0 -89
  74. package/src/shell/Toast.tsx +0 -46
  75. package/src/shell/ToolbarButton.tsx +0 -30
  76. package/src/shell/VersionHistory.tsx +0 -212
  77. package/src/shell/Watermark.tsx +0 -9
  78. package/src/shell/fonts/fonts.css +0 -1133
  79. package/src/shell/icons.tsx +0 -335
  80. package/src/shell/toolbar-actions.ts +0 -59
  81. package/src/source/SourceView.tsx +0 -111
  82. package/src/source/find-highlight-source.ts +0 -57
  83. package/src/source/sutra-lang.ts +0 -121
  84. package/src/spellcheck/dictionaries.ts +0 -21
  85. package/src/spellcheck/hunspell.ts +0 -110
  86. package/src/spellcheck/spellcheck-plugin.ts +0 -83
  87. package/src/styles/StyleDialog.tsx +0 -148
  88. package/src/styles/builtin-styles.ts +0 -139
  89. package/src/styles/css-adapter.ts +0 -76
  90. package/src/styles/docx-adapter.ts +0 -109
  91. package/src/styles/embed-fonts.ts +0 -81
  92. package/src/styles/pagination.ts +0 -27
  93. package/src/styles/print-adapter.ts +0 -288
  94. package/src/styles/registry.ts +0 -20
  95. package/src/styles/screenplay.css +0 -3006
  96. package/src/styles/script-ranges.ts +0 -17
  97. package/src/styles/types.ts +0 -104
  98. package/src/styles/validate.ts +0 -114
  99. package/src/templates/templates.ts +0 -194
  100. package/src/titlepage/ExportDialog.tsx +0 -494
  101. package/src/titlepage/TitlePageDialog.tsx +0 -312
  102. package/src/titlepage/frontmatter-form.ts +0 -280
  103. package/src/titlepage/languages.ts +0 -37
  104. package/src/types.ts +0 -7
  105. package/tests/AppBar.test.tsx +0 -35
  106. package/tests/AppShell.test.tsx +0 -22
  107. package/tests/ElementToolbar.test.tsx +0 -36
  108. package/tests/LanguageStatus.test.tsx +0 -129
  109. package/tests/SelectionMenu.test.tsx +0 -71
  110. package/tests/ToolbarButton.test.tsx +0 -24
  111. package/tests/ai-onboarding.test.ts +0 -102
  112. package/tests/ai-status.test.tsx +0 -72
  113. package/tests/ai-structured.test.ts +0 -94
  114. package/tests/autocomplete/AutocompletePlugin.test.ts +0 -20
  115. package/tests/autocomplete/suggestions.test.ts +0 -106
  116. package/tests/document-context.test.tsx +0 -565
  117. package/tests/docx-importer.test.ts +0 -160
  118. package/tests/editor/character-decoration-plugin.test.ts +0 -101
  119. package/tests/editor/character-extension.test.ts +0 -44
  120. package/tests/editor/grapheme-cursor.test.ts +0 -118
  121. package/tests/editor/ime-plugin.test.ts +0 -72
  122. package/tests/editor/inline-note-bridge.test.ts +0 -32
  123. package/tests/editor/language-class-plugin.test.ts +0 -265
  124. package/tests/editor/title-page.test.ts +0 -124
  125. package/tests/embed-fonts.test.ts +0 -17
  126. package/tests/exporters.test.ts +0 -365
  127. package/tests/findreplace/FindReplace.test.tsx +0 -391
  128. package/tests/findreplace/find-engine.test.ts +0 -86
  129. package/tests/findreplace/scroll-to-match.test.ts +0 -50
  130. package/tests/fonts/tofu-detect.test.ts +0 -122
  131. package/tests/i18n/LanguageContext.test.tsx +0 -146
  132. package/tests/i18n/language-detect.test.ts +0 -54
  133. package/tests/i18n/locale-parity.test.ts +0 -25
  134. package/tests/i18n/useTranslation.test.tsx +0 -45
  135. package/tests/keymap.test.ts +0 -18
  136. package/tests/navigator/SceneNavigator.test.tsx +0 -177
  137. package/tests/navigator/StatisticsDialog.test.tsx +0 -63
  138. package/tests/navigator/scene-list.test.ts +0 -71
  139. package/tests/print-paginator.test.ts +0 -82
  140. package/tests/shell/VersionHistory.test.tsx +0 -129
  141. package/tests/spellcheck/hunspell.test.ts +0 -82
  142. package/tests/spellcheck/spellcheck-plugin.test.ts +0 -56
  143. package/tests/styles.test.ts +0 -315
  144. package/tests/templates/templates.test.ts +0 -83
  145. package/tests/titlepage/TitlePageDialog.test.tsx +0 -90
  146. package/tests/titlepage/apply-title-page.test.ts +0 -61
  147. package/tests/titlepage/frontmatter-form.test.ts +0 -115
  148. package/tests/toolbar-actions.test.ts +0 -93
  149. package/tests/voice-commands.test.ts +0 -58
  150. package/tsconfig.json +0 -17
  151. package/vitest.config.ts +0 -11
  152. 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
@@ -1,7 +0,0 @@
1
- export type EditorMode = 'formatted' | 'source'
2
-
3
- export interface VersionEntry {
4
- id: string
5
- timestamp: number
6
- content: string
7
- }
@@ -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
- })