fountainjs-editor 0.1.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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +460 -0
  3. package/dist/fountainjs-react.cjs +1 -0
  4. package/dist/fountainjs-react.js +8 -0
  5. package/dist/fountainjs.cjs +1 -0
  6. package/dist/fountainjs.js +219 -0
  7. package/dist/index-1c508d95.js +1172 -0
  8. package/dist/index-cafd8e26.cjs +40 -0
  9. package/package.json +76 -0
  10. package/src/core/editor.ts +15 -0
  11. package/src/core/index.ts +6 -0
  12. package/src/core/plugin.ts +4 -0
  13. package/src/core/schema/index.ts +5 -0
  14. package/src/core/schema/mark-spec.ts +5 -0
  15. package/src/core/schema/mark.ts +7 -0
  16. package/src/core/schema/node-spec.ts +12 -0
  17. package/src/core/schema/node.ts +12 -0
  18. package/src/core/schema/schema.ts +17 -0
  19. package/src/core/selection.ts +17 -0
  20. package/src/core/state.ts +13 -0
  21. package/src/core/transaction/add-mark-step.ts +19 -0
  22. package/src/core/transaction/index.ts +9 -0
  23. package/src/core/transaction/insert-text-step.ts +18 -0
  24. package/src/core/transaction/remove-mark-step.ts +17 -0
  25. package/src/core/transaction/replace-step.ts +6 -0
  26. package/src/core/transaction/replace-text-step.ts +33 -0
  27. package/src/core/transaction/set-node-attrs-step.ts +30 -0
  28. package/src/core/transaction/step.ts +2 -0
  29. package/src/core/transaction/transaction.ts +8 -0
  30. package/src/core/transaction/transform.ts +23 -0
  31. package/src/extensions/index.ts +64 -0
  32. package/src/extensions/marks/em.ts +2 -0
  33. package/src/extensions/marks/strong.ts +2 -0
  34. package/src/extensions/nodes/bullet-list.ts +9 -0
  35. package/src/extensions/nodes/doc.ts +2 -0
  36. package/src/extensions/nodes/figcaption.ts +5 -0
  37. package/src/extensions/nodes/heading.ts +7 -0
  38. package/src/extensions/nodes/image-super-view.ts +90 -0
  39. package/src/extensions/nodes/image-super.ts +7 -0
  40. package/src/extensions/nodes/list-item.ts +9 -0
  41. package/src/extensions/nodes/paragraph.ts +2 -0
  42. package/src/extensions/nodes/table-cell.ts +5 -0
  43. package/src/extensions/nodes/table-row.ts +2 -0
  44. package/src/extensions/nodes/table.ts +5 -0
  45. package/src/extensions/nodes/text.ts +2 -0
  46. package/src/extensions/plugins/history.ts +12 -0
  47. package/src/extensions/plugins/markdown-shortcuts.ts +52 -0
  48. package/src/index.ts +12 -0
  49. package/src/react/FountainEditor.tsx +19 -0
  50. package/src/react/Navigator.tsx +36 -0
  51. package/src/react/index.ts +4 -0
  52. package/src/react/useFountain.ts +10 -0
  53. package/src/react/useNavigatorState.ts +41 -0
  54. package/src/view/dom-renderer.ts +77 -0
  55. package/src/view/index.ts +2 -0
  56. package/src/view/input.ts +74 -0
  57. package/src/view/node-view.ts +9 -0
  58. package/src/view/selection-handler.ts +76 -0
  59. package/src/view/view.ts +290 -0
@@ -0,0 +1,290 @@
1
+ import { Editor, EditorState, Node, Selection } from '../core';
2
+
3
+ // Advanced editor view with multi-block support
4
+ export class EditorView {
5
+ public readonly editor: Editor;
6
+ public readonly dom: HTMLElement;
7
+ private isDestroyed = false;
8
+ private isReconciling = false;
9
+ private readonly unsubscribe: () => void;
10
+ private nodeToDOM = new WeakMap<Node, HTMLElement>();
11
+ private domToPath = new WeakMap<HTMLElement, number[]>();
12
+
13
+ constructor(mount: HTMLElement, editor: Editor) {
14
+ this.editor = editor;
15
+ this.dom = document.createElement('div');
16
+ this.dom.setAttribute('role', 'textbox');
17
+ this.dom.setAttribute('aria-label', 'Editor');
18
+ this.dom.contentEditable = 'true';
19
+ this.dom.style.cssText = `
20
+ padding: 12px;
21
+ min-height: 200px;
22
+ outline: none;
23
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
24
+ font-size: 16px;
25
+ line-height: 1.6;
26
+ color: #333;
27
+ `;
28
+
29
+ mount.appendChild(this.dom);
30
+
31
+ this.dom.addEventListener('input', this.handleInput);
32
+ this.dom.addEventListener('keydown', this.handleKeyDown);
33
+ this.dom.addEventListener('paste', this.handlePaste);
34
+
35
+ this.unsubscribe = this.editor.subscribe(this.onStateChange);
36
+ this.render(this.editor.state);
37
+ }
38
+
39
+ private handleInput = (): void => {
40
+ if (this.isReconciling) return;
41
+ this.reconcile();
42
+ };
43
+
44
+ private handleKeyDown = (e: KeyboardEvent): void => {
45
+ // Ctrl/Cmd + B: Bold
46
+ if ((e.ctrlKey || e.metaKey) && e.key === 'b') {
47
+ e.preventDefault();
48
+ this.toggleMark('strong');
49
+ return;
50
+ }
51
+ // Ctrl/Cmd + I: Italic
52
+ if ((e.ctrlKey || e.metaKey) && e.key === 'i') {
53
+ e.preventDefault();
54
+ this.toggleMark('em');
55
+ return;
56
+ }
57
+ // Ctrl/Cmd + Z: Undo
58
+ if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
59
+ e.preventDefault();
60
+ // Undo would be handled by history plugin
61
+ return;
62
+ }
63
+ };
64
+
65
+ private handlePaste = (e: ClipboardEvent): void => {
66
+ e.preventDefault();
67
+ const text = e.clipboardData?.getData('text/plain');
68
+ if (text) {
69
+ document.execCommand('insertText', false, text);
70
+ }
71
+ };
72
+
73
+ private toggleMark(markName: string): void {
74
+ const { state } = this.editor;
75
+ const selection = window.getSelection();
76
+
77
+ if (!selection || selection.isCollapsed) return;
78
+
79
+ const { anchorNode, focusNode, anchorOffset, focusOffset } = selection;
80
+ if (!anchorNode || !focusNode) return;
81
+
82
+ // Simple mark toggle using DOM manipulation
83
+ const selectedText = selection.toString();
84
+ const markType = state.schema.marks[markName];
85
+
86
+ if (markType) {
87
+ const span = document.createElement('span');
88
+ if (markName === 'strong') span.style.fontWeight = 'bold';
89
+ if (markName === 'em') span.style.fontStyle = 'italic';
90
+ span.textContent = selectedText;
91
+
92
+ try {
93
+ const range = selection.getRangeAt(0);
94
+ range.deleteContents();
95
+ range.insertNode(span);
96
+ } catch (e) {
97
+ // Fallback to document.execCommand
98
+ if (markName === 'strong') document.execCommand('bold');
99
+ if (markName === 'em') document.execCommand('italic');
100
+ }
101
+ }
102
+
103
+ queueMicrotask(() => this.reconcile());
104
+ }
105
+
106
+ private reconcile = (): void => {
107
+ if (this.isReconciling) return;
108
+ this.isReconciling = true;
109
+
110
+ try {
111
+ const { state } = this.editor;
112
+ const selection = window.getSelection();
113
+
114
+ if (!selection || !selection.anchorNode) {
115
+ this.isReconciling = false;
116
+ return;
117
+ }
118
+
119
+ // Reconstruct content from DOM
120
+ const newContent = this.extractContent(this.dom);
121
+
122
+ if (newContent.length > 0) {
123
+ const tr = state.createTransaction().replace(0, state.doc.content.length, newContent);
124
+
125
+ // Try to preserve cursor position
126
+ if (selection.anchorNode) {
127
+ const offset = selection.anchorOffset;
128
+ tr.setSelection(Selection.createCursor([0, 0], offset));
129
+ }
130
+
131
+ this.editor.dispatch(tr);
132
+ }
133
+ } finally {
134
+ queueMicrotask(() => { this.isReconciling = false; });
135
+ }
136
+ };
137
+
138
+ private extractContent(domNode: HTMLElement): Node[] {
139
+ const content: Node[] = [];
140
+
141
+ for (let i = 0; i < domNode.childNodes.length; i++) {
142
+ const child = domNode.childNodes[i];
143
+
144
+ if (child.nodeType === 3) { // Node.TEXT_NODE
145
+ const text = child.textContent || '';
146
+ if (text.trim()) {
147
+ const textNode = new Node(this.editor.state.schema.nodes.text, {}, [], text);
148
+ const para = new Node(this.editor.state.schema.nodes.paragraph, {}, [textNode]);
149
+ content.push(para);
150
+ }
151
+ } else if (child.nodeType === 1) { // Node.ELEMENT_NODE
152
+ const el = child as HTMLElement;
153
+ const nodeName = el.tagName.toLowerCase();
154
+
155
+ if (nodeName === 'p' || nodeName === 'div') {
156
+ const text = el.textContent || '';
157
+ const textNode = new Node(this.editor.state.schema.nodes.text, {}, [], text);
158
+ const para = new Node(this.editor.state.schema.nodes.paragraph, {}, [textNode]);
159
+ content.push(para);
160
+ } else if (nodeName === 'h1' || nodeName === 'h2' || nodeName === 'h3') {
161
+ const level = parseInt(nodeName[1]);
162
+ const text = el.textContent || '';
163
+ const textNode = new Node(this.editor.state.schema.nodes.text, {}, [], text);
164
+ const heading = new Node(this.editor.state.schema.nodes.heading, { level }, [textNode]);
165
+ content.push(heading);
166
+ }
167
+ }
168
+ }
169
+
170
+ return content.length > 0 ? content : [
171
+ new Node(this.editor.state.schema.nodes.paragraph, {}, [
172
+ new Node(this.editor.state.schema.nodes.text, {}, [], '')
173
+ ])
174
+ ];
175
+ }
176
+
177
+ private render(state: EditorState): void {
178
+ this.nodeToDOM = new WeakMap<Node, HTMLElement>();
179
+ this.domToPath = new WeakMap<HTMLElement, number[]>();
180
+
181
+ const newDOM = this.renderNode(state.doc, []);
182
+
183
+ if (newDOM.childNodes.length > 0) {
184
+ this.dom.innerHTML = '';
185
+ for (let i = 0; i < newDOM.childNodes.length; i++) {
186
+ this.dom.appendChild(newDOM.childNodes[i].cloneNode(true));
187
+ }
188
+ }
189
+
190
+ queueMicrotask(() => this.restoreSelection(state.selection));
191
+ }
192
+
193
+ private renderNode(node: Node, path: number[]): HTMLElement {
194
+ const container = document.createElement('div');
195
+ const isInline = node.type && node.type.name === 'text';
196
+
197
+ if (!isInline) {
198
+ const tag = this.getTagForNode(node);
199
+ const el = document.createElement(tag);
200
+
201
+ // Add attributes
202
+ if (node.attrs && node.attrs.level) {
203
+ el.setAttribute('data-level', node.attrs.level);
204
+ }
205
+ if (node.attrs && node.attrs.src) {
206
+ const img = document.createElement('img');
207
+ img.src = node.attrs.src;
208
+ img.style.maxWidth = '100%';
209
+ img.style.height = 'auto';
210
+ el.appendChild(img);
211
+ }
212
+
213
+ // Render children
214
+ if (node.content) {
215
+ for (let i = 0; i < node.content.length; i++) {
216
+ const childNode = node.content[i];
217
+ const childPath = [...path, i];
218
+ const childDOM = this.renderNode(childNode, childPath);
219
+
220
+ for (let j = 0; j < childDOM.childNodes.length; j++) {
221
+ el.appendChild(childDOM.childNodes[j].cloneNode(true));
222
+ }
223
+ }
224
+ } else if (node.text) {
225
+ el.textContent = node.text;
226
+ }
227
+
228
+ this.nodeToDOM.set(node, el);
229
+ container.appendChild(el);
230
+ } else {
231
+ container.textContent = node.text || '';
232
+ }
233
+
234
+ return container;
235
+ }
236
+
237
+ private getTagForNode(node: Node): string {
238
+ const type = node.type.name;
239
+ const tagMap: { [key: string]: string } = {
240
+ heading: `h${node.attrs?.level || 1}`,
241
+ paragraph: 'p',
242
+ bullet_list: 'ul',
243
+ list_item: 'li',
244
+ table: 'table',
245
+ table_row: 'tr',
246
+ table_cell: 'td',
247
+ image_super: 'figure',
248
+ figcaption: 'figcaption',
249
+ };
250
+ return tagMap[type] || 'div';
251
+ }
252
+
253
+ private restoreSelection(selection: Selection): void {
254
+ const sel = window.getSelection();
255
+ if (!sel) return;
256
+
257
+ try {
258
+ const firstElement = this.dom.querySelector('p, h1, h2, h3, h4, h5, h6');
259
+ if (firstElement?.firstChild) {
260
+ const range = document.createRange();
261
+ const offset = Math.min(selection.to, (firstElement.firstChild.textContent?.length ?? 0) - 1);
262
+ range.setStart(firstElement.firstChild, Math.max(0, offset));
263
+ range.collapse(true);
264
+ sel.removeAllRanges();
265
+ sel.addRange(range);
266
+ }
267
+ } catch (e) {
268
+ // Selection restoration failed, continue
269
+ }
270
+ }
271
+
272
+ public execCommand(command: string, value?: string): boolean {
273
+ return document.execCommand(command, false, value);
274
+ }
275
+
276
+ private onStateChange = (newState: EditorState): void => {
277
+ if (this.isDestroyed || this.isReconciling) return;
278
+ this.render(newState);
279
+ };
280
+
281
+ public destroy(): void {
282
+ if (this.isDestroyed) return;
283
+ this.isDestroyed = true;
284
+ this.unsubscribe();
285
+ this.dom.removeEventListener('input', this.handleInput);
286
+ this.dom.removeEventListener('keydown', this.handleKeyDown);
287
+ this.dom.removeEventListener('paste', this.handlePaste);
288
+ this.dom.remove();
289
+ }
290
+ }