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.
- package/LICENSE +21 -0
- package/README.md +460 -0
- package/dist/fountainjs-react.cjs +1 -0
- package/dist/fountainjs-react.js +8 -0
- package/dist/fountainjs.cjs +1 -0
- package/dist/fountainjs.js +219 -0
- package/dist/index-1c508d95.js +1172 -0
- package/dist/index-cafd8e26.cjs +40 -0
- package/package.json +76 -0
- package/src/core/editor.ts +15 -0
- package/src/core/index.ts +6 -0
- package/src/core/plugin.ts +4 -0
- package/src/core/schema/index.ts +5 -0
- package/src/core/schema/mark-spec.ts +5 -0
- package/src/core/schema/mark.ts +7 -0
- package/src/core/schema/node-spec.ts +12 -0
- package/src/core/schema/node.ts +12 -0
- package/src/core/schema/schema.ts +17 -0
- package/src/core/selection.ts +17 -0
- package/src/core/state.ts +13 -0
- package/src/core/transaction/add-mark-step.ts +19 -0
- package/src/core/transaction/index.ts +9 -0
- package/src/core/transaction/insert-text-step.ts +18 -0
- package/src/core/transaction/remove-mark-step.ts +17 -0
- package/src/core/transaction/replace-step.ts +6 -0
- package/src/core/transaction/replace-text-step.ts +33 -0
- package/src/core/transaction/set-node-attrs-step.ts +30 -0
- package/src/core/transaction/step.ts +2 -0
- package/src/core/transaction/transaction.ts +8 -0
- package/src/core/transaction/transform.ts +23 -0
- package/src/extensions/index.ts +64 -0
- package/src/extensions/marks/em.ts +2 -0
- package/src/extensions/marks/strong.ts +2 -0
- package/src/extensions/nodes/bullet-list.ts +9 -0
- package/src/extensions/nodes/doc.ts +2 -0
- package/src/extensions/nodes/figcaption.ts +5 -0
- package/src/extensions/nodes/heading.ts +7 -0
- package/src/extensions/nodes/image-super-view.ts +90 -0
- package/src/extensions/nodes/image-super.ts +7 -0
- package/src/extensions/nodes/list-item.ts +9 -0
- package/src/extensions/nodes/paragraph.ts +2 -0
- package/src/extensions/nodes/table-cell.ts +5 -0
- package/src/extensions/nodes/table-row.ts +2 -0
- package/src/extensions/nodes/table.ts +5 -0
- package/src/extensions/nodes/text.ts +2 -0
- package/src/extensions/plugins/history.ts +12 -0
- package/src/extensions/plugins/markdown-shortcuts.ts +52 -0
- package/src/index.ts +12 -0
- package/src/react/FountainEditor.tsx +19 -0
- package/src/react/Navigator.tsx +36 -0
- package/src/react/index.ts +4 -0
- package/src/react/useFountain.ts +10 -0
- package/src/react/useNavigatorState.ts +41 -0
- package/src/view/dom-renderer.ts +77 -0
- package/src/view/index.ts +2 -0
- package/src/view/input.ts +74 -0
- package/src/view/node-view.ts +9 -0
- package/src/view/selection-handler.ts +76 -0
- package/src/view/view.ts +290 -0
package/src/view/view.ts
ADDED
|
@@ -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
|
+
}
|