@shadow-garden/bapbong-input-bridge 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Le Phuoc Minh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # @shadow-garden/bapbong-input-bridge
2
+
3
+ The canvas can't receive text input, so this package wraps a **hidden
4
+ ProseMirror `EditorView`** (a real, visually-invisible contenteditable) as the
5
+ input sink. The browser delivers keyboard and **IME composition** (Vietnamese,
6
+ CJK, …) to it; ProseMirror owns the document, undo history and clipboard. Every
7
+ transaction fires `onUpdate`, where the host re-runs layout and repaints.
8
+
9
+ - **Scope:** `scope:input`
10
+ - **Depends on:** `@shadow-garden/bapbong-model`, `prosemirror-{state,view,model,commands,keymap,history,transform}`
11
+
12
+ ## What it provides
13
+
14
+ - **`InputBridge`** — the hidden editor: `state`, `dispatch(tr)`, `setSelection`,
15
+ `selectWordAt`, `place(x, y, h)` (anchors the IME popup at the caret), `focus`.
16
+ - **Layout-aware commands:** `moveCaretCommand`, `splitListItem`, `wordRangeAt`.
17
+ - **Comment authoring** (undoable transactions on `doc.attrs.comments`):
18
+ `addCommentTr`, `replyCommentTr`, `resolveCommentTr`, `editCommentTr`,
19
+ `deleteCommentTr`.
20
+ - **`CommentComposer`** — a small standalone PM editor for comment bodies, with an
21
+ optional **@mention** suggestion plugin (`MentionHandlers` / `MentionUser` /
22
+ `MentionCoords`), a `placeholder`, and `onEnter` / `onEscape` hooks.
23
+
24
+ ```ts
25
+ const bridge = new InputBridge({ doc, keys: { ArrowUp, ArrowDown }, onUpdate: repaint });
26
+ container.appendChild(bridge.dom);
27
+ bridge.setSelection(pos); bridge.focus(); bridge.place(x, y, h);
28
+ ```
29
+
30
+ ## Build / test
31
+
32
+ ```sh
33
+ pnpm nx build @shadow-garden/bapbong-input-bridge
34
+ pnpm nx test @shadow-garden/bapbong-input-bridge
35
+ ```
package/dist/index.cjs ADDED
@@ -0,0 +1,300 @@
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
+ // packages/input-bridge/src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ CommentComposer: () => CommentComposer,
24
+ InputBridge: () => InputBridge,
25
+ createEditingState: () => createEditingState,
26
+ moveCaretCommand: () => moveCaretCommand,
27
+ splitListItem: () => splitListItem,
28
+ wordRangeAt: () => wordRangeAt
29
+ });
30
+ module.exports = __toCommonJS(index_exports);
31
+
32
+ // packages/input-bridge/src/lib/input-bridge.ts
33
+ var import_prosemirror_commands = require("prosemirror-commands");
34
+ var import_prosemirror_history = require("prosemirror-history");
35
+ var import_prosemirror_keymap = require("prosemirror-keymap");
36
+ var import_prosemirror_state = require("prosemirror-state");
37
+ var import_prosemirror_transform = require("prosemirror-transform");
38
+ var import_prosemirror_view = require("prosemirror-view");
39
+ function moveCaretCommand(compute, extend = false) {
40
+ return (state, dispatch) => {
41
+ const pos = compute(state);
42
+ if (pos == null) return false;
43
+ const sel = extend ? import_prosemirror_state.TextSelection.create(state.doc, state.selection.anchor, pos) : import_prosemirror_state.TextSelection.create(state.doc, pos);
44
+ dispatch?.(state.tr.setSelection(sel));
45
+ return true;
46
+ };
47
+ }
48
+ var splitListItem = (state, dispatch) => {
49
+ const { $from, $to } = state.selection;
50
+ const parent = $from.parent;
51
+ if (!parent.isTextblock || !parent.attrs["list"]) return false;
52
+ if ($from.parent !== $to.parent) return false;
53
+ if (parent.content.size === 0) {
54
+ dispatch?.(
55
+ state.tr.setNodeMarkup($from.before(), null, { ...parent.attrs, list: null })
56
+ );
57
+ return true;
58
+ }
59
+ const tr = state.tr.deleteSelection();
60
+ const pos = tr.selection.from;
61
+ if (!(0, import_prosemirror_transform.canSplit)(tr.doc, pos)) return false;
62
+ tr.split(pos, 1, [{ type: parent.type, attrs: parent.attrs }]);
63
+ dispatch?.(tr);
64
+ return true;
65
+ };
66
+ var WORD_CHAR = /[\p{L}\p{N}_]/u;
67
+ function wordRangeAt(doc, pos) {
68
+ if (pos < 0 || pos > doc.content.size) return null;
69
+ const $pos = doc.resolve(pos);
70
+ const parent = $pos.parent;
71
+ if (!parent.isTextblock) return null;
72
+ const text = parent.textBetween(0, parent.content.size, void 0, "\uFFFC");
73
+ const offset = pos - $pos.start();
74
+ let from = offset;
75
+ let to = offset;
76
+ while (from > 0 && WORD_CHAR.test(text[from - 1])) from--;
77
+ while (to < text.length && WORD_CHAR.test(text[to])) to++;
78
+ if (from === to) return null;
79
+ return { from: $pos.start() + from, to: $pos.start() + to };
80
+ }
81
+ function createEditingState(doc, keys = {}) {
82
+ return import_prosemirror_state.EditorState.create({
83
+ doc,
84
+ plugins: [
85
+ (0, import_prosemirror_history.history)(),
86
+ (0, import_prosemirror_keymap.keymap)({ "Mod-z": import_prosemirror_history.undo, "Shift-Mod-z": import_prosemirror_history.redo, "Mod-y": import_prosemirror_history.redo }),
87
+ (0, import_prosemirror_keymap.keymap)(keys),
88
+ (0, import_prosemirror_keymap.keymap)(import_prosemirror_commands.baseKeymap)
89
+ ]
90
+ });
91
+ }
92
+ var InputBridge = class {
93
+ /** Clip layer appended near the canvas: it fills the positioned ancestor
94
+ * and `overflow: hidden`s the 800px-wide host riding the caret inside —
95
+ * otherwise that tail pokes past the right page edge and gifts the
96
+ * scroll viewport a horizontal scrollbar. */
97
+ dom;
98
+ /** The hidden editor's host — the element `place()` actually moves. */
99
+ host;
100
+ view;
101
+ constructor(options) {
102
+ this.dom = document.createElement("div");
103
+ this.dom.className = "bapbong-input-bridge";
104
+ Object.assign(this.dom.style, {
105
+ position: "absolute",
106
+ inset: "0",
107
+ overflow: "hidden",
108
+ pointerEvents: "none",
109
+ zIndex: "-1"
110
+ });
111
+ this.host = document.createElement("div");
112
+ Object.assign(this.host.style, {
113
+ position: "absolute",
114
+ top: "0",
115
+ left: "0",
116
+ // MUST be wide: a 1px host makes the hidden document wrap one character
117
+ // per line (tens of thousands of line boxes), and every DOM-selection
118
+ // placement then re-runs that layout — ~500ms PER CLICK on multi-page
119
+ // documents. At a page-ish width the same layout is ~10ms and stays
120
+ // warm. The host is invisible either way (opacity 0, clipped height).
121
+ width: "800px",
122
+ height: "1em",
123
+ overflow: "hidden",
124
+ opacity: "0",
125
+ // Keep it focusable but out of the way of canvas pointer events.
126
+ pointerEvents: "none"
127
+ });
128
+ this.dom.appendChild(this.host);
129
+ this.view = new import_prosemirror_view.EditorView(this.host, {
130
+ state: createEditingState(options.doc, options.keys),
131
+ dispatchTransaction: (tr) => {
132
+ const state = this.view.state.apply(tr);
133
+ this.view.updateState(state);
134
+ options.onUpdate(state, tr);
135
+ }
136
+ });
137
+ }
138
+ get state() {
139
+ return this.view.state;
140
+ }
141
+ /** Dispatch a transaction (e.g. a comment authoring command) through the
142
+ * editor, so it routes through onUpdate + history like any edit. */
143
+ dispatch(tr) {
144
+ this.view.dispatch(tr);
145
+ }
146
+ focus() {
147
+ this.view.focus();
148
+ }
149
+ /** Set a text selection (collapsed caret or anchor→head range). Positions
150
+ * are clamped to the nearest valid text slots. */
151
+ setSelection(anchor, head = anchor) {
152
+ const { doc } = this.view.state;
153
+ const clamp = (p) => Math.max(0, Math.min(p, doc.content.size));
154
+ const sel = import_prosemirror_state.TextSelection.between(doc.resolve(clamp(anchor)), doc.resolve(clamp(head)));
155
+ this.view.dispatch(this.view.state.tr.setSelection(sel));
156
+ }
157
+ /** Select the word around `pos` (double-click); falls back to a collapsed
158
+ * caret when there is no word at that position. */
159
+ selectWordAt(pos) {
160
+ const range = wordRangeAt(this.view.state.doc, pos);
161
+ if (range) this.setSelection(range.from, range.to);
162
+ else this.setSelection(pos);
163
+ }
164
+ /** Move the hidden editor to the painted caret (CSS px, relative to the
165
+ * positioned ancestor) so IME popups anchor in the right place. */
166
+ place(x, y, height) {
167
+ this.host.style.transform = `translate(${x}px, ${y}px)`;
168
+ this.host.style.height = `${height}px`;
169
+ }
170
+ destroy() {
171
+ this.view.destroy();
172
+ this.dom.remove();
173
+ }
174
+ };
175
+ var mentionKey = new import_prosemirror_state.PluginKey("mention");
176
+ var INACTIVE = { active: false, from: 0, query: "" };
177
+ var MENTION_RE = /(?:^|\s)@([\p{L}\p{N}_]*)$/u;
178
+ function placeholderPlugin(text) {
179
+ return new import_prosemirror_state.Plugin({
180
+ props: {
181
+ decorations(state) {
182
+ const doc = state.doc;
183
+ const first = doc.firstChild;
184
+ if (doc.childCount === 1 && first?.isTextblock && first.content.size === 0) {
185
+ return import_prosemirror_view.DecorationSet.create(doc, [
186
+ import_prosemirror_view.Decoration.node(0, first.nodeSize, { class: "is-empty", "data-placeholder": text })
187
+ ]);
188
+ }
189
+ return null;
190
+ }
191
+ }
192
+ });
193
+ }
194
+ function mentionPlugin(handlers) {
195
+ return new import_prosemirror_state.Plugin({
196
+ key: mentionKey,
197
+ state: {
198
+ init: () => INACTIVE,
199
+ apply(tr) {
200
+ const sel = tr.selection;
201
+ if (!sel.empty) return INACTIVE;
202
+ const $from = sel.$from;
203
+ const textBefore = $from.parent.textBetween(0, $from.parentOffset, "\n", "\uFFFC");
204
+ const m = MENTION_RE.exec(textBefore);
205
+ if (!m) return INACTIVE;
206
+ const query = m[1];
207
+ return { active: true, from: $from.pos - query.length - 1, query };
208
+ }
209
+ },
210
+ view: (editorView) => {
211
+ const emit = (view) => {
212
+ const st = mentionKey.getState(view.state);
213
+ if (!st?.active) return handlers.query(null);
214
+ const c = view.coordsAtPos(view.state.selection.from);
215
+ handlers.query({ query: st.query, coords: { left: c.left, top: c.top, bottom: c.bottom } });
216
+ };
217
+ emit(editorView);
218
+ return { update: emit, destroy: () => handlers.query(null) };
219
+ },
220
+ props: {
221
+ handleKeyDown(view, event) {
222
+ if (!mentionKey.getState(view.state)?.active) return false;
223
+ const map = {
224
+ ArrowUp: "up",
225
+ ArrowDown: "down",
226
+ Enter: "enter",
227
+ Tab: "enter",
228
+ Escape: "esc"
229
+ };
230
+ const k = map[event.key];
231
+ if (k && handlers.key(k)) {
232
+ event.preventDefault();
233
+ return true;
234
+ }
235
+ return false;
236
+ }
237
+ }
238
+ });
239
+ }
240
+ var CommentComposer = class {
241
+ constructor(schema, mount, initialDoc, mention, opts) {
242
+ this.schema = schema;
243
+ const doc = initialDoc ? schema.nodeFromJSON(initialDoc) : void 0;
244
+ const plugins = [(0, import_prosemirror_history.history)()];
245
+ if (opts?.placeholder) plugins.push(placeholderPlugin(opts.placeholder));
246
+ if (mention && schema.nodes["mention"]) plugins.push(mentionPlugin(mention));
247
+ const keys = {};
248
+ if (opts?.onEnter) {
249
+ keys["Enter"] = () => (opts.onEnter?.(), true);
250
+ keys["Shift-Enter"] = import_prosemirror_commands.baseKeymap["Enter"];
251
+ }
252
+ if (opts?.onEscape) keys["Escape"] = () => (opts.onEscape?.(), true);
253
+ if (Object.keys(keys).length) plugins.push((0, import_prosemirror_keymap.keymap)(keys));
254
+ plugins.push((0, import_prosemirror_keymap.keymap)({ "Mod-z": import_prosemirror_history.undo, "Shift-Mod-z": import_prosemirror_history.redo }), (0, import_prosemirror_keymap.keymap)(import_prosemirror_commands.baseKeymap));
255
+ this.view = new import_prosemirror_view.EditorView(mount, {
256
+ state: import_prosemirror_state.EditorState.create({ ...doc ? { doc } : { schema }, plugins })
257
+ });
258
+ }
259
+ schema;
260
+ view;
261
+ /** Replace the active "@query" with a mention node + trailing space. */
262
+ applyMention(user) {
263
+ const st = mentionKey.getState(this.view.state);
264
+ if (!st?.active) return;
265
+ const to = this.view.state.selection.from;
266
+ const node = this.schema.nodes["mention"].create({ id: user.id, label: user.label });
267
+ const tr = this.view.state.tr.replaceWith(st.from, to, [node, this.schema.text(" ")]);
268
+ tr.setSelection(import_prosemirror_state.TextSelection.create(tr.doc, st.from + 2));
269
+ this.view.dispatch(tr);
270
+ this.view.focus();
271
+ }
272
+ /** Whether the composer has no visible text. */
273
+ isEmpty() {
274
+ return this.view.state.doc.textContent.trim().length === 0;
275
+ }
276
+ /** The composed body as commentSchema doc JSON. */
277
+ getJSON() {
278
+ return this.view.state.doc.toJSON();
279
+ }
280
+ /** Reset to an empty document. */
281
+ clear() {
282
+ const empty = import_prosemirror_state.EditorState.create({ schema: this.schema, plugins: this.view.state.plugins });
283
+ this.view.updateState(empty);
284
+ }
285
+ focus() {
286
+ this.view.focus();
287
+ }
288
+ destroy() {
289
+ this.view.destroy();
290
+ }
291
+ };
292
+ // Annotate the CommonJS export names for ESM import in node:
293
+ 0 && (module.exports = {
294
+ CommentComposer,
295
+ InputBridge,
296
+ createEditingState,
297
+ moveCaretCommand,
298
+ splitListItem,
299
+ wordRangeAt
300
+ });
@@ -0,0 +1,2 @@
1
+ export * from './lib/input-bridge.js';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,uBAAuB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,268 @@
1
+ // packages/input-bridge/src/lib/input-bridge.ts
2
+ import { baseKeymap } from "prosemirror-commands";
3
+ import { history, redo, undo } from "prosemirror-history";
4
+ import { keymap } from "prosemirror-keymap";
5
+ import { EditorState, Plugin, PluginKey, TextSelection } from "prosemirror-state";
6
+ import { canSplit } from "prosemirror-transform";
7
+ import { Decoration, DecorationSet, EditorView } from "prosemirror-view";
8
+ function moveCaretCommand(compute, extend = false) {
9
+ return (state, dispatch) => {
10
+ const pos = compute(state);
11
+ if (pos == null) return false;
12
+ const sel = extend ? TextSelection.create(state.doc, state.selection.anchor, pos) : TextSelection.create(state.doc, pos);
13
+ dispatch?.(state.tr.setSelection(sel));
14
+ return true;
15
+ };
16
+ }
17
+ var splitListItem = (state, dispatch) => {
18
+ const { $from, $to } = state.selection;
19
+ const parent = $from.parent;
20
+ if (!parent.isTextblock || !parent.attrs["list"]) return false;
21
+ if ($from.parent !== $to.parent) return false;
22
+ if (parent.content.size === 0) {
23
+ dispatch?.(
24
+ state.tr.setNodeMarkup($from.before(), null, { ...parent.attrs, list: null })
25
+ );
26
+ return true;
27
+ }
28
+ const tr = state.tr.deleteSelection();
29
+ const pos = tr.selection.from;
30
+ if (!canSplit(tr.doc, pos)) return false;
31
+ tr.split(pos, 1, [{ type: parent.type, attrs: parent.attrs }]);
32
+ dispatch?.(tr);
33
+ return true;
34
+ };
35
+ var WORD_CHAR = /[\p{L}\p{N}_]/u;
36
+ function wordRangeAt(doc, pos) {
37
+ if (pos < 0 || pos > doc.content.size) return null;
38
+ const $pos = doc.resolve(pos);
39
+ const parent = $pos.parent;
40
+ if (!parent.isTextblock) return null;
41
+ const text = parent.textBetween(0, parent.content.size, void 0, "\uFFFC");
42
+ const offset = pos - $pos.start();
43
+ let from = offset;
44
+ let to = offset;
45
+ while (from > 0 && WORD_CHAR.test(text[from - 1])) from--;
46
+ while (to < text.length && WORD_CHAR.test(text[to])) to++;
47
+ if (from === to) return null;
48
+ return { from: $pos.start() + from, to: $pos.start() + to };
49
+ }
50
+ function createEditingState(doc, keys = {}) {
51
+ return EditorState.create({
52
+ doc,
53
+ plugins: [
54
+ history(),
55
+ keymap({ "Mod-z": undo, "Shift-Mod-z": redo, "Mod-y": redo }),
56
+ keymap(keys),
57
+ keymap(baseKeymap)
58
+ ]
59
+ });
60
+ }
61
+ var InputBridge = class {
62
+ /** Clip layer appended near the canvas: it fills the positioned ancestor
63
+ * and `overflow: hidden`s the 800px-wide host riding the caret inside —
64
+ * otherwise that tail pokes past the right page edge and gifts the
65
+ * scroll viewport a horizontal scrollbar. */
66
+ dom;
67
+ /** The hidden editor's host — the element `place()` actually moves. */
68
+ host;
69
+ view;
70
+ constructor(options) {
71
+ this.dom = document.createElement("div");
72
+ this.dom.className = "bapbong-input-bridge";
73
+ Object.assign(this.dom.style, {
74
+ position: "absolute",
75
+ inset: "0",
76
+ overflow: "hidden",
77
+ pointerEvents: "none",
78
+ zIndex: "-1"
79
+ });
80
+ this.host = document.createElement("div");
81
+ Object.assign(this.host.style, {
82
+ position: "absolute",
83
+ top: "0",
84
+ left: "0",
85
+ // MUST be wide: a 1px host makes the hidden document wrap one character
86
+ // per line (tens of thousands of line boxes), and every DOM-selection
87
+ // placement then re-runs that layout — ~500ms PER CLICK on multi-page
88
+ // documents. At a page-ish width the same layout is ~10ms and stays
89
+ // warm. The host is invisible either way (opacity 0, clipped height).
90
+ width: "800px",
91
+ height: "1em",
92
+ overflow: "hidden",
93
+ opacity: "0",
94
+ // Keep it focusable but out of the way of canvas pointer events.
95
+ pointerEvents: "none"
96
+ });
97
+ this.dom.appendChild(this.host);
98
+ this.view = new EditorView(this.host, {
99
+ state: createEditingState(options.doc, options.keys),
100
+ dispatchTransaction: (tr) => {
101
+ const state = this.view.state.apply(tr);
102
+ this.view.updateState(state);
103
+ options.onUpdate(state, tr);
104
+ }
105
+ });
106
+ }
107
+ get state() {
108
+ return this.view.state;
109
+ }
110
+ /** Dispatch a transaction (e.g. a comment authoring command) through the
111
+ * editor, so it routes through onUpdate + history like any edit. */
112
+ dispatch(tr) {
113
+ this.view.dispatch(tr);
114
+ }
115
+ focus() {
116
+ this.view.focus();
117
+ }
118
+ /** Set a text selection (collapsed caret or anchor→head range). Positions
119
+ * are clamped to the nearest valid text slots. */
120
+ setSelection(anchor, head = anchor) {
121
+ const { doc } = this.view.state;
122
+ const clamp = (p) => Math.max(0, Math.min(p, doc.content.size));
123
+ const sel = TextSelection.between(doc.resolve(clamp(anchor)), doc.resolve(clamp(head)));
124
+ this.view.dispatch(this.view.state.tr.setSelection(sel));
125
+ }
126
+ /** Select the word around `pos` (double-click); falls back to a collapsed
127
+ * caret when there is no word at that position. */
128
+ selectWordAt(pos) {
129
+ const range = wordRangeAt(this.view.state.doc, pos);
130
+ if (range) this.setSelection(range.from, range.to);
131
+ else this.setSelection(pos);
132
+ }
133
+ /** Move the hidden editor to the painted caret (CSS px, relative to the
134
+ * positioned ancestor) so IME popups anchor in the right place. */
135
+ place(x, y, height) {
136
+ this.host.style.transform = `translate(${x}px, ${y}px)`;
137
+ this.host.style.height = `${height}px`;
138
+ }
139
+ destroy() {
140
+ this.view.destroy();
141
+ this.dom.remove();
142
+ }
143
+ };
144
+ var mentionKey = new PluginKey("mention");
145
+ var INACTIVE = { active: false, from: 0, query: "" };
146
+ var MENTION_RE = /(?:^|\s)@([\p{L}\p{N}_]*)$/u;
147
+ function placeholderPlugin(text) {
148
+ return new Plugin({
149
+ props: {
150
+ decorations(state) {
151
+ const doc = state.doc;
152
+ const first = doc.firstChild;
153
+ if (doc.childCount === 1 && first?.isTextblock && first.content.size === 0) {
154
+ return DecorationSet.create(doc, [
155
+ Decoration.node(0, first.nodeSize, { class: "is-empty", "data-placeholder": text })
156
+ ]);
157
+ }
158
+ return null;
159
+ }
160
+ }
161
+ });
162
+ }
163
+ function mentionPlugin(handlers) {
164
+ return new Plugin({
165
+ key: mentionKey,
166
+ state: {
167
+ init: () => INACTIVE,
168
+ apply(tr) {
169
+ const sel = tr.selection;
170
+ if (!sel.empty) return INACTIVE;
171
+ const $from = sel.$from;
172
+ const textBefore = $from.parent.textBetween(0, $from.parentOffset, "\n", "\uFFFC");
173
+ const m = MENTION_RE.exec(textBefore);
174
+ if (!m) return INACTIVE;
175
+ const query = m[1];
176
+ return { active: true, from: $from.pos - query.length - 1, query };
177
+ }
178
+ },
179
+ view: (editorView) => {
180
+ const emit = (view) => {
181
+ const st = mentionKey.getState(view.state);
182
+ if (!st?.active) return handlers.query(null);
183
+ const c = view.coordsAtPos(view.state.selection.from);
184
+ handlers.query({ query: st.query, coords: { left: c.left, top: c.top, bottom: c.bottom } });
185
+ };
186
+ emit(editorView);
187
+ return { update: emit, destroy: () => handlers.query(null) };
188
+ },
189
+ props: {
190
+ handleKeyDown(view, event) {
191
+ if (!mentionKey.getState(view.state)?.active) return false;
192
+ const map = {
193
+ ArrowUp: "up",
194
+ ArrowDown: "down",
195
+ Enter: "enter",
196
+ Tab: "enter",
197
+ Escape: "esc"
198
+ };
199
+ const k = map[event.key];
200
+ if (k && handlers.key(k)) {
201
+ event.preventDefault();
202
+ return true;
203
+ }
204
+ return false;
205
+ }
206
+ }
207
+ });
208
+ }
209
+ var CommentComposer = class {
210
+ constructor(schema, mount, initialDoc, mention, opts) {
211
+ this.schema = schema;
212
+ const doc = initialDoc ? schema.nodeFromJSON(initialDoc) : void 0;
213
+ const plugins = [history()];
214
+ if (opts?.placeholder) plugins.push(placeholderPlugin(opts.placeholder));
215
+ if (mention && schema.nodes["mention"]) plugins.push(mentionPlugin(mention));
216
+ const keys = {};
217
+ if (opts?.onEnter) {
218
+ keys["Enter"] = () => (opts.onEnter?.(), true);
219
+ keys["Shift-Enter"] = baseKeymap["Enter"];
220
+ }
221
+ if (opts?.onEscape) keys["Escape"] = () => (opts.onEscape?.(), true);
222
+ if (Object.keys(keys).length) plugins.push(keymap(keys));
223
+ plugins.push(keymap({ "Mod-z": undo, "Shift-Mod-z": redo }), keymap(baseKeymap));
224
+ this.view = new EditorView(mount, {
225
+ state: EditorState.create({ ...doc ? { doc } : { schema }, plugins })
226
+ });
227
+ }
228
+ schema;
229
+ view;
230
+ /** Replace the active "@query" with a mention node + trailing space. */
231
+ applyMention(user) {
232
+ const st = mentionKey.getState(this.view.state);
233
+ if (!st?.active) return;
234
+ const to = this.view.state.selection.from;
235
+ const node = this.schema.nodes["mention"].create({ id: user.id, label: user.label });
236
+ const tr = this.view.state.tr.replaceWith(st.from, to, [node, this.schema.text(" ")]);
237
+ tr.setSelection(TextSelection.create(tr.doc, st.from + 2));
238
+ this.view.dispatch(tr);
239
+ this.view.focus();
240
+ }
241
+ /** Whether the composer has no visible text. */
242
+ isEmpty() {
243
+ return this.view.state.doc.textContent.trim().length === 0;
244
+ }
245
+ /** The composed body as commentSchema doc JSON. */
246
+ getJSON() {
247
+ return this.view.state.doc.toJSON();
248
+ }
249
+ /** Reset to an empty document. */
250
+ clear() {
251
+ const empty = EditorState.create({ schema: this.schema, plugins: this.view.state.plugins });
252
+ this.view.updateState(empty);
253
+ }
254
+ focus() {
255
+ this.view.focus();
256
+ }
257
+ destroy() {
258
+ this.view.destroy();
259
+ }
260
+ };
261
+ export {
262
+ CommentComposer,
263
+ InputBridge,
264
+ createEditingState,
265
+ moveCaretCommand,
266
+ splitListItem,
267
+ wordRangeAt
268
+ };
@@ -0,0 +1,120 @@
1
+ import type { Node as PMNode, Schema } from 'prosemirror-model';
2
+ import { EditorState, type Command, type Transaction } from 'prosemirror-state';
3
+ import { EditorView } from 'prosemirror-view';
4
+ export type { Command, EditorState, Transaction } from 'prosemirror-state';
5
+ /** A command that moves the caret to the position computed by `compute`
6
+ * (e.g. layout-aware ArrowUp/ArrowDown from bapbong-selection). With `extend`,
7
+ * the anchor stays put and only the head moves (Shift+arrow selection).
8
+ * Returns false — leaving the key to the next handler — when `compute`
9
+ * yields null. */
10
+ export declare function moveCaretCommand(compute: (state: EditorState) => number | null, extend?: boolean): Command;
11
+ /** Enter inside a list item: split the paragraph KEEPING its list attrs (the
12
+ * layout engine recounts markers, so the new item numbers itself and
13
+ * everything below renumbers). Enter on an EMPTY item exits the list, like
14
+ * Word. Returns false outside lists so the base keymap takes over. */
15
+ export declare const splitListItem: Command;
16
+ /** The word range around `pos` (for double-click selection), or null when the
17
+ * position isn't inside a textblock or doesn't touch a word character.
18
+ * Unicode-aware: Vietnamese diacritics count as word characters. */
19
+ export declare function wordRangeAt(doc: PMNode, pos: number): {
20
+ from: number;
21
+ to: number;
22
+ } | null;
23
+ export interface InputBridgeOptions {
24
+ /** The initial document (its schema drives the editor). */
25
+ doc: PMNode;
26
+ /** Extra bindings, checked before the base keymap — e.g. ArrowUp/ArrowDown
27
+ * wired to layout-aware caret motion from bapbong-selection. */
28
+ keys?: Record<string, Command>;
29
+ /** Called after every dispatched transaction (typing, IME composition
30
+ * steps, undo, selection changes). Re-layout + repaint here. */
31
+ onUpdate: (state: EditorState, tr: Transaction) => void;
32
+ }
33
+ /** Editing state with history + base keymap; exported for headless tests. */
34
+ export declare function createEditingState(doc: PMNode, keys?: Record<string, Command>): EditorState;
35
+ /**
36
+ * Hidden ProseMirror editor acting as the canvas's input sink.
37
+ *
38
+ * The browser routes keyboard and IME composition into a real (but invisible)
39
+ * contenteditable; ProseMirror keeps the model, history and clipboard. The
40
+ * host positions `dom` at the canvas caret via `place()` so IME candidate
41
+ * popups appear next to the visible (painted) caret.
42
+ */
43
+ export declare class InputBridge {
44
+ /** Clip layer appended near the canvas: it fills the positioned ancestor
45
+ * and `overflow: hidden`s the 800px-wide host riding the caret inside —
46
+ * otherwise that tail pokes past the right page edge and gifts the
47
+ * scroll viewport a horizontal scrollbar. */
48
+ readonly dom: HTMLElement;
49
+ /** The hidden editor's host — the element `place()` actually moves. */
50
+ private readonly host;
51
+ readonly view: EditorView;
52
+ constructor(options: InputBridgeOptions);
53
+ get state(): EditorState;
54
+ /** Dispatch a transaction (e.g. a comment authoring command) through the
55
+ * editor, so it routes through onUpdate + history like any edit. */
56
+ dispatch(tr: Transaction): void;
57
+ focus(): void;
58
+ /** Set a text selection (collapsed caret or anchor→head range). Positions
59
+ * are clamped to the nearest valid text slots. */
60
+ setSelection(anchor: number, head?: number): void;
61
+ /** Select the word around `pos` (double-click); falls back to a collapsed
62
+ * caret when there is no word at that position. */
63
+ selectWordAt(pos: number): void;
64
+ /** Move the hidden editor to the painted caret (CSS px, relative to the
65
+ * positioned ancestor) so IME popups anchor in the right place. */
66
+ place(x: number, y: number, height: number): void;
67
+ destroy(): void;
68
+ }
69
+ /** A user the composer can @mention. */
70
+ export interface MentionUser {
71
+ id: string;
72
+ label: string;
73
+ }
74
+ /** Where the @query popup should sit (client coords of the caret). */
75
+ export interface MentionCoords {
76
+ left: number;
77
+ top: number;
78
+ bottom: number;
79
+ }
80
+ /** Host hooks for the @mention popup. The composer owns detection + insertion;
81
+ * the host owns rendering the popup and choosing the user. */
82
+ export interface MentionHandlers {
83
+ /** Popup should show/move (state) or hide (null). */
84
+ query(state: {
85
+ query: string;
86
+ coords: MentionCoords;
87
+ } | null): void;
88
+ /** A nav key fired while the popup is open; return true if the host consumed
89
+ * it (then the composer swallows it instead of editing text). */
90
+ key(key: 'up' | 'down' | 'enter' | 'esc'): boolean;
91
+ }
92
+ /**
93
+ * A small standalone ProseMirror editor for composing a comment body. The host
94
+ * passes the body schema (bapbong-model's commentSchema) so input-bridge stays
95
+ * schema-agnostic; `getJSON()` returns the composed doc to store on the thread.
96
+ * Pass `mention` handlers to enable @-mentions (the host renders the popup).
97
+ * `opts.onEnter` makes Enter submit (Shift-Enter newlines) — used by the inline
98
+ * reply box; `opts.onEscape` cancels. The mention plugin is ordered first so it
99
+ * gets Enter/Esc while its popup is open, before these keymaps.
100
+ */
101
+ export declare class CommentComposer {
102
+ private readonly schema;
103
+ readonly view: EditorView;
104
+ constructor(schema: Schema, mount: HTMLElement, initialDoc?: unknown, mention?: MentionHandlers, opts?: {
105
+ onEnter?: () => void;
106
+ onEscape?: () => void;
107
+ placeholder?: string;
108
+ });
109
+ /** Replace the active "@query" with a mention node + trailing space. */
110
+ applyMention(user: MentionUser): void;
111
+ /** Whether the composer has no visible text. */
112
+ isEmpty(): boolean;
113
+ /** The composed body as commentSchema doc JSON. */
114
+ getJSON(): unknown;
115
+ /** Reset to an empty document. */
116
+ clear(): void;
117
+ focus(): void;
118
+ destroy(): void;
119
+ }
120
+ //# sourceMappingURL=input-bridge.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"input-bridge.d.ts","sourceRoot":"","sources":["../../src/lib/input-bridge.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@shadow-garden/bapbong-input-bridge",
3
+ "version": "0.1.0",
4
+ "description": "bapbong — hidden ProseMirror editor as the input/IME sink for the canvas",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/shadowgarden-app/bapbong.git",
9
+ "directory": "packages/input-bridge"
10
+ },
11
+ "type": "module",
12
+ "main": "./dist/index.cjs",
13
+ "module": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ "./package.json": "./package.json",
17
+ ".": {
18
+ "@shadow-garden/source": "./src/index.ts",
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js",
21
+ "require": "./dist/index.cjs",
22
+ "default": "./dist/index.js"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "!**/*.tsbuildinfo"
28
+ ],
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "nx": {
33
+ "tags": [
34
+ "scope:input"
35
+ ],
36
+ "targets": {
37
+ "build": {
38
+ "executor": "@nx/esbuild:esbuild",
39
+ "outputs": [
40
+ "{options.outputPath}"
41
+ ],
42
+ "options": {
43
+ "outputPath": "packages/input-bridge/dist",
44
+ "main": "packages/input-bridge/src/index.ts",
45
+ "tsConfig": "packages/input-bridge/tsconfig.lib.json",
46
+ "format": [
47
+ "esm",
48
+ "cjs"
49
+ ],
50
+ "declarationRootDir": "packages/input-bridge/src"
51
+ }
52
+ }
53
+ }
54
+ },
55
+ "dependencies": {
56
+ "prosemirror-commands": "^1.7.1",
57
+ "prosemirror-history": "^1.4.1",
58
+ "prosemirror-keymap": "^1.2.3",
59
+ "prosemirror-model": "^1.25.7",
60
+ "prosemirror-state": "^1.4.4",
61
+ "prosemirror-transform": "^1.10.0",
62
+ "prosemirror-view": "^1.41.3"
63
+ }
64
+ }