@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,82 +0,0 @@
1
- import React, { useEffect, useRef } from 'react'
2
- import { EditorState } from 'prosemirror-state'
3
- import { EditorView as PmEditorView } from 'prosemirror-view'
4
- import { parse } from '@sutrata/parser'
5
- import { schema } from './schema'
6
- import { buildPlugins } from './plugins'
7
- import { sutraToProsemirror } from './sutra-to-prosemirror'
8
- import { prosemirrorToSutra } from './prosemirror-to-sutra'
9
- import { setEditorView, emit } from './editor-bus'
10
- import { useDocument } from '../context/DocumentContext'
11
- import { SceneHeadingView } from './scene-heading-view'
12
- import { FrontmatterFieldView } from './frontmatter-field-view'
13
- import { applyStyleVars } from '../styles/css-adapter'
14
-
15
- export function EditorView() {
16
- const { text, setText, resolvedStyle } = useDocument()
17
- const mountRef = useRef<HTMLDivElement>(null)
18
- const viewRef = useRef<PmEditorView | null>(null)
19
- const textRef = useRef(text)
20
- textRef.current = text
21
- // When true, the next text change originated from the editor itself —
22
- // the sync effect must skip it to avoid clobbering in-flight transactions.
23
- const selfChangeRef = useRef(false)
24
-
25
- // Mount once: create ProseMirror view
26
- useEffect(() => {
27
- if (!mountRef.current) return
28
-
29
- const doc = sutraToProsemirror(parse(textRef.current))
30
- const state = EditorState.create({ doc, schema, plugins: buildPlugins() })
31
-
32
- const view = new PmEditorView(mountRef.current, {
33
- state,
34
- nodeViews: {
35
- scene_heading: (node, view, getPos) => new SceneHeadingView(node, view, getPos),
36
- frontmatter_field: (node, view, getPos) => new FrontmatterFieldView(node, view, getPos),
37
- },
38
- dispatchTransaction(tr) {
39
- const newState = view.state.apply(tr)
40
- view.updateState(newState)
41
- if (tr.docChanged) {
42
- const newText = prosemirrorToSutra(newState.doc)
43
- if (newText !== textRef.current) {
44
- selfChangeRef.current = true
45
- setText(newText)
46
- }
47
- }
48
- emit()
49
- },
50
- })
51
- viewRef.current = view
52
- setEditorView(view)
53
-
54
- return () => { setEditorView(null); view.destroy(); viewRef.current = null }
55
- }, []) // eslint-disable-line react-hooks/exhaustive-deps
56
-
57
- // Sync external text changes into ProseMirror (e.g. switching from source mode).
58
- // Skip when the editor itself produced the change — it already has the right state.
59
- useEffect(() => {
60
- if (selfChangeRef.current) {
61
- selfChangeRef.current = false
62
- return
63
- }
64
- const view = viewRef.current
65
- if (!view) return
66
- const currentText = prosemirrorToSutra(view.state.doc)
67
- if (currentText !== text) {
68
- const doc = sutraToProsemirror(parse(text))
69
- const state = EditorState.create({ doc, schema, plugins: buildPlugins() })
70
- view.updateState(state)
71
- }
72
- }, [text])
73
-
74
- // Apply the document's resolved style as CSS custom properties on the mount element.
75
- // Works identically for built-in and user-imported styles — both are plain
76
- // ScreenplayStyleDefinition objects by the time they reach here.
77
- useEffect(() => {
78
- if (mountRef.current) applyStyleVars(mountRef.current, resolvedStyle)
79
- }, [resolvedStyle])
80
-
81
- return <div ref={mountRef} className="cs-editor-mount" />
82
- }
@@ -1,152 +0,0 @@
1
- import React, { useState, useEffect } from 'react'
2
- import { useLanguage } from '../i18n/LanguageContext'
3
- import { useDocument } from '../context/DocumentContext'
4
- import { insertSutra } from './insert-helper'
5
- import { VoiceConfirmDialog } from '../ai/VoiceConfirmDialog'
6
- import { SparklesIcon } from '../shell/icons'
7
-
8
- const VOICE_LANG_MAP: Record<string, string> = {
9
- en: 'en-US',
10
- hi: 'hi-IN',
11
- ta: 'ta-IN',
12
- te: 'te-IN',
13
- ml: 'ml-IN',
14
- kn: 'kn-IN',
15
- bn: 'bn-IN',
16
- gu: 'gu-IN',
17
- mr: 'mr-IN',
18
- }
19
-
20
- function isSelectionInEditor(sel: Selection): boolean {
21
- if (sel.rangeCount === 0) return false
22
- const range = sel.getRangeAt(0)
23
- let node: Node | null = range.commonAncestorContainer
24
- while (node) {
25
- if (node instanceof HTMLElement) {
26
- if (node.classList.contains('ProseMirror') || node.classList.contains('cm-content')) {
27
- return true
28
- }
29
- }
30
- node = node.parentNode
31
- }
32
- return false
33
- }
34
-
35
- export function SelectionMenu() {
36
- const { currentLanguage } = useLanguage()
37
- const { mode } = useDocument()
38
- const [menuPosition, setMenuPosition] = useState<{ x: number; y: number } | null>(null)
39
- const [selectedText, setSelectedText] = useState<string>('')
40
- const [showConfirm, setShowConfirm] = useState<boolean>(false)
41
-
42
- useEffect(() => {
43
- const handleSelectionChange = () => {
44
- if (showConfirm) return
45
-
46
- const sel = window.getSelection()
47
- if (!sel || sel.isCollapsed || !isSelectionInEditor(sel)) {
48
- setMenuPosition(null)
49
- setSelectedText('')
50
- return
51
- }
52
-
53
- const text = sel.toString().trim()
54
- if (!text) {
55
- setMenuPosition(null)
56
- setSelectedText('')
57
- return
58
- }
59
-
60
- const range = sel.getRangeAt(0)
61
- const rect = range.getBoundingClientRect()
62
-
63
- setMenuPosition({
64
- x: rect.left + rect.width / 2,
65
- y: rect.top - 8,
66
- })
67
- setSelectedText(sel.toString())
68
- }
69
-
70
- document.addEventListener('selectionchange', handleSelectionChange)
71
- return () => {
72
- document.removeEventListener('selectionchange', handleSelectionChange)
73
- }
74
- }, [showConfirm])
75
-
76
- const handleCut = (e: React.MouseEvent) => {
77
- e.preventDefault()
78
- e.stopPropagation()
79
- document.execCommand('cut')
80
- setMenuPosition(null)
81
- }
82
-
83
- const handleCopy = (e: React.MouseEvent) => {
84
- e.preventDefault()
85
- e.stopPropagation()
86
- document.execCommand('copy')
87
- setMenuPosition(null)
88
- }
89
-
90
- const handleFormat = (e: React.MouseEvent) => {
91
- e.preventDefault()
92
- e.stopPropagation()
93
- setShowConfirm(true)
94
- setMenuPosition(null)
95
- }
96
-
97
- if (showConfirm) {
98
- const langCode = VOICE_LANG_MAP[currentLanguage] || 'en-US'
99
- return (
100
- <VoiceConfirmDialog
101
- rawTranscript={selectedText}
102
- langCode={langCode}
103
- onConfirm={(formattedText) => {
104
- insertSutra(formattedText, mode)
105
- setShowConfirm(false)
106
- }}
107
- onCancel={() => {
108
- setShowConfirm(false)
109
- }}
110
- />
111
- )
112
- }
113
-
114
- if (!menuPosition) return null
115
-
116
- return (
117
- <div
118
- className="cs-selection-menu"
119
- style={{
120
- left: `${menuPosition.x}px`,
121
- top: `${menuPosition.y}px`,
122
- }}
123
- role="toolbar"
124
- aria-label="Text Selection Actions"
125
- >
126
- <button
127
- type="button"
128
- className="cs-selection-btn cs-selection-btn-format"
129
- onClick={handleFormat}
130
- title="Format selected text using AI"
131
- >
132
- <SparklesIcon size={12} />
133
- <span>Format</span>
134
- </button>
135
- <div className="cs-selection-sep" />
136
- <button
137
- type="button"
138
- className="cs-selection-btn"
139
- onClick={handleCut}
140
- >
141
- Cut
142
- </button>
143
- <button
144
- type="button"
145
- className="cs-selection-btn"
146
- onClick={handleCopy}
147
- >
148
- Copy
149
- </button>
150
- </div>
151
- )
152
- }
@@ -1,172 +0,0 @@
1
- /**
2
- * Attribute pencil plugin — uniform inline editing affordance for scene
3
- * attributes and title-page frontmatter.
4
- *
5
- * A pencil widget (visible on hover) sits at the end of every scene heading
6
- * and at the top-right of the title page card.
7
- *
8
- * - Scene heading: opens a dropdown of attribute keys not yet present (plus a
9
- * custom-key input). Picking one inserts the node via a normal PM
10
- * transaction and places the cursor in the empty value, so there is no full
11
- * state rebuild and no cursor jump.
12
- * - Title page: opens the sectioned title page form (TitlePageDialog), where
13
- * frontmatter fields are added and edited.
14
- */
15
- import { Plugin, PluginKey, TextSelection } from 'prosemirror-state'
16
- import { Decoration, DecorationSet } from 'prosemirror-view'
17
- import type { EditorView } from 'prosemirror-view'
18
- import type { Node as PmNode } from 'prosemirror-model'
19
- import { schema } from './schema'
20
- import { openTitlePageForm } from './editor-bus'
21
-
22
- const SCENE_META_KEYS = ['synopsis', 'status', 'tags', 'location', 'time', 'lang']
23
-
24
- let openMenu: HTMLElement | null = null
25
-
26
- function closeMenu() {
27
- if (openMenu) {
28
- openMenu.remove()
29
- openMenu = null
30
- document.removeEventListener('mousedown', onDocMousedown)
31
- }
32
- }
33
-
34
- function onDocMousedown(e: MouseEvent) {
35
- if (openMenu && !openMenu.contains(e.target as Node)) closeMenu()
36
- }
37
-
38
- function showMenu(anchor: HTMLElement, options: string[], onPick: (key: string) => void) {
39
- closeMenu()
40
- const menu = document.createElement('div')
41
- menu.className = 'cs-attr-menu'
42
-
43
- for (const key of options) {
44
- const item = document.createElement('button')
45
- item.type = 'button'
46
- item.className = 'cs-attr-menu-item'
47
- item.textContent = `+ ${key}`
48
- item.addEventListener('mousedown', e => {
49
- e.preventDefault()
50
- closeMenu()
51
- onPick(key)
52
- })
53
- menu.appendChild(item)
54
- }
55
-
56
- const customRow = document.createElement('div')
57
- customRow.className = 'cs-attr-menu-custom'
58
- const input = document.createElement('input')
59
- input.type = 'text'
60
- input.placeholder = 'custom key…'
61
- input.addEventListener('keydown', e => {
62
- e.stopPropagation()
63
- if (e.key === 'Enter') {
64
- const k = input.value.trim()
65
- if (k) { closeMenu(); onPick(k) }
66
- } else if (e.key === 'Escape') {
67
- closeMenu()
68
- }
69
- })
70
- input.addEventListener('mousedown', e => e.stopPropagation())
71
- customRow.appendChild(input)
72
- menu.appendChild(customRow)
73
-
74
- const rect = anchor.getBoundingClientRect()
75
- menu.style.left = `${rect.left}px`
76
- menu.style.top = `${rect.bottom + 4}px`
77
- document.body.appendChild(menu)
78
- openMenu = menu
79
- // Defer so the click that opened the menu doesn't immediately close it
80
- setTimeout(() => document.addEventListener('mousedown', onDocMousedown), 0)
81
- input.focus()
82
- }
83
-
84
- /** Insert an attribute node and place the cursor inside its (empty) content. */
85
- function insertNode(view: EditorView, pos: number, node: PmNode) {
86
- const tr = view.state.tr.insert(pos, node)
87
- view.dispatch(tr.setSelection(TextSelection.near(tr.doc.resolve(pos + 1))))
88
- view.focus()
89
- }
90
-
91
- /**
92
- * For a scene heading at top-level index `index`, compute which attribute
93
- * keys already exist and where a new attribute line should be inserted
94
- * (after the heading, synopsis, and existing metadata lines).
95
- */
96
- function sceneAttrContext(view: EditorView, headingPos: number) {
97
- const doc = view.state.doc
98
- const $heading = doc.resolve(headingPos)
99
- const headingNode = doc.nodeAt(headingPos)!
100
- let pos = headingPos + headingNode.nodeSize
101
- let index = $heading.index(0) + 1
102
- const presentKeys = new Set<string>()
103
-
104
- while (index < doc.childCount) {
105
- const sibling = doc.child(index)
106
- if (sibling.type.name === 'scene_metadata') {
107
- presentKeys.add(sibling.attrs['metaKey'] as string)
108
- } else {
109
- break
110
- }
111
- pos += sibling.nodeSize
112
- index++
113
- }
114
- return { insertPos: pos, presentKeys }
115
- }
116
-
117
- function makePencil(className: string, title: string, onClick: (btn: HTMLElement) => void): HTMLElement {
118
- const btn = document.createElement('button')
119
- btn.type = 'button'
120
- btn.className = className
121
- btn.title = title
122
- btn.textContent = '✎'
123
- btn.contentEditable = 'false'
124
- btn.addEventListener('mousedown', e => {
125
- e.preventDefault()
126
- e.stopPropagation()
127
- onClick(btn)
128
- })
129
- return btn
130
- }
131
-
132
- export const attrPencilKey = new PluginKey('attrPencil')
133
-
134
- export function createAttrPencilPlugin(): Plugin {
135
- return new Plugin({
136
- key: attrPencilKey,
137
- props: {
138
- decorations(state) {
139
- const decos: Decoration[] = []
140
- state.doc.forEach((node, offset) => {
141
- if (node.type.name === 'scene_heading') {
142
- // Widget inside the heading, after its text
143
- decos.push(Decoration.widget(offset + node.nodeSize - 1, view =>
144
- makePencil('cs-attr-pencil', 'Add scene attribute', btn => {
145
- const ctx = sceneAttrContext(view, offset)
146
- const options = SCENE_META_KEYS.filter(k => !ctx.presentKeys.has(k))
147
- showMenu(btn, options, key => {
148
- const fresh = sceneAttrContext(view, offset)
149
- insertNode(view, fresh.insertPos, schema.nodes['scene_metadata']!.create({ metaKey: key }))
150
- })
151
- }),
152
- { side: 1 }))
153
- }
154
- if (node.type.name === 'title_page') {
155
- // Title page fields are added and edited in the title page form; the pencil
156
- // (top-right of the card, positioned by CSS) opens it.
157
- decos.push(Decoration.widget(offset + 1, () =>
158
- makePencil('cs-attr-pencil cs-tp-pencil', 'Edit title page', () => {
159
- closeMenu()
160
- openTitlePageForm()
161
- }),
162
- { side: -1, key: 'tp-pencil' }))
163
- }
164
- })
165
- return DecorationSet.create(state.doc, decos)
166
- },
167
- },
168
- view() {
169
- return { destroy: closeMenu }
170
- },
171
- })
172
- }
@@ -1,104 +0,0 @@
1
- import { Plugin } from 'prosemirror-state'
2
- import { Decoration, DecorationSet } from 'prosemirror-view'
3
- import type { Node as PmNode } from 'prosemirror-model'
4
- import type { EditorState } from 'prosemirror-state'
5
-
6
- /**
7
- * ProseMirror plugin that styles a character cue's trailing extension
8
- * "(V.O.)"/"(O.S.)" and dual-dialogue "^" marker live, purely from the text
9
- * pattern — there's no dedicated UI to set these; per the format spec, any
10
- * content in parentheses at the end of a character line is an extension,
11
- * and a trailing "^" marks dual dialogue. No node attrs or persisted marks
12
- * are involved — decorations are recomputed from scratch on every doc
13
- * change, the same way createLanguageClassPlugin() does for script classes.
14
- */
15
- export function createCharacterDecorationPlugin() {
16
- return new Plugin({
17
- state: {
18
- init(_config, state) {
19
- return computeDecorations(state.doc)
20
- },
21
- apply(tr, set, _oldState, newState) {
22
- if (tr.docChanged) {
23
- return computeDecorations(newState.doc)
24
- }
25
- return set
26
- },
27
- },
28
- props: {
29
- decorations(state: EditorState) {
30
- return this.getState(state)
31
- },
32
- },
33
- })
34
- }
35
-
36
- interface DecorationRanges {
37
- parenStart?: number
38
- parenEnd?: number
39
- dualStart?: number
40
- dualEnd?: number
41
- }
42
-
43
- /**
44
- * Finds the trailing "(...)"-group span and/or trailing "^" span in a
45
- * character cue's text, as character offsets into `text`. Either, both, or
46
- * neither may be present. The "^" is checked (and stripped from
47
- * consideration) first, since it comes after any extension groups.
48
- */
49
- export function findCharacterDecorationRanges(text: string): DecorationRanges {
50
- const ranges: DecorationRanges = {}
51
-
52
- let working = text
53
- const dualMatch = /\s(\^)\s*$/.exec(text)
54
- if (dualMatch) {
55
- ranges.dualStart = dualMatch.index + dualMatch[0].indexOf('^')
56
- ranges.dualEnd = ranges.dualStart + 1
57
- working = text.slice(0, dualMatch.index)
58
- }
59
-
60
- const parenMatch = /(\s+\([^()]*\))+\s*$/.exec(working)
61
- if (parenMatch) {
62
- const matched = parenMatch[0]
63
- const leadingWs = matched.length - matched.trimStart().length
64
- ranges.parenStart = parenMatch.index + leadingWs
65
- ranges.parenEnd = parenMatch.index + matched.trimEnd().length
66
- }
67
-
68
- return ranges
69
- }
70
-
71
- function computeDecorations(doc: PmNode): DecorationSet {
72
- const decorations: Decoration[] = []
73
-
74
- doc.forEach((node, pos) => {
75
- if (node.type.name !== 'character') return
76
- const text = node.textContent
77
- if (!text) return
78
-
79
- const { parenStart, parenEnd, dualStart, dualEnd } = findCharacterDecorationRanges(text)
80
- // +1: text content starts one position inside the block node
81
- const contentStart = pos + 1
82
-
83
- if (parenStart !== undefined && parenEnd !== undefined) {
84
- // class passed in both attrs (DOM rendering) and spec (testing/reference) —
85
- // matches language-class-plugin.ts's convention.
86
- decorations.push(Decoration.inline(
87
- contentStart + parenStart,
88
- contentStart + parenEnd,
89
- { class: 'cs-character-extension' },
90
- { class: 'cs-character-extension' }
91
- ))
92
- }
93
- if (dualStart !== undefined && dualEnd !== undefined) {
94
- decorations.push(Decoration.inline(
95
- contentStart + dualStart,
96
- contentStart + dualEnd,
97
- { class: 'cs-character-dual-marker' },
98
- { class: 'cs-character-dual-marker' }
99
- ))
100
- }
101
- })
102
-
103
- return DecorationSet.create(doc, decorations)
104
- }
@@ -1,51 +0,0 @@
1
- import type { EditorView } from 'prosemirror-view'
2
- import type { Command } from 'prosemirror-state'
3
- import type { EditorView as CmEditorView } from '@codemirror/view'
4
-
5
- let currentView: EditorView | null = null
6
- let currentSourceView: CmEditorView | null = null
7
- const listeners = new Set<() => void>()
8
-
9
- export function setEditorView(view: EditorView | null): void {
10
- currentView = view
11
- emit()
12
- }
13
-
14
- export function getEditorView(): EditorView | null {
15
- return currentView
16
- }
17
-
18
- export function setSourceView(view: CmEditorView | null): void {
19
- currentSourceView = view
20
- }
21
-
22
- export function getSourceView(): CmEditorView | null {
23
- return currentSourceView
24
- }
25
-
26
- export function runCommand(cmd: Command): void {
27
- if (!currentView) return
28
- cmd(currentView.state, currentView.dispatch, currentView)
29
- currentView.focus()
30
- }
31
-
32
- export function activeBlockType(): string | null {
33
- if (!currentView) return null
34
- return currentView.state.selection.$from.parent.type.name
35
- }
36
-
37
- export function subscribe(fn: () => void): () => void {
38
- listeners.add(fn)
39
- return () => listeners.delete(fn)
40
- }
41
-
42
- export function emit(): void {
43
- listeners.forEach(fn => fn())
44
- }
45
-
46
- /** Window event that opens the title page form (TitlePageDialog, mounted by AppShell). */
47
- export const OPEN_TITLE_PAGE_EVENT = 'cs:open-title-page'
48
-
49
- export function openTitlePageForm(): void {
50
- window.dispatchEvent(new CustomEvent(OPEN_TITLE_PAGE_EVENT))
51
- }
@@ -1,68 +0,0 @@
1
- import { Plugin, PluginKey } from 'prosemirror-state'
2
- import { Decoration, DecorationSet } from 'prosemirror-view'
3
- import type { PmMatch } from '../findreplace/scroll-to-match'
4
-
5
- const findHighlightKey = new PluginKey<DecorationSet>('findHighlight')
6
-
7
- // Module-level state pushed from FindReplace; the plugin reads this on each update.
8
- let currentMatches: PmMatch[] = []
9
- let currentIndex = 0
10
- let pluginView: import('prosemirror-view').EditorView | null = null
11
-
12
- /** Called by FindReplace whenever matches or active index change. */
13
- export function setFindHighlights(matches: PmMatch[], activeIndex: number): void {
14
- currentMatches = matches
15
- currentIndex = activeIndex
16
- if (pluginView) {
17
- // Dispatch a no-op meta transaction to trigger decoration recompute.
18
- const tr = pluginView.state.tr.setMeta(findHighlightKey, true)
19
- pluginView.dispatch(tr)
20
- }
21
- }
22
-
23
- /** Called by FindReplace when the panel closes — clears all highlights. */
24
- export function clearFindHighlights(): void {
25
- setFindHighlights([], 0)
26
- }
27
-
28
- function buildDecorations(matches: PmMatch[], activeIndex: number, doc: import('prosemirror-model').Node): DecorationSet {
29
- if (matches.length === 0) return DecorationSet.empty
30
- const decos = matches.map((m, i) =>
31
- Decoration.inline(m.pmFrom, m.pmTo, {
32
- class: i === activeIndex ? 'cs-find-active' : 'cs-find-match',
33
- })
34
- )
35
- return DecorationSet.create(doc, decos)
36
- }
37
-
38
- export function createFindHighlightPlugin(): Plugin {
39
- return new Plugin<DecorationSet>({
40
- key: findHighlightKey,
41
-
42
- view(view) {
43
- pluginView = view
44
- return {
45
- destroy() { pluginView = null },
46
- }
47
- },
48
-
49
- state: {
50
- init(_config, state) {
51
- return buildDecorations(currentMatches, currentIndex, state.doc)
52
- },
53
- apply(tr, _old, _oldState, newState) {
54
- // Recompute on doc change (positions shift) or when we push a meta update.
55
- if (tr.docChanged || tr.getMeta(findHighlightKey)) {
56
- return buildDecorations(currentMatches, currentIndex, newState.doc)
57
- }
58
- return _old
59
- },
60
- },
61
-
62
- props: {
63
- decorations(state) {
64
- return this.getState(state)
65
- },
66
- },
67
- })
68
- }
@@ -1,52 +0,0 @@
1
- import type { Node as PmNode } from 'prosemirror-model'
2
- import type { EditorView, NodeView } from 'prosemirror-view'
3
-
4
- export class FrontmatterFieldView implements NodeView {
5
- dom: HTMLElement
6
- contentDOM: HTMLElement
7
- private counter: HTMLElement | null = null
8
-
9
- constructor(node: PmNode, view: EditorView, getPos: () => number | undefined) {
10
- this.dom = document.createElement('div')
11
- this.dom.className = 'cs-fm-field'
12
- this.dom.dataset['key'] = node.attrs['fmKey'] as string
13
-
14
- this.contentDOM = document.createElement('span')
15
- this.contentDOM.className = 'cs-fm-field-content'
16
- this.dom.appendChild(this.contentDOM)
17
-
18
- if (node.attrs['fmKey'] === 'logline') {
19
- this.counter = document.createElement('span')
20
- this.counter.className = 'cs-fm-logline-counter'
21
- this.updateCounter(node.textContent)
22
- this.dom.appendChild(this.counter)
23
- }
24
- }
25
-
26
- private updateCounter(text: string) {
27
- if (!this.counter) return
28
- const words = text.split(/\s+/).filter(Boolean).length
29
- this.counter.textContent = `${words} words`
30
- if (words > 50) {
31
- this.counter.style.color = '#c5221f' // red warning
32
- this.counter.style.background = '#fce8e6'
33
- this.counter.style.borderColor = '#fad2cf'
34
- } else if (words > 30) {
35
- this.counter.style.color = '#b06000' // orange warn/target
36
- this.counter.style.background = '#fef7e0'
37
- this.counter.style.borderColor = '#feebc8'
38
- } else {
39
- this.counter.style.color = '#8a887f' // clean gray
40
- this.counter.style.background = '#f0efe9'
41
- this.counter.style.borderColor = '#e2e0d8'
42
- }
43
- }
44
-
45
- update(node: PmNode): boolean {
46
- if (node.type.name !== 'frontmatter_field') return false
47
- if (node.attrs['fmKey'] === 'logline' && this.counter) {
48
- this.updateCounter(node.textContent)
49
- }
50
- return true
51
- }
52
- }