@pm-cm/core 0.0.1

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 ADDED
@@ -0,0 +1,87 @@
1
+ # @pm-cm/core
2
+
3
+ Bidirectional sync between ProseMirror and CodeMirror for split-editor UIs.
4
+
5
+ Keeps a WYSIWYG pane (ProseMirror) and a text pane (CodeMirror) in sync. The serialization format is pluggable — you provide `serialize` and `parse` functions (e.g. Markdown, AsciiDoc, plain text).
6
+
7
+ ## Install
8
+
9
+ ```
10
+ npm install @pm-cm/core prosemirror-model prosemirror-state prosemirror-view
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ### View Bridge
16
+
17
+ Propagates changes between the ProseMirror pane and the CodeMirror pane.
18
+
19
+ ```ts
20
+ import { createViewBridge } from '@pm-cm/core'
21
+
22
+ const bridge = createViewBridge({
23
+ schema, // ProseMirror Schema
24
+ serialize, // (doc: Node) => string
25
+ parse, // (text: string, schema: Schema) => Node
26
+ })
27
+
28
+ // CodeMirror changed → push into ProseMirror
29
+ bridge.applyText(pmView, cmValue)
30
+
31
+ // ProseMirror changed → pull for CodeMirror
32
+ const text = bridge.extractText(pmView)
33
+
34
+ // In dispatchTransaction, skip bridge-originated changes
35
+ function dispatchTransaction(tr: Transaction) {
36
+ pmView.updateState(pmView.state.apply(tr))
37
+ if (!bridge.isBridgeChange(tr) && tr.docChanged) {
38
+ updateCodeMirror(bridge.extractText(pmView))
39
+ }
40
+ }
41
+ ```
42
+
43
+ ### Cursor Mapping
44
+
45
+ Maps cursor positions between ProseMirror and CodeMirror.
46
+
47
+ ```ts
48
+ import { buildCursorMap, cursorMapLookup, reverseCursorMapLookup } from '@pm-cm/core'
49
+
50
+ const map = buildCursorMap(pmDoc, serialize)
51
+
52
+ // PM position → CM offset
53
+ const cmOffset = cursorMapLookup(map, pmPos)
54
+
55
+ // CM offset → PM position
56
+ const pmPos = reverseCursorMapLookup(map, cmOffset)
57
+ ```
58
+
59
+ #### Limitations
60
+
61
+ - Uses `indexOf`-based forward matching: if the serializer transforms text (e.g. escaping, entity encoding), the mapping may be inaccurate.
62
+ - Segments with no text nodes (e.g. empty paragraphs, horizontal rules) produce no mapping entry. Lookups return `null` only when the entire map has zero segments.
63
+ - Positions that fall between segments (inside serialization syntax like `**`, `- `, `| `) snap to the nearest text boundary.
64
+
65
+ ## API
66
+
67
+ | Export | Description |
68
+ |---|---|
69
+ | `createViewBridge(config)` | Returns a `ViewBridgeHandle` with `applyText`, `extractText`, `isBridgeChange` |
70
+ | `createBoundViewBridge(view, config)` | Returns a `BoundViewBridgeHandle` — same as above but with the `EditorView` bound |
71
+ | `buildCursorMap(doc, serialize, locate?)` | Build a PM position ↔ CM offset mapping |
72
+ | `cursorMapLookup(map, pmPos)` | PM position → CM offset |
73
+ | `reverseCursorMapLookup(map, cmOffset)` | CM (CodeMirror) offset → PM position |
74
+ | `Serialize` | Type: `(doc: Node) => string` |
75
+ | `Parse` | Type: `(text: string, schema: Schema) => Node` |
76
+ | `Normalize` | Type: `(text: string) => string` |
77
+ | `ErrorCode` | Type: `'parse-error' \| 'serialize-error'` |
78
+ | `ErrorEvent` | Type: `{ code: ErrorCode, message: string, cause: unknown }` |
79
+ | `OnError` | Type: `(event: ErrorEvent) => void` |
80
+ | `LocateText` | Type: `(serialized: string, nodeText: string, searchFrom: number) => number` |
81
+ | `ViewBridgeConfig` | `{ schema, serialize, parse, normalize?, onError? }` |
82
+ | `ViewBridgeHandle` | `{ applyText(view, text, options?): ApplyTextResult, extractText(view), isBridgeChange(tr) }` |
83
+ | `BoundViewBridgeHandle` | `{ applyText(text, options?), extractText(), isBridgeChange(tr), setView(view) }` |
84
+ | `ApplyTextOptions` | `{ addToHistory?: boolean }` |
85
+ | `ApplyTextResult` | `{ ok: true } \| { ok: false; reason: 'unchanged' \| 'parse-error' }` |
86
+ | `CursorMap` | `{ segments: TextSegment[], textLength, skippedNodes }` |
87
+ | `TextSegment` | `{ pmStart, pmEnd, textStart, textEnd }` |
package/dist/index.cjs ADDED
@@ -0,0 +1,180 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ buildCursorMap: () => buildCursorMap,
24
+ createBoundViewBridge: () => createBoundViewBridge,
25
+ createViewBridge: () => createViewBridge,
26
+ cursorMapLookup: () => cursorMapLookup,
27
+ reverseCursorMapLookup: () => reverseCursorMapLookup
28
+ });
29
+ module.exports = __toCommonJS(index_exports);
30
+
31
+ // src/bridge.ts
32
+ var BRIDGE_META = "pm-cm-bridge";
33
+ var defaultNormalize = (s) => s.replace(/\r\n?/g, "\n");
34
+ var defaultOnError = (event) => console.error(`[bridge] ${event.code}: ${event.message}`, event.cause);
35
+ function createViewBridge(config) {
36
+ const { schema, serialize, parse } = config;
37
+ const normalize = config.normalize ?? defaultNormalize;
38
+ const onError = config.onError ?? defaultOnError;
39
+ return {
40
+ applyText(view, text, options) {
41
+ const incoming = normalize(text);
42
+ const current = normalize(serialize(view.state.doc));
43
+ if (incoming === current) {
44
+ return { ok: false, reason: "unchanged" };
45
+ }
46
+ let nextDoc;
47
+ try {
48
+ nextDoc = parse(incoming, schema);
49
+ } catch (error) {
50
+ onError({ code: "parse-error", message: "failed to parse text into ProseMirror document", cause: error });
51
+ return { ok: false, reason: "parse-error" };
52
+ }
53
+ const tr = view.state.tr;
54
+ tr.replaceWith(0, tr.doc.content.size, nextDoc.content);
55
+ tr.setMeta(BRIDGE_META, true);
56
+ if (options?.addToHistory === false) {
57
+ tr.setMeta("addToHistory", false);
58
+ }
59
+ view.dispatch(tr);
60
+ return { ok: true };
61
+ },
62
+ extractText(view) {
63
+ return serialize(view.state.doc);
64
+ },
65
+ isBridgeChange(tr) {
66
+ return tr.getMeta(BRIDGE_META) === true;
67
+ }
68
+ };
69
+ }
70
+ function createBoundViewBridge(view, config) {
71
+ const inner = createViewBridge(config);
72
+ let currentView = view;
73
+ return {
74
+ applyText(text, options) {
75
+ return inner.applyText(currentView, text, options);
76
+ },
77
+ extractText() {
78
+ return inner.extractText(currentView);
79
+ },
80
+ isBridgeChange(tr) {
81
+ return inner.isBridgeChange(tr);
82
+ },
83
+ setView(v) {
84
+ currentView = v;
85
+ }
86
+ };
87
+ }
88
+
89
+ // src/cursor-map.ts
90
+ var defaultLocate = (serialized, nodeText, from) => serialized.indexOf(nodeText, from);
91
+ function buildCursorMap(doc, serialize, locate = defaultLocate) {
92
+ const fullText = serialize(doc);
93
+ const segments = [];
94
+ let searchFrom = 0;
95
+ let skippedNodes = 0;
96
+ function walkChildren(node, contentStart) {
97
+ node.forEach((child, offset) => {
98
+ const childPos = contentStart + offset;
99
+ if (child.isText && child.text) {
100
+ const text = child.text;
101
+ const idx = locate(fullText, text, searchFrom);
102
+ if (idx >= 0) {
103
+ segments.push({
104
+ pmStart: childPos,
105
+ pmEnd: childPos + text.length,
106
+ textStart: idx,
107
+ textEnd: idx + text.length
108
+ });
109
+ searchFrom = idx + text.length;
110
+ } else {
111
+ skippedNodes++;
112
+ }
113
+ return;
114
+ }
115
+ if (child.isLeaf) {
116
+ return;
117
+ }
118
+ walkChildren(child, childPos + 1);
119
+ });
120
+ }
121
+ walkChildren(doc, 0);
122
+ return { segments, textLength: fullText.length, skippedNodes };
123
+ }
124
+ function cursorMapLookup(map, pmPos) {
125
+ const { segments } = map;
126
+ if (segments.length === 0) return null;
127
+ let lo = 0;
128
+ let hi = segments.length - 1;
129
+ while (lo <= hi) {
130
+ const mid = lo + hi >>> 1;
131
+ const seg = segments[mid];
132
+ if (pmPos < seg.pmStart) {
133
+ hi = mid - 1;
134
+ } else if (pmPos >= seg.pmEnd) {
135
+ lo = mid + 1;
136
+ } else {
137
+ return seg.textStart + (pmPos - seg.pmStart);
138
+ }
139
+ }
140
+ const before = hi >= 0 ? segments[hi] : null;
141
+ const after = lo < segments.length ? segments[lo] : null;
142
+ if (!before) return after ? after.textStart : 0;
143
+ if (!after) return before.textEnd;
144
+ const distBefore = pmPos - before.pmEnd;
145
+ const distAfter = after.pmStart - pmPos;
146
+ return distBefore <= distAfter ? before.textEnd : after.textStart;
147
+ }
148
+ function reverseCursorMapLookup(map, cmOffset) {
149
+ const { segments } = map;
150
+ if (segments.length === 0) return null;
151
+ let lo = 0;
152
+ let hi = segments.length - 1;
153
+ while (lo <= hi) {
154
+ const mid = lo + hi >>> 1;
155
+ const seg = segments[mid];
156
+ if (cmOffset < seg.textStart) {
157
+ hi = mid - 1;
158
+ } else if (cmOffset >= seg.textEnd) {
159
+ lo = mid + 1;
160
+ } else {
161
+ return seg.pmStart + (cmOffset - seg.textStart);
162
+ }
163
+ }
164
+ const before = hi >= 0 ? segments[hi] : null;
165
+ const after = lo < segments.length ? segments[lo] : null;
166
+ if (!before) return after ? after.pmStart : 0;
167
+ if (!after) return before.pmEnd;
168
+ const distBefore = cmOffset - before.textEnd;
169
+ const distAfter = after.textStart - cmOffset;
170
+ return distBefore <= distAfter ? before.pmEnd : after.pmStart;
171
+ }
172
+ // Annotate the CommonJS export names for ESM import in node:
173
+ 0 && (module.exports = {
174
+ buildCursorMap,
175
+ createBoundViewBridge,
176
+ createViewBridge,
177
+ cursorMapLookup,
178
+ reverseCursorMapLookup
179
+ });
180
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/bridge.ts","../src/cursor-map.ts"],"sourcesContent":["export { createViewBridge, createBoundViewBridge } from './bridge.js'\nexport type { ViewBridgeConfig, ViewBridgeHandle, BoundViewBridgeHandle, ApplyTextOptions, ApplyTextResult } from './bridge.js'\nexport type { Serialize, Parse, Normalize, OnError, ErrorCode, ErrorEvent } from './types.js'\nexport { buildCursorMap, cursorMapLookup, reverseCursorMapLookup } from './cursor-map.js'\nexport type { TextSegment, CursorMap, LocateText } from './cursor-map.js'\n","import type { Node, Schema } from 'prosemirror-model'\nimport type { Transaction } from 'prosemirror-state'\nimport type { EditorView } from 'prosemirror-view'\nimport type { Normalize, Serialize, Parse, OnError } from './types.js'\n\nconst BRIDGE_META = 'pm-cm-bridge'\nconst defaultNormalize: Normalize = (s) => s.replace(/\\r\\n?/g, '\\n')\nconst defaultOnError: OnError = (event) => console.error(`[bridge] ${event.code}: ${event.message}`, event.cause)\n\n/** Configuration for {@link createViewBridge}. */\nexport type ViewBridgeConfig = {\n schema: Schema\n serialize: Serialize\n parse: Parse\n normalize?: Normalize\n /** Called on non-fatal errors (e.g. parse failures). Defaults to `console.error`. */\n onError?: OnError\n}\n\n/** Options for {@link ViewBridgeHandle.applyText}. */\nexport type ApplyTextOptions = {\n /** Set `false` to prevent the change from being added to undo history. Default `true`. */\n addToHistory?: boolean\n}\n\n/**\n * Discriminated-union result of {@link ViewBridgeHandle.applyText}.\n * `ok: true` when the text was applied; `ok: false` with a `reason` otherwise.\n */\nexport type ApplyTextResult =\n | { ok: true }\n | { ok: false; reason: 'unchanged' | 'parse-error' }\n\n/** Handle returned by {@link createViewBridge}. */\nexport type ViewBridgeHandle = {\n /** Parse `text` and replace the ProseMirror document. Returns an {@link ApplyTextResult}. */\n applyText(view: EditorView, text: string, options?: ApplyTextOptions): ApplyTextResult\n /** Serialize the current ProseMirror document to text. */\n extractText(view: EditorView): string\n /** Returns `true` if the transaction was dispatched by {@link applyText}. */\n isBridgeChange(tr: Transaction): boolean\n}\n\n/** Handle returned by {@link createBoundViewBridge}. View is bound; no need to pass it each call. */\nexport type BoundViewBridgeHandle = {\n /** Parse `text` and replace the ProseMirror document. */\n applyText(text: string, options?: ApplyTextOptions): ApplyTextResult\n /** Serialize the current ProseMirror document to text. */\n extractText(): string\n /** Returns `true` if the transaction was dispatched by {@link applyText}. */\n isBridgeChange(tr: Transaction): boolean\n /** Replace the bound EditorView. */\n setView(view: EditorView): void\n}\n\n/**\n * Create a document-sync bridge between ProseMirror and a text editor.\n *\n * Returns a {@link ViewBridgeHandle} with methods to push/pull text and\n * detect bridge-originated transactions.\n */\nexport function createViewBridge(config: ViewBridgeConfig): ViewBridgeHandle {\n const { schema, serialize, parse } = config\n const normalize = config.normalize ?? defaultNormalize\n const onError = config.onError ?? defaultOnError\n\n return {\n applyText(view: EditorView, text: string, options?: ApplyTextOptions): ApplyTextResult {\n const incoming = normalize(text)\n const current = normalize(serialize(view.state.doc))\n\n if (incoming === current) {\n return { ok: false, reason: 'unchanged' }\n }\n\n let nextDoc: Node\n try {\n nextDoc = parse(incoming, schema)\n } catch (error) {\n onError({ code: 'parse-error', message: 'failed to parse text into ProseMirror document', cause: error })\n return { ok: false, reason: 'parse-error' }\n }\n\n const tr = view.state.tr\n tr.replaceWith(0, tr.doc.content.size, nextDoc.content)\n tr.setMeta(BRIDGE_META, true)\n if (options?.addToHistory === false) {\n tr.setMeta('addToHistory', false)\n }\n view.dispatch(tr)\n return { ok: true }\n },\n\n extractText(view: EditorView): string {\n return serialize(view.state.doc)\n },\n\n isBridgeChange(tr: Transaction): boolean {\n return tr.getMeta(BRIDGE_META) === true\n },\n }\n}\n\n/**\n * Create a view-bound document-sync bridge. Wraps {@link createViewBridge}\n * so that the `EditorView` does not need to be passed to each method call.\n *\n * @param view - The initial EditorView to bind.\n * @param config - Configuration for the underlying bridge.\n */\nexport function createBoundViewBridge(view: EditorView, config: ViewBridgeConfig): BoundViewBridgeHandle {\n const inner = createViewBridge(config)\n let currentView = view\n\n return {\n applyText(text: string, options?: ApplyTextOptions): ApplyTextResult {\n return inner.applyText(currentView, text, options)\n },\n extractText(): string {\n return inner.extractText(currentView)\n },\n isBridgeChange(tr: Transaction): boolean {\n return inner.isBridgeChange(tr)\n },\n setView(v: EditorView): void {\n currentView = v\n },\n }\n}\n","import type { Node } from 'prosemirror-model'\nimport type { Serialize } from './types.js'\n\n/** A mapping between a ProseMirror position range and a serialized-text offset range. */\nexport type TextSegment = {\n pmStart: number // PM position (inclusive)\n pmEnd: number // PM position (exclusive)\n textStart: number // serialized text offset (inclusive)\n textEnd: number // serialized text offset (exclusive)\n}\n\n/**\n * Sorted list of {@link TextSegment}s produced by {@link buildCursorMap}.\n * Use {@link cursorMapLookup} and {@link reverseCursorMapLookup} for O(log n) queries.\n */\nexport type CursorMap = {\n segments: TextSegment[]\n textLength: number\n /** Number of text nodes that could not be located in the serialized output. */\n skippedNodes: number\n}\n\n/**\n * Locate a text-node string within the serialized output.\n * Return the starting index, or -1 if not found.\n * Default: `(serialized, nodeText, from) => serialized.indexOf(nodeText, from)`\n */\nexport type LocateText = (serialized: string, nodeText: string, searchFrom: number) => number\n\nconst defaultLocate: LocateText = (serialized, nodeText, from) =>\n serialized.indexOf(nodeText, from)\n\n/**\n * Build a cursor map that aligns ProseMirror positions with serialized-text offsets.\n *\n * Walks the document tree and locates each text node within the serialized output,\n * producing a sorted list of {@link TextSegment}s.\n *\n * @param doc - The ProseMirror document to map.\n * @param serialize - Serializer used to produce the full text.\n * @param locate - Optional custom text-location function. Defaults to `indexOf`.\n */\nexport function buildCursorMap(\n doc: Node,\n serialize: Serialize,\n locate: LocateText = defaultLocate,\n): CursorMap {\n const fullText = serialize(doc)\n const segments: TextSegment[] = []\n let searchFrom = 0\n let skippedNodes = 0\n\n function walkChildren(node: Node, contentStart: number): void {\n node.forEach((child, offset) => {\n const childPos = contentStart + offset\n\n if (child.isText && child.text) {\n const text = child.text\n const idx = locate(fullText, text, searchFrom)\n if (idx >= 0) {\n segments.push({\n pmStart: childPos,\n pmEnd: childPos + text.length,\n textStart: idx,\n textEnd: idx + text.length,\n })\n searchFrom = idx + text.length\n } else {\n skippedNodes++\n }\n return\n }\n\n if (child.isLeaf) {\n return\n }\n\n // Container node: content starts at childPos + 1 (open tag)\n walkChildren(child, childPos + 1)\n })\n }\n\n // doc's content starts at position 0\n walkChildren(doc, 0)\n\n return { segments, textLength: fullText.length, skippedNodes }\n}\n\n/**\n * Look up a ProseMirror position in a cursor map and return the corresponding text offset.\n * Returns `null` when the map has no segments.\n */\nexport function cursorMapLookup(map: CursorMap, pmPos: number): number | null {\n const { segments } = map\n if (segments.length === 0) return null\n\n // Binary search for the segment containing pmPos\n let lo = 0\n let hi = segments.length - 1\n\n while (lo <= hi) {\n const mid = (lo + hi) >>> 1\n const seg = segments[mid]\n\n if (pmPos < seg.pmStart) {\n hi = mid - 1\n } else if (pmPos >= seg.pmEnd) {\n lo = mid + 1\n } else {\n // Inside segment: exact mapping\n return seg.textStart + (pmPos - seg.pmStart)\n }\n }\n\n // pmPos is between segments — snap to nearest boundary\n // After binary search: hi < lo, pmPos falls between segments[hi] and segments[lo]\n const before = hi >= 0 ? segments[hi] : null\n const after = lo < segments.length ? segments[lo] : null\n\n if (!before) return after ? after.textStart : 0\n if (!after) return before.textEnd\n\n const distBefore = pmPos - before.pmEnd\n const distAfter = after.pmStart - pmPos\n return distBefore <= distAfter ? before.textEnd : after.textStart\n}\n\n/**\n * Look up a text offset (e.g. CodeMirror position) in a cursor map and return the corresponding ProseMirror position.\n * Returns `null` when the map has no segments.\n */\nexport function reverseCursorMapLookup(map: CursorMap, cmOffset: number): number | null {\n const { segments } = map\n if (segments.length === 0) return null\n\n // Binary search for the segment containing cmOffset\n let lo = 0\n let hi = segments.length - 1\n\n while (lo <= hi) {\n const mid = (lo + hi) >>> 1\n const seg = segments[mid]\n\n if (cmOffset < seg.textStart) {\n hi = mid - 1\n } else if (cmOffset >= seg.textEnd) {\n lo = mid + 1\n } else {\n // Inside segment: exact mapping\n return seg.pmStart + (cmOffset - seg.textStart)\n }\n }\n\n // cmOffset is between segments — snap to nearest boundary\n const before = hi >= 0 ? segments[hi] : null\n const after = lo < segments.length ? segments[lo] : null\n\n if (!before) return after ? after.pmStart : 0\n if (!after) return before.pmEnd\n\n const distBefore = cmOffset - before.textEnd\n const distAfter = after.textStart - cmOffset\n return distBefore <= distAfter ? before.pmEnd : after.pmStart\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACKA,IAAM,cAAc;AACpB,IAAM,mBAA8B,CAAC,MAAM,EAAE,QAAQ,UAAU,IAAI;AACnE,IAAM,iBAA0B,CAAC,UAAU,QAAQ,MAAM,YAAY,MAAM,IAAI,KAAK,MAAM,OAAO,IAAI,MAAM,KAAK;AAsDzG,SAAS,iBAAiB,QAA4C;AAC3E,QAAM,EAAE,QAAQ,WAAW,MAAM,IAAI;AACrC,QAAM,YAAY,OAAO,aAAa;AACtC,QAAM,UAAU,OAAO,WAAW;AAElC,SAAO;AAAA,IACL,UAAU,MAAkB,MAAc,SAA6C;AACrF,YAAM,WAAW,UAAU,IAAI;AAC/B,YAAM,UAAU,UAAU,UAAU,KAAK,MAAM,GAAG,CAAC;AAEnD,UAAI,aAAa,SAAS;AACxB,eAAO,EAAE,IAAI,OAAO,QAAQ,YAAY;AAAA,MAC1C;AAEA,UAAI;AACJ,UAAI;AACF,kBAAU,MAAM,UAAU,MAAM;AAAA,MAClC,SAAS,OAAO;AACd,gBAAQ,EAAE,MAAM,eAAe,SAAS,kDAAkD,OAAO,MAAM,CAAC;AACxG,eAAO,EAAE,IAAI,OAAO,QAAQ,cAAc;AAAA,MAC5C;AAEA,YAAM,KAAK,KAAK,MAAM;AACtB,SAAG,YAAY,GAAG,GAAG,IAAI,QAAQ,MAAM,QAAQ,OAAO;AACtD,SAAG,QAAQ,aAAa,IAAI;AAC5B,UAAI,SAAS,iBAAiB,OAAO;AACnC,WAAG,QAAQ,gBAAgB,KAAK;AAAA,MAClC;AACA,WAAK,SAAS,EAAE;AAChB,aAAO,EAAE,IAAI,KAAK;AAAA,IACpB;AAAA,IAEA,YAAY,MAA0B;AACpC,aAAO,UAAU,KAAK,MAAM,GAAG;AAAA,IACjC;AAAA,IAEA,eAAe,IAA0B;AACvC,aAAO,GAAG,QAAQ,WAAW,MAAM;AAAA,IACrC;AAAA,EACF;AACF;AASO,SAAS,sBAAsB,MAAkB,QAAiD;AACvG,QAAM,QAAQ,iBAAiB,MAAM;AACrC,MAAI,cAAc;AAElB,SAAO;AAAA,IACL,UAAU,MAAc,SAA6C;AACnE,aAAO,MAAM,UAAU,aAAa,MAAM,OAAO;AAAA,IACnD;AAAA,IACA,cAAsB;AACpB,aAAO,MAAM,YAAY,WAAW;AAAA,IACtC;AAAA,IACA,eAAe,IAA0B;AACvC,aAAO,MAAM,eAAe,EAAE;AAAA,IAChC;AAAA,IACA,QAAQ,GAAqB;AAC3B,oBAAc;AAAA,IAChB;AAAA,EACF;AACF;;;ACnGA,IAAM,gBAA4B,CAAC,YAAY,UAAU,SACvD,WAAW,QAAQ,UAAU,IAAI;AAY5B,SAAS,eACd,KACA,WACA,SAAqB,eACV;AACX,QAAM,WAAW,UAAU,GAAG;AAC9B,QAAM,WAA0B,CAAC;AACjC,MAAI,aAAa;AACjB,MAAI,eAAe;AAEnB,WAAS,aAAa,MAAY,cAA4B;AAC5D,SAAK,QAAQ,CAAC,OAAO,WAAW;AAC9B,YAAM,WAAW,eAAe;AAEhC,UAAI,MAAM,UAAU,MAAM,MAAM;AAC9B,cAAM,OAAO,MAAM;AACnB,cAAM,MAAM,OAAO,UAAU,MAAM,UAAU;AAC7C,YAAI,OAAO,GAAG;AACZ,mBAAS,KAAK;AAAA,YACZ,SAAS;AAAA,YACT,OAAO,WAAW,KAAK;AAAA,YACvB,WAAW;AAAA,YACX,SAAS,MAAM,KAAK;AAAA,UACtB,CAAC;AACD,uBAAa,MAAM,KAAK;AAAA,QAC1B,OAAO;AACL;AAAA,QACF;AACA;AAAA,MACF;AAEA,UAAI,MAAM,QAAQ;AAChB;AAAA,MACF;AAGA,mBAAa,OAAO,WAAW,CAAC;AAAA,IAClC,CAAC;AAAA,EACH;AAGA,eAAa,KAAK,CAAC;AAEnB,SAAO,EAAE,UAAU,YAAY,SAAS,QAAQ,aAAa;AAC/D;AAMO,SAAS,gBAAgB,KAAgB,OAA8B;AAC5E,QAAM,EAAE,SAAS,IAAI;AACrB,MAAI,SAAS,WAAW,EAAG,QAAO;AAGlC,MAAI,KAAK;AACT,MAAI,KAAK,SAAS,SAAS;AAE3B,SAAO,MAAM,IAAI;AACf,UAAM,MAAO,KAAK,OAAQ;AAC1B,UAAM,MAAM,SAAS,GAAG;AAExB,QAAI,QAAQ,IAAI,SAAS;AACvB,WAAK,MAAM;AAAA,IACb,WAAW,SAAS,IAAI,OAAO;AAC7B,WAAK,MAAM;AAAA,IACb,OAAO;AAEL,aAAO,IAAI,aAAa,QAAQ,IAAI;AAAA,IACtC;AAAA,EACF;AAIA,QAAM,SAAS,MAAM,IAAI,SAAS,EAAE,IAAI;AACxC,QAAM,QAAQ,KAAK,SAAS,SAAS,SAAS,EAAE,IAAI;AAEpD,MAAI,CAAC,OAAQ,QAAO,QAAQ,MAAM,YAAY;AAC9C,MAAI,CAAC,MAAO,QAAO,OAAO;AAE1B,QAAM,aAAa,QAAQ,OAAO;AAClC,QAAM,YAAY,MAAM,UAAU;AAClC,SAAO,cAAc,YAAY,OAAO,UAAU,MAAM;AAC1D;AAMO,SAAS,uBAAuB,KAAgB,UAAiC;AACtF,QAAM,EAAE,SAAS,IAAI;AACrB,MAAI,SAAS,WAAW,EAAG,QAAO;AAGlC,MAAI,KAAK;AACT,MAAI,KAAK,SAAS,SAAS;AAE3B,SAAO,MAAM,IAAI;AACf,UAAM,MAAO,KAAK,OAAQ;AAC1B,UAAM,MAAM,SAAS,GAAG;AAExB,QAAI,WAAW,IAAI,WAAW;AAC5B,WAAK,MAAM;AAAA,IACb,WAAW,YAAY,IAAI,SAAS;AAClC,WAAK,MAAM;AAAA,IACb,OAAO;AAEL,aAAO,IAAI,WAAW,WAAW,IAAI;AAAA,IACvC;AAAA,EACF;AAGA,QAAM,SAAS,MAAM,IAAI,SAAS,EAAE,IAAI;AACxC,QAAM,QAAQ,KAAK,SAAS,SAAS,SAAS,EAAE,IAAI;AAEpD,MAAI,CAAC,OAAQ,QAAO,QAAQ,MAAM,UAAU;AAC5C,MAAI,CAAC,MAAO,QAAO,OAAO;AAE1B,QAAM,aAAa,WAAW,OAAO;AACrC,QAAM,YAAY,MAAM,YAAY;AACpC,SAAO,cAAc,YAAY,OAAO,QAAQ,MAAM;AACxD;","names":[]}
@@ -0,0 +1,133 @@
1
+ import { Schema, Node } from 'prosemirror-model';
2
+ import { Transaction } from 'prosemirror-state';
3
+ import { EditorView } from 'prosemirror-view';
4
+
5
+ /** Converts a ProseMirror document to a serialized text string. */
6
+ type Serialize = (doc: Node) => string;
7
+ /** Parses a serialized text string into a ProseMirror document. */
8
+ type Parse = (text: string, schema: Schema) => Node;
9
+ /** Normalizes a text string (e.g. line endings). Default strips `\r`. */
10
+ type Normalize = (text: string) => string;
11
+ /** Known error codes emitted by the bridge. */
12
+ type ErrorCode = 'parse-error' | 'serialize-error';
13
+ /** Structured error event for non-fatal errors (e.g. parse failures). */
14
+ type ErrorEvent = {
15
+ code: ErrorCode;
16
+ message: string;
17
+ cause: unknown;
18
+ };
19
+ /**
20
+ * Error handler callback for non-fatal errors (e.g. parse failures).
21
+ *
22
+ * Known codes:
23
+ * - `'parse-error'` — failed to parse text into a ProseMirror document.
24
+ * - `'serialize-error'` — failed to serialize a ProseMirror document to text.
25
+ */
26
+ type OnError = (event: ErrorEvent) => void;
27
+
28
+ /** Configuration for {@link createViewBridge}. */
29
+ type ViewBridgeConfig = {
30
+ schema: Schema;
31
+ serialize: Serialize;
32
+ parse: Parse;
33
+ normalize?: Normalize;
34
+ /** Called on non-fatal errors (e.g. parse failures). Defaults to `console.error`. */
35
+ onError?: OnError;
36
+ };
37
+ /** Options for {@link ViewBridgeHandle.applyText}. */
38
+ type ApplyTextOptions = {
39
+ /** Set `false` to prevent the change from being added to undo history. Default `true`. */
40
+ addToHistory?: boolean;
41
+ };
42
+ /**
43
+ * Discriminated-union result of {@link ViewBridgeHandle.applyText}.
44
+ * `ok: true` when the text was applied; `ok: false` with a `reason` otherwise.
45
+ */
46
+ type ApplyTextResult = {
47
+ ok: true;
48
+ } | {
49
+ ok: false;
50
+ reason: 'unchanged' | 'parse-error';
51
+ };
52
+ /** Handle returned by {@link createViewBridge}. */
53
+ type ViewBridgeHandle = {
54
+ /** Parse `text` and replace the ProseMirror document. Returns an {@link ApplyTextResult}. */
55
+ applyText(view: EditorView, text: string, options?: ApplyTextOptions): ApplyTextResult;
56
+ /** Serialize the current ProseMirror document to text. */
57
+ extractText(view: EditorView): string;
58
+ /** Returns `true` if the transaction was dispatched by {@link applyText}. */
59
+ isBridgeChange(tr: Transaction): boolean;
60
+ };
61
+ /** Handle returned by {@link createBoundViewBridge}. View is bound; no need to pass it each call. */
62
+ type BoundViewBridgeHandle = {
63
+ /** Parse `text` and replace the ProseMirror document. */
64
+ applyText(text: string, options?: ApplyTextOptions): ApplyTextResult;
65
+ /** Serialize the current ProseMirror document to text. */
66
+ extractText(): string;
67
+ /** Returns `true` if the transaction was dispatched by {@link applyText}. */
68
+ isBridgeChange(tr: Transaction): boolean;
69
+ /** Replace the bound EditorView. */
70
+ setView(view: EditorView): void;
71
+ };
72
+ /**
73
+ * Create a document-sync bridge between ProseMirror and a text editor.
74
+ *
75
+ * Returns a {@link ViewBridgeHandle} with methods to push/pull text and
76
+ * detect bridge-originated transactions.
77
+ */
78
+ declare function createViewBridge(config: ViewBridgeConfig): ViewBridgeHandle;
79
+ /**
80
+ * Create a view-bound document-sync bridge. Wraps {@link createViewBridge}
81
+ * so that the `EditorView` does not need to be passed to each method call.
82
+ *
83
+ * @param view - The initial EditorView to bind.
84
+ * @param config - Configuration for the underlying bridge.
85
+ */
86
+ declare function createBoundViewBridge(view: EditorView, config: ViewBridgeConfig): BoundViewBridgeHandle;
87
+
88
+ /** A mapping between a ProseMirror position range and a serialized-text offset range. */
89
+ type TextSegment = {
90
+ pmStart: number;
91
+ pmEnd: number;
92
+ textStart: number;
93
+ textEnd: number;
94
+ };
95
+ /**
96
+ * Sorted list of {@link TextSegment}s produced by {@link buildCursorMap}.
97
+ * Use {@link cursorMapLookup} and {@link reverseCursorMapLookup} for O(log n) queries.
98
+ */
99
+ type CursorMap = {
100
+ segments: TextSegment[];
101
+ textLength: number;
102
+ /** Number of text nodes that could not be located in the serialized output. */
103
+ skippedNodes: number;
104
+ };
105
+ /**
106
+ * Locate a text-node string within the serialized output.
107
+ * Return the starting index, or -1 if not found.
108
+ * Default: `(serialized, nodeText, from) => serialized.indexOf(nodeText, from)`
109
+ */
110
+ type LocateText = (serialized: string, nodeText: string, searchFrom: number) => number;
111
+ /**
112
+ * Build a cursor map that aligns ProseMirror positions with serialized-text offsets.
113
+ *
114
+ * Walks the document tree and locates each text node within the serialized output,
115
+ * producing a sorted list of {@link TextSegment}s.
116
+ *
117
+ * @param doc - The ProseMirror document to map.
118
+ * @param serialize - Serializer used to produce the full text.
119
+ * @param locate - Optional custom text-location function. Defaults to `indexOf`.
120
+ */
121
+ declare function buildCursorMap(doc: Node, serialize: Serialize, locate?: LocateText): CursorMap;
122
+ /**
123
+ * Look up a ProseMirror position in a cursor map and return the corresponding text offset.
124
+ * Returns `null` when the map has no segments.
125
+ */
126
+ declare function cursorMapLookup(map: CursorMap, pmPos: number): number | null;
127
+ /**
128
+ * Look up a text offset (e.g. CodeMirror position) in a cursor map and return the corresponding ProseMirror position.
129
+ * Returns `null` when the map has no segments.
130
+ */
131
+ declare function reverseCursorMapLookup(map: CursorMap, cmOffset: number): number | null;
132
+
133
+ export { type ApplyTextOptions, type ApplyTextResult, type BoundViewBridgeHandle, type CursorMap, type ErrorCode, type ErrorEvent, type LocateText, type Normalize, type OnError, type Parse, type Serialize, type TextSegment, type ViewBridgeConfig, type ViewBridgeHandle, buildCursorMap, createBoundViewBridge, createViewBridge, cursorMapLookup, reverseCursorMapLookup };
@@ -0,0 +1,133 @@
1
+ import { Schema, Node } from 'prosemirror-model';
2
+ import { Transaction } from 'prosemirror-state';
3
+ import { EditorView } from 'prosemirror-view';
4
+
5
+ /** Converts a ProseMirror document to a serialized text string. */
6
+ type Serialize = (doc: Node) => string;
7
+ /** Parses a serialized text string into a ProseMirror document. */
8
+ type Parse = (text: string, schema: Schema) => Node;
9
+ /** Normalizes a text string (e.g. line endings). Default strips `\r`. */
10
+ type Normalize = (text: string) => string;
11
+ /** Known error codes emitted by the bridge. */
12
+ type ErrorCode = 'parse-error' | 'serialize-error';
13
+ /** Structured error event for non-fatal errors (e.g. parse failures). */
14
+ type ErrorEvent = {
15
+ code: ErrorCode;
16
+ message: string;
17
+ cause: unknown;
18
+ };
19
+ /**
20
+ * Error handler callback for non-fatal errors (e.g. parse failures).
21
+ *
22
+ * Known codes:
23
+ * - `'parse-error'` — failed to parse text into a ProseMirror document.
24
+ * - `'serialize-error'` — failed to serialize a ProseMirror document to text.
25
+ */
26
+ type OnError = (event: ErrorEvent) => void;
27
+
28
+ /** Configuration for {@link createViewBridge}. */
29
+ type ViewBridgeConfig = {
30
+ schema: Schema;
31
+ serialize: Serialize;
32
+ parse: Parse;
33
+ normalize?: Normalize;
34
+ /** Called on non-fatal errors (e.g. parse failures). Defaults to `console.error`. */
35
+ onError?: OnError;
36
+ };
37
+ /** Options for {@link ViewBridgeHandle.applyText}. */
38
+ type ApplyTextOptions = {
39
+ /** Set `false` to prevent the change from being added to undo history. Default `true`. */
40
+ addToHistory?: boolean;
41
+ };
42
+ /**
43
+ * Discriminated-union result of {@link ViewBridgeHandle.applyText}.
44
+ * `ok: true` when the text was applied; `ok: false` with a `reason` otherwise.
45
+ */
46
+ type ApplyTextResult = {
47
+ ok: true;
48
+ } | {
49
+ ok: false;
50
+ reason: 'unchanged' | 'parse-error';
51
+ };
52
+ /** Handle returned by {@link createViewBridge}. */
53
+ type ViewBridgeHandle = {
54
+ /** Parse `text` and replace the ProseMirror document. Returns an {@link ApplyTextResult}. */
55
+ applyText(view: EditorView, text: string, options?: ApplyTextOptions): ApplyTextResult;
56
+ /** Serialize the current ProseMirror document to text. */
57
+ extractText(view: EditorView): string;
58
+ /** Returns `true` if the transaction was dispatched by {@link applyText}. */
59
+ isBridgeChange(tr: Transaction): boolean;
60
+ };
61
+ /** Handle returned by {@link createBoundViewBridge}. View is bound; no need to pass it each call. */
62
+ type BoundViewBridgeHandle = {
63
+ /** Parse `text` and replace the ProseMirror document. */
64
+ applyText(text: string, options?: ApplyTextOptions): ApplyTextResult;
65
+ /** Serialize the current ProseMirror document to text. */
66
+ extractText(): string;
67
+ /** Returns `true` if the transaction was dispatched by {@link applyText}. */
68
+ isBridgeChange(tr: Transaction): boolean;
69
+ /** Replace the bound EditorView. */
70
+ setView(view: EditorView): void;
71
+ };
72
+ /**
73
+ * Create a document-sync bridge between ProseMirror and a text editor.
74
+ *
75
+ * Returns a {@link ViewBridgeHandle} with methods to push/pull text and
76
+ * detect bridge-originated transactions.
77
+ */
78
+ declare function createViewBridge(config: ViewBridgeConfig): ViewBridgeHandle;
79
+ /**
80
+ * Create a view-bound document-sync bridge. Wraps {@link createViewBridge}
81
+ * so that the `EditorView` does not need to be passed to each method call.
82
+ *
83
+ * @param view - The initial EditorView to bind.
84
+ * @param config - Configuration for the underlying bridge.
85
+ */
86
+ declare function createBoundViewBridge(view: EditorView, config: ViewBridgeConfig): BoundViewBridgeHandle;
87
+
88
+ /** A mapping between a ProseMirror position range and a serialized-text offset range. */
89
+ type TextSegment = {
90
+ pmStart: number;
91
+ pmEnd: number;
92
+ textStart: number;
93
+ textEnd: number;
94
+ };
95
+ /**
96
+ * Sorted list of {@link TextSegment}s produced by {@link buildCursorMap}.
97
+ * Use {@link cursorMapLookup} and {@link reverseCursorMapLookup} for O(log n) queries.
98
+ */
99
+ type CursorMap = {
100
+ segments: TextSegment[];
101
+ textLength: number;
102
+ /** Number of text nodes that could not be located in the serialized output. */
103
+ skippedNodes: number;
104
+ };
105
+ /**
106
+ * Locate a text-node string within the serialized output.
107
+ * Return the starting index, or -1 if not found.
108
+ * Default: `(serialized, nodeText, from) => serialized.indexOf(nodeText, from)`
109
+ */
110
+ type LocateText = (serialized: string, nodeText: string, searchFrom: number) => number;
111
+ /**
112
+ * Build a cursor map that aligns ProseMirror positions with serialized-text offsets.
113
+ *
114
+ * Walks the document tree and locates each text node within the serialized output,
115
+ * producing a sorted list of {@link TextSegment}s.
116
+ *
117
+ * @param doc - The ProseMirror document to map.
118
+ * @param serialize - Serializer used to produce the full text.
119
+ * @param locate - Optional custom text-location function. Defaults to `indexOf`.
120
+ */
121
+ declare function buildCursorMap(doc: Node, serialize: Serialize, locate?: LocateText): CursorMap;
122
+ /**
123
+ * Look up a ProseMirror position in a cursor map and return the corresponding text offset.
124
+ * Returns `null` when the map has no segments.
125
+ */
126
+ declare function cursorMapLookup(map: CursorMap, pmPos: number): number | null;
127
+ /**
128
+ * Look up a text offset (e.g. CodeMirror position) in a cursor map and return the corresponding ProseMirror position.
129
+ * Returns `null` when the map has no segments.
130
+ */
131
+ declare function reverseCursorMapLookup(map: CursorMap, cmOffset: number): number | null;
132
+
133
+ export { type ApplyTextOptions, type ApplyTextResult, type BoundViewBridgeHandle, type CursorMap, type ErrorCode, type ErrorEvent, type LocateText, type Normalize, type OnError, type Parse, type Serialize, type TextSegment, type ViewBridgeConfig, type ViewBridgeHandle, buildCursorMap, createBoundViewBridge, createViewBridge, cursorMapLookup, reverseCursorMapLookup };
package/dist/index.js ADDED
@@ -0,0 +1,149 @@
1
+ // src/bridge.ts
2
+ var BRIDGE_META = "pm-cm-bridge";
3
+ var defaultNormalize = (s) => s.replace(/\r\n?/g, "\n");
4
+ var defaultOnError = (event) => console.error(`[bridge] ${event.code}: ${event.message}`, event.cause);
5
+ function createViewBridge(config) {
6
+ const { schema, serialize, parse } = config;
7
+ const normalize = config.normalize ?? defaultNormalize;
8
+ const onError = config.onError ?? defaultOnError;
9
+ return {
10
+ applyText(view, text, options) {
11
+ const incoming = normalize(text);
12
+ const current = normalize(serialize(view.state.doc));
13
+ if (incoming === current) {
14
+ return { ok: false, reason: "unchanged" };
15
+ }
16
+ let nextDoc;
17
+ try {
18
+ nextDoc = parse(incoming, schema);
19
+ } catch (error) {
20
+ onError({ code: "parse-error", message: "failed to parse text into ProseMirror document", cause: error });
21
+ return { ok: false, reason: "parse-error" };
22
+ }
23
+ const tr = view.state.tr;
24
+ tr.replaceWith(0, tr.doc.content.size, nextDoc.content);
25
+ tr.setMeta(BRIDGE_META, true);
26
+ if (options?.addToHistory === false) {
27
+ tr.setMeta("addToHistory", false);
28
+ }
29
+ view.dispatch(tr);
30
+ return { ok: true };
31
+ },
32
+ extractText(view) {
33
+ return serialize(view.state.doc);
34
+ },
35
+ isBridgeChange(tr) {
36
+ return tr.getMeta(BRIDGE_META) === true;
37
+ }
38
+ };
39
+ }
40
+ function createBoundViewBridge(view, config) {
41
+ const inner = createViewBridge(config);
42
+ let currentView = view;
43
+ return {
44
+ applyText(text, options) {
45
+ return inner.applyText(currentView, text, options);
46
+ },
47
+ extractText() {
48
+ return inner.extractText(currentView);
49
+ },
50
+ isBridgeChange(tr) {
51
+ return inner.isBridgeChange(tr);
52
+ },
53
+ setView(v) {
54
+ currentView = v;
55
+ }
56
+ };
57
+ }
58
+
59
+ // src/cursor-map.ts
60
+ var defaultLocate = (serialized, nodeText, from) => serialized.indexOf(nodeText, from);
61
+ function buildCursorMap(doc, serialize, locate = defaultLocate) {
62
+ const fullText = serialize(doc);
63
+ const segments = [];
64
+ let searchFrom = 0;
65
+ let skippedNodes = 0;
66
+ function walkChildren(node, contentStart) {
67
+ node.forEach((child, offset) => {
68
+ const childPos = contentStart + offset;
69
+ if (child.isText && child.text) {
70
+ const text = child.text;
71
+ const idx = locate(fullText, text, searchFrom);
72
+ if (idx >= 0) {
73
+ segments.push({
74
+ pmStart: childPos,
75
+ pmEnd: childPos + text.length,
76
+ textStart: idx,
77
+ textEnd: idx + text.length
78
+ });
79
+ searchFrom = idx + text.length;
80
+ } else {
81
+ skippedNodes++;
82
+ }
83
+ return;
84
+ }
85
+ if (child.isLeaf) {
86
+ return;
87
+ }
88
+ walkChildren(child, childPos + 1);
89
+ });
90
+ }
91
+ walkChildren(doc, 0);
92
+ return { segments, textLength: fullText.length, skippedNodes };
93
+ }
94
+ function cursorMapLookup(map, pmPos) {
95
+ const { segments } = map;
96
+ if (segments.length === 0) return null;
97
+ let lo = 0;
98
+ let hi = segments.length - 1;
99
+ while (lo <= hi) {
100
+ const mid = lo + hi >>> 1;
101
+ const seg = segments[mid];
102
+ if (pmPos < seg.pmStart) {
103
+ hi = mid - 1;
104
+ } else if (pmPos >= seg.pmEnd) {
105
+ lo = mid + 1;
106
+ } else {
107
+ return seg.textStart + (pmPos - seg.pmStart);
108
+ }
109
+ }
110
+ const before = hi >= 0 ? segments[hi] : null;
111
+ const after = lo < segments.length ? segments[lo] : null;
112
+ if (!before) return after ? after.textStart : 0;
113
+ if (!after) return before.textEnd;
114
+ const distBefore = pmPos - before.pmEnd;
115
+ const distAfter = after.pmStart - pmPos;
116
+ return distBefore <= distAfter ? before.textEnd : after.textStart;
117
+ }
118
+ function reverseCursorMapLookup(map, cmOffset) {
119
+ const { segments } = map;
120
+ if (segments.length === 0) return null;
121
+ let lo = 0;
122
+ let hi = segments.length - 1;
123
+ while (lo <= hi) {
124
+ const mid = lo + hi >>> 1;
125
+ const seg = segments[mid];
126
+ if (cmOffset < seg.textStart) {
127
+ hi = mid - 1;
128
+ } else if (cmOffset >= seg.textEnd) {
129
+ lo = mid + 1;
130
+ } else {
131
+ return seg.pmStart + (cmOffset - seg.textStart);
132
+ }
133
+ }
134
+ const before = hi >= 0 ? segments[hi] : null;
135
+ const after = lo < segments.length ? segments[lo] : null;
136
+ if (!before) return after ? after.pmStart : 0;
137
+ if (!after) return before.pmEnd;
138
+ const distBefore = cmOffset - before.textEnd;
139
+ const distAfter = after.textStart - cmOffset;
140
+ return distBefore <= distAfter ? before.pmEnd : after.pmStart;
141
+ }
142
+ export {
143
+ buildCursorMap,
144
+ createBoundViewBridge,
145
+ createViewBridge,
146
+ cursorMapLookup,
147
+ reverseCursorMapLookup
148
+ };
149
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/bridge.ts","../src/cursor-map.ts"],"sourcesContent":["import type { Node, Schema } from 'prosemirror-model'\nimport type { Transaction } from 'prosemirror-state'\nimport type { EditorView } from 'prosemirror-view'\nimport type { Normalize, Serialize, Parse, OnError } from './types.js'\n\nconst BRIDGE_META = 'pm-cm-bridge'\nconst defaultNormalize: Normalize = (s) => s.replace(/\\r\\n?/g, '\\n')\nconst defaultOnError: OnError = (event) => console.error(`[bridge] ${event.code}: ${event.message}`, event.cause)\n\n/** Configuration for {@link createViewBridge}. */\nexport type ViewBridgeConfig = {\n schema: Schema\n serialize: Serialize\n parse: Parse\n normalize?: Normalize\n /** Called on non-fatal errors (e.g. parse failures). Defaults to `console.error`. */\n onError?: OnError\n}\n\n/** Options for {@link ViewBridgeHandle.applyText}. */\nexport type ApplyTextOptions = {\n /** Set `false` to prevent the change from being added to undo history. Default `true`. */\n addToHistory?: boolean\n}\n\n/**\n * Discriminated-union result of {@link ViewBridgeHandle.applyText}.\n * `ok: true` when the text was applied; `ok: false` with a `reason` otherwise.\n */\nexport type ApplyTextResult =\n | { ok: true }\n | { ok: false; reason: 'unchanged' | 'parse-error' }\n\n/** Handle returned by {@link createViewBridge}. */\nexport type ViewBridgeHandle = {\n /** Parse `text` and replace the ProseMirror document. Returns an {@link ApplyTextResult}. */\n applyText(view: EditorView, text: string, options?: ApplyTextOptions): ApplyTextResult\n /** Serialize the current ProseMirror document to text. */\n extractText(view: EditorView): string\n /** Returns `true` if the transaction was dispatched by {@link applyText}. */\n isBridgeChange(tr: Transaction): boolean\n}\n\n/** Handle returned by {@link createBoundViewBridge}. View is bound; no need to pass it each call. */\nexport type BoundViewBridgeHandle = {\n /** Parse `text` and replace the ProseMirror document. */\n applyText(text: string, options?: ApplyTextOptions): ApplyTextResult\n /** Serialize the current ProseMirror document to text. */\n extractText(): string\n /** Returns `true` if the transaction was dispatched by {@link applyText}. */\n isBridgeChange(tr: Transaction): boolean\n /** Replace the bound EditorView. */\n setView(view: EditorView): void\n}\n\n/**\n * Create a document-sync bridge between ProseMirror and a text editor.\n *\n * Returns a {@link ViewBridgeHandle} with methods to push/pull text and\n * detect bridge-originated transactions.\n */\nexport function createViewBridge(config: ViewBridgeConfig): ViewBridgeHandle {\n const { schema, serialize, parse } = config\n const normalize = config.normalize ?? defaultNormalize\n const onError = config.onError ?? defaultOnError\n\n return {\n applyText(view: EditorView, text: string, options?: ApplyTextOptions): ApplyTextResult {\n const incoming = normalize(text)\n const current = normalize(serialize(view.state.doc))\n\n if (incoming === current) {\n return { ok: false, reason: 'unchanged' }\n }\n\n let nextDoc: Node\n try {\n nextDoc = parse(incoming, schema)\n } catch (error) {\n onError({ code: 'parse-error', message: 'failed to parse text into ProseMirror document', cause: error })\n return { ok: false, reason: 'parse-error' }\n }\n\n const tr = view.state.tr\n tr.replaceWith(0, tr.doc.content.size, nextDoc.content)\n tr.setMeta(BRIDGE_META, true)\n if (options?.addToHistory === false) {\n tr.setMeta('addToHistory', false)\n }\n view.dispatch(tr)\n return { ok: true }\n },\n\n extractText(view: EditorView): string {\n return serialize(view.state.doc)\n },\n\n isBridgeChange(tr: Transaction): boolean {\n return tr.getMeta(BRIDGE_META) === true\n },\n }\n}\n\n/**\n * Create a view-bound document-sync bridge. Wraps {@link createViewBridge}\n * so that the `EditorView` does not need to be passed to each method call.\n *\n * @param view - The initial EditorView to bind.\n * @param config - Configuration for the underlying bridge.\n */\nexport function createBoundViewBridge(view: EditorView, config: ViewBridgeConfig): BoundViewBridgeHandle {\n const inner = createViewBridge(config)\n let currentView = view\n\n return {\n applyText(text: string, options?: ApplyTextOptions): ApplyTextResult {\n return inner.applyText(currentView, text, options)\n },\n extractText(): string {\n return inner.extractText(currentView)\n },\n isBridgeChange(tr: Transaction): boolean {\n return inner.isBridgeChange(tr)\n },\n setView(v: EditorView): void {\n currentView = v\n },\n }\n}\n","import type { Node } from 'prosemirror-model'\nimport type { Serialize } from './types.js'\n\n/** A mapping between a ProseMirror position range and a serialized-text offset range. */\nexport type TextSegment = {\n pmStart: number // PM position (inclusive)\n pmEnd: number // PM position (exclusive)\n textStart: number // serialized text offset (inclusive)\n textEnd: number // serialized text offset (exclusive)\n}\n\n/**\n * Sorted list of {@link TextSegment}s produced by {@link buildCursorMap}.\n * Use {@link cursorMapLookup} and {@link reverseCursorMapLookup} for O(log n) queries.\n */\nexport type CursorMap = {\n segments: TextSegment[]\n textLength: number\n /** Number of text nodes that could not be located in the serialized output. */\n skippedNodes: number\n}\n\n/**\n * Locate a text-node string within the serialized output.\n * Return the starting index, or -1 if not found.\n * Default: `(serialized, nodeText, from) => serialized.indexOf(nodeText, from)`\n */\nexport type LocateText = (serialized: string, nodeText: string, searchFrom: number) => number\n\nconst defaultLocate: LocateText = (serialized, nodeText, from) =>\n serialized.indexOf(nodeText, from)\n\n/**\n * Build a cursor map that aligns ProseMirror positions with serialized-text offsets.\n *\n * Walks the document tree and locates each text node within the serialized output,\n * producing a sorted list of {@link TextSegment}s.\n *\n * @param doc - The ProseMirror document to map.\n * @param serialize - Serializer used to produce the full text.\n * @param locate - Optional custom text-location function. Defaults to `indexOf`.\n */\nexport function buildCursorMap(\n doc: Node,\n serialize: Serialize,\n locate: LocateText = defaultLocate,\n): CursorMap {\n const fullText = serialize(doc)\n const segments: TextSegment[] = []\n let searchFrom = 0\n let skippedNodes = 0\n\n function walkChildren(node: Node, contentStart: number): void {\n node.forEach((child, offset) => {\n const childPos = contentStart + offset\n\n if (child.isText && child.text) {\n const text = child.text\n const idx = locate(fullText, text, searchFrom)\n if (idx >= 0) {\n segments.push({\n pmStart: childPos,\n pmEnd: childPos + text.length,\n textStart: idx,\n textEnd: idx + text.length,\n })\n searchFrom = idx + text.length\n } else {\n skippedNodes++\n }\n return\n }\n\n if (child.isLeaf) {\n return\n }\n\n // Container node: content starts at childPos + 1 (open tag)\n walkChildren(child, childPos + 1)\n })\n }\n\n // doc's content starts at position 0\n walkChildren(doc, 0)\n\n return { segments, textLength: fullText.length, skippedNodes }\n}\n\n/**\n * Look up a ProseMirror position in a cursor map and return the corresponding text offset.\n * Returns `null` when the map has no segments.\n */\nexport function cursorMapLookup(map: CursorMap, pmPos: number): number | null {\n const { segments } = map\n if (segments.length === 0) return null\n\n // Binary search for the segment containing pmPos\n let lo = 0\n let hi = segments.length - 1\n\n while (lo <= hi) {\n const mid = (lo + hi) >>> 1\n const seg = segments[mid]\n\n if (pmPos < seg.pmStart) {\n hi = mid - 1\n } else if (pmPos >= seg.pmEnd) {\n lo = mid + 1\n } else {\n // Inside segment: exact mapping\n return seg.textStart + (pmPos - seg.pmStart)\n }\n }\n\n // pmPos is between segments — snap to nearest boundary\n // After binary search: hi < lo, pmPos falls between segments[hi] and segments[lo]\n const before = hi >= 0 ? segments[hi] : null\n const after = lo < segments.length ? segments[lo] : null\n\n if (!before) return after ? after.textStart : 0\n if (!after) return before.textEnd\n\n const distBefore = pmPos - before.pmEnd\n const distAfter = after.pmStart - pmPos\n return distBefore <= distAfter ? before.textEnd : after.textStart\n}\n\n/**\n * Look up a text offset (e.g. CodeMirror position) in a cursor map and return the corresponding ProseMirror position.\n * Returns `null` when the map has no segments.\n */\nexport function reverseCursorMapLookup(map: CursorMap, cmOffset: number): number | null {\n const { segments } = map\n if (segments.length === 0) return null\n\n // Binary search for the segment containing cmOffset\n let lo = 0\n let hi = segments.length - 1\n\n while (lo <= hi) {\n const mid = (lo + hi) >>> 1\n const seg = segments[mid]\n\n if (cmOffset < seg.textStart) {\n hi = mid - 1\n } else if (cmOffset >= seg.textEnd) {\n lo = mid + 1\n } else {\n // Inside segment: exact mapping\n return seg.pmStart + (cmOffset - seg.textStart)\n }\n }\n\n // cmOffset is between segments — snap to nearest boundary\n const before = hi >= 0 ? segments[hi] : null\n const after = lo < segments.length ? segments[lo] : null\n\n if (!before) return after ? after.pmStart : 0\n if (!after) return before.pmEnd\n\n const distBefore = cmOffset - before.textEnd\n const distAfter = after.textStart - cmOffset\n return distBefore <= distAfter ? before.pmEnd : after.pmStart\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@pm-cm/core",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "./dist/index.cjs",
6
+ "module": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "require": "./dist/index.cjs"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist/index.*"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsup"
20
+ },
21
+ "peerDependencies": {
22
+ "prosemirror-model": "^1.19.0",
23
+ "prosemirror-state": "^1.4.0",
24
+ "prosemirror-view": "^1.20.0"
25
+ },
26
+ "description": "Cursor mapping between ProseMirror and text editors",
27
+ "license": "MIT",
28
+ "author": "munenick",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/MuNeNICK/prosemirror-codemirror-sync.git",
32
+ "directory": "packages/core"
33
+ },
34
+ "keywords": ["prosemirror", "codemirror", "sync", "bridge", "cursor"],
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "devDependencies": {
39
+ "prosemirror-model": "^1.25.4",
40
+ "prosemirror-state": "^1.4.4",
41
+ "prosemirror-view": "^1.41.5",
42
+ "tsup": "^8.0.0",
43
+ "typescript": "~5.9.3"
44
+ }
45
+ }