@sutrata/editor 0.1.1 → 0.2.0

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 (268) hide show
  1. package/README.md +87 -0
  2. package/dist/ai/VoiceConfirmDialog.d.ts +1 -0
  3. package/dist/ai/VoiceConfirmDialog.js +6 -6
  4. package/dist/ai/VoiceToolbar.d.ts +1 -0
  5. package/dist/ai/VoiceToolbar.js +22 -16
  6. package/dist/ai/clean-response.d.ts +7 -0
  7. package/dist/ai/clean-response.js +58 -0
  8. package/dist/ai/format-transcript.d.ts +3 -0
  9. package/dist/ai/format-transcript.js +7 -0
  10. package/dist/ai/prompts.d.ts +8 -1
  11. package/dist/ai/prompts.js +34 -7
  12. package/dist/autocomplete/suggestions.js +1 -1
  13. package/dist/context/DocumentContext.d.ts +28 -4
  14. package/dist/context/DocumentContext.js +67 -37
  15. package/dist/editor/EditorView.js +32 -4
  16. package/dist/editor/SelectionMenu.js +5 -3
  17. package/dist/editor/external-decorations-plugin.d.ts +23 -0
  18. package/dist/editor/external-decorations-plugin.js +151 -0
  19. package/dist/editor/omitted-scene-plugin.d.ts +9 -0
  20. package/dist/editor/omitted-scene-plugin.js +44 -0
  21. package/dist/editor/plugins.js +4 -0
  22. package/dist/editor/private-metadata-plugin.d.ts +10 -0
  23. package/dist/editor/private-metadata-plugin.js +60 -0
  24. package/dist/editor/prosemirror-to-sutra.js +179 -74
  25. package/dist/editor/scene-heading-view.js +2 -0
  26. package/dist/editor/schema.js +69 -12
  27. package/dist/editor/sutra-to-prosemirror.d.ts +2 -0
  28. package/dist/editor/sutra-to-prosemirror.js +32 -34
  29. package/dist/extensions/ai-provider.d.ts +44 -0
  30. package/dist/extensions/ai-provider.js +27 -0
  31. package/dist/extensions/collab.d.ts +21 -0
  32. package/dist/extensions/collab.js +6 -0
  33. package/dist/extensions/command-registry.d.ts +49 -0
  34. package/dist/extensions/command-registry.js +28 -0
  35. package/dist/extensions/decorations.d.ts +48 -0
  36. package/dist/extensions/decorations.js +6 -0
  37. package/dist/extensions/export-registry.d.ts +59 -0
  38. package/dist/extensions/export-registry.js +10 -0
  39. package/dist/extensions/panel-registry.d.ts +30 -0
  40. package/dist/extensions/panel-registry.js +16 -0
  41. package/dist/extensions/registries.d.ts +15 -0
  42. package/dist/extensions/registries.js +12 -0
  43. package/dist/extensions/registry-store.d.ts +26 -0
  44. package/dist/extensions/registry-store.js +35 -0
  45. package/dist/extensions/session.d.ts +29 -0
  46. package/dist/extensions/session.js +20 -0
  47. package/dist/extensions/speech-provider.d.ts +35 -0
  48. package/dist/extensions/speech-provider.js +17 -0
  49. package/dist/file/builtin-exporters.d.ts +9 -0
  50. package/dist/file/builtin-exporters.js +72 -0
  51. package/dist/file/docx-exporter.js +7 -4
  52. package/dist/file/docx-importer.d.ts +9 -0
  53. package/dist/file/docx-importer.js +47 -19
  54. package/dist/file/scene-number.d.ts +5 -0
  55. package/dist/file/scene-number.js +8 -0
  56. package/dist/file/workflow-reports.js +13 -7
  57. package/dist/findreplace/FindReplace.js +5 -2
  58. package/dist/import/AIImportDialog.d.ts +11 -0
  59. package/dist/import/AIImportDialog.js +112 -0
  60. package/dist/import/ai-import.d.ts +51 -0
  61. package/dist/import/ai-import.js +200 -0
  62. package/dist/import/apply-import.d.ts +7 -0
  63. package/dist/import/apply-import.js +35 -0
  64. package/dist/import/extract.d.ts +17 -0
  65. package/dist/import/extract.js +18 -0
  66. package/dist/index.d.ts +17 -2
  67. package/dist/index.js +7 -0
  68. package/dist/locales/bn.json +8 -0
  69. package/dist/locales/en.json +8 -0
  70. package/dist/locales/gu.json +8 -0
  71. package/dist/locales/hi.json +8 -0
  72. package/dist/locales/kn.json +8 -0
  73. package/dist/locales/ml.json +8 -0
  74. package/dist/locales/mr.json +8 -0
  75. package/dist/locales/or.json +8 -0
  76. package/dist/locales/pa.json +8 -0
  77. package/dist/locales/si.json +8 -0
  78. package/dist/locales/ta.json +8 -0
  79. package/dist/locales/te.json +8 -0
  80. package/dist/navigator/SceneNavigator.js +125 -131
  81. package/dist/navigator/StatisticsDialog.js +5 -1
  82. package/dist/navigator/scene-edits.d.ts +18 -0
  83. package/dist/navigator/scene-edits.js +143 -0
  84. package/dist/navigator/scene-list.js +4 -4
  85. package/dist/navigator/scene-numbering.d.ts +37 -0
  86. package/dist/navigator/scene-numbering.js +88 -0
  87. package/dist/settings/SettingsDialog.js +8 -120
  88. package/dist/shell/AppBar.js +21 -2
  89. package/dist/shell/AppShell.js +18 -4
  90. package/dist/shell/ElementToolbar.d.ts +5 -0
  91. package/dist/shell/ElementToolbar.js +35 -25
  92. package/dist/shell/LanguageStatus.js +6 -0
  93. package/dist/shell/StatusBar.js +11 -6
  94. package/dist/shell/builtin-commands.d.ts +10 -0
  95. package/dist/shell/builtin-commands.js +43 -0
  96. package/dist/shell/icons.d.ts +6 -0
  97. package/dist/shell/icons.js +6 -0
  98. package/dist/shell/use-commands.d.ts +7 -0
  99. package/dist/shell/use-commands.js +24 -0
  100. package/dist/shell/use-panel-context.d.ts +3 -0
  101. package/dist/shell/use-panel-context.js +39 -0
  102. package/dist/source/SourceView.js +18 -1
  103. package/dist/styles/screenplay.css +113 -1
  104. package/dist/titlepage/ExportDialog.d.ts +5 -0
  105. package/dist/titlepage/ExportDialog.js +59 -96
  106. package/package.json +32 -5
  107. package/dist/ai/OnboardingDialog.d.ts +0 -6
  108. package/dist/ai/OnboardingDialog.js +0 -115
  109. package/dist/ai/ai-client.d.ts +0 -71
  110. package/dist/ai/ai-client.js +0 -494
  111. package/dist/ai/generated-providers.json +0 -3696
  112. package/dist/ai/voice-service.d.ts +0 -15
  113. package/dist/ai/voice-service.js +0 -81
  114. package/dist/editor/character-view.d.ts +0 -35
  115. package/dist/editor/character-view.js +0 -112
  116. package/dist/editor/transliterate-input.d.ts +0 -10
  117. package/dist/editor/transliterate-input.js +0 -88
  118. package/dist/extensions/types.d.ts +0 -73
  119. package/dist/extensions/types.js +0 -12
  120. package/scripts/copy-assets.mjs +0 -16
  121. package/scripts/generate-providers.mjs +0 -127
  122. package/src/ai/OnboardingDialog.tsx +0 -390
  123. package/src/ai/VoiceConfirmDialog.tsx +0 -137
  124. package/src/ai/VoiceToolbar.tsx +0 -321
  125. package/src/ai/ai-client.ts +0 -596
  126. package/src/ai/commands.ts +0 -164
  127. package/src/ai/generated-providers.json +0 -3696
  128. package/src/ai/prompts.ts +0 -62
  129. package/src/ai/voice-service.ts +0 -96
  130. package/src/autocomplete/AutocompletePlugin.ts +0 -21
  131. package/src/autocomplete/suggestions.ts +0 -147
  132. package/src/context/DocumentContext.tsx +0 -648
  133. package/src/desktop-api.d.ts +0 -40
  134. package/src/editor/EditorView.tsx +0 -82
  135. package/src/editor/SelectionMenu.tsx +0 -152
  136. package/src/editor/attr-pencil-plugin.ts +0 -172
  137. package/src/editor/character-decoration-plugin.ts +0 -104
  138. package/src/editor/editor-bus.ts +0 -51
  139. package/src/editor/find-highlight-plugin.ts +0 -68
  140. package/src/editor/frontmatter-field-view.ts +0 -52
  141. package/src/editor/frontmatter-field.ts +0 -115
  142. package/src/editor/grapheme-cursor.ts +0 -99
  143. package/src/editor/ime-plugin.ts +0 -85
  144. package/src/editor/insert-helper.ts +0 -43
  145. package/src/editor/keymap.ts +0 -124
  146. package/src/editor/language-class-plugin.ts +0 -91
  147. package/src/editor/plugins.ts +0 -26
  148. package/src/editor/prosemirror-to-sutra.ts +0 -192
  149. package/src/editor/scene-heading-view.ts +0 -147
  150. package/src/editor/schema.ts +0 -156
  151. package/src/editor/sutra-to-prosemirror.ts +0 -156
  152. package/src/extensions/storage-adapter.ts +0 -65
  153. package/src/extensions/types.ts +0 -60
  154. package/src/file/docx-exporter.ts +0 -585
  155. package/src/file/docx-importer.ts +0 -313
  156. package/src/file/print-paginator.ts +0 -301
  157. package/src/file/templates.ts +0 -73
  158. package/src/file/workflow-reports.ts +0 -820
  159. package/src/findreplace/FindReplace.tsx +0 -242
  160. package/src/findreplace/find-engine.ts +0 -97
  161. package/src/findreplace/scroll-to-match.ts +0 -89
  162. package/src/fonts/tofu-detect.ts +0 -53
  163. package/src/i18n/LanguageContext.tsx +0 -207
  164. package/src/i18n/language-detect.ts +0 -73
  165. package/src/i18n/useTranslation.tsx +0 -74
  166. package/src/index.ts +0 -27
  167. package/src/locales/bn.json +0 -143
  168. package/src/locales/en.json +0 -143
  169. package/src/locales/gu.json +0 -143
  170. package/src/locales/hi.json +0 -143
  171. package/src/locales/kn.json +0 -143
  172. package/src/locales/ml.json +0 -143
  173. package/src/locales/mr.json +0 -143
  174. package/src/locales/or.json +0 -143
  175. package/src/locales/pa.json +0 -143
  176. package/src/locales/si.json +0 -143
  177. package/src/locales/ta.json +0 -143
  178. package/src/locales/te.json +0 -143
  179. package/src/navigator/SceneNavigator.tsx +0 -844
  180. package/src/navigator/StatisticsDialog.tsx +0 -249
  181. package/src/navigator/scene-list.ts +0 -84
  182. package/src/settings/SettingsDialog.tsx +0 -303
  183. package/src/shell/AppBar.tsx +0 -354
  184. package/src/shell/AppShell.tsx +0 -71
  185. package/src/shell/ConfirmDialog.tsx +0 -71
  186. package/src/shell/ElementToolbar.tsx +0 -175
  187. package/src/shell/LanguageStatus.tsx +0 -101
  188. package/src/shell/StatusBar.tsx +0 -135
  189. package/src/shell/SutrataLogo.tsx +0 -89
  190. package/src/shell/Toast.tsx +0 -46
  191. package/src/shell/ToolbarButton.tsx +0 -30
  192. package/src/shell/VersionHistory.tsx +0 -212
  193. package/src/shell/Watermark.tsx +0 -9
  194. package/src/shell/fonts/fonts.css +0 -1133
  195. package/src/shell/icons.tsx +0 -335
  196. package/src/shell/toolbar-actions.ts +0 -59
  197. package/src/source/SourceView.tsx +0 -111
  198. package/src/source/find-highlight-source.ts +0 -57
  199. package/src/source/sutra-lang.ts +0 -121
  200. package/src/spellcheck/dictionaries.ts +0 -21
  201. package/src/spellcheck/hunspell.ts +0 -110
  202. package/src/spellcheck/spellcheck-plugin.ts +0 -83
  203. package/src/styles/StyleDialog.tsx +0 -148
  204. package/src/styles/builtin-styles.ts +0 -139
  205. package/src/styles/css-adapter.ts +0 -76
  206. package/src/styles/docx-adapter.ts +0 -109
  207. package/src/styles/embed-fonts.ts +0 -81
  208. package/src/styles/pagination.ts +0 -27
  209. package/src/styles/print-adapter.ts +0 -288
  210. package/src/styles/registry.ts +0 -20
  211. package/src/styles/screenplay.css +0 -3006
  212. package/src/styles/script-ranges.ts +0 -17
  213. package/src/styles/types.ts +0 -104
  214. package/src/styles/validate.ts +0 -114
  215. package/src/templates/templates.ts +0 -194
  216. package/src/titlepage/ExportDialog.tsx +0 -494
  217. package/src/titlepage/TitlePageDialog.tsx +0 -312
  218. package/src/titlepage/frontmatter-form.ts +0 -280
  219. package/src/titlepage/languages.ts +0 -37
  220. package/src/types.ts +0 -7
  221. package/tests/AppBar.test.tsx +0 -35
  222. package/tests/AppShell.test.tsx +0 -22
  223. package/tests/ElementToolbar.test.tsx +0 -36
  224. package/tests/LanguageStatus.test.tsx +0 -129
  225. package/tests/SelectionMenu.test.tsx +0 -71
  226. package/tests/ToolbarButton.test.tsx +0 -24
  227. package/tests/ai-onboarding.test.ts +0 -102
  228. package/tests/ai-status.test.tsx +0 -72
  229. package/tests/ai-structured.test.ts +0 -94
  230. package/tests/autocomplete/AutocompletePlugin.test.ts +0 -20
  231. package/tests/autocomplete/suggestions.test.ts +0 -106
  232. package/tests/document-context.test.tsx +0 -565
  233. package/tests/docx-importer.test.ts +0 -160
  234. package/tests/editor/character-decoration-plugin.test.ts +0 -101
  235. package/tests/editor/character-extension.test.ts +0 -44
  236. package/tests/editor/grapheme-cursor.test.ts +0 -118
  237. package/tests/editor/ime-plugin.test.ts +0 -72
  238. package/tests/editor/inline-note-bridge.test.ts +0 -32
  239. package/tests/editor/language-class-plugin.test.ts +0 -265
  240. package/tests/editor/title-page.test.ts +0 -124
  241. package/tests/embed-fonts.test.ts +0 -17
  242. package/tests/exporters.test.ts +0 -365
  243. package/tests/findreplace/FindReplace.test.tsx +0 -391
  244. package/tests/findreplace/find-engine.test.ts +0 -86
  245. package/tests/findreplace/scroll-to-match.test.ts +0 -50
  246. package/tests/fonts/tofu-detect.test.ts +0 -122
  247. package/tests/i18n/LanguageContext.test.tsx +0 -146
  248. package/tests/i18n/language-detect.test.ts +0 -54
  249. package/tests/i18n/locale-parity.test.ts +0 -25
  250. package/tests/i18n/useTranslation.test.tsx +0 -45
  251. package/tests/keymap.test.ts +0 -18
  252. package/tests/navigator/SceneNavigator.test.tsx +0 -177
  253. package/tests/navigator/StatisticsDialog.test.tsx +0 -63
  254. package/tests/navigator/scene-list.test.ts +0 -71
  255. package/tests/print-paginator.test.ts +0 -82
  256. package/tests/shell/VersionHistory.test.tsx +0 -129
  257. package/tests/spellcheck/hunspell.test.ts +0 -82
  258. package/tests/spellcheck/spellcheck-plugin.test.ts +0 -56
  259. package/tests/styles.test.ts +0 -315
  260. package/tests/templates/templates.test.ts +0 -83
  261. package/tests/titlepage/TitlePageDialog.test.tsx +0 -90
  262. package/tests/titlepage/apply-title-page.test.ts +0 -61
  263. package/tests/titlepage/frontmatter-form.test.ts +0 -115
  264. package/tests/toolbar-actions.test.ts +0 -93
  265. package/tests/voice-commands.test.ts +0 -58
  266. package/tsconfig.json +0 -17
  267. package/vitest.config.ts +0 -11
  268. package/vitest.setup.ts +0 -65
@@ -1,192 +0,0 @@
1
- import { Node as PmNode } from 'prosemirror-model'
2
- import { schema } from './schema'
3
-
4
- function nodeText(node: PmNode): string {
5
- return node.textContent
6
- }
7
-
8
- function inlineToMarkdown(node: PmNode): string {
9
- let result = ''
10
- node.forEach(child => {
11
- if (child.isText) {
12
- let text = child.text ?? ''
13
- if (child.marks.find(m => m.type === schema.marks['bold'])) text = `**${text}**`
14
- else if (child.marks.find(m => m.type === schema.marks['italic'])) text = `*${text}*`
15
- else if (child.marks.find(m => m.type === schema.marks['underline'])) text = `_${text}_`
16
- else if (child.marks.find(m => m.type === schema.marks['note_mark'])) text = `[[${text}]]`
17
- result += text
18
- }
19
- })
20
- return result
21
- }
22
-
23
- /**
24
- * Extract the text value of a frontmatter_field node, converting hard_break
25
- * nodes back to '\n' so multi-line values round-trip correctly.
26
- */
27
- function fieldText(node: PmNode): string {
28
- let result = ''
29
- node.forEach(child => {
30
- if (child.isText) result += child.text ?? ''
31
- else if (child.type === schema.nodes['hard_break']) result += '\n'
32
- })
33
- return result.trim()
34
- }
35
-
36
- /**
37
- * title_page node → frontmatter text block. Dotted keys ("title.hi") are
38
- * reassembled into one-level nested maps (2-space indent, matching the
39
- * parser's minimal-YAML frontmatter format). Fields with empty key AND
40
- * empty value are skipped; returns '' if nothing remains.
41
- * Multi-line values are serialized as YAML literal block scalars (|).
42
- */
43
- function serializeTitlePage(node: PmNode): string {
44
- const data = new Map<string, string | Record<string, string>>()
45
- node.forEach(field => {
46
- if (field.type.name !== 'frontmatter_field') return
47
- const key = ((field.attrs['fmKey'] as string) ?? '').trim()
48
- const value = fieldText(field)
49
- if (!key) return
50
- const dot = key.indexOf('.')
51
- if (dot > 0 && dot < key.length - 1) {
52
- const parent = key.slice(0, dot)
53
- const sub = key.slice(dot + 1)
54
- const existing = data.get(parent)
55
- const map: Record<string, string> = existing !== undefined && typeof existing === 'object' ? existing : {}
56
- map[sub] = value
57
- data.set(parent, map)
58
- } else {
59
- data.set(key, value)
60
- }
61
- })
62
- if (data.size === 0) return ''
63
- const lines: string[] = ['---']
64
- for (const [key, value] of data) {
65
- if (typeof value === 'string') {
66
- if (value === '') {
67
- lines.push(`${key}:`)
68
- } else if (value.includes('\n')) {
69
- // YAML literal block scalar: preserves newlines verbatim
70
- lines.push(`${key}: |`)
71
- for (const ln of value.split('\n')) lines.push(` ${ln}`)
72
- } else {
73
- lines.push(`${key}: ${value}`)
74
- }
75
- } else {
76
- lines.push(`${key}:`)
77
- for (const [sub, sv] of Object.entries(value)) lines.push(` ${sub}: ${sv}`)
78
- }
79
- }
80
- lines.push('---')
81
- return lines.join('\n') + '\n\n'
82
- }
83
-
84
- export function prosemirrorToSutra(doc: PmNode): string {
85
- // Collect all top-level nodes into an array first
86
- const pmNodes: PmNode[] = []
87
- doc.forEach(node => pmNodes.push(node))
88
-
89
- const blocks: string[] = []
90
- let i = 0
91
-
92
- let frontmatter = ''
93
- if (pmNodes.length > 0 && pmNodes[0]!.type.name === 'title_page') {
94
- frontmatter = serializeTitlePage(pmNodes[0]!)
95
- i = 1
96
- }
97
-
98
- while (i < pmNodes.length) {
99
- const node = pmNodes[i]!
100
- const type = node.type.name
101
-
102
- if (type === 'character') {
103
- // Group: character cue + following dialogue/parenthetical lines (no blank line between them).
104
- // Name, extension and dual-marker are all just typed text now — see
105
- // character-decoration-plugin.ts — so the node's own text is the
106
- // whole cue line, verbatim.
107
- const lines: string[] = [`@${nodeText(node)}`]
108
- i++
109
- while (i < pmNodes.length) {
110
- const next = pmNodes[i]!
111
- const nextType = next.type.name
112
- if (nextType === 'dialogue') {
113
- lines.push(inlineToMarkdown(next))
114
- i++
115
- } else if (nextType === 'parenthetical') {
116
- lines.push(`( ${nodeText(next)} )`)
117
- i++
118
- } else {
119
- break
120
- }
121
- }
122
- blocks.push(lines.join('\n'))
123
- continue
124
- }
125
-
126
- if (type === 'scene_heading') {
127
- const id = node.attrs['id'] ? ` {#${node.attrs['id'] as string}}` : ''
128
- // Group heading + optional synopsis + metadata lines with single newlines (no blank
129
- // line between them), so the parser recognises them as one heading block.
130
- const headingLines: string[] = [`## ${nodeText(node)}${id}`]
131
- i++
132
- while (i < pmNodes.length && pmNodes[i]!.type.name === 'scene_metadata') {
133
- const m = pmNodes[i]!
134
- headingLines.push(`& ${m.attrs['metaKey'] as string}: ${nodeText(m)}`)
135
- i++
136
- }
137
- blocks.push(headingLines.join('\n'))
138
- continue
139
- }
140
- if (type === 'scene_metadata') {
141
- // Orphan metadata (outside a heading group) — emit as-is
142
- blocks.push(`& ${node.attrs['metaKey'] as string}: ${nodeText(node)}`)
143
- i++; continue
144
- }
145
- if (type === 'section') {
146
- const id = node.attrs['id'] ? ` {#${node.attrs['id'] as string}}` : ''
147
- blocks.push(`# ${nodeText(node)}${id}`)
148
- i++; continue
149
- }
150
- if (type === 'action') {
151
- blocks.push(inlineToMarkdown(node))
152
- i++; continue
153
- }
154
- if (type === 'dialogue') {
155
- // Orphan dialogue (should not occur after the grouping above, but handle gracefully)
156
- blocks.push(inlineToMarkdown(node))
157
- i++; continue
158
- }
159
- if (type === 'parenthetical') {
160
- // Orphan parenthetical
161
- blocks.push(`( ${nodeText(node)} )`)
162
- i++; continue
163
- }
164
- if (type === 'transition') {
165
- blocks.push(`>> ${nodeText(node)}`)
166
- i++; continue
167
- }
168
- if (type === 'centered') {
169
- blocks.push(`>> ${nodeText(node)} <<`)
170
- i++; continue
171
- }
172
- if (type === 'lyrics') {
173
- blocks.push(`~ ${inlineToMarkdown(node)}`)
174
- i++; continue
175
- }
176
- if (type === 'note') {
177
- blocks.push(`[[ ${nodeText(node)} ]]`)
178
- i++; continue
179
- }
180
- if (type === 'comment') {
181
- blocks.push(`<!-- ${nodeText(node)} -->`)
182
- i++; continue
183
- }
184
- if (type === 'page_break') {
185
- blocks.push('===')
186
- i++; continue
187
- }
188
- i++ // unknown node type — skip
189
- }
190
-
191
- return frontmatter + blocks.join('\n\n') + '\n'
192
- }
@@ -1,147 +0,0 @@
1
- import type { Node as PmNode } from 'prosemirror-model'
2
- import { TextSelection } from 'prosemirror-state'
3
- import type { EditorView, NodeView, ViewMutationRecord } from 'prosemirror-view'
4
-
5
- /**
6
- * NodeView for scene_heading. Renders the heading text (ProseMirror-managed)
7
- * plus an editable scene-number input on the right. Tab from inside the text
8
- * area or a click on the number triggers focus of the input.
9
- *
10
- * The id value is stored purely as a node attribute — the PM content model
11
- * only contains the heading text.
12
- */
13
- export class SceneHeadingView implements NodeView {
14
- dom: HTMLElement
15
- contentDOM: HTMLElement
16
- private input: HTMLInputElement
17
- private pmView: EditorView
18
- private getPos: () => number | undefined
19
-
20
- constructor(node: PmNode, view: EditorView, getPos: () => number | undefined) {
21
- this.pmView = view
22
- this.getPos = getPos
23
-
24
- // Outer wrapper — flex row, same as CSS sets for .cs-scene-heading
25
- this.dom = document.createElement('div')
26
- this.dom.className = 'cs-scene-heading'
27
- if (node.attrs['id']) {
28
- this.dom.dataset['sceneId'] = node.attrs['id'] as string
29
- }
30
-
31
- // Left: ProseMirror manages text content here
32
- this.contentDOM = document.createElement('span')
33
- this.contentDOM.className = 'cs-scene-heading-text'
34
- this.dom.appendChild(this.contentDOM)
35
-
36
- // Right: scene number input
37
- this.input = document.createElement('input')
38
- this.input.type = 'text'
39
- this.input.className = 'cs-scene-id-input'
40
- this.input.placeholder = '#'
41
- this.input.value = (node.attrs['id'] as string | null) ?? ''
42
- this.input.setAttribute('aria-label', 'Scene number')
43
- this.input.spellcheck = false
44
-
45
- this.input.addEventListener('keydown', this.onInputKeydown)
46
- this.input.addEventListener('blur', this.onInputBlur)
47
- // Prevent PM from handling clicks inside the input
48
- this.input.addEventListener('mousedown', (e) => e.stopPropagation())
49
-
50
- this.dom.appendChild(this.input)
51
- }
52
-
53
- /** Focus the id input — called by Tab keymap handler */
54
- focusInput() {
55
- this.input.focus()
56
- this.input.select()
57
- }
58
-
59
- private commit() {
60
- const raw = this.input.value.trim()
61
- const id = raw === '' ? null : raw
62
- const pos = this.getPos()
63
- if (pos === undefined) return
64
- const node = this.pmView.state.doc.nodeAt(pos)
65
- if (!node) return
66
- if (node.attrs['id'] === id) return
67
- const tr = this.pmView.state.tr.setNodeMarkup(pos, undefined, { ...node.attrs, id })
68
- this.pmView.dispatch(tr)
69
- }
70
-
71
- private onInputKeydown = (e: KeyboardEvent) => {
72
- if (e.key === 'Escape') {
73
- e.preventDefault()
74
- e.stopPropagation()
75
- this.input.value = (this.pmView.state.doc.nodeAt(this.getPos() ?? 0)?.attrs['id'] as string | null) ?? ''
76
- this.pmView.focus()
77
- return
78
- }
79
- if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) {
80
- e.preventDefault()
81
- e.stopPropagation()
82
- this.commit()
83
- const pos = this.getPos()
84
- if (pos !== undefined) {
85
- const node = this.pmView.state.doc.nodeAt(pos)
86
- if (node) {
87
- const endOfText = pos + node.nodeSize - 1
88
- const sel = TextSelection.near(this.pmView.state.doc.resolve(endOfText), -1)
89
- this.pmView.dispatch(this.pmView.state.tr.setSelection(sel))
90
- }
91
- }
92
- this.pmView.focus()
93
- return
94
- }
95
- if (e.key === 'Enter' || e.key === 'Tab' || e.key === 'ArrowDown') {
96
- e.preventDefault()
97
- e.stopPropagation()
98
- this.commit()
99
- // Move cursor to the start of the node after the scene heading
100
- const pos = this.getPos()
101
- if (pos !== undefined) {
102
- const node = this.pmView.state.doc.nodeAt(pos)
103
- if (node) {
104
- const afterPos = pos + node.nodeSize
105
- const { doc } = this.pmView.state
106
- if (afterPos <= doc.content.size) {
107
- const sel = TextSelection.near(doc.resolve(afterPos), 1)
108
- this.pmView.dispatch(this.pmView.state.tr.setSelection(sel))
109
- }
110
- }
111
- }
112
- this.pmView.focus()
113
- return
114
- }
115
- }
116
-
117
- private onInputBlur = () => {
118
- this.commit()
119
- }
120
-
121
- update(node: PmNode): boolean {
122
- if (node.type.name !== 'scene_heading') return false
123
- const id = (node.attrs['id'] as string | null) ?? ''
124
- // Keep DOM attr in sync
125
- if (id) this.dom.dataset['sceneId'] = id
126
- else delete this.dom.dataset['sceneId']
127
- // Only update input if it doesn't have focus (avoid clobbering in-progress edits)
128
- if (document.activeElement !== this.input) {
129
- this.input.value = id
130
- }
131
- return true
132
- }
133
-
134
- destroy() {
135
- this.input.removeEventListener('keydown', this.onInputKeydown)
136
- this.input.removeEventListener('blur', this.onInputBlur)
137
- }
138
-
139
- stopEvent(event: Event): boolean {
140
- // Let PM handle events inside contentDOM; swallow events on the input
141
- return event.target === this.input || (this.input.contains(event.target as Node))
142
- }
143
-
144
- ignoreMutation(mutation: ViewMutationRecord): boolean {
145
- return this.input.contains(mutation.target as Node) || mutation.target === this.input
146
- }
147
- }
@@ -1,156 +0,0 @@
1
- import { Schema } from 'prosemirror-model'
2
- import type { NodeSpec, MarkSpec } from 'prosemirror-model'
3
-
4
- const nodes: Record<string, NodeSpec> = {
5
- doc: { content: 'title_page? block+' },
6
-
7
- // Frontmatter rendered as an editable card at the top of the document.
8
- // Only valid as the first child of doc (not in the block group).
9
- title_page: {
10
- content: 'frontmatter_field+',
11
- toDOM: () => ['div', { class: 'cs-title-page-block' }, 0],
12
- parseDOM: [{ tag: 'div.cs-title-page-block' }],
13
- },
14
-
15
- frontmatter_field: {
16
- content: '(text | hard_break)*',
17
- marks: '',
18
- attrs: { fmKey: { default: '' } },
19
- toDOM: (node) => ['div', { class: 'cs-fm-field', 'data-key': node.attrs['fmKey'] as string }, 0],
20
- parseDOM: [{ tag: 'div.cs-fm-field', getAttrs: (dom) => ({ fmKey: (dom as HTMLElement).dataset['key'] ?? '' }) }],
21
- },
22
-
23
- scene_heading: {
24
- group: 'block',
25
- content: 'inline*',
26
- attrs: { id: { default: null } },
27
- toDOM: (node) => {
28
- const attrs: Record<string, string> = { class: 'cs-scene-heading' }
29
- if (node.attrs['id']) attrs['data-scene-id'] = node.attrs['id'] as string
30
- return ['div', attrs, 0]
31
- },
32
- parseDOM: [{ tag: 'div.cs-scene-heading', getAttrs: (dom) => ({ id: (dom as HTMLElement).dataset['sceneId'] ?? null }) }],
33
- },
34
-
35
- action: {
36
- group: 'block',
37
- content: 'inline*',
38
- toDOM: () => ['div', { class: 'cs-action' }, 0],
39
- parseDOM: [{ tag: 'div.cs-action' }],
40
- },
41
-
42
- character: {
43
- group: 'block',
44
- content: 'inline*',
45
- toDOM: () => ['div', { class: 'cs-character' }, 0],
46
- parseDOM: [{ tag: 'div.cs-character' }],
47
- },
48
-
49
- dialogue: {
50
- group: 'block',
51
- content: 'inline*',
52
- toDOM: () => ['div', { class: 'cs-dialogue' }, 0],
53
- parseDOM: [{ tag: 'div.cs-dialogue' }],
54
- },
55
-
56
- parenthetical: {
57
- group: 'block',
58
- content: 'inline*',
59
- toDOM: () => ['div', { class: 'cs-parenthetical' }, 0],
60
- parseDOM: [{ tag: 'div.cs-parenthetical' }],
61
- },
62
-
63
- transition: {
64
- group: 'block',
65
- content: 'inline*',
66
- toDOM: () => ['div', { class: 'cs-transition' }, 0],
67
- parseDOM: [{ tag: 'div.cs-transition' }],
68
- },
69
-
70
- centered: {
71
- group: 'block',
72
- content: 'inline*',
73
- toDOM: () => ['div', { class: 'cs-centered' }, 0],
74
- parseDOM: [{ tag: 'div.cs-centered' }],
75
- },
76
-
77
- lyrics: {
78
- group: 'block',
79
- content: 'inline*',
80
- toDOM: () => ['div', { class: 'cs-lyrics' }, 0],
81
- parseDOM: [{ tag: 'div.cs-lyrics' }],
82
- },
83
-
84
- scene_metadata: {
85
- group: 'block',
86
- content: 'inline*',
87
- attrs: { metaKey: { default: '' } },
88
- toDOM: (node) => ['div', { class: 'cs-scene-metadata', 'data-key': node.attrs['metaKey'] as string }, 0],
89
- parseDOM: [{ tag: 'div.cs-scene-metadata', getAttrs: (dom) => ({ metaKey: (dom as HTMLElement).dataset['key'] ?? '' }) }],
90
- },
91
-
92
- note: {
93
- group: 'block',
94
- content: 'inline*',
95
- toDOM: () => ['div', { class: 'cs-note' }, 0],
96
- parseDOM: [{ tag: 'div.cs-note' }],
97
- },
98
-
99
- comment: {
100
- group: 'block',
101
- content: 'inline*',
102
- toDOM: () => ['div', { class: 'cs-comment' }, 0],
103
- parseDOM: [{ tag: 'div.cs-comment' }],
104
- },
105
-
106
- page_break: {
107
- group: 'block',
108
- toDOM: () => ['div', { class: 'cs-page-break' }],
109
- parseDOM: [{ tag: 'div.cs-page-break' }],
110
- },
111
-
112
- section: {
113
- group: 'block',
114
- content: 'inline*',
115
- attrs: { level: { default: 1 }, id: { default: null } },
116
- toDOM: (node) => [`h${(node.attrs['level'] as number) + 1}`, { class: 'cs-section' }, 0],
117
- parseDOM: [
118
- { tag: 'h2.cs-section', getAttrs: () => ({ level: 1 }) },
119
- { tag: 'h3.cs-section', getAttrs: () => ({ level: 2 }) },
120
- ],
121
- },
122
-
123
- hard_break: {
124
- inline: true,
125
- group: 'inline',
126
- selectable: false,
127
- toDOM: () => ['br'],
128
- parseDOM: [{ tag: 'br' }],
129
- },
130
-
131
- text: { group: 'inline' },
132
- }
133
-
134
- const marks: Record<string, MarkSpec> = {
135
- bold: {
136
- toDOM: () => ['strong', 0],
137
- parseDOM: [{ tag: 'strong' }],
138
- },
139
- italic: {
140
- toDOM: () => ['em', 0],
141
- parseDOM: [{ tag: 'em' }],
142
- },
143
- underline: {
144
- toDOM: () => ['u', 0],
145
- parseDOM: [{ tag: 'u' }],
146
- },
147
- // Named note_mark, not note, since a block-level `note` node already exists
148
- // (whole-line [[ ]] notes) and ProseMirror forbids a name being both.
149
- note_mark: {
150
- toDOM: () => ['span', { class: 'cs-inline-note' }, 0],
151
- parseDOM: [{ tag: 'span.cs-inline-note' }],
152
- },
153
- }
154
-
155
- export const schema = new Schema({ nodes, marks })
156
- export type SutraSchema = typeof schema
@@ -1,156 +0,0 @@
1
- import { Node as PmNode } from 'prosemirror-model'
2
- import { schema } from './schema'
3
- import type { DocumentNode, SceneHeadingNode, SceneContentNode,
4
- InlineSpan } from '@sutrata/parser'
5
-
6
- function spansToNodes(spans: InlineSpan[]): PmNode[] {
7
- return spans.flatMap(span => {
8
- if (span.type === 'text') return span.text ? [schema.text(span.text)] : []
9
- if (span.type === 'note') {
10
- if (!span.text) return []
11
- const markType = schema.marks['note_mark']!
12
- return [schema.text(span.text).mark([markType.create()])]
13
- }
14
- const inner = spansToNodes(span.spans)
15
- const markType = schema.marks[span.type]
16
- if (!markType || inner.length === 0) return inner
17
- return inner.map(n => n.mark([markType.create()]))
18
- })
19
- }
20
-
21
- function textNodes(text: string): PmNode[] {
22
- const lines = text.split('\n')
23
- const nodes: PmNode[] = []
24
- for (let i = 0; i < lines.length; i++) {
25
- const line = lines[i]
26
- if (line) nodes.push(schema.text(line))
27
- if (i < lines.length - 1) nodes.push(schema.nodes['hard_break']!.create())
28
- }
29
- return nodes
30
- }
31
-
32
- function sceneContentToNodes(nodes: SceneContentNode[]): PmNode[] {
33
- const result: PmNode[] = []
34
- for (const node of nodes) {
35
- switch (node.type) {
36
- case 'action':
37
- result.push(schema.nodes['action']!.create({}, spansToNodes(node.spans)))
38
- break
39
- case 'character': {
40
- // Name, extension and dual-marker are rendered as one continuous
41
- // text run — see character-decoration-plugin.ts, which recognizes
42
- // a trailing "(...)"/"^" live and styles it, rather than storing
43
- // them as separate node attrs.
44
- const cueText = [node.name, node.extension, node.isDual ? '^' : null]
45
- .filter((part): part is string => !!part)
46
- .join(' ')
47
- result.push(schema.nodes['character']!.create({}, textNodes(cueText)))
48
- for (const child of node.children) {
49
- if (child.type === 'dialogue') {
50
- result.push(schema.nodes['dialogue']!.create({}, spansToNodes(child.spans)))
51
- } else {
52
- result.push(schema.nodes['parenthetical']!.create({}, textNodes(child.text)))
53
- }
54
- }
55
- break
56
- }
57
- case 'dual-dialogue':
58
- result.push(...sceneContentToNodes([node.left]))
59
- result.push(...sceneContentToNodes([node.right]))
60
- break
61
- case 'transition':
62
- result.push(schema.nodes['transition']!.create({}, textNodes(node.text)))
63
- break
64
- case 'centered':
65
- result.push(schema.nodes['centered']!.create({}, textNodes(node.text)))
66
- break
67
- case 'lyrics':
68
- result.push(schema.nodes['lyrics']!.create({}, spansToNodes(node.spans)))
69
- break
70
- case 'note':
71
- result.push(schema.nodes['note']!.create({}, textNodes(node.text)))
72
- break
73
- case 'comment':
74
- result.push(schema.nodes['comment']!.create({}, textNodes(node.text)))
75
- break
76
- case 'page-break':
77
- result.push(schema.nodes['page_break']!.create())
78
- break
79
- }
80
- }
81
- return result
82
- }
83
-
84
- /**
85
- * Frontmatter → title_page node. One frontmatter_field per key; one-level
86
- * nested maps (multilingual title:) flatten to dotted keys ("title.hi").
87
- */
88
- function frontmatterToNode(doc: DocumentNode): PmNode | null {
89
- const fm = doc.frontmatter
90
- if (!fm) return null
91
- const fields: PmNode[] = []
92
- for (const [key, value] of Object.entries(fm.data)) {
93
- if (value !== null && typeof value === 'object') {
94
- for (const [sub, subVal] of Object.entries(value as Record<string, string>)) {
95
- fields.push(schema.nodes['frontmatter_field']!.create({ fmKey: `${key}.${sub}` }, textNodes(String(subVal))))
96
- }
97
- } else {
98
- fields.push(schema.nodes['frontmatter_field']!.create({ fmKey: key }, textNodes(String(value ?? ''))))
99
- }
100
- }
101
- if (fields.length === 0) return null
102
- return schema.nodes['title_page']!.create({}, fields)
103
- }
104
-
105
- export function sutraToProsemirror(doc: DocumentNode): PmNode {
106
- const topNodes: PmNode[] = []
107
-
108
- const titlePage = frontmatterToNode(doc)
109
- if (titlePage) topNodes.push(titlePage)
110
-
111
- for (const node of doc.children) {
112
- switch (node.type) {
113
- case 'section':
114
- topNodes.push(schema.nodes['section']!.create({ level: node.level, id: node.id }, textNodes(node.text)))
115
- break
116
- case 'scene-heading': {
117
- const sceneNode = node as SceneHeadingNode
118
- topNodes.push(schema.nodes['scene_heading']!.create({ id: sceneNode.id }, textNodes(sceneNode.text)))
119
- for (const meta of sceneNode.metadata) {
120
- topNodes.push(schema.nodes['scene_metadata']!.create({ metaKey: meta.key }, textNodes(meta.value)))
121
- }
122
- topNodes.push(...sceneContentToNodes(sceneNode.children))
123
- break
124
- }
125
- case 'action':
126
- topNodes.push(schema.nodes['action']!.create({}, spansToNodes(node.spans)))
127
- break
128
- case 'transition':
129
- topNodes.push(schema.nodes['transition']!.create({}, textNodes(node.text)))
130
- break
131
- case 'centered':
132
- topNodes.push(schema.nodes['centered']!.create({}, textNodes(node.text)))
133
- break
134
- case 'lyrics':
135
- topNodes.push(schema.nodes['lyrics']!.create({}, spansToNodes(node.spans)))
136
- break
137
- case 'note':
138
- topNodes.push(schema.nodes['note']!.create({}, textNodes(node.text)))
139
- break
140
- case 'comment':
141
- topNodes.push(schema.nodes['comment']!.create({}, textNodes(node.text)))
142
- break
143
- case 'page-break':
144
- topNodes.push(schema.nodes['page_break']!.create())
145
- break
146
- }
147
- }
148
-
149
- // doc content is 'title_page? block+' — always need at least one body block
150
- const hasBlock = topNodes.some(n => n.type.name !== 'title_page')
151
- if (!hasBlock) {
152
- topNodes.push(schema.nodes['action']!.create({}, []))
153
- }
154
-
155
- return schema.nodes['doc']!.create({}, topNodes)
156
- }