@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,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
- }