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,90 @@
1
+ import { Node } from '../../core';
2
+ import { EditorView } from '../../view';
3
+
4
+ export class ImageSuperNodeView {
5
+ public readonly dom: HTMLElement;
6
+ public readonly contentDOM: HTMLElement;
7
+ private readonly img: HTMLImageElement;
8
+ private readonly getPos: () => number | undefined; // The function to get the node's position
9
+
10
+ constructor(private node: Node, private view: EditorView, getPos: () => number | undefined) {
11
+ // Store the getPos function
12
+ this.getPos = getPos;
13
+
14
+ // --- Create DOM Structure ---
15
+ this.dom = document.createElement('figure');
16
+ this.dom.style.position = 'relative';
17
+ this.dom.style.margin = '1rem 0';
18
+ this.dom.style.display = 'inline-block'; // Important for resizing
19
+
20
+ this.img = document.createElement('img');
21
+ this.updateImageAttributes(node.attrs);
22
+
23
+ this.contentDOM = document.createElement('div'); // For the figcaption
24
+
25
+ const resizeHandle = document.createElement('div');
26
+ resizeHandle.style.position = 'absolute';
27
+ resizeHandle.style.bottom = '5px';
28
+ resizeHandle.style.right = '5px';
29
+ resizeHandle.style.width = '10px';
30
+ resizeHandle.style.height = '10px';
31
+ resizeHandle.style.backgroundColor = '#007bff';
32
+ resizeHandle.style.cursor = 'nwse-resize';
33
+ resizeHandle.style.border = '1px solid white';
34
+
35
+ this.dom.appendChild(this.img);
36
+ this.dom.appendChild(this.contentDOM);
37
+ this.dom.appendChild(resizeHandle);
38
+
39
+ resizeHandle.addEventListener('mousedown', this.onResizeStart);
40
+ }
41
+
42
+ // Called by the main EditorView when the node changes
43
+ update(node: Node): boolean {
44
+ if (node.type !== this.node.type) return false;
45
+ this.updateImageAttributes(node.attrs);
46
+ this.node = node;
47
+ return true;
48
+ }
49
+
50
+ // Helper to sync node attributes to the DOM
51
+ private updateImageAttributes(attrs: { [key: string]: any }): void {
52
+ this.img.src = attrs.src;
53
+ this.img.alt = attrs.alt;
54
+ this.img.title = attrs.title;
55
+ this.dom.style.width = attrs.width;
56
+ this.img.style.width = '100%';
57
+ }
58
+
59
+ // --- Resize Logic ---
60
+ private onResizeStart = (event: MouseEvent): void => {
61
+ event.preventDefault();
62
+ const startX = event.clientX;
63
+ const startWidth = this.dom.offsetWidth;
64
+
65
+ const onResizeMove = (moveEvent: MouseEvent) => {
66
+ const newWidth = startWidth + (moveEvent.clientX - startX);
67
+ this.dom.style.width = `${newWidth}px`;
68
+ };
69
+
70
+ const onResizeEnd = () => {
71
+ window.removeEventListener('mousemove', onResizeMove);
72
+ window.removeEventListener('mouseup', onResizeEnd);
73
+
74
+ const pos = this.getPos();
75
+ if (pos === undefined) return;
76
+
77
+ const newAttrs = { ...this.node.attrs, width: this.dom.style.width };
78
+
79
+ // THIS IS THE KEY: We find the node's position and create a transaction
80
+ // A real implementation needs a more robust way to find the path
81
+ const path = [pos]; // Simplified path for top-level nodes
82
+
83
+ const tr = this.view.editor.createTransaction().setNodeAttrs(path, newAttrs);
84
+ this.view.editor.dispatch(tr);
85
+ };
86
+
87
+ window.addEventListener('mousemove', onResizeMove);
88
+ window.addEventListener('mouseup', onResizeEnd);
89
+ };
90
+ }
@@ -0,0 +1,7 @@
1
+ import { Node, NodeSpec } from '../../core';
2
+ import { ImageSuperNodeView } from './image-super-view';
3
+ export const imageSuper: NodeSpec = {
4
+ group: 'block', content: 'figcaption?', attrs: { src: { default: '' }, alt: { default: '' }, title: { default: '' }, width: { default: '100%' }, },
5
+ toDOM: (node: Node) => { const { src, alt, title, width } = node.attrs; return ['figure', { style: `width: ${width};` }, ['img', { src, alt, title }], 0]; },
6
+ nodeView: ImageSuperNodeView,
7
+ };
@@ -0,0 +1,9 @@
1
+ import { NodeSpec } from '../../core';
2
+
3
+ export const listItem: NodeSpec = {
4
+ // A list item can contain paragraphs, and even nested lists.
5
+ content: 'paragraph+ (bullet_list)?',
6
+ toDOM() {
7
+ return ['li', 0];
8
+ },
9
+ };
@@ -0,0 +1,2 @@
1
+ import { NodeSpec } from '../../core';
2
+ export const paragraph: NodeSpec = { content: 'inline*', group: 'block', toDOM() { return ['p', 0]; } };
@@ -0,0 +1,5 @@
1
+ import { NodeSpec } from '../../core';
2
+ export const tableCell: NodeSpec = {
3
+ content: 'paragraph+', attrs: { colspan: { default: 1 }, rowspan: { default: 1 }, },
4
+ toDOM(node) { const attrs = { style: 'border: 1px solid #ddd; padding: 8px;', ...node.attrs, }; return ['td', attrs, 0]; },
5
+ };
@@ -0,0 +1,2 @@
1
+ import { NodeSpec } from '../../core';
2
+ export const tableRow: NodeSpec = { content: 'table_cell+', toDOM() { return ['tr', 0]; }, };
@@ -0,0 +1,5 @@
1
+ import { NodeSpec } from '../../core';
2
+ export const table: NodeSpec = {
3
+ group: 'block', content: 'table_row+',
4
+ toDOM() { return ['table', { style: 'border-collapse: collapse; width: 100%;' }, ['tbody', 0]]; },
5
+ };
@@ -0,0 +1,2 @@
1
+ import { NodeSpec } from '../../core';
2
+ export const text: NodeSpec = { group: 'inline' };
@@ -0,0 +1,12 @@
1
+ import { Plugin, Transaction, EditorState } from '../../core';
2
+ const MAX_HISTORY_DEPTH = 100;
3
+ interface HistoryState { done: Transaction[]; undone: Transaction[]; }
4
+ function initHistoryState(): HistoryState { return { done: [], undone: [] }; }
5
+ export const historyPlugin = new Plugin({
6
+ state: {
7
+ init: initHistoryState,
8
+ apply: (tr, value: HistoryState): HistoryState => { if (tr.steps.length > 0) { const newDone = [...value.done, tr]; if (newDone.length > MAX_HISTORY_DEPTH) { newDone.shift(); } return { done: newDone, undone: [] }; } return value; },
9
+ },
10
+ });
11
+ export function undo(state: EditorState): boolean { console.log('Undo command called (not implemented)'); return false; }
12
+ export function redo(state: EditorState): boolean { console.log('Redo command called (not implemented)'); return false; }
@@ -0,0 +1,52 @@
1
+ import { Plugin, Node, EditorState, Transaction } from '../../core';
2
+
3
+ export interface InputRule {
4
+ pattern: RegExp;
5
+ handler: (props: { state: EditorState; match: RegExpMatchArray; from: number; to: number }) => Transaction | null;
6
+ }
7
+
8
+ export const markdownShortcutsPlugin = new Plugin({});
9
+
10
+ // --- Define our rules with more robust handlers ---
11
+
12
+ export const headingRule: InputRule = {
13
+ // Matches '## ' at the start of a string.
14
+ pattern: /^(##\s)$/,
15
+ handler: ({ state, from, to }) => {
16
+ // Find the path to the start of the current text block
17
+ const selectionPath = state.selection.path;
18
+ if (selectionPath.length < 2) return null; // Must be inside a paragraph
19
+ const blockPath = selectionPath.slice(0, -1);
20
+
21
+ // Create a new heading node
22
+ const { heading } = state.schema.nodes;
23
+ if (!heading) return null;
24
+ const newHeading = new Node(heading, { level: 2 });
25
+
26
+ // This is a simplified transaction that replaces the entire parent paragraph
27
+ const tr = state.createTransaction().replace(blockPath[0], blockPath[0] + 1, [newHeading]);
28
+ return tr;
29
+ },
30
+ };
31
+
32
+ export const bulletListRule: InputRule = {
33
+ // Matches '* ' at the start of a string.
34
+ pattern: /^(\*\s)$/,
35
+ handler: ({ state, from, to }) => {
36
+ const selectionPath = state.selection.path;
37
+ if (selectionPath.length < 2) return null; // Must be inside a paragraph
38
+ const blockPath = selectionPath.slice(0, -1);
39
+
40
+ const { list_item, bullet_list, paragraph } = state.schema.nodes;
41
+ if (!list_item || !bullet_list || !paragraph) return null;
42
+
43
+ // Create a new bullet list with one item containing an empty paragraph
44
+ const newListItem = new Node(list_item, {}, [new Node(paragraph, {})]);
45
+ const newList = new Node(bullet_list, {}, [newListItem]);
46
+
47
+ const tr = state.createTransaction().replace(blockPath[0], blockPath[0] + 1, [newList]);
48
+ return tr;
49
+ },
50
+ };
51
+
52
+ export const markdownRules = [headingRule, bulletListRule];
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ // All core concepts
2
+ export * from './core';
3
+
4
+ // The main view layer
5
+ export * from './view';
6
+
7
+ // All extensions, nodes, marks, and the CoreSchemaSpec
8
+ export * from './extensions';
9
+
10
+ // All React components and hooks
11
+ export * from './react';
12
+
@@ -0,0 +1,19 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+ import { Editor } from '../core';
3
+ import { EditorView } from '../view';
4
+
5
+ interface FountainEditorProps {
6
+ editor: Editor | null;
7
+ }
8
+
9
+ export const FountainEditor: React.FC<FountainEditorProps> = ({ editor }) => {
10
+ const editorRef = useRef<HTMLDivElement>(null);
11
+
12
+ useEffect(() => {
13
+ if (!editor || !editorRef.current) { return; }
14
+ const view = new EditorView(editorRef.current, editor);
15
+ return () => { view.destroy(); };
16
+ }, [editor]);
17
+
18
+ return <div ref={editorRef} />;
19
+ };
@@ -0,0 +1,36 @@
1
+ import React from 'react';
2
+ import { Editor, Selection } from '../core';
3
+ import { useNavigatorState } from './useNavigatorState';
4
+
5
+ interface NavigatorProps {
6
+ editor: Editor | null;
7
+ }
8
+
9
+ export const Navigator: React.FC<NavigatorProps> = ({ editor }) => {
10
+ const outline = useNavigatorState(editor);
11
+ if (!editor) return null;
12
+
13
+ const handleClick = (path: number[]) => {
14
+ const selection = Selection.createCursor(path, 0);
15
+ const tr = editor.createTransaction().setSelection(selection);
16
+ editor.dispatch(tr);
17
+ };
18
+
19
+ return (
20
+ <div style={{ padding: '1rem', border: '1px solid #eee', background: '#fcfcfc' }}>
21
+ <h3 style={{ marginTop: 0 }}>Navigator</h3>
22
+ {outline.length === 0 && <p style={{ color: '#999' }}>No headings yet.</p>}
23
+ <ul>
24
+ {outline.map(item => (
25
+ <li
26
+ key={item.id}
27
+ onClick={() => handleClick(item.path)}
28
+ style={{ listStyle: 'none', paddingLeft: `${(item.level - 1) * 20}px`, cursor: 'pointer', marginBottom: '0.5rem', }}
29
+ >
30
+ {item.text}
31
+ </li>
32
+ ))}
33
+ </ul>
34
+ </div>
35
+ );
36
+ };
@@ -0,0 +1,4 @@
1
+ export { useFountain } from './useFountain';
2
+ export { FountainEditor } from './FountainEditor';
3
+ export { useNavigatorState } from './useNavigatorState';
4
+ export { Navigator } from './Navigator';
@@ -0,0 +1,10 @@
1
+ import { useState } from 'react';
2
+ import { createEditor, Editor, EditorConfig } from '../core';
3
+
4
+ export function useFountain(config: EditorConfig): Editor | null {
5
+ const [editor] = useState(() => {
6
+ if (!config) return null;
7
+ return createEditor(config);
8
+ });
9
+ return editor;
10
+ }
@@ -0,0 +1,41 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { Editor, Node as FountainNode } from '../core';
3
+
4
+ interface OutlineItem {
5
+ id: string;
6
+ level: number;
7
+ text: string;
8
+ path: number[];
9
+ }
10
+
11
+ function parseDocument(doc: FountainNode): OutlineItem[] {
12
+ const headings: OutlineItem[] = [];
13
+ function traverse(node: FountainNode, path: number[]) {
14
+ if (node.type.name === 'heading') {
15
+ headings.push({
16
+ id: `${path.join('-')}-${node.attrs.level}`,
17
+ level: node.attrs.level,
18
+ text: node.content.map(c => c.text).join('') || 'Untitled Heading',
19
+ path,
20
+ });
21
+ }
22
+ node.content.forEach((child, i) => { traverse(child, [...path, i]); });
23
+ }
24
+ traverse(doc, []);
25
+ return headings;
26
+ }
27
+
28
+ export function useNavigatorState(editor: Editor | null): OutlineItem[] {
29
+ const [outline, setOutline] = useState<OutlineItem[]>([]);
30
+ useEffect(() => {
31
+ if (!editor) return;
32
+ const update = () => {
33
+ const newOutline = parseDocument(editor.state.doc);
34
+ setOutline(newOutline);
35
+ };
36
+ update();
37
+ const unsubscribe = editor.subscribe(update);
38
+ return () => unsubscribe();
39
+ }, [editor]);
40
+ return outline;
41
+ }
@@ -0,0 +1,77 @@
1
+ import { Node } from '../core';
2
+ import { EditorView } from './view';
3
+ import { NodeView } from './node-view';
4
+
5
+ function renderSpecToDOM(spec: string | any[]): { dom: HTMLElement; contentDOM: HTMLElement } {
6
+ if (typeof spec === 'string') {
7
+ const dom = document.createElement(spec);
8
+ return { dom, contentDOM: dom };
9
+ }
10
+
11
+ const dom = document.createElement(spec[0]);
12
+ const attrs = spec[1];
13
+ let contentDOM = dom;
14
+ if (attrs && typeof attrs === 'object' && !Array.isArray(attrs)) { for (const attr in attrs) dom.setAttribute(attr, attrs[attr]); }
15
+ for (let i = 1; i < spec.length; i++) {
16
+ if (spec[i] === 0) return { dom, contentDOM };
17
+ if (typeof spec[i] === 'object' && Array.isArray(spec[i])) { const nested = renderSpecToDOM(spec[i]); dom.appendChild(nested.dom); }
18
+ }
19
+ return { dom, contentDOM };
20
+ }
21
+
22
+ // The render function now keeps track of the position in the document
23
+ function renderNode(node: Node, view: EditorView, pos: number): { dom: HTMLElement | Text, pos: number } {
24
+ const NodeViewConstructor = node.type.spec.nodeView;
25
+
26
+ // Track position for children
27
+ let currentPos = pos + 1;
28
+
29
+ if (NodeViewConstructor) {
30
+ const getPos = () => pos;
31
+ const nodeView = new NodeViewConstructor(node, view, getPos);
32
+
33
+ if (nodeView.contentDOM) {
34
+ node.content.forEach(child => {
35
+ const result = renderNode(child, view, currentPos);
36
+ nodeView.contentDOM!.appendChild(result.dom);
37
+ currentPos = result.pos;
38
+ });
39
+ }
40
+ return { dom: nodeView.dom, pos: currentPos };
41
+ }
42
+
43
+ if (node.isText) {
44
+ let dom: HTMLElement | Text = document.createTextNode(node.text || '');
45
+ for (const mark of node.marks) {
46
+ const markSpec = mark.type.spec.toDOM?.(mark);
47
+ if (markSpec) {
48
+ const markDom = renderSpecToDOM(markSpec).dom;
49
+ markDom.appendChild(dom as any);
50
+ dom = markDom;
51
+ }
52
+ }
53
+ // Text nodes have a size equal to their length
54
+ return { dom, pos: pos + (node.text?.length || 0) };
55
+ }
56
+
57
+ const spec = node.type.spec.toDOM?.(node);
58
+ if (!spec) throw new Error(`No render spec for node type: ${node.type.name}`);
59
+ const { dom, contentDOM } = renderSpecToDOM(spec);
60
+
61
+ node.content.forEach(child => {
62
+ const result = renderNode(child, view, currentPos);
63
+ contentDOM.appendChild(result.dom);
64
+ currentPos = result.pos;
65
+ });
66
+
67
+ // A block node has open and close tags, so it takes up 2 positions + content size
68
+ return { dom, pos: currentPos + 1 };
69
+ }
70
+
71
+ export function renderDOM(view: EditorView): void {
72
+ const doc = view.editor.state.doc;
73
+ // Start rendering at position 0
74
+ const { dom: renderedDoc } = renderNode(doc, view, 0);
75
+ view.dom.innerHTML = '';
76
+ view.dom.appendChild(renderedDoc);
77
+ }
@@ -0,0 +1,2 @@
1
+ export * from './node-view';
2
+ export * from './view';
@@ -0,0 +1,74 @@
1
+ import { Editor, Selection } from '../core';
2
+
3
+ export class InputManager {
4
+ constructor(private editor: Editor, private dom: HTMLElement) {
5
+ this.dom.addEventListener('beforeinput', this.onBeforeInput);
6
+ }
7
+
8
+ private onBeforeInput = (event: InputEvent): void => {
9
+ // Let the browser handle complex inputs for now
10
+ if (event.inputType.startsWith('format')) return;
11
+
12
+ const { state } = this.editor;
13
+ const { selection } = state;
14
+
15
+ // If our selection isn't set, we can't do anything.
16
+ if (!selection || !selection.path) {
17
+ // Allow browser to handle it, but log a warning.
18
+ console.warn("Fountain.js: No selection found, letting browser handle input.");
19
+ return;
20
+ }
21
+
22
+ event.preventDefault();
23
+ let tr = state.createTransaction();
24
+
25
+ switch (event.inputType) {
26
+ case 'insertText':
27
+ if (event.data) {
28
+ tr.replaceText(selection.path, selection.from, selection.to, event.data);
29
+ tr.setSelection(Selection.createCursor(selection.path, selection.from + event.data.length));
30
+ }
31
+ break;
32
+
33
+ case 'deleteContentBackward': // Backspace
34
+ if (selection.isCollapsed) {
35
+ if (selection.from > 0) {
36
+ tr.replaceText(selection.path, selection.from - 1, selection.from, '');
37
+ tr.setSelection(Selection.createCursor(selection.path, selection.from - 1));
38
+ }
39
+ } else {
40
+ // If there's a range selection, delete the whole range.
41
+ tr.replaceText(selection.path, selection.from, selection.to, '');
42
+ tr.setSelection(Selection.createCursor(selection.path, selection.from));
43
+ }
44
+ break;
45
+
46
+ case 'deleteContentForward': // Delete key
47
+ if (selection.isCollapsed) {
48
+ tr.replaceText(selection.path, selection.from, selection.from + 1, '');
49
+ tr.setSelection(Selection.createCursor(selection.path, selection.from));
50
+ } else {
51
+ tr.replaceText(selection.path, selection.from, selection.to, '');
52
+ tr.setSelection(Selection.createCursor(selection.path, selection.from));
53
+ }
54
+ break;
55
+
56
+ case 'insertParagraph': // Enter key
57
+ console.log("Enter key pressed - not implemented yet.");
58
+ break;
59
+
60
+ default:
61
+ // For any other input type, do nothing and let the browser be prevented.
62
+ console.log(`Unhandled inputType: ${event.inputType}`);
63
+ break;
64
+ }
65
+
66
+ if (tr.steps.length > 0) {
67
+ this.editor.dispatch(tr);
68
+ }
69
+ };
70
+
71
+ public destroy(): void {
72
+ this.dom.removeEventListener('beforeinput', this.onBeforeInput);
73
+ }
74
+ }
@@ -0,0 +1,9 @@
1
+ import { Node } from '../core/schema/node';
2
+ import { EditorView } from './view';
3
+
4
+ export interface NodeView {
5
+ dom: HTMLElement;
6
+ contentDOM?: HTMLElement;
7
+ update?(node: Node): boolean;
8
+ destroy?(): void;
9
+ }
@@ -0,0 +1,76 @@
1
+ import { Editor, Selection } from '../core';
2
+
3
+ export class SelectionHandler {
4
+ constructor(private editor: Editor, private dom: HTMLElement) {
5
+ document.addEventListener('selectionchange', this.onSelectionChange);
6
+ }
7
+
8
+ private onSelectionChange = (): void => {
9
+ const domSel = document.getSelection();
10
+ if (!domSel || !domSel.anchorNode || !this.dom.contains(domSel.anchorNode)) {
11
+ return;
12
+ }
13
+
14
+ // --- NEW, MORE ROBUST PATH/OFFSET FINDING ---
15
+ const { anchorNode, anchorOffset, focusNode, focusOffset } = domSel;
16
+
17
+ const start = this.findPosition(anchorNode, anchorOffset);
18
+ const end = this.findPosition(focusNode, focusOffset);
19
+
20
+ // If we can't map the DOM selection to our model, do nothing.
21
+ if (start === null || end === null) {
22
+ return;
23
+ }
24
+
25
+ // Create a new selection. For simplicity, we only support ranges within the same paragraph.
26
+ const newSelection = new Selection(start.path, start.offset, end.offset);
27
+
28
+ // Only dispatch a transaction if the selection has actually changed.
29
+ if (JSON.stringify(this.editor.state.selection) !== JSON.stringify(newSelection)) {
30
+ this.editor.dispatch(this.editor.createTransaction().setSelection(newSelection));
31
+ }
32
+ };
33
+
34
+ // This is the hard part: mapping a DOM node + offset to our model's path + offset.
35
+ private findPosition(domNode: globalThis.Node | null, domOffset: number): { path: number[], offset: number } | null {
36
+ if (!domNode) return null;
37
+
38
+ let textNode: globalThis.Node | null = null;
39
+ let textOffset = 0;
40
+
41
+ // If the selection is directly on a text node, use it.
42
+ if (domNode.nodeType === Node.TEXT_NODE) {
43
+ textNode = domNode;
44
+ textOffset = domOffset;
45
+ } else { // If it's on an element (like a <p>), find the text node inside.
46
+ textNode = domNode.firstChild;
47
+ textOffset = domOffset;
48
+ }
49
+
50
+ if (!textNode) return null;
51
+
52
+ // Find the parent paragraph to get its index.
53
+ let parentParagraph = textNode.parentNode;
54
+ while (parentParagraph && parentParagraph.nodeName !== 'P') {
55
+ parentParagraph = parentParagraph.parentNode;
56
+ }
57
+
58
+ if (!parentParagraph || !this.dom.contains(parentParagraph)) return null;
59
+
60
+ const blockIndex = Array.from(this.dom.childNodes).indexOf(parentParagraph as any);
61
+ if (blockIndex === -1) return null;
62
+
63
+ // Our simplified path is [paragraphIndex, textNodeIndex (always 0 for now)]
64
+ return { path: [blockIndex, 0], offset: textOffset };
65
+ }
66
+
67
+
68
+ public syncSelectionToDOM(selection: Selection): void {
69
+ // This part is also very complex. For now, we will let the browser lead.
70
+ // The onSelectionChange handler will keep our state in sync with the browser.
71
+ }
72
+
73
+ public destroy(): void {
74
+ document.removeEventListener('selectionchange', this.onSelectionChange);
75
+ }
76
+ }