@blintz/comments 0.0.0-stage → 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 Dork Labs
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 CHANGED
@@ -1,3 +1,103 @@
1
- # Temporary Holding Version
1
+ # @blintz/comments
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Google-Docs-style comments for the [Blintz](https://github.com/dork-labs/blintz) editor: threads anchored to text, kept out of the markdown.
4
+
5
+ ```bash
6
+ npm install @blintz/comments blintz
7
+ ```
8
+
9
+ You own the threads: where they're stored, who wrote them, the replies and the sidebar. This package owns where they sit in the text. It highlights them, keeps them attached as the document is edited, finds them again when the document is reopened, and tells you when a thread's text is gone.
10
+
11
+ ```tsx
12
+ import { MarkdownEditor } from "blintz";
13
+ import { useBlintzComments } from "@blintz/comments";
14
+ import "@blintz/comments/styles.css";
15
+
16
+ function Doc({ md, setMd, threads, store }) {
17
+ const comments = useBlintzComments({
18
+ threads, // [{ id, anchor, resolved? }] from your store
19
+ activeThreadId: openThread,
20
+ onThreadClick: (ids) => openSidebarAt(ids[0]),
21
+ onAnchorsChange: (updates) => store.saveAnchors(updates),
22
+ onOrphanedChange: (ids) => setOrphaned(ids),
23
+ });
24
+
25
+ const startThread = async () => {
26
+ const anchor = comments.anchorSelection();
27
+ if (anchor) await store.create({ anchor, body: draft });
28
+ };
29
+
30
+ return <MarkdownEditor value={md} onChange={setMd} plugins={[comments.plugin]} />;
31
+ }
32
+ ```
33
+
34
+ ## How anchoring works
35
+
36
+ A thread's `anchor` is opaque to your store: save it as JSON and hand it back. It is a [W3C text-quote selector](https://www.w3.org/TR/annotation-model/#text-quote-selector): the quoted words, a little text before and after them, and the position they were at.
37
+
38
+ - **While the document is open,** a thread follows its words through every edit. Typing at either edge doesn't extend it.
39
+ - **When the document opens,** each thread finds its words again. It prefers the occurrence with the most matching context, then the one nearest the old position. Edits elsewhere don't matter. `rangeOf(id).match` says how sure that was. `ambiguous` means another occurrence fit as well, or little of the context survived; that's worth flagging in your UI.
40
+ - **`onAnchorsChange`** sends fresh anchors shortly after editing pauses. Save them, so tomorrow's document finds today's text.
41
+ - **A thread whose words were deleted or can't be found is orphaned.** `onOrphanedChange` lists those threads. Keep them and show them apart; a lost comment is worse than a homeless one. A deleted thread never jumps to the same words elsewhere. It comes back if its words do, with their context (undo, say). A thread that wasn't found keeps being looked for as the document changes, for example once a newly opened document arrives. Handing over a different anchor tries again too.
42
+ - **Threads follow collaborators' edits** (with `blintz/collab`). A collaborator's change reaches the editor as one replacement, and threads are mapped through a word-by-word diff of it, so an agent rewriting text around a comment leaves the comment in place. A rewrite too large to diff (more than about 500 changed words in one write) finds each affected thread again by its words with all of their context, and a thread it orphaned comes back the same way if the rewrite is reverted. One limit: identical copies of a commented passage, with identical text around them, can't be told apart. If a collaborator deletes the other copy, the thread may be orphaned even though its words remain; after a rewrite too large to diff, a thread whose copy was deleted may move to the surviving one.
43
+
44
+ Comments never change the document: no marks, no markdown, no history entries. The editor's source mapping (`onSourceReady`) keeps working, and `port.range(...comments.rangeOf(id))` gives a thread's exact span in the raw markdown, so an agent can quote or address it.
45
+
46
+ ## API
47
+
48
+ `useBlintzComments(options)` returns:
49
+
50
+ | Member | What it does |
51
+ | --- | --- |
52
+ | `plugin` | Pass in `<MarkdownEditor plugins={[comments.plugin]}>`. Stable across renders. |
53
+ | `anchorSelection()` | Returns an anchor for the current selection, to create a thread. Returns `null` if the selection covers no text. |
54
+ | `rangeOf(id)` | Returns a thread's current `{ from, to, match }` in the editor, or `null` if it's orphaned. |
55
+ | `threadsAtSelection()` | Lists the threads at the caret, innermost first. |
56
+ | `reveal(id)` | Selects a thread's text and scrolls to it. |
57
+
58
+ Options:
59
+
60
+ | Option | Meaning |
61
+ | --- | --- |
62
+ | `threads` | `{ id, anchor, resolved? }[]`. |
63
+ | `activeThreadId` | The thread to emphasize. |
64
+ | `showResolved` | Show resolved threads faintly. Defaults to `false`. |
65
+ | `onThreadClick(ids)` | Called when someone clicks highlighted text, with every thread under the click, innermost first. |
66
+ | `onAnchorsChange(updates)` | Called with fresh anchors to save. |
67
+ | `onOrphanedChange(ids)` | Called when the set of orphaned threads changes. |
68
+
69
+ One hook serves one editor; give each editor its own `useBlintzComments`.
70
+
71
+ Highlights are styled by `.blintz-comment`, `.is-active` and `.is-resolved` in `styles.css`.
72
+
73
+ ## Without an editor
74
+
75
+ `@blintz/comments/core` finds threads and makes anchors on plain markdown, with no editor, no DOM and no React, so a server or an agent can work on commented documents. It uses the same document as the editor (Blintz's headless model from `blintz/collab`), the same matching, and the same mapping to raw markdown as the editor's source port, so a thread lands in the same place on the server and in the editor.
76
+
77
+ ```ts
78
+ import { createMarkdownModel } from "blintz/collab";
79
+ import { createAnchor, resolveAnchor } from "@blintz/comments/core";
80
+
81
+ const model = await createMarkdownModel(); // once per process or worker
82
+
83
+ // An agent comments on raw markdown: the anchor is what the editor would make.
84
+ const start = markdown.indexOf("Friday");
85
+ const anchor = createAnchor(model, markdown, start, start + "Friday".length);
86
+
87
+ // Later, on whatever the document has become:
88
+ const found = resolveAnchor(model, latest, anchor);
89
+ // null: orphaned. Otherwise { match, from, to, source }, where
90
+ // source = { start, end, ranges } in `latest`, or null when part of the text
91
+ // has no exact source (a wiki link, an escape, an entity).
92
+ ```
93
+
94
+ - **`resolveAnchor(model, markdown, anchor)`** finds a thread as the editor does on opening the document. `from`/`to` are positions in `model.document(markdown)`, equal to the editor's `rangeOf(id)`. `source.ranges` equals the editor's `port.range(from, to)`: one raw range per text run, with markup between runs (`**`) left out. `source.start`/`source.end` span them all.
95
+ - **`createAnchor(model, markdown, start, end)`** anchors the text inside a raw range, as the editor would from that selection. Ends inside markup snap inward to the text (`**bold**` anchors `bold`), and atoms the range covers whole (wiki links, images, math) are included. An anchor needs at least one character of text, as in the editor, so an image or a formula on its own can't be anchored.
96
+ - **Many threads, one parse:** pass `model.source(markdown)` in place of the markdown.
97
+
98
+ Some text has no character-for-character source in the markdown: a text run containing an escape (`snake\_case`) or an entity (`&amp;`), a paragraph wrapped over several lines (both often a whole paragraph), and inline code, code blocks and frontmatter. Such a run is addressed only whole, delimiters included (a range ending in a code span's backticks doesn't snap inward the way it does at `**`):
99
+
100
+ - `createAnchor` includes it when the range covers it whole, and returns `null` when the range starts or ends inside it, rather than anchoring fewer words than asked.
101
+ - `resolveAnchor` still finds a thread on it, but with `source: null`, exactly as the editor's `port.range` refuses it. So does a thread spanning more than 100 text runs.
102
+
103
+ `createMarkdownModel` comes from `blintz/collab`, so a server needs `yjs`, `y-prosemirror` and `y-protocols` installed (the optional peers of `blintz`) even without collaboration. React isn't needed for `core`; it's an optional peer, required only by the editor half.
@@ -0,0 +1,94 @@
1
+ const z = 32;
2
+ function h(i) {
3
+ let e = "";
4
+ const n = [];
5
+ let t = !0;
6
+ return i.descendants((r, s) => r.isTextblock ? (t || (e += `
7
+ `), t = !1, r.forEach((o, u) => {
8
+ const x = s + 1 + u;
9
+ o.isText ? (n.push({ textStart: e.length, length: o.text.length, pos: x, size: o.nodeSize }), e += o.text) : (n.push({ textStart: e.length, length: 1, pos: x, size: o.nodeSize }), e += "");
10
+ }), !1) : !0), { text: e, segments: n };
11
+ }
12
+ function f(i, e) {
13
+ let n = 0;
14
+ for (const t of i.segments) {
15
+ if (e < t.pos) break;
16
+ if (e <= t.pos + t.size)
17
+ return t.length === t.size ? t.textStart + (e - t.pos) : t.textStart + (e > t.pos ? t.length : 0);
18
+ n = t.textStart + t.length;
19
+ }
20
+ return n;
21
+ }
22
+ function g(i, e, n) {
23
+ for (const t of i.segments) {
24
+ const r = t.textStart + t.length;
25
+ if (n === "start" ? e >= t.textStart && e < r : e > t.textStart && e <= r)
26
+ return t.length === t.size ? t.pos + (e - t.textStart) : e === t.textStart ? t.pos : t.pos + t.size;
27
+ }
28
+ return null;
29
+ }
30
+ function C(i, e, n, t = h(i)) {
31
+ let r = f(t, e), s = f(t, n);
32
+ for (; r < s && t.text[r] === `
33
+ `; ) r++;
34
+ for (; s > r && t.text[s - 1] === `
35
+ `; ) s--;
36
+ const o = t.text.slice(r, s);
37
+ return o.replace(/[\n\s]/g, "").length === 0 ? null : {
38
+ v: 1,
39
+ quote: o,
40
+ prefix: t.text.slice(Math.max(0, r - 32), r),
41
+ suffix: t.text.slice(s, s + 32),
42
+ start: r,
43
+ end: s
44
+ };
45
+ }
46
+ function S(i, e) {
47
+ let n = 0;
48
+ for (; n < i.length && n < e.length && i[i.length - 1 - n] === e[e.length - 1 - n]; ) n++;
49
+ return n;
50
+ }
51
+ function m(i, e) {
52
+ let n = 0;
53
+ for (; n < i.length && n < e.length && i[n] === e[n]; ) n++;
54
+ return n;
55
+ }
56
+ function d(i, e) {
57
+ const { quote: n } = e;
58
+ if (!n) return null;
59
+ const { text: t } = i, r = (l, c) => !c || l.context > c.context || l.context === c.context && l.distance < c.distance;
60
+ let s, o;
61
+ for (let l = t.indexOf(n); l !== -1; l = t.indexOf(n, l + 1)) {
62
+ const c = t.slice(Math.max(0, l - e.prefix.length), l), T = t.slice(l + n.length, l + n.length + e.suffix.length), a = {
63
+ start: l,
64
+ context: S(c, e.prefix) + m(T, e.suffix),
65
+ distance: Math.abs(l - e.start)
66
+ };
67
+ r(a, s) ? (o = s, s = a) : r(a, o) && (o = a);
68
+ }
69
+ if (!s) return null;
70
+ const u = e.prefix.length + e.suffix.length, x = s.start === e.start && s.context === u, p = !x && (o !== void 0 && o.context === s.context || s.context * 4 < u);
71
+ return {
72
+ start: s.start,
73
+ end: s.start + n.length,
74
+ match: x ? "exact" : p ? "ambiguous" : "moved",
75
+ context: s.context,
76
+ fullContext: u
77
+ };
78
+ }
79
+ function E(i, e, n = h(i)) {
80
+ const t = d(n, e);
81
+ if (!t) return null;
82
+ const r = g(n, t.start, "start"), s = g(n, t.end, "end");
83
+ return r === null || s === null || r >= s ? null : { from: r, to: s, match: t.match, context: t.context, fullContext: t.fullContext };
84
+ }
85
+ export {
86
+ z as C,
87
+ C as a,
88
+ d as b,
89
+ h as d,
90
+ E as l,
91
+ f as o,
92
+ g as p
93
+ };
94
+ //# sourceMappingURL=anchor-SWdmlF1n.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"anchor-SWdmlF1n.js","sources":["../src/anchor.ts"],"sourcesContent":["import type { Node } from \"@milkdown/kit/prose/model\";\n\n/**\n * Where a comment sits, independent of the editor: the W3C Web Annotation\n * text-quote selector (the quoted text plus a little context on each side)\n * and a text-position hint. It is opaque to the host: store it, hand it back.\n *\n * Offsets count characters of the document's plain text: each textblock's\n * text, an inline atom (image, wiki link) as one character, and a line break\n * between blocks.\n */\nexport interface CommentAnchor {\n /** Format version, for future migrations. */\n v: 1;\n /** The commented text, exactly. */\n quote: string;\n /** Up to {@link CONTEXT_LENGTH} characters before the quote. */\n prefix: string;\n /** Up to {@link CONTEXT_LENGTH} characters after the quote. */\n suffix: string;\n /** Where the quote started, in plain-text characters. */\n start: number;\n /** Where the quote ended. */\n end: number;\n}\n\nexport const CONTEXT_LENGTH = 32;\nconst ATOM = \"\";\n\ninterface Segment {\n /** Offset of this segment in the plain text. */\n textStart: number;\n /** Characters this segment contributes. */\n length: number;\n /** Document position where it starts. */\n pos: number;\n /** Document size it spans (equals `length` for text). */\n size: number;\n}\n\n/** A document's plain text and the map between text offsets and positions. */\nexport interface DocText {\n text: string;\n segments: Segment[];\n}\n\nexport function docText(doc: Node): DocText {\n let text = \"\";\n const segments: Segment[] = [];\n let firstBlock = true;\n doc.descendants((node, pos) => {\n if (!node.isTextblock) return true;\n if (!firstBlock) text += \"\\n\";\n firstBlock = false;\n node.forEach((child, offset) => {\n const at = pos + 1 + offset;\n if (child.isText) {\n segments.push({ textStart: text.length, length: child.text!.length, pos: at, size: child.nodeSize });\n text += child.text!;\n } else {\n segments.push({ textStart: text.length, length: 1, pos: at, size: child.nodeSize });\n text += ATOM;\n }\n });\n return false;\n });\n return { text, segments };\n}\n\n/** Text offset of a document position (positions between blocks clamp). */\nexport function offsetAt(model: DocText, pos: number): number {\n let best = 0;\n for (const segment of model.segments) {\n if (pos < segment.pos) break;\n if (pos <= segment.pos + segment.size) {\n if (segment.length === segment.size) return segment.textStart + (pos - segment.pos);\n return segment.textStart + (pos > segment.pos ? segment.length : 0);\n }\n best = segment.textStart + segment.length;\n }\n return best;\n}\n\n/** Document position of a text offset; `side` picks the boundary at an edge. */\nexport function positionAt(model: DocText, offset: number, side: \"start\" | \"end\"): number | null {\n for (const segment of model.segments) {\n const end = segment.textStart + segment.length;\n const inside = side === \"start\" ? offset >= segment.textStart && offset < end : offset > segment.textStart && offset <= end;\n if (!inside) continue;\n if (segment.length === segment.size) return segment.pos + (offset - segment.textStart);\n return offset === segment.textStart ? segment.pos : segment.pos + segment.size;\n }\n return null;\n}\n\n/** The anchor for a document range, or `null` when it covers no text. */\nexport function anchorFromRange(doc: Node, from: number, to: number, model = docText(doc)): CommentAnchor | null {\n let start = offsetAt(model, from);\n let end = offsetAt(model, to);\n // A selection that runs to (or from) a block edge (a triple-click) picks up\n // the line break between blocks; the comment is on the words, not on it.\n while (start < end && model.text[start] === \"\\n\") start++;\n while (end > start && model.text[end - 1] === \"\\n\") end--;\n const quote = model.text.slice(start, end);\n if (quote.replace(/[\\n\\s]/g, \"\").length === 0) return null;\n return {\n v: 1,\n quote,\n prefix: model.text.slice(Math.max(0, start - CONTEXT_LENGTH), start),\n suffix: model.text.slice(end, end + CONTEXT_LENGTH),\n start,\n end,\n };\n}\n\nfunction commonSuffix(a: string, b: string): number {\n let n = 0;\n while (n < a.length && n < b.length && a[a.length - 1 - n] === b[b.length - 1 - n]) n++;\n return n;\n}\n\nfunction commonPrefix(a: string, b: string): number {\n let n = 0;\n while (n < a.length && n < b.length && a[n] === b[n]) n++;\n return n;\n}\n\n/**\n * How a located anchor matched: `exact` (same place, same context), `moved`\n * (found elsewhere with the best context), or `ambiguous` (low confidence:\n * another occurrence fits as well, or under a quarter of its context survives).\n */\nexport type AnchorMatch = \"exact\" | \"moved\" | \"ambiguous\";\n\n/**\n * Find an anchor's text again. Every occurrence of the quote is scored by how\n * much of the stored prefix and suffix still surrounds it, with nearness to\n * the old position breaking ties. `null` means the text is gone: the comment\n * is orphaned, never silently dropped or attached somewhere arbitrary.\n */\nexport function locateAnchor(\n model: DocText,\n anchor: CommentAnchor,\n): { start: number; end: number; match: AnchorMatch; context: number; fullContext: number } | null {\n const { quote } = anchor;\n if (!quote) return null;\n const { text } = model;\n type Candidate = { start: number; context: number; distance: number };\n // Every occurrence is scored, keeping only the best two: no cap, so a\n // comment late in a long document can't lose to an earlier occurrence.\n const better = (a: Candidate, b: Candidate | undefined) =>\n !b || a.context > b.context || (a.context === b.context && a.distance < b.distance);\n let best: Candidate | undefined;\n let runnerUp: Candidate | undefined;\n for (let at = text.indexOf(quote); at !== -1; at = text.indexOf(quote, at + 1)) {\n const before = text.slice(Math.max(0, at - anchor.prefix.length), at);\n const after = text.slice(at + quote.length, at + quote.length + anchor.suffix.length);\n const candidate = {\n start: at,\n context: commonSuffix(before, anchor.prefix) + commonPrefix(after, anchor.suffix),\n distance: Math.abs(at - anchor.start),\n };\n if (better(candidate, best)) {\n runnerUp = best;\n best = candidate;\n } else if (better(candidate, runnerUp)) runnerUp = candidate;\n }\n if (!best) return null;\n const fullContext = anchor.prefix.length + anchor.suffix.length;\n const exact = best.start === anchor.start && best.context === fullContext;\n // Low confidence: another occurrence fits as well, or less than a quarter\n // of the stored context survives around it.\n const ambiguous =\n !exact && ((runnerUp !== undefined && runnerUp.context === best.context) || best.context * 4 < fullContext);\n return {\n start: best.start,\n end: best.start + quote.length,\n match: exact ? \"exact\" : ambiguous ? \"ambiguous\" : \"moved\",\n context: best.context,\n fullContext,\n };\n}\n\n/**\n * {@link locateAnchor}, as document positions: where the editor puts a thread\n * when it first sees its anchor. `null` when the text is gone.\n */\nexport function locateRange(\n doc: Node,\n anchor: CommentAnchor,\n model = docText(doc),\n): { from: number; to: number; match: AnchorMatch; context: number; fullContext: number } | null {\n const found = locateAnchor(model, anchor);\n if (!found) return null;\n const from = positionAt(model, found.start, \"start\");\n const to = positionAt(model, found.end, \"end\");\n if (from === null || to === null || from >= to) return null;\n return { from, to, match: found.match, context: found.context, fullContext: found.fullContext };\n}\n"],"names":["CONTEXT_LENGTH","docText","doc","text","segments","firstBlock","node","pos","child","offset","at","offsetAt","model","best","segment","positionAt","side","end","anchorFromRange","from","to","start","quote","commonSuffix","a","b","commonPrefix","locateAnchor","anchor","better","runnerUp","before","after","candidate","fullContext","exact","ambiguous","locateRange","found"],"mappings":"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"}
@@ -0,0 +1,78 @@
1
+ import { Node } from '@milkdown/kit/prose/model';
2
+ /**
3
+ * Where a comment sits, independent of the editor: the W3C Web Annotation
4
+ * text-quote selector (the quoted text plus a little context on each side)
5
+ * and a text-position hint. It is opaque to the host: store it, hand it back.
6
+ *
7
+ * Offsets count characters of the document's plain text: each textblock's
8
+ * text, an inline atom (image, wiki link) as one character, and a line break
9
+ * between blocks.
10
+ */
11
+ export interface CommentAnchor {
12
+ /** Format version, for future migrations. */
13
+ v: 1;
14
+ /** The commented text, exactly. */
15
+ quote: string;
16
+ /** Up to {@link CONTEXT_LENGTH} characters before the quote. */
17
+ prefix: string;
18
+ /** Up to {@link CONTEXT_LENGTH} characters after the quote. */
19
+ suffix: string;
20
+ /** Where the quote started, in plain-text characters. */
21
+ start: number;
22
+ /** Where the quote ended. */
23
+ end: number;
24
+ }
25
+ export declare const CONTEXT_LENGTH = 32;
26
+ interface Segment {
27
+ /** Offset of this segment in the plain text. */
28
+ textStart: number;
29
+ /** Characters this segment contributes. */
30
+ length: number;
31
+ /** Document position where it starts. */
32
+ pos: number;
33
+ /** Document size it spans (equals `length` for text). */
34
+ size: number;
35
+ }
36
+ /** A document's plain text and the map between text offsets and positions. */
37
+ export interface DocText {
38
+ text: string;
39
+ segments: Segment[];
40
+ }
41
+ export declare function docText(doc: Node): DocText;
42
+ /** Text offset of a document position (positions between blocks clamp). */
43
+ export declare function offsetAt(model: DocText, pos: number): number;
44
+ /** Document position of a text offset; `side` picks the boundary at an edge. */
45
+ export declare function positionAt(model: DocText, offset: number, side: "start" | "end"): number | null;
46
+ /** The anchor for a document range, or `null` when it covers no text. */
47
+ export declare function anchorFromRange(doc: Node, from: number, to: number, model?: DocText): CommentAnchor | null;
48
+ /**
49
+ * How a located anchor matched: `exact` (same place, same context), `moved`
50
+ * (found elsewhere with the best context), or `ambiguous` (low confidence:
51
+ * another occurrence fits as well, or under a quarter of its context survives).
52
+ */
53
+ export type AnchorMatch = "exact" | "moved" | "ambiguous";
54
+ /**
55
+ * Find an anchor's text again. Every occurrence of the quote is scored by how
56
+ * much of the stored prefix and suffix still surrounds it, with nearness to
57
+ * the old position breaking ties. `null` means the text is gone: the comment
58
+ * is orphaned, never silently dropped or attached somewhere arbitrary.
59
+ */
60
+ export declare function locateAnchor(model: DocText, anchor: CommentAnchor): {
61
+ start: number;
62
+ end: number;
63
+ match: AnchorMatch;
64
+ context: number;
65
+ fullContext: number;
66
+ } | null;
67
+ /**
68
+ * {@link locateAnchor}, as document positions: where the editor puts a thread
69
+ * when it first sees its anchor. `null` when the text is gone.
70
+ */
71
+ export declare function locateRange(doc: Node, anchor: CommentAnchor, model?: DocText): {
72
+ from: number;
73
+ to: number;
74
+ match: AnchorMatch;
75
+ context: number;
76
+ fullContext: number;
77
+ } | null;
78
+ export {};
@@ -0,0 +1 @@
1
+ .milkdown .blintz-comment{background:color-mix(in srgb,#f5b301 22%,transparent);border-bottom:2px solid color-mix(in srgb,#f5b301 70%,transparent);cursor:pointer;border-radius:2px}.milkdown .blintz-comment.is-active{background:color-mix(in srgb,#f5b301 45%,transparent);border-bottom-color:#d99a00}.milkdown .blintz-comment.is-resolved{background:transparent;border-bottom:1px dashed color-mix(in srgb,var(--crepe-color-on-surface-variant) 60%,transparent)}
package/dist/core.d.ts ADDED
@@ -0,0 +1,38 @@
1
+ import { MarkdownModel, MarkdownSource, RawSourceRange } from 'blintz/collab';
2
+ import { AnchorMatch, CommentAnchor } from './anchor';
3
+ export { anchorFromRange, docText, locateAnchor, locateRange, CONTEXT_LENGTH } from './anchor';
4
+ export type { AnchorMatch, CommentAnchor, DocText } from './anchor';
5
+ /** Where a thread's words are in a markdown document. */
6
+ export interface ResolvedAnchor {
7
+ /** How confidently it was found, as the editor reports it. */
8
+ match: AnchorMatch;
9
+ /** The range in `model.document(markdown)`, as the editor's `rangeOf` gives it. */
10
+ from: number;
11
+ to: number;
12
+ /**
13
+ * The raw markdown the words came from: `ranges` exactly as the editor's
14
+ * `port.range(from, to)` maps them (one per text run; markup between runs,
15
+ * such as `**`, is left out), and `start`/`end` spanning them all. `null`
16
+ * when part of the range has no exact source, as the port refuses it:
17
+ * an atom such as a wiki link, an escape or an entity.
18
+ */
19
+ source: {
20
+ start: number;
21
+ end: number;
22
+ ranges: RawSourceRange[];
23
+ } | null;
24
+ }
25
+ /** Pass a `model.source(markdown)` instead of markdown to resolve many anchors with one parse. */
26
+ type Markdown = string | MarkdownSource;
27
+ /**
28
+ * Find a thread's words in markdown, as the editor would on opening it.
29
+ * `null` when they're gone: the thread is orphaned.
30
+ */
31
+ export declare function resolveAnchor(model: MarkdownModel, markdown: Markdown, anchor: CommentAnchor): ResolvedAnchor | null;
32
+ /**
33
+ * An anchor for the text inside the raw markdown range `[start, end)`, as
34
+ * the editor would make it from that selection. Ends inside markdown syntax
35
+ * snap inward to the text (`**bold**` anchors `bold`). `null` when no text
36
+ * lies inside.
37
+ */
38
+ export declare function createAnchor(model: MarkdownModel, markdown: Markdown, start: number, end: number): CommentAnchor | null;
package/dist/core.js ADDED
@@ -0,0 +1,33 @@
1
+ import { a as f, l, d as m } from "./anchor-SWdmlF1n.js";
2
+ import { C as p, b as T } from "./anchor-SWdmlF1n.js";
3
+ const s = /* @__PURE__ */ new WeakMap();
4
+ function a(e) {
5
+ let t = s.get(e);
6
+ return t || s.set(e, t = m(e)), t;
7
+ }
8
+ const u = (e, t) => typeof t == "string" ? e.source(t) : t;
9
+ function g(e, t, c) {
10
+ const n = u(e, t), o = l(n.doc, c, a(n.doc));
11
+ if (!o) return null;
12
+ const r = n.toSource(o.from, o.to);
13
+ return {
14
+ match: o.match,
15
+ from: o.from,
16
+ to: o.to,
17
+ source: r ? { start: r[0].start, end: r.at(-1).end, ranges: r } : null
18
+ };
19
+ }
20
+ function h(e, t, c, n) {
21
+ const o = u(e, t), r = o.fromSource(c, n);
22
+ return r ? f(o.doc, r.from, r.to, a(o.doc)) : null;
23
+ }
24
+ export {
25
+ p as CONTEXT_LENGTH,
26
+ f as anchorFromRange,
27
+ h as createAnchor,
28
+ m as docText,
29
+ T as locateAnchor,
30
+ l as locateRange,
31
+ g as resolveAnchor
32
+ };
33
+ //# sourceMappingURL=core.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"core.js","sources":["../src/core.ts"],"sourcesContent":["// @blintz/comments/core: comment anchors without an editor.\n//\n// A server or an agent works on markdown with no editor open. These find a\n// thread's words in markdown, and make anchors from markdown ranges, exactly\n// as the editor does: the same document (Blintz's headless model), the same\n// matching, and the same mapping to raw markdown as the editor's source port.\n// No DOM, no React.\nimport type { Node } from \"@milkdown/kit/prose/model\";\nimport type { MarkdownModel, MarkdownSource, RawSourceRange } from \"blintz/collab\";\n\nimport { anchorFromRange, docText, locateRange, type AnchorMatch, type CommentAnchor, type DocText } from \"./anchor\";\n\nexport { anchorFromRange, docText, locateAnchor, locateRange, CONTEXT_LENGTH } from \"./anchor\";\nexport type { AnchorMatch, CommentAnchor, DocText } from \"./anchor\";\n\n/** Where a thread's words are in a markdown document. */\nexport interface ResolvedAnchor {\n /** How confidently it was found, as the editor reports it. */\n match: AnchorMatch;\n /** The range in `model.document(markdown)`, as the editor's `rangeOf` gives it. */\n from: number;\n to: number;\n /**\n * The raw markdown the words came from: `ranges` exactly as the editor's\n * `port.range(from, to)` maps them (one per text run; markup between runs,\n * such as `**`, is left out), and `start`/`end` spanning them all. `null`\n * when part of the range has no exact source, as the port refuses it:\n * an atom such as a wiki link, an escape or an entity.\n */\n source: { start: number; end: number; ranges: RawSourceRange[] } | null;\n}\n\nconst texts = new WeakMap<Node, DocText>();\nfunction textOf(doc: Node): DocText {\n let text = texts.get(doc);\n if (!text) texts.set(doc, (text = docText(doc)));\n return text;\n}\n\n/** Pass a `model.source(markdown)` instead of markdown to resolve many anchors with one parse. */\ntype Markdown = string | MarkdownSource;\nconst sourceOf = (model: MarkdownModel, markdown: Markdown) =>\n typeof markdown === \"string\" ? model.source(markdown) : markdown;\n\n/**\n * Find a thread's words in markdown, as the editor would on opening it.\n * `null` when they're gone: the thread is orphaned.\n */\nexport function resolveAnchor(model: MarkdownModel, markdown: Markdown, anchor: CommentAnchor): ResolvedAnchor | null {\n const source = sourceOf(model, markdown);\n const found = locateRange(source.doc, anchor, textOf(source.doc));\n if (!found) return null;\n const ranges = source.toSource(found.from, found.to);\n return {\n match: found.match,\n from: found.from,\n to: found.to,\n source: ranges ? { start: ranges[0]!.start, end: ranges.at(-1)!.end, ranges } : null,\n };\n}\n\n/**\n * An anchor for the text inside the raw markdown range `[start, end)`, as\n * the editor would make it from that selection. Ends inside markdown syntax\n * snap inward to the text (`**bold**` anchors `bold`). `null` when no text\n * lies inside.\n */\nexport function createAnchor(model: MarkdownModel, markdown: Markdown, start: number, end: number): CommentAnchor | null {\n const source = sourceOf(model, markdown);\n const range = source.fromSource(start, end);\n return range ? anchorFromRange(source.doc, range.from, range.to, textOf(source.doc)) : null;\n}\n"],"names":["texts","textOf","doc","text","docText","sourceOf","model","markdown","resolveAnchor","anchor","source","found","locateRange","ranges","createAnchor","start","end","range","anchorFromRange"],"mappings":";;AAgCA,MAAMA,wBAAY,QAAA;AAClB,SAASC,EAAOC,GAAoB;AAClC,MAAIC,IAAOH,EAAM,IAAIE,CAAG;AACxB,SAAKC,KAAMH,EAAM,IAAIE,GAAMC,IAAOC,EAAQF,CAAG,CAAE,GACxCC;AACT;AAIA,MAAME,IAAW,CAACC,GAAsBC,MACtC,OAAOA,KAAa,WAAWD,EAAM,OAAOC,CAAQ,IAAIA;AAMnD,SAASC,EAAcF,GAAsBC,GAAoBE,GAA8C;AACpH,QAAMC,IAASL,EAASC,GAAOC,CAAQ,GACjCI,IAAQC,EAAYF,EAAO,KAAKD,GAAQR,EAAOS,EAAO,GAAG,CAAC;AAChE,MAAI,CAACC,EAAO,QAAO;AACnB,QAAME,IAASH,EAAO,SAASC,EAAM,MAAMA,EAAM,EAAE;AACnD,SAAO;AAAA,IACL,OAAOA,EAAM;AAAA,IACb,MAAMA,EAAM;AAAA,IACZ,IAAIA,EAAM;AAAA,IACV,QAAQE,IAAS,EAAE,OAAOA,EAAO,CAAC,EAAG,OAAO,KAAKA,EAAO,GAAG,EAAE,EAAG,KAAK,QAAAA,MAAW;AAAA,EAAA;AAEpF;AAQO,SAASC,EAAaR,GAAsBC,GAAoBQ,GAAeC,GAAmC;AACvH,QAAMN,IAASL,EAASC,GAAOC,CAAQ,GACjCU,IAAQP,EAAO,WAAWK,GAAOC,CAAG;AAC1C,SAAOC,IAAQC,EAAgBR,EAAO,KAAKO,EAAM,MAAMA,EAAM,IAAIhB,EAAOS,EAAO,GAAG,CAAC,IAAI;AACzF;"}
@@ -0,0 +1,15 @@
1
+ import { Node } from '@milkdown/kit/prose/model';
2
+ import { StepMap } from '@milkdown/kit/prose/transform';
3
+ /**
4
+ * A position map between two versions of a document that changed in one
5
+ * opaque step (a collaborator's update), built from a word-level diff so
6
+ * text that did not change keeps its place even when edits surround it.
7
+ * Only the top-level blocks between the first and last difference are compared.
8
+ * `exact` is false when the change was too big to diff and the whole differing
9
+ * span is mapped as one replacement, so ranges inside it collapse even if
10
+ * their words survived.
11
+ */
12
+ export declare function diffMap(before: Node, after: Node): {
13
+ map: StepMap;
14
+ exact: boolean;
15
+ };
@@ -0,0 +1,5 @@
1
+ export { useBlintzComments } from './useBlintzComments';
2
+ export type { BlintzComments, UseBlintzCommentsOptions } from './useBlintzComments';
3
+ export type { CommentThreadRef } from './plugin';
4
+ export { anchorFromRange, docText, locateAnchor, CONTEXT_LENGTH } from './anchor';
5
+ export type { AnchorMatch, CommentAnchor, DocText } from './anchor';
package/dist/index.js ADDED
@@ -0,0 +1,326 @@
1
+ import { useRef as T, useLayoutEffect as U, useCallback as E, useMemo as $, useEffect as _ } from "react";
2
+ import { PluginKey as V, Plugin as j, TextSelection as q } from "@milkdown/kit/prose/state";
3
+ import { $prose as B } from "@milkdown/kit/utils";
4
+ import { d as k, a as v, l as C, o as X, p as I } from "./anchor-SWdmlF1n.js";
5
+ import { C as ye, b as xe } from "./anchor-SWdmlF1n.js";
6
+ import { DecorationSet as H, Decoration as F } from "@milkdown/kit/prose/view";
7
+ import { isCollaboratorChange as Y, DOCUMENT_RESET_META as G } from "blintz";
8
+ import { StepMap as R } from "@milkdown/kit/prose/transform";
9
+ const J = 1e3, W = /\s+|[\p{L}\p{N}_]+|[^\s\p{L}\p{N}_]/gu;
10
+ function D(e, t, o, r) {
11
+ const l = [];
12
+ let d = o;
13
+ for (let c = t, u = o; c < e.childCount && u < r; c++) {
14
+ const f = e.child(c);
15
+ N(l, f, u), f.descendants((p, m) => N(l, p, u + 1 + m)), u += f.nodeSize, d = u;
16
+ }
17
+ return l.sort((c, u) => c.pos - u.pos), { list: l, to: d };
18
+ }
19
+ function N(e, t, o) {
20
+ if (t.isText) {
21
+ for (const r of t.text.matchAll(W))
22
+ e.push({ pos: o + r.index, size: r[0].length, key: `t${r[0]}` });
23
+ return !1;
24
+ }
25
+ return t.isLeaf ? (e.push({ pos: o, size: t.nodeSize, key: `l${t.type.name}${JSON.stringify(t.attrs)}` }), !1) : (e.push({ pos: o, size: 1, key: `o${t.type.name}` }), e.push({ pos: o + t.nodeSize - 1, size: 1, key: `c${t.type.name}` }), !0);
26
+ }
27
+ function Q(e, t) {
28
+ const o = e.length, r = t.length, l = Math.min(o + r, J), d = l + 1, c = new Int32Array(2 * l + 3), u = [];
29
+ let f = !1;
30
+ for (let a = 0; a <= l && !f; a++) {
31
+ u.push(c.slice(d - a - 1, d + a + 2));
32
+ for (let s = -a; s <= a; s += 2) {
33
+ let n = s === -a || s !== a && c[d + s - 1] < c[d + s + 1] ? c[d + s + 1] : c[d + s - 1] + 1, i = n - s;
34
+ for (; n < o && i < r && e[n].key === t[i].key; )
35
+ n++, i++;
36
+ if (c[d + s] = n, n >= o && i >= r) {
37
+ f = !0;
38
+ break;
39
+ }
40
+ }
41
+ }
42
+ if (!f) return null;
43
+ const p = [];
44
+ let m = o, h = r;
45
+ for (let a = u.length - 1; a >= 0 && (m > 0 || h > 0); a--) {
46
+ const s = u[a], n = (x) => s[x + a + 1], i = m - h, g = i === -a || i !== a && n(i - 1) < n(i + 1) ? i + 1 : i - 1, M = a === 0 ? 0 : n(g), y = a === 0 ? 0 : M - g;
47
+ for (; m > M && h > y; )
48
+ m--, h--;
49
+ if (a > 0) {
50
+ const x = [M, m, y, h], S = p[p.length - 1];
51
+ S && S[0] === m && S[2] === h ? (S[0] = M, S[2] = y) : p.push(x);
52
+ }
53
+ m = M, h = y;
54
+ }
55
+ return p.reverse();
56
+ }
57
+ function b(e, t, o) {
58
+ return t < e.length ? e[t].pos : o;
59
+ }
60
+ function Z(e, t) {
61
+ const o = e.content.findDiffStart(t.content);
62
+ if (o === null) return { map: R.empty, exact: !0 };
63
+ let { a: r, b: l } = e.content.findDiffEnd(t.content);
64
+ const d = o - Math.min(r, l);
65
+ d > 0 && (r += d, l += d);
66
+ let c = 0, u = 0;
67
+ for (; c < e.childCount && u + e.child(c).nodeSize <= o; )
68
+ u += e.child(c).nodeSize, c++;
69
+ const f = D(e, c, u, r), p = D(t, c, u, l), m = Q(f.list, p.list);
70
+ if (!m) return { map: new R([o, r - o, l - o]), exact: !1 };
71
+ const h = [];
72
+ for (const [a, s, n, i] of m) {
73
+ const w = b(f.list, a, f.to);
74
+ h.push(w, b(f.list, s, f.to) - w, b(p.list, i, p.to) - b(p.list, n, p.to));
75
+ }
76
+ return { map: new R(h), exact: !0 };
77
+ }
78
+ const A = new V("blintz-comments"), O = (e) => `${e.start}:${e.end}:${e.prefix}\0${e.quote}\0${e.suffix}`;
79
+ function ee(e, t, o = k(e)) {
80
+ return C(e, t, o);
81
+ }
82
+ function L(e, t, o) {
83
+ const r = [];
84
+ for (const l of o.threads) {
85
+ const d = t.ranges.get(l.id);
86
+ if (!d || l.resolved && !o.showResolved) continue;
87
+ const c = ["blintz-comment"];
88
+ l.resolved && c.push("is-resolved"), l.id === o.activeThreadId && c.push("is-active"), r.push(
89
+ F.inline(
90
+ d.from,
91
+ d.to,
92
+ { class: c.join(" "), "data-comment-id": l.id },
93
+ { threadId: l.id }
94
+ )
95
+ );
96
+ }
97
+ return H.create(e, r);
98
+ }
99
+ function K(e, t, o) {
100
+ const r = /* @__PURE__ */ new Map(), l = /* @__PURE__ */ new Map(), d = /* @__PURE__ */ new Map(), c = /* @__PURE__ */ new Map();
101
+ let u;
102
+ for (const a of o.threads) {
103
+ const s = O(a.anchor);
104
+ d.set(a.id, s), c.set(a.id, e.lastAnchors.get(a.id) ?? a.anchor);
105
+ const n = e.ranges.get(a.id);
106
+ if (n) {
107
+ r.set(a.id, n);
108
+ continue;
109
+ }
110
+ const i = e.orphaned.get(a.id);
111
+ if (i && e.anchorKeys.get(a.id) === s) {
112
+ l.set(a.id, i);
113
+ continue;
114
+ }
115
+ u ??= k(t);
116
+ const w = ee(t, a.anchor, u);
117
+ c.set(a.id, a.anchor), w ? r.set(a.id, w) : l.set(a.id, "missing");
118
+ }
119
+ const f = (a) => new Map([...a].filter(([s]) => l.get(s) === "deleted")), p = f(e.deletedAnchors), m = f(e.deletedAt), h = { ranges: r, orphaned: l, anchorKeys: d, lastAnchors: c, deletedAnchors: p, deletedAt: m };
120
+ return { ...h, decorations: L(t, h, o) };
121
+ }
122
+ function te(e) {
123
+ return e.getMeta(G) === !0;
124
+ }
125
+ function ne(e) {
126
+ return Y(e) ? (({ map: t, exact: o }) => ({ mapping: t, exact: o }))(Z(e.before, e.doc)) : { mapping: e.mapping, exact: !0 };
127
+ }
128
+ function oe(e, t, o) {
129
+ if (o === void 0) return null;
130
+ const r = X(e, o), { text: l } = e, { quote: d, prefix: c, suffix: u } = t;
131
+ for (const f of [r, r - d.length]) {
132
+ if (f < 0 || l.slice(f, f + d.length) !== d) continue;
133
+ const p = l.slice(Math.max(0, f - c.length), f), m = l.slice(f + d.length, f + d.length + u.length);
134
+ if (p !== c || m !== u) continue;
135
+ const h = I(e, f, "start"), a = I(e, f + d.length, "end");
136
+ if (h === null || a === null || h >= a) continue;
137
+ const s = c.length + u.length;
138
+ return { from: h, to: a, match: "exact", context: s, fullContext: s };
139
+ }
140
+ return null;
141
+ }
142
+ function z(e, t) {
143
+ return t === "deleted" ? e.context === e.fullContext : e.context * 2 >= e.fullContext;
144
+ }
145
+ function se(e, t, o) {
146
+ const r = /* @__PURE__ */ new Map(), l = new Map(e.orphaned), d = new Map(e.lastAnchors), c = new Map(e.deletedAnchors), { mapping: u, exact: f } = ne(t), p = new Map([...e.deletedAt].map(([n, i]) => [n, u.map(i, -1)])), m = te(t);
147
+ let h, a;
148
+ for (const [n, i] of e.ranges) {
149
+ const w = u.map(i.from, 1), g = u.map(i.to, -1);
150
+ if (w < g) {
151
+ r.set(n, { from: w, to: g, match: i.match });
152
+ continue;
153
+ }
154
+ if (m) {
155
+ const y = d.get(n);
156
+ h ??= k(t.doc);
157
+ const x = y ? C(t.doc, y, h) : null;
158
+ x ? r.set(n, x) : l.set(n, "missing");
159
+ continue;
160
+ }
161
+ if (!f) {
162
+ const y = v(t.before, i.from, i.to);
163
+ h ??= k(t.doc);
164
+ const x = y ? C(t.doc, y, h) : null;
165
+ if (x && z(x, "deleted")) {
166
+ r.set(n, x);
167
+ continue;
168
+ }
169
+ }
170
+ const M = v(t.before, i.from, i.to);
171
+ M && c.set(n, M), p.set(n, w), l.set(n, "deleted");
172
+ }
173
+ for (const [n, i] of e.orphaned) {
174
+ const w = [c.get(n), d.get(n)].filter(
175
+ (g) => g !== void 0
176
+ );
177
+ h ??= k(t.doc);
178
+ for (const g of w) {
179
+ const M = i === "deleted" && !m && f ? oe(h, g, p.get(n)) : C(t.doc, g, h);
180
+ if (!(i === "deleted" && !m && !f && M && (() => {
181
+ a ??= k(t.before);
182
+ const x = C(t.before, g, a);
183
+ return x !== null && z(x, "deleted");
184
+ })()) && M && (m || z(M, i))) {
185
+ r.set(n, M), l.delete(n), c.delete(n), p.delete(n);
186
+ break;
187
+ }
188
+ }
189
+ }
190
+ const s = { ...e, ranges: r, orphaned: l, lastAnchors: d, deletedAnchors: c, deletedAt: p };
191
+ return { ...s, decorations: L(t.doc, s, o) };
192
+ }
193
+ const re = {
194
+ ranges: /* @__PURE__ */ new Map(),
195
+ orphaned: /* @__PURE__ */ new Map(),
196
+ anchorKeys: /* @__PURE__ */ new Map(),
197
+ lastAnchors: /* @__PURE__ */ new Map(),
198
+ deletedAnchors: /* @__PURE__ */ new Map(),
199
+ deletedAt: /* @__PURE__ */ new Map(),
200
+ decorations: H.empty
201
+ };
202
+ function ce(e) {
203
+ return new j({
204
+ key: A,
205
+ state: {
206
+ init: (t, o) => K(re, o.doc, e.display()),
207
+ apply(t, o, r, l) {
208
+ const d = t.getMeta(A);
209
+ let c = t.docChanged ? se(o, t, e.display()) : o;
210
+ if (d?.sync && (c = K(c, l.doc, d.sync)), d?.remember) {
211
+ const u = new Map(c.lastAnchors);
212
+ for (const [f, p] of d.remember) c.ranges.has(f) && u.set(f, p);
213
+ c = { ...c, lastAnchors: u };
214
+ }
215
+ return c;
216
+ }
217
+ },
218
+ props: {
219
+ decorations: (t) => A.getState(t)?.decorations,
220
+ handleClick(t, o) {
221
+ const r = P(t.state, o);
222
+ return r.length > 0 && e.onClick?.(r), !1;
223
+ }
224
+ },
225
+ view(t) {
226
+ return e.onView?.(t), {
227
+ update: (o, r) => e.onUpdate?.(o, r),
228
+ destroy: () => e.onView?.(null)
229
+ };
230
+ }
231
+ });
232
+ }
233
+ function P(e, t) {
234
+ const o = A.getState(e)?.ranges;
235
+ return o ? [...o].filter(([, r]) => r.from <= t && t <= r.to).sort(([, r], [, l]) => r.to - r.from - (l.to - l.from)).map(([r]) => r) : [];
236
+ }
237
+ function ae(e) {
238
+ const t = A.getState(e), o = /* @__PURE__ */ new Map();
239
+ if (!t) return o;
240
+ const r = k(e.doc);
241
+ for (const [l, d] of t.ranges) {
242
+ const c = v(e.doc, d.from, d.to, r);
243
+ c && o.set(l, c);
244
+ }
245
+ return o;
246
+ }
247
+ const ie = 600;
248
+ function ge(e) {
249
+ const t = T(e);
250
+ U(() => {
251
+ t.current = e;
252
+ });
253
+ const o = T(null), r = T(/* @__PURE__ */ new Map()), l = T(""), d = T(void 0), c = E(() => {
254
+ const { threads: s, activeThreadId: n, showResolved: i } = t.current;
255
+ return { threads: s, activeThreadId: n, showResolved: i };
256
+ }, []), u = E((s) => {
257
+ const n = [...A.getState(s)?.orphaned.keys() ?? []].sort(), i = n.join("\0");
258
+ i !== l.current && (l.current = i, t.current.onOrphanedChange?.(n));
259
+ }, []), f = E(() => {
260
+ const s = o.current;
261
+ if (!s) return;
262
+ const n = ae(s.state), i = new Map(t.current.threads.map((g) => [g.id, O(g.anchor)])), w = [];
263
+ for (const [g, M] of n) {
264
+ const y = O(M);
265
+ y !== (r.current.get(g) ?? i.get(g)) && (r.current.set(g, y), w.push({ id: g, anchor: M }));
266
+ }
267
+ s.dispatch(s.state.tr.setMeta(A, { remember: n }).setMeta("addToHistory", !1)), w.length > 0 && t.current.onAnchorsChange?.(w);
268
+ }, []), p = $(
269
+ () => Object.assign(({ editor: s }) => {
270
+ s.use(
271
+ B(
272
+ () => ce({
273
+ display: c,
274
+ onClick: (n) => t.current.onThreadClick?.(n),
275
+ onView: (n) => {
276
+ o.current = n, n && queueMicrotask(() => u(n.state));
277
+ },
278
+ onUpdate: (n, i) => {
279
+ u(n.state), n.state.doc !== i.doc && (clearTimeout(d.current), d.current = setTimeout(f, ie));
280
+ }
281
+ })
282
+ )
283
+ );
284
+ }, { preservesSourceMapping: !0 }),
285
+ [c, f, u]
286
+ ), { threads: m, activeThreadId: h, showResolved: a } = e;
287
+ return _(() => {
288
+ const s = o.current;
289
+ s && s.dispatch(
290
+ s.state.tr.setMeta(A, { sync: { threads: m, activeThreadId: h, showResolved: a } }).setMeta("addToHistory", !1)
291
+ );
292
+ }, [m, h, a]), _(() => () => clearTimeout(d.current), []), $(
293
+ () => ({
294
+ plugin: p,
295
+ anchorSelection() {
296
+ const s = o.current;
297
+ if (!s) return null;
298
+ const { from: n, to: i, empty: w } = s.state.selection;
299
+ return w ? null : v(s.state.doc, n, i);
300
+ },
301
+ rangeOf(s) {
302
+ const n = o.current && A.getState(o.current.state)?.ranges.get(s);
303
+ return n ? { from: n.from, to: n.to, match: n.match } : null;
304
+ },
305
+ threadsAtSelection() {
306
+ const s = o.current;
307
+ return s ? P(s.state, s.state.selection.from) : [];
308
+ },
309
+ reveal(s) {
310
+ const n = o.current, i = n && A.getState(n.state)?.ranges.get(s);
311
+ return !n || !i ? !1 : (n.dispatch(
312
+ n.state.tr.setSelection(q.create(n.state.doc, i.from, i.to)).scrollIntoView()
313
+ ), n.focus(), !0);
314
+ }
315
+ }),
316
+ [p]
317
+ );
318
+ }
319
+ export {
320
+ ye as CONTEXT_LENGTH,
321
+ v as anchorFromRange,
322
+ k as docText,
323
+ xe as locateAnchor,
324
+ ge as useBlintzComments
325
+ };
326
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sources":["../src/diff-map.ts","../src/plugin.ts","../src/useBlintzComments.ts"],"sourcesContent":["import type { Node } from \"@milkdown/kit/prose/model\";\nimport { StepMap } from \"@milkdown/kit/prose/transform\";\n\n/** A run of positions that is compared as one unit: a word, a gap, a node edge. */\ninterface Token {\n pos: number;\n size: number;\n key: string;\n}\n\n/** Past this many differing tokens, fall back to one span (rewrites are rare and big). */\nconst MAX_EDITS = 1000;\n\nconst WORDS = /\\s+|[\\p{L}\\p{N}_]+|[^\\s\\p{L}\\p{N}_]/gu;\n\n/** The top-level blocks of `doc` from index `first` that start before `end`, as tokens, and where they end. */\nfunction tokens(doc: Node, first: number, start: number, end: number): { list: Token[]; to: number } {\n const list: Token[] = [];\n let to = start;\n for (let index = first, offset = start; index < doc.childCount && offset < end; index++) {\n const block = doc.child(index);\n push(list, block, offset);\n block.descendants((node, pos) => push(list, node, offset + 1 + pos));\n offset += block.nodeSize;\n to = offset;\n }\n // Close tokens were pushed early; put everything in document order.\n list.sort((x, y) => x.pos - y.pos);\n return { list, to };\n}\n\nfunction push(list: Token[], node: Node, pos: number): boolean {\n if (node.isText) {\n for (const match of node.text!.matchAll(WORDS)) {\n list.push({ pos: pos + match.index!, size: match[0].length, key: `t${match[0]}` });\n }\n return false;\n }\n if (node.isLeaf) {\n list.push({ pos, size: node.nodeSize, key: `l${node.type.name}${JSON.stringify(node.attrs)}` });\n return false;\n }\n list.push({ pos, size: 1, key: `o${node.type.name}` });\n list.push({ pos: pos + node.nodeSize - 1, size: 1, key: `c${node.type.name}` });\n return true;\n}\n\n/** The edit script between two token lists, as [aStart, aEnd, bStart, bEnd] hunks (Myers). */\nfunction hunks(a: Token[], b: Token[]): Array<[number, number, number, number]> | null {\n const n = a.length;\n const m = b.length;\n const max = Math.min(n + m, MAX_EDITS);\n const offset = max + 1;\n const v = new Int32Array(2 * max + 3);\n const trace: Int32Array[] = [];\n let found = false;\n for (let d = 0; d <= max && !found; d++) {\n // Round d only reads diagonals -d-1..d+1, so keep just that window.\n trace.push(v.slice(offset - d - 1, offset + d + 2));\n for (let k = -d; k <= d; k += 2) {\n let x =\n k === -d || (k !== d && v[offset + k - 1]! < v[offset + k + 1]!) ? v[offset + k + 1]! : v[offset + k - 1]! + 1;\n let y = x - k;\n while (x < n && y < m && a[x]!.key === b[y]!.key) {\n x++;\n y++;\n }\n v[offset + k] = x;\n if (x >= n && y >= m) {\n found = true;\n break;\n }\n }\n }\n if (!found) return null;\n // Walk back through the trace, collecting the non-matching runs.\n const out: Array<[number, number, number, number]> = [];\n let x = n;\n let y = m;\n for (let d = trace.length - 1; d >= 0 && (x > 0 || y > 0); d--) {\n const window = trace[d]!;\n const at = (diagonal: number) => window[diagonal + d + 1]!;\n const k = x - y;\n const down = k === -d || (k !== d && at(k - 1) < at(k + 1));\n const prevK = down ? k + 1 : k - 1;\n const prevX = d === 0 ? 0 : at(prevK);\n const prevY = d === 0 ? 0 : prevX - prevK;\n while (x > prevX && y > prevY) {\n x--;\n y--;\n }\n if (d > 0) {\n const hunk: [number, number, number, number] = [prevX, x, prevY, y];\n const last = out[out.length - 1];\n // Adjacent single edits merge into one hunk.\n if (last && last[0] === x && last[2] === y) {\n last[0] = prevX;\n last[2] = prevY;\n } else out.push(hunk);\n }\n x = prevX;\n y = prevY;\n }\n return out.reverse();\n}\n\n/** Where a token run starts; the end of the compared blocks when the run is empty at the end. */\nfunction posOf(list: Token[], index: number, end: number): number {\n return index < list.length ? list[index]!.pos : end;\n}\n\n/**\n * A position map between two versions of a document that changed in one\n * opaque step (a collaborator's update), built from a word-level diff so\n * text that did not change keeps its place even when edits surround it.\n * Only the top-level blocks between the first and last difference are compared.\n * `exact` is false when the change was too big to diff and the whole differing\n * span is mapped as one replacement, so ranges inside it collapse even if\n * their words survived.\n */\nexport function diffMap(before: Node, after: Node): { map: StepMap; exact: boolean } {\n const start = before.content.findDiffStart(after.content);\n if (start === null) return { map: StepMap.empty, exact: true };\n let { a: endBefore, b: endAfter } = before.content.findDiffEnd(after.content)!;\n // On repeated content the two scans can cross; widen to stay consistent.\n const overlap = start - Math.min(endBefore, endAfter);\n if (overlap > 0) {\n endBefore += overlap;\n endAfter += overlap;\n }\n // The blocks before `start` are identical, so both sides begin at the same block.\n let first = 0;\n let blockStart = 0;\n while (first < before.childCount && blockStart + before.child(first).nodeSize <= start) {\n blockStart += before.child(first).nodeSize;\n first++;\n }\n const a = tokens(before, first, blockStart, endBefore);\n const b = tokens(after, first, blockStart, endAfter);\n const found = hunks(a.list, b.list);\n if (!found) return { map: new StepMap([start, endBefore - start, endAfter - start]), exact: false };\n const ranges: number[] = [];\n for (const [aStart, aEnd, bStart, bEnd] of found) {\n const from = posOf(a.list, aStart, a.to);\n ranges.push(from, posOf(a.list, aEnd, a.to) - from, posOf(b.list, bEnd, b.to) - posOf(b.list, bStart, b.to));\n }\n return { map: new StepMap(ranges), exact: true };\n}\n","import type { Node } from \"@milkdown/kit/prose/model\";\nimport { Plugin, PluginKey, type EditorState, type Transaction } from \"@milkdown/kit/prose/state\";\nimport { Decoration, DecorationSet, type EditorView } from \"@milkdown/kit/prose/view\";\nimport { DOCUMENT_RESET_META, isCollaboratorChange } from \"blintz\";\nimport type { Mappable } from \"@milkdown/kit/prose/transform\";\n\nimport { diffMap } from \"./diff-map\";\n\nimport {\n anchorFromRange,\n docText,\n locateRange,\n offsetAt,\n positionAt,\n type AnchorMatch,\n type CommentAnchor,\n} from \"./anchor\";\n\n/** A thread as the editor needs it: an id, where it is, and whether it's resolved. */\nexport interface CommentThreadRef {\n id: string;\n anchor: CommentAnchor;\n resolved?: boolean;\n}\n\ninterface LiveRange {\n from: number;\n to: number;\n /** How confidently the thread was placed; kept as it follows edits. */\n match: AnchorMatch;\n}\n\n/**\n * Why a thread is orphaned: its anchor was never found (`missing`: retried\n * normally on every change, e.g. once the host's new document arrives), or\n * its words were deleted (`deleted`: retried only where the full stored\n * context reappears, e.g. on undo, so it never jumps to other words).\n */\nexport type OrphanKind = \"missing\" | \"deleted\";\n\nexport interface CommentsState {\n ranges: Map<string, LiveRange>;\n /** Threads whose text is gone or could not be found. */\n orphaned: Map<string, OrphanKind>;\n /** The anchor key each thread was located from, to notice a new anchor. */\n anchorKeys: Map<string, string>;\n /** The last anchor each thread was at, to find it again after a reset. */\n lastAnchors: Map<string, CommentAnchor>;\n /**\n * For a deleted thread, its words exactly as they were at the deletion.\n * Undo may restore that text or the older one (history groups quick edits),\n * so both this and `lastAnchors` are tried.\n */\n deletedAnchors: Map<string, CommentAnchor>;\n /** Where each deleted thread's words were, mapped through later edits. */\n deletedAt: Map<string, number>;\n decorations: DecorationSet;\n}\n\nexport interface CommentsDisplay {\n threads: readonly CommentThreadRef[];\n activeThreadId?: string | null;\n showResolved?: boolean;\n}\n\nexport const commentsKey = new PluginKey<CommentsState>(\"blintz-comments\");\n\ntype Meta = { sync?: CommentsDisplay; remember?: Map<string, CommentAnchor> };\n\nconst anchorKey = (anchor: CommentAnchor) =>\n `${anchor.start}:${anchor.end}:${anchor.prefix}\\u0000${anchor.quote}\\u0000${anchor.suffix}`;\n\nfunction locate(doc: Node, anchor: CommentAnchor, model = docText(doc)): LiveRange | null {\n return locateRange(doc, anchor, model);\n}\n\nfunction decorate(doc: Node, state: Omit<CommentsState, \"decorations\">, display: CommentsDisplay): DecorationSet {\n const decorations: Decoration[] = [];\n for (const thread of display.threads) {\n const range = state.ranges.get(thread.id);\n if (!range) continue;\n if (thread.resolved && !display.showResolved) continue;\n const classes = [\"blintz-comment\"];\n if (thread.resolved) classes.push(\"is-resolved\");\n if (thread.id === display.activeThreadId) classes.push(\"is-active\");\n decorations.push(\n Decoration.inline(\n range.from,\n range.to,\n { class: classes.join(\" \"), \"data-comment-id\": thread.id },\n { threadId: thread.id },\n ),\n );\n }\n return DecorationSet.create(doc, decorations);\n}\n\nfunction sync(previous: CommentsState, doc: Node, display: CommentsDisplay): CommentsState {\n const ranges = new Map<string, LiveRange>();\n const orphaned = new Map<string, OrphanKind>();\n const anchorKeys = new Map<string, string>();\n const lastAnchors = new Map<string, CommentAnchor>();\n let model: ReturnType<typeof docText> | undefined;\n for (const thread of display.threads) {\n const key = anchorKey(thread.anchor);\n anchorKeys.set(thread.id, key);\n lastAnchors.set(thread.id, previous.lastAnchors.get(thread.id) ?? thread.anchor);\n const live = previous.ranges.get(thread.id);\n // A thread already in the document stays where edits carried it; the\n // host's anchor is only used to find it (on first sight, or when the\n // host hands over a different anchor for an orphaned thread).\n if (live) {\n ranges.set(thread.id, live);\n continue;\n }\n const wasOrphaned = previous.orphaned.get(thread.id);\n if (wasOrphaned && previous.anchorKeys.get(thread.id) === key) {\n orphaned.set(thread.id, wasOrphaned);\n continue;\n }\n model ??= docText(doc);\n const found = locate(doc, thread.anchor, model);\n lastAnchors.set(thread.id, thread.anchor);\n if (found) ranges.set(thread.id, found);\n else orphaned.set(thread.id, \"missing\");\n }\n const keep = <T,>(map: Map<string, T>) => new Map([...map].filter(([id]) => orphaned.get(id) === \"deleted\"));\n const deletedAnchors = keep(previous.deletedAnchors);\n const deletedAt = keep(previous.deletedAt);\n const next = { ranges, orphaned, anchorKeys, lastAnchors, deletedAnchors, deletedAt };\n return { ...next, decorations: decorate(doc, next, display) };\n}\n\n/**\n * Whether the host replaced the document (a new `value`, as Blintz tags it).\n * Select-all and type looks identical step for step, so the step shape is\n * never used to decide this.\n */\nfunction isHostReset(tr: Transaction): boolean {\n return tr.getMeta(DOCUMENT_RESET_META) === true;\n}\n\n/**\n * How positions move through a transaction. A collaborator's change arrives\n * as a whole-document replacement, so its own mapping would collapse every\n * thread; map through what actually differs instead, word by word, as if it\n * were ordinary edits (a remote deletion orphans, a remote undo restores, and\n * edits on both sides of a comment leave it alone).\n */\nfunction mappingFor(tr: Transaction): { mapping: Mappable; exact: boolean } {\n return isCollaboratorChange(tr)\n ? (({ map, exact }) => ({ mapping: map, exact }))(diffMap(tr.before, tr.doc))\n : { mapping: tr.mapping, exact: true };\n}\n\n/**\n * A deleted thread comes back only where its words were: the anchor's quote\n * sits exactly at the (mapped) deletion point, with its full context. An\n * identical copy elsewhere never counts.\n */\nfunction restoredAt(\n model: ReturnType<typeof docText>,\n anchor: CommentAnchor,\n pos: number | undefined,\n): (LiveRange & { context: number; fullContext: number }) | null {\n if (pos === undefined) return null;\n const at = offsetAt(model, pos);\n const { text } = model;\n const { quote, prefix, suffix } = anchor;\n // Undo puts the words back at the deletion point; depending on how history\n // merged the steps, that point lands at their start or at their end.\n for (const start of [at, at - quote.length]) {\n if (start < 0 || text.slice(start, start + quote.length) !== quote) continue;\n const before = text.slice(Math.max(0, start - prefix.length), start);\n const after = text.slice(start + quote.length, start + quote.length + suffix.length);\n if (before !== prefix || after !== suffix) continue;\n const from = positionAt(model, start, \"start\");\n const to = positionAt(model, start + quote.length, \"end\");\n if (from === null || to === null || from >= to) continue;\n const fullContext = prefix.length + suffix.length;\n return { from, to, match: \"exact\", context: fullContext, fullContext };\n }\n return null;\n}\n\n/** A located range is good enough to re-attach a thread during editing. */\nfunction enoughContext(found: LiveRange & { context: number; fullContext: number }, kind: OrphanKind): boolean {\n // Deleted words must come back with all their context (undo); a thread\n // never found needs at least half, so it can't grab freshly typed words.\n return kind === \"deleted\" ? found.context === found.fullContext : found.context * 2 >= found.fullContext;\n}\n\nfunction mapThrough(previous: CommentsState, tr: Transaction, display: CommentsDisplay): CommentsState {\n const ranges = new Map<string, LiveRange>();\n const orphaned = new Map(previous.orphaned);\n const lastAnchors = new Map(previous.lastAnchors);\n const deletedAnchors = new Map(previous.deletedAnchors);\n // Keep each deletion point on the spot where undo would put the words back.\n const { mapping, exact } = mappingFor(tr);\n const deletedAt = new Map([...previous.deletedAt].map(([id, pos]) => [id, mapping.map(pos, -1)]));\n const reset = isHostReset(tr);\n let model: ReturnType<typeof docText> | undefined;\n let beforeModel: ReturnType<typeof docText> | undefined;\n for (const [id, range] of previous.ranges) {\n // Typing at either edge does not extend a comment.\n const from = mapping.map(range.from, 1);\n const to = mapping.map(range.to, -1);\n if (from < to) {\n ranges.set(id, { from, to, match: range.match });\n continue;\n }\n if (reset) {\n // A different document arrived: find the words in it.\n const last = lastAnchors.get(id);\n model ??= docText(tr.doc);\n const found = last ? locateRange(tr.doc, last, model) : null;\n if (found) ranges.set(id, found);\n else orphaned.set(id, \"missing\");\n continue;\n }\n if (!exact) {\n // A collaborator's change too big to diff collapsed the range whether\n // or not its words survived: find them again only with all of their\n // current context, so deleted words never land on the same words in\n // another sentence.\n const fresh = anchorFromRange(tr.before, range.from, range.to);\n model ??= docText(tr.doc);\n const found = fresh ? locateRange(tr.doc, fresh, model) : null;\n if (found && enoughContext(found, \"deleted\")) {\n ranges.set(id, found);\n continue;\n }\n }\n // The user deleted the words. Remember them exactly as they were, with\n // their context right now, so undo can bring the thread back, and never\n // re-attach it to the same words elsewhere.\n const fresh = anchorFromRange(tr.before, range.from, range.to);\n if (fresh) deletedAnchors.set(id, fresh);\n deletedAt.set(id, from);\n orphaned.set(id, \"deleted\");\n }\n // Orphans get another chance on every change: anywhere their anchor fits\n // after a host reset; during editing only with enough of their context.\n // A deleted thread comes back at its deletion point, or, after a change\n // too big to diff (which loses that point), wherever all its context is.\n for (const [id, kind] of previous.orphaned) {\n const candidates = [deletedAnchors.get(id), lastAnchors.get(id)].filter(\n (anchor): anchor is CommentAnchor => anchor !== undefined,\n );\n model ??= docText(tr.doc);\n for (const anchor of candidates) {\n const found =\n kind === \"deleted\" && !reset && exact\n ? restoredAt(model, anchor, deletedAt.get(id))\n : locateRange(tr.doc, anchor, model);\n // After a change too big to diff, a deleted thread returns only if its\n // words came back with it, never to a copy that was there all along.\n const wasThere = () => {\n beforeModel ??= docText(tr.before);\n const before = locateRange(tr.before, anchor, beforeModel);\n return before !== null && enoughContext(before, \"deleted\");\n };\n if (kind === \"deleted\" && !reset && !exact && found && wasThere()) continue;\n if (found && (reset || enoughContext(found, kind))) {\n ranges.set(id, found);\n orphaned.delete(id);\n deletedAnchors.delete(id);\n deletedAt.delete(id);\n break;\n }\n }\n }\n const next = { ...previous, ranges, orphaned, lastAnchors, deletedAnchors, deletedAt };\n return { ...next, decorations: decorate(tr.doc, next, display) };\n}\n\nexport interface CommentsPluginOptions {\n /** The latest display settings (threads, active thread, resolved visibility). */\n display: () => CommentsDisplay;\n /** Clicks on highlighted text: every thread under the click, innermost first. */\n onClick?: (ids: string[]) => void;\n /** Called once the plugin has a view, and with `null` on teardown. */\n onView?: (view: EditorView | null) => void;\n /** Called after each state change, for host notifications. */\n onUpdate?: (view: EditorView, previous: EditorState) => void;\n}\n\nconst EMPTY: CommentsState = {\n ranges: new Map(),\n orphaned: new Map(),\n anchorKeys: new Map(),\n lastAnchors: new Map(),\n deletedAnchors: new Map(),\n deletedAt: new Map(),\n decorations: DecorationSet.empty,\n};\n\n/**\n * Comments live beside the document, never in it: this plugin keeps each\n * thread's range in plugin state, maps it through every edit, and paints it\n * with decorations. The document, its markdown and its source mapping are\n * untouched.\n */\nexport function createCommentsPlugin(options: CommentsPluginOptions): Plugin<CommentsState> {\n return new Plugin<CommentsState>({\n key: commentsKey,\n state: {\n init: (_config, state) => sync(EMPTY, state.doc, options.display()),\n apply(tr, previous, _old, state) {\n const meta = tr.getMeta(commentsKey) as Meta | undefined;\n let next = tr.docChanged ? mapThrough(previous, tr, options.display()) : previous;\n if (meta?.sync) next = sync(next, state.doc, meta.sync);\n if (meta?.remember) {\n const lastAnchors = new Map(next.lastAnchors);\n for (const [id, anchor] of meta.remember) if (next.ranges.has(id)) lastAnchors.set(id, anchor);\n next = { ...next, lastAnchors };\n }\n return next;\n },\n },\n props: {\n decorations: (state) => commentsKey.getState(state)?.decorations,\n handleClick(view, pos) {\n const ids = threadsAt(view.state, pos);\n if (ids.length > 0) options.onClick?.(ids);\n return false;\n },\n },\n view(view) {\n options.onView?.(view);\n return {\n update: (updated, previous) => options.onUpdate?.(updated, previous),\n destroy: () => options.onView?.(null),\n };\n },\n });\n}\n\n/** Threads whose range covers `pos`, the shortest (innermost) first. */\nexport function threadsAt(state: EditorState, pos: number): string[] {\n const ranges = commentsKey.getState(state)?.ranges;\n if (!ranges) return [];\n return [...ranges]\n .filter(([, range]) => range.from <= pos && pos <= range.to)\n .sort(([, a], [, b]) => a.to - a.from - (b.to - b.from))\n .map(([id]) => id);\n}\n\n/** Fresh anchors for every live thread, from the current text. */\nexport function currentAnchors(state: EditorState): Map<string, CommentAnchor> {\n const plugin = commentsKey.getState(state);\n const anchors = new Map<string, CommentAnchor>();\n if (!plugin) return anchors;\n const model = docText(state.doc);\n for (const [id, range] of plugin.ranges) {\n const anchor = anchorFromRange(state.doc, range.from, range.to, model);\n if (anchor) anchors.set(id, anchor);\n }\n return anchors;\n}\n\nexport { anchorKey };\n","import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from \"react\";\nimport { TextSelection, type EditorState } from \"@milkdown/kit/prose/state\";\nimport type { EditorView } from \"@milkdown/kit/prose/view\";\nimport { $prose } from \"@milkdown/kit/utils\";\nimport type { BlintzPlugin } from \"blintz\";\n\nimport { anchorFromRange, type AnchorMatch, type CommentAnchor } from \"./anchor\";\nimport {\n anchorKey,\n commentsKey,\n createCommentsPlugin,\n currentAnchors,\n threadsAt,\n type CommentThreadRef,\n} from \"./plugin\";\n\nexport interface UseBlintzCommentsOptions {\n /** The document's threads, from your store. Reactive. */\n threads: readonly CommentThreadRef[];\n /** The thread to emphasize (e.g. the one open in your sidebar). */\n activeThreadId?: string | null;\n /** Show resolved threads' highlights (faintly). Defaults to `false`. */\n showResolved?: boolean;\n /** Clicked highlighted text: every thread under the click, innermost first. */\n onThreadClick?: (ids: string[]) => void;\n /**\n * Fresh anchors after the text around threads changed, a moment after\n * editing pauses. Save them so threads re-attach to the current text on the\n * next load.\n */\n onAnchorsChange?: (updates: Array<{ id: string; anchor: CommentAnchor }>) => void;\n /** Threads whose text was deleted or can't be found. Keep them; show them apart. */\n onOrphanedChange?: (ids: string[]) => void;\n}\n\nexport interface BlintzComments {\n /** Pass in `<MarkdownEditor plugins={[comments.plugin]}>`. Stable. */\n plugin: BlintzPlugin;\n /** An anchor for the current selection, to create a thread; `null` if it covers no text. */\n anchorSelection(): CommentAnchor | null;\n /**\n * A thread's current document range (`[from, to)`, e.g. for `port.range()`)\n * and how confidently it was placed: `exact`, `moved`, or `ambiguous`\n * (worth flagging). The label stays as the thread follows edits. `null` if\n * orphaned.\n */\n rangeOf(id: string): { from: number; to: number; match: AnchorMatch } | null;\n /** Threads at the caret, innermost first. */\n threadsAtSelection(): string[];\n /** Select a thread's text and scroll it into view. */\n reveal(id: string): boolean;\n}\n\nconst ANCHOR_DEBOUNCE_MS = 600;\n\n/**\n * Comments for a Blintz editor. You own the threads (storage, authors,\n * replies, UI); this hook owns where they sit in the text: it highlights\n * them, keeps them attached through edits, re-attaches them on load from\n * their anchors, and tells you when an anchor moved or a thread lost its text.\n */\nexport function useBlintzComments(options: UseBlintzCommentsOptions): BlintzComments {\n const optionsRef = useRef(options);\n useLayoutEffect(() => {\n optionsRef.current = options;\n });\n const viewRef = useRef<EditorView | null>(null);\n const reported = useRef(new Map<string, string>());\n const orphanedReported = useRef(\"\");\n const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n\n const display = useCallback(() => {\n const { threads, activeThreadId, showResolved } = optionsRef.current;\n return { threads, activeThreadId, showResolved };\n }, []);\n\n const reportOrphans = useCallback((state: EditorState) => {\n const orphaned = [...(commentsKey.getState(state)?.orphaned.keys() ?? [])].sort();\n const key = orphaned.join(\"\\u0000\");\n if (key === orphanedReported.current) return;\n orphanedReported.current = key;\n optionsRef.current.onOrphanedChange?.(orphaned);\n }, []);\n\n const reportAnchors = useCallback(() => {\n const view = viewRef.current;\n if (!view) return;\n const anchors = currentAnchors(view.state);\n const stored = new Map(optionsRef.current.threads.map((thread) => [thread.id, anchorKey(thread.anchor)]));\n const updates: Array<{ id: string; anchor: CommentAnchor }> = [];\n for (const [id, anchor] of anchors) {\n const key = anchorKey(anchor);\n // Compare with what the host last heard: our last report, else its own.\n if (key === (reported.current.get(id) ?? stored.get(id))) continue;\n reported.current.set(id, key);\n updates.push({ id, anchor });\n }\n view.dispatch(view.state.tr.setMeta(commentsKey, { remember: anchors }).setMeta(\"addToHistory\", false));\n if (updates.length > 0) optionsRef.current.onAnchorsChange?.(updates);\n }, []);\n\n const plugin = useMemo<BlintzPlugin>(\n () =>\n Object.assign(({ editor }: Parameters<BlintzPlugin>[0]) => {\n editor.use(\n $prose(() =>\n createCommentsPlugin({\n display,\n onClick: (ids) => optionsRef.current.onThreadClick?.(ids),\n onView: (view) => {\n viewRef.current = view;\n if (view) queueMicrotask(() => reportOrphans(view.state));\n },\n onUpdate: (view, previous) => {\n reportOrphans(view.state);\n if (view.state.doc !== previous.doc) {\n clearTimeout(timer.current);\n timer.current = setTimeout(reportAnchors, ANCHOR_DEBOUNCE_MS);\n }\n },\n }),\n ),\n );\n }, { preservesSourceMapping: true } as const),\n [display, reportAnchors, reportOrphans],\n );\n\n // Re-sync whenever the host's threads or display settings change.\n const { threads, activeThreadId, showResolved } = options;\n useEffect(() => {\n const view = viewRef.current;\n if (!view) return;\n view.dispatch(\n view.state.tr\n .setMeta(commentsKey, { sync: { threads, activeThreadId, showResolved } })\n .setMeta(\"addToHistory\", false),\n );\n }, [threads, activeThreadId, showResolved]);\n\n useEffect(() => () => clearTimeout(timer.current), []);\n\n return useMemo<BlintzComments>(\n () => ({\n plugin,\n anchorSelection() {\n const view = viewRef.current;\n if (!view) return null;\n const { from, to, empty } = view.state.selection;\n return empty ? null : anchorFromRange(view.state.doc, from, to);\n },\n rangeOf(id) {\n const range = viewRef.current && commentsKey.getState(viewRef.current.state)?.ranges.get(id);\n return range ? { from: range.from, to: range.to, match: range.match } : null;\n },\n threadsAtSelection() {\n const view = viewRef.current;\n return view ? threadsAt(view.state, view.state.selection.from) : [];\n },\n reveal(id) {\n const view = viewRef.current;\n const range = view && commentsKey.getState(view.state)?.ranges.get(id);\n if (!view || !range) return false;\n view.dispatch(\n view.state.tr.setSelection(TextSelection.create(view.state.doc, range.from, range.to)).scrollIntoView(),\n );\n view.focus();\n return true;\n },\n }),\n [plugin],\n );\n}\n"],"names":["MAX_EDITS","WORDS","tokens","doc","first","start","end","list","to","index","offset","block","push","node","pos","x","y","match","hunks","a","b","n","m","max","v","trace","found","d","k","out","window","at","diagonal","prevK","prevX","prevY","hunk","last","posOf","diffMap","before","after","StepMap","endBefore","endAfter","overlap","blockStart","ranges","aStart","aEnd","bStart","bEnd","from","commentsKey","PluginKey","anchorKey","anchor","locate","model","docText","locateRange","decorate","state","display","decorations","thread","range","classes","Decoration","DecorationSet","sync","previous","orphaned","anchorKeys","lastAnchors","key","live","wasOrphaned","keep","map","id","deletedAnchors","deletedAt","next","isHostReset","tr","DOCUMENT_RESET_META","mappingFor","isCollaboratorChange","exact","restoredAt","offsetAt","text","quote","prefix","suffix","positionAt","fullContext","enoughContext","kind","mapThrough","mapping","reset","beforeModel","fresh","anchorFromRange","candidates","EMPTY","createCommentsPlugin","options","Plugin","_config","_old","meta","view","ids","threadsAt","updated","currentAnchors","plugin","anchors","ANCHOR_DEBOUNCE_MS","useBlintzComments","optionsRef","useRef","useLayoutEffect","viewRef","reported","orphanedReported","timer","useCallback","threads","activeThreadId","showResolved","reportOrphans","reportAnchors","stored","updates","useMemo","editor","$prose","useEffect","empty","TextSelection"],"mappings":"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"}
@@ -0,0 +1,69 @@
1
+ import { Plugin, PluginKey, EditorState } from '@milkdown/kit/prose/state';
2
+ import { DecorationSet, EditorView } from '@milkdown/kit/prose/view';
3
+ import { AnchorMatch, CommentAnchor } from './anchor';
4
+ /** A thread as the editor needs it: an id, where it is, and whether it's resolved. */
5
+ export interface CommentThreadRef {
6
+ id: string;
7
+ anchor: CommentAnchor;
8
+ resolved?: boolean;
9
+ }
10
+ interface LiveRange {
11
+ from: number;
12
+ to: number;
13
+ /** How confidently the thread was placed; kept as it follows edits. */
14
+ match: AnchorMatch;
15
+ }
16
+ /**
17
+ * Why a thread is orphaned: its anchor was never found (`missing`: retried
18
+ * normally on every change, e.g. once the host's new document arrives), or
19
+ * its words were deleted (`deleted`: retried only where the full stored
20
+ * context reappears, e.g. on undo, so it never jumps to other words).
21
+ */
22
+ export type OrphanKind = "missing" | "deleted";
23
+ export interface CommentsState {
24
+ ranges: Map<string, LiveRange>;
25
+ /** Threads whose text is gone or could not be found. */
26
+ orphaned: Map<string, OrphanKind>;
27
+ /** The anchor key each thread was located from, to notice a new anchor. */
28
+ anchorKeys: Map<string, string>;
29
+ /** The last anchor each thread was at, to find it again after a reset. */
30
+ lastAnchors: Map<string, CommentAnchor>;
31
+ /**
32
+ * For a deleted thread, its words exactly as they were at the deletion.
33
+ * Undo may restore that text or the older one (history groups quick edits),
34
+ * so both this and `lastAnchors` are tried.
35
+ */
36
+ deletedAnchors: Map<string, CommentAnchor>;
37
+ /** Where each deleted thread's words were, mapped through later edits. */
38
+ deletedAt: Map<string, number>;
39
+ decorations: DecorationSet;
40
+ }
41
+ export interface CommentsDisplay {
42
+ threads: readonly CommentThreadRef[];
43
+ activeThreadId?: string | null;
44
+ showResolved?: boolean;
45
+ }
46
+ export declare const commentsKey: PluginKey<CommentsState>;
47
+ declare const anchorKey: (anchor: CommentAnchor) => string;
48
+ export interface CommentsPluginOptions {
49
+ /** The latest display settings (threads, active thread, resolved visibility). */
50
+ display: () => CommentsDisplay;
51
+ /** Clicks on highlighted text: every thread under the click, innermost first. */
52
+ onClick?: (ids: string[]) => void;
53
+ /** Called once the plugin has a view, and with `null` on teardown. */
54
+ onView?: (view: EditorView | null) => void;
55
+ /** Called after each state change, for host notifications. */
56
+ onUpdate?: (view: EditorView, previous: EditorState) => void;
57
+ }
58
+ /**
59
+ * Comments live beside the document, never in it: this plugin keeps each
60
+ * thread's range in plugin state, maps it through every edit, and paints it
61
+ * with decorations. The document, its markdown and its source mapping are
62
+ * untouched.
63
+ */
64
+ export declare function createCommentsPlugin(options: CommentsPluginOptions): Plugin<CommentsState>;
65
+ /** Threads whose range covers `pos`, the shortest (innermost) first. */
66
+ export declare function threadsAt(state: EditorState, pos: number): string[];
67
+ /** Fresh anchors for every live thread, from the current text. */
68
+ export declare function currentAnchors(state: EditorState): Map<string, CommentAnchor>;
69
+ export { anchorKey };
@@ -0,0 +1,52 @@
1
+ import { BlintzPlugin } from 'blintz';
2
+ import { AnchorMatch, CommentAnchor } from './anchor';
3
+ import { CommentThreadRef } from './plugin';
4
+ export interface UseBlintzCommentsOptions {
5
+ /** The document's threads, from your store. Reactive. */
6
+ threads: readonly CommentThreadRef[];
7
+ /** The thread to emphasize (e.g. the one open in your sidebar). */
8
+ activeThreadId?: string | null;
9
+ /** Show resolved threads' highlights (faintly). Defaults to `false`. */
10
+ showResolved?: boolean;
11
+ /** Clicked highlighted text: every thread under the click, innermost first. */
12
+ onThreadClick?: (ids: string[]) => void;
13
+ /**
14
+ * Fresh anchors after the text around threads changed, a moment after
15
+ * editing pauses. Save them so threads re-attach to the current text on the
16
+ * next load.
17
+ */
18
+ onAnchorsChange?: (updates: Array<{
19
+ id: string;
20
+ anchor: CommentAnchor;
21
+ }>) => void;
22
+ /** Threads whose text was deleted or can't be found. Keep them; show them apart. */
23
+ onOrphanedChange?: (ids: string[]) => void;
24
+ }
25
+ export interface BlintzComments {
26
+ /** Pass in `<MarkdownEditor plugins={[comments.plugin]}>`. Stable. */
27
+ plugin: BlintzPlugin;
28
+ /** An anchor for the current selection, to create a thread; `null` if it covers no text. */
29
+ anchorSelection(): CommentAnchor | null;
30
+ /**
31
+ * A thread's current document range (`[from, to)`, e.g. for `port.range()`)
32
+ * and how confidently it was placed: `exact`, `moved`, or `ambiguous`
33
+ * (worth flagging). The label stays as the thread follows edits. `null` if
34
+ * orphaned.
35
+ */
36
+ rangeOf(id: string): {
37
+ from: number;
38
+ to: number;
39
+ match: AnchorMatch;
40
+ } | null;
41
+ /** Threads at the caret, innermost first. */
42
+ threadsAtSelection(): string[];
43
+ /** Select a thread's text and scroll it into view. */
44
+ reveal(id: string): boolean;
45
+ }
46
+ /**
47
+ * Comments for a Blintz editor. You own the threads (storage, authors,
48
+ * replies, UI); this hook owns where they sit in the text: it highlights
49
+ * them, keeps them attached through edits, re-attaches them on load from
50
+ * their anchors, and tells you when an anchor moved or a thread lost its text.
51
+ */
52
+ export declare function useBlintzComments(options: UseBlintzCommentsOptions): BlintzComments;
package/package.json CHANGED
@@ -1,6 +1,77 @@
1
1
  {
2
2
  "name": "@blintz/comments",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Google-Docs-style comments for the Blintz editor: threads anchored to text, kept out of the markdown.",
6
+ "keywords": [
7
+ "blintz",
8
+ "comments",
9
+ "annotations",
10
+ "markdown",
11
+ "editor",
12
+ "prosemirror",
13
+ "milkdown"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "Dork Labs",
17
+ "homepage": "https://github.com/dork-labs/blintz/tree/main/packages/comments#readme",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/dork-labs/blintz.git",
21
+ "directory": "packages/comments"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/dork-labs/blintz/issues"
25
+ },
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "import": "./dist/index.js"
30
+ },
31
+ "./core": {
32
+ "types": "./dist/core.d.ts",
33
+ "import": "./dist/core.js"
34
+ },
35
+ "./styles.css": "./dist/comments.css"
36
+ },
37
+ "main": "./dist/index.js",
38
+ "module": "./dist/index.js",
39
+ "types": "./dist/index.d.ts",
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "files": [
44
+ "dist",
45
+ "README.md"
46
+ ],
47
+ "sideEffects": [
48
+ "*.css"
49
+ ],
50
+ "scripts": {
51
+ "build": "vite build",
52
+ "typecheck": "tsc --noEmit",
53
+ "prepublishOnly": "npm run build"
54
+ },
55
+ "peerDependencies": {
56
+ "@milkdown/kit": "^7.22.1",
57
+ "blintz": ">=0.8.0",
58
+ "react": ">=18",
59
+ "react-dom": ">=18"
60
+ },
61
+ "peerDependenciesMeta": {
62
+ "react": {
63
+ "optional": true
64
+ },
65
+ "react-dom": {
66
+ "optional": true
67
+ }
68
+ },
69
+ "devDependencies": {
70
+ "@types/react": "^19.2.0",
71
+ "@types/react-dom": "^19.2.0",
72
+ "@vitejs/plugin-react": "^4.7.0",
73
+ "typescript": "^5.9.0",
74
+ "vite": "^7.3.6",
75
+ "vite-plugin-dts": "^4.5.4"
76
+ }
77
+ }