@sutrata/editor 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -0
- package/dist/ai/generated-providers.json +1 -1
- package/package.json +30 -3
- package/scripts/copy-assets.mjs +0 -16
- package/scripts/generate-providers.mjs +0 -127
- package/src/ai/OnboardingDialog.tsx +0 -390
- package/src/ai/VoiceConfirmDialog.tsx +0 -137
- package/src/ai/VoiceToolbar.tsx +0 -321
- package/src/ai/ai-client.ts +0 -596
- package/src/ai/commands.ts +0 -164
- package/src/ai/generated-providers.json +0 -3696
- package/src/ai/prompts.ts +0 -62
- package/src/ai/voice-service.ts +0 -96
- package/src/autocomplete/AutocompletePlugin.ts +0 -21
- package/src/autocomplete/suggestions.ts +0 -147
- package/src/context/DocumentContext.tsx +0 -648
- package/src/desktop-api.d.ts +0 -40
- package/src/editor/EditorView.tsx +0 -82
- package/src/editor/SelectionMenu.tsx +0 -152
- package/src/editor/attr-pencil-plugin.ts +0 -172
- package/src/editor/character-decoration-plugin.ts +0 -104
- package/src/editor/editor-bus.ts +0 -51
- package/src/editor/find-highlight-plugin.ts +0 -68
- package/src/editor/frontmatter-field-view.ts +0 -52
- package/src/editor/frontmatter-field.ts +0 -115
- package/src/editor/grapheme-cursor.ts +0 -99
- package/src/editor/ime-plugin.ts +0 -85
- package/src/editor/insert-helper.ts +0 -43
- package/src/editor/keymap.ts +0 -124
- package/src/editor/language-class-plugin.ts +0 -91
- package/src/editor/plugins.ts +0 -26
- package/src/editor/prosemirror-to-sutra.ts +0 -192
- package/src/editor/scene-heading-view.ts +0 -147
- package/src/editor/schema.ts +0 -156
- package/src/editor/sutra-to-prosemirror.ts +0 -156
- package/src/extensions/storage-adapter.ts +0 -65
- package/src/extensions/types.ts +0 -60
- package/src/file/docx-exporter.ts +0 -585
- package/src/file/docx-importer.ts +0 -313
- package/src/file/print-paginator.ts +0 -301
- package/src/file/templates.ts +0 -73
- package/src/file/workflow-reports.ts +0 -820
- package/src/findreplace/FindReplace.tsx +0 -242
- package/src/findreplace/find-engine.ts +0 -97
- package/src/findreplace/scroll-to-match.ts +0 -89
- package/src/fonts/tofu-detect.ts +0 -53
- package/src/i18n/LanguageContext.tsx +0 -207
- package/src/i18n/language-detect.ts +0 -73
- package/src/i18n/useTranslation.tsx +0 -74
- package/src/index.ts +0 -27
- package/src/locales/bn.json +0 -143
- package/src/locales/en.json +0 -143
- package/src/locales/gu.json +0 -143
- package/src/locales/hi.json +0 -143
- package/src/locales/kn.json +0 -143
- package/src/locales/ml.json +0 -143
- package/src/locales/mr.json +0 -143
- package/src/locales/or.json +0 -143
- package/src/locales/pa.json +0 -143
- package/src/locales/si.json +0 -143
- package/src/locales/ta.json +0 -143
- package/src/locales/te.json +0 -143
- package/src/navigator/SceneNavigator.tsx +0 -844
- package/src/navigator/StatisticsDialog.tsx +0 -249
- package/src/navigator/scene-list.ts +0 -84
- package/src/settings/SettingsDialog.tsx +0 -303
- package/src/shell/AppBar.tsx +0 -354
- package/src/shell/AppShell.tsx +0 -71
- package/src/shell/ConfirmDialog.tsx +0 -71
- package/src/shell/ElementToolbar.tsx +0 -175
- package/src/shell/LanguageStatus.tsx +0 -101
- package/src/shell/StatusBar.tsx +0 -135
- package/src/shell/SutrataLogo.tsx +0 -89
- package/src/shell/Toast.tsx +0 -46
- package/src/shell/ToolbarButton.tsx +0 -30
- package/src/shell/VersionHistory.tsx +0 -212
- package/src/shell/Watermark.tsx +0 -9
- package/src/shell/fonts/fonts.css +0 -1133
- package/src/shell/icons.tsx +0 -335
- package/src/shell/toolbar-actions.ts +0 -59
- package/src/source/SourceView.tsx +0 -111
- package/src/source/find-highlight-source.ts +0 -57
- package/src/source/sutra-lang.ts +0 -121
- package/src/spellcheck/dictionaries.ts +0 -21
- package/src/spellcheck/hunspell.ts +0 -110
- package/src/spellcheck/spellcheck-plugin.ts +0 -83
- package/src/styles/StyleDialog.tsx +0 -148
- package/src/styles/builtin-styles.ts +0 -139
- package/src/styles/css-adapter.ts +0 -76
- package/src/styles/docx-adapter.ts +0 -109
- package/src/styles/embed-fonts.ts +0 -81
- package/src/styles/pagination.ts +0 -27
- package/src/styles/print-adapter.ts +0 -288
- package/src/styles/registry.ts +0 -20
- package/src/styles/screenplay.css +0 -3006
- package/src/styles/script-ranges.ts +0 -17
- package/src/styles/types.ts +0 -104
- package/src/styles/validate.ts +0 -114
- package/src/templates/templates.ts +0 -194
- package/src/titlepage/ExportDialog.tsx +0 -494
- package/src/titlepage/TitlePageDialog.tsx +0 -312
- package/src/titlepage/frontmatter-form.ts +0 -280
- package/src/titlepage/languages.ts +0 -37
- package/src/types.ts +0 -7
- package/tests/AppBar.test.tsx +0 -35
- package/tests/AppShell.test.tsx +0 -22
- package/tests/ElementToolbar.test.tsx +0 -36
- package/tests/LanguageStatus.test.tsx +0 -129
- package/tests/SelectionMenu.test.tsx +0 -71
- package/tests/ToolbarButton.test.tsx +0 -24
- package/tests/ai-onboarding.test.ts +0 -102
- package/tests/ai-status.test.tsx +0 -72
- package/tests/ai-structured.test.ts +0 -94
- package/tests/autocomplete/AutocompletePlugin.test.ts +0 -20
- package/tests/autocomplete/suggestions.test.ts +0 -106
- package/tests/document-context.test.tsx +0 -565
- package/tests/docx-importer.test.ts +0 -160
- package/tests/editor/character-decoration-plugin.test.ts +0 -101
- package/tests/editor/character-extension.test.ts +0 -44
- package/tests/editor/grapheme-cursor.test.ts +0 -118
- package/tests/editor/ime-plugin.test.ts +0 -72
- package/tests/editor/inline-note-bridge.test.ts +0 -32
- package/tests/editor/language-class-plugin.test.ts +0 -265
- package/tests/editor/title-page.test.ts +0 -124
- package/tests/embed-fonts.test.ts +0 -17
- package/tests/exporters.test.ts +0 -365
- package/tests/findreplace/FindReplace.test.tsx +0 -391
- package/tests/findreplace/find-engine.test.ts +0 -86
- package/tests/findreplace/scroll-to-match.test.ts +0 -50
- package/tests/fonts/tofu-detect.test.ts +0 -122
- package/tests/i18n/LanguageContext.test.tsx +0 -146
- package/tests/i18n/language-detect.test.ts +0 -54
- package/tests/i18n/locale-parity.test.ts +0 -25
- package/tests/i18n/useTranslation.test.tsx +0 -45
- package/tests/keymap.test.ts +0 -18
- package/tests/navigator/SceneNavigator.test.tsx +0 -177
- package/tests/navigator/StatisticsDialog.test.tsx +0 -63
- package/tests/navigator/scene-list.test.ts +0 -71
- package/tests/print-paginator.test.ts +0 -82
- package/tests/shell/VersionHistory.test.tsx +0 -129
- package/tests/spellcheck/hunspell.test.ts +0 -82
- package/tests/spellcheck/spellcheck-plugin.test.ts +0 -56
- package/tests/styles.test.ts +0 -315
- package/tests/templates/templates.test.ts +0 -83
- package/tests/titlepage/TitlePageDialog.test.tsx +0 -90
- package/tests/titlepage/apply-title-page.test.ts +0 -61
- package/tests/titlepage/frontmatter-form.test.ts +0 -115
- package/tests/toolbar-actions.test.ts +0 -93
- package/tests/voice-commands.test.ts +0 -58
- package/tsconfig.json +0 -17
- package/vitest.config.ts +0 -11
- package/vitest.setup.ts +0 -65
|
@@ -1,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
|
-
}
|
package/src/editor/schema.ts
DELETED
|
@@ -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
|
-
}
|