@defensestation/y-comments-prosemirror 1.0.0-dev.84f89ec
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/dist/anchors.d.ts +27 -0
- package/dist/anchors.js +72 -0
- package/dist/doc-adapter.d.ts +12 -0
- package/dist/doc-adapter.js +105 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/plugin.d.ts +72 -0
- package/dist/plugin.js +256 -0
- package/dist/ysync.d.ts +23 -0
- package/dist/ysync.js +16 -0
- package/package.json +52 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Anchor } from "@defensestation/y-comments-core";
|
|
2
|
+
import type { EditorState } from "prosemirror-state";
|
|
3
|
+
import type { EditorView } from "prosemirror-view";
|
|
4
|
+
export declare const MAX_QUOTE_LENGTH = 2000;
|
|
5
|
+
export declare const DEFAULT_CONTEXT_LENGTH = 32;
|
|
6
|
+
/**
|
|
7
|
+
* Block separator used for every plain-text projection of the document
|
|
8
|
+
* (quoted text, prefix/suffix, DocAdapter docText/containerText). All of
|
|
9
|
+
* them must agree, or a quote that spans blocks could never re-anchor.
|
|
10
|
+
*/
|
|
11
|
+
export declare const BLOCK_SEPARATOR = "\n";
|
|
12
|
+
/** Inline leaf nodes project to one character, keeping offsets aligned. */
|
|
13
|
+
export declare const LEAF_TEXT = " ";
|
|
14
|
+
export interface AnchorFromSelectionOptions {
|
|
15
|
+
/** Max chars of prefix/suffix context stored. Default 32. */
|
|
16
|
+
contextLen?: number;
|
|
17
|
+
/**
|
|
18
|
+
* Max selection size (chars) we are willing to anchor. Larger selections
|
|
19
|
+
* are refused rather than truncated — a truncated quote would silently
|
|
20
|
+
* degrade re-anchoring. Default 2000.
|
|
21
|
+
*/
|
|
22
|
+
maxQuoteLength?: number;
|
|
23
|
+
}
|
|
24
|
+
/** Build an Anchor for the current selection. Throws on empty selections. */
|
|
25
|
+
export declare function anchorFromSelection(view: EditorView, opts?: AnchorFromSelectionOptions): Anchor;
|
|
26
|
+
/** Build an Anchor for an arbitrary document range. */
|
|
27
|
+
export declare function anchorFromRange(state: EditorState, from: number, to: number, opts?: AnchorFromSelectionOptions): Anchor;
|
package/dist/anchors.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { absolutePositionToRelativePosition } from "y-prosemirror";
|
|
2
|
+
import * as Y from "yjs";
|
|
3
|
+
import { getYSyncState } from "./ysync.js";
|
|
4
|
+
export const MAX_QUOTE_LENGTH = 2000;
|
|
5
|
+
export const DEFAULT_CONTEXT_LENGTH = 32;
|
|
6
|
+
/**
|
|
7
|
+
* Block separator used for every plain-text projection of the document
|
|
8
|
+
* (quoted text, prefix/suffix, DocAdapter docText/containerText). All of
|
|
9
|
+
* them must agree, or a quote that spans blocks could never re-anchor.
|
|
10
|
+
*/
|
|
11
|
+
export const BLOCK_SEPARATOR = "\n";
|
|
12
|
+
/** Inline leaf nodes project to one character, keeping offsets aligned. */
|
|
13
|
+
export const LEAF_TEXT = " ";
|
|
14
|
+
/** Build an Anchor for the current selection. Throws on empty selections. */
|
|
15
|
+
export function anchorFromSelection(view, opts) {
|
|
16
|
+
const { from, to } = view.state.selection;
|
|
17
|
+
return anchorFromRange(view.state, from, to, opts);
|
|
18
|
+
}
|
|
19
|
+
/** Build an Anchor for an arbitrary document range. */
|
|
20
|
+
export function anchorFromRange(state, from, to, opts) {
|
|
21
|
+
const contextLen = opts?.contextLen ?? DEFAULT_CONTEXT_LENGTH;
|
|
22
|
+
const maxQuoteLength = opts?.maxQuoteLength ?? MAX_QUOTE_LENGTH;
|
|
23
|
+
const ystate = getYSyncState(state);
|
|
24
|
+
const quotedText = state.doc.textBetween(from, to, BLOCK_SEPARATOR, LEAF_TEXT);
|
|
25
|
+
if (quotedText.trim().length === 0) {
|
|
26
|
+
throw new Error("y-comments: cannot create an anchor for an empty or whitespace-only selection.");
|
|
27
|
+
}
|
|
28
|
+
if (quotedText.length > maxQuoteLength) {
|
|
29
|
+
throw new Error(`y-comments: refusing to anchor a selection of ${quotedText.length} chars ` +
|
|
30
|
+
`(limit ${maxQuoteLength}). Comment on a smaller selection instead.`);
|
|
31
|
+
}
|
|
32
|
+
const $from = state.doc.resolve(from);
|
|
33
|
+
const $to = state.doc.resolve(to);
|
|
34
|
+
// Context clamped to the enclosing textblock on each side.
|
|
35
|
+
const prefixStart = Math.max($from.start(), from - contextLen);
|
|
36
|
+
const prefix = state.doc.textBetween(prefixStart, from, BLOCK_SEPARATOR, LEAF_TEXT);
|
|
37
|
+
const suffixEnd = Math.min($to.end(), to + contextLen);
|
|
38
|
+
const suffix = state.doc.textBetween(to, suffixEnd, BLOCK_SEPARATOR, LEAF_TEXT);
|
|
39
|
+
// Nearest ancestor with a stable id attr — BlockNote's blockContainer.
|
|
40
|
+
let containerId;
|
|
41
|
+
for (let depth = $from.depth; depth > 0; depth--) {
|
|
42
|
+
const id = $from.node(depth).attrs?.id;
|
|
43
|
+
if (typeof id === "string" && id.length > 0) {
|
|
44
|
+
containerId = id;
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// `from` associates right and `to` associates left, so typing at the
|
|
49
|
+
// edges of the range does not grow the highlight.
|
|
50
|
+
const relFrom = absolutePositionToRelativePosition(from, ystate.type, ystate.binding.mapping);
|
|
51
|
+
const relTo = withLeftAssociation(absolutePositionToRelativePosition(to, ystate.type, ystate.binding.mapping), ystate.doc);
|
|
52
|
+
return {
|
|
53
|
+
relFrom: Y.encodeRelativePosition(relFrom),
|
|
54
|
+
relTo: Y.encodeRelativePosition(relTo),
|
|
55
|
+
containerId,
|
|
56
|
+
quotedText,
|
|
57
|
+
prefix,
|
|
58
|
+
suffix,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* y-prosemirror always creates right-associated relative positions; rebuild
|
|
63
|
+
* the range end as left-associated so it sticks to the last selected
|
|
64
|
+
* character instead of whatever follows it.
|
|
65
|
+
*/
|
|
66
|
+
function withLeftAssociation(relPos, doc) {
|
|
67
|
+
const abs = Y.createAbsolutePositionFromRelativePosition(relPos, doc);
|
|
68
|
+
if (abs === null || !(abs.type instanceof Y.XmlText)) {
|
|
69
|
+
return relPos;
|
|
70
|
+
}
|
|
71
|
+
return Y.createRelativePositionFromTypeIndex(abs.type, abs.index, -1);
|
|
72
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { DocAdapter } from "@defensestation/y-comments-core";
|
|
2
|
+
import type { EditorState } from "prosemirror-state";
|
|
3
|
+
/**
|
|
4
|
+
* DocAdapter over a live y-prosemirror-bound EditorState. All resolved
|
|
5
|
+
* positions are ProseMirror document positions (`RelResolved.index`) inside
|
|
6
|
+
* a single shared position space (`RelResolved.type`), so core's
|
|
7
|
+
* "same type, from < to" exact check keeps working.
|
|
8
|
+
*
|
|
9
|
+
* Build a fresh adapter per resolution pass — text maps are cached for the
|
|
10
|
+
* lifetime of the adapter and go stale with the next document change.
|
|
11
|
+
*/
|
|
12
|
+
export declare function createDocAdapter(state: EditorState): DocAdapter;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { relativePositionToAbsolutePosition } from "y-prosemirror";
|
|
2
|
+
import * as Y from "yjs";
|
|
3
|
+
import { BLOCK_SEPARATOR, LEAF_TEXT } from "./anchors.js";
|
|
4
|
+
import { getYSyncState } from "./ysync.js";
|
|
5
|
+
/**
|
|
6
|
+
* Flatten every textblock under `root` into one string, joined with
|
|
7
|
+
* BLOCK_SEPARATOR, recording enough to map text offsets back to PM
|
|
8
|
+
* positions. Inline leaves count as one LEAF_TEXT char, matching how
|
|
9
|
+
* anchors capture quoted text.
|
|
10
|
+
*/
|
|
11
|
+
function buildTextMap(root, contentBase) {
|
|
12
|
+
let text = "";
|
|
13
|
+
const segments = [];
|
|
14
|
+
const addTextblock = (node, pmStart) => {
|
|
15
|
+
if (text.length > 0) {
|
|
16
|
+
text += BLOCK_SEPARATOR;
|
|
17
|
+
}
|
|
18
|
+
const blockText = node.textBetween(0, node.content.size, BLOCK_SEPARATOR, LEAF_TEXT);
|
|
19
|
+
segments.push({ textStart: text.length, pmStart, length: blockText.length });
|
|
20
|
+
text += blockText;
|
|
21
|
+
};
|
|
22
|
+
if (root.isTextblock) {
|
|
23
|
+
addTextblock(root, contentBase);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
root.descendants((node, pos) => {
|
|
27
|
+
if (node.isTextblock) {
|
|
28
|
+
addTextblock(node, contentBase + pos + 1);
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
return true;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return { text, segments };
|
|
35
|
+
}
|
|
36
|
+
function offsetToPmPos(map, offset) {
|
|
37
|
+
for (const seg of map.segments) {
|
|
38
|
+
if (offset >= seg.textStart && offset <= seg.textStart + seg.length) {
|
|
39
|
+
return seg.pmStart + (offset - seg.textStart);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* DocAdapter over a live y-prosemirror-bound EditorState. All resolved
|
|
46
|
+
* positions are ProseMirror document positions (`RelResolved.index`) inside
|
|
47
|
+
* a single shared position space (`RelResolved.type`), so core's
|
|
48
|
+
* "same type, from < to" exact check keeps working.
|
|
49
|
+
*
|
|
50
|
+
* Build a fresh adapter per resolution pass — text maps are cached for the
|
|
51
|
+
* lifetime of the adapter and go stale with the next document change.
|
|
52
|
+
*/
|
|
53
|
+
export function createDocAdapter(state) {
|
|
54
|
+
const ystate = getYSyncState(state);
|
|
55
|
+
const positionSpace = { doc: state.doc };
|
|
56
|
+
let docMap = null;
|
|
57
|
+
const containerMaps = new Map();
|
|
58
|
+
const getDocMap = () => {
|
|
59
|
+
if (docMap === null) {
|
|
60
|
+
docMap = buildTextMap(state.doc, 0);
|
|
61
|
+
}
|
|
62
|
+
return docMap;
|
|
63
|
+
};
|
|
64
|
+
const getContainerMap = (containerId) => {
|
|
65
|
+
if (!containerMaps.has(containerId)) {
|
|
66
|
+
let found = null;
|
|
67
|
+
state.doc.descendants((node, pos) => {
|
|
68
|
+
if (found !== null) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
if (node.attrs?.id === containerId) {
|
|
72
|
+
found = buildTextMap(node, pos + 1);
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
});
|
|
77
|
+
containerMaps.set(containerId, found);
|
|
78
|
+
}
|
|
79
|
+
return containerMaps.get(containerId) ?? null;
|
|
80
|
+
};
|
|
81
|
+
return {
|
|
82
|
+
resolveRel(encoded) {
|
|
83
|
+
const relPos = Y.decodeRelativePosition(encoded);
|
|
84
|
+
const pos = relativePositionToAbsolutePosition(ystate.doc, ystate.type, relPos, ystate.binding.mapping);
|
|
85
|
+
if (pos === null || pos < 0 || pos > state.doc.content.size) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
return { type: positionSpace, index: pos };
|
|
89
|
+
},
|
|
90
|
+
containerText(containerId) {
|
|
91
|
+
return getContainerMap(containerId)?.text ?? null;
|
|
92
|
+
},
|
|
93
|
+
docText() {
|
|
94
|
+
return getDocMap().text;
|
|
95
|
+
},
|
|
96
|
+
fromTextOffset(containerId, offset) {
|
|
97
|
+
const map = containerId === null ? getDocMap() : getContainerMap(containerId);
|
|
98
|
+
if (map === null) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
const pos = offsetToPmPos(map, offset);
|
|
102
|
+
return pos === null ? null : { type: positionSpace, index: pos };
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { anchorFromSelection, anchorFromRange, MAX_QUOTE_LENGTH, DEFAULT_CONTEXT_LENGTH, BLOCK_SEPARATOR, LEAF_TEXT, type AnchorFromSelectionOptions, } from "./anchors.js";
|
|
2
|
+
export { createDocAdapter } from "./doc-adapter.js";
|
|
3
|
+
export { getYSyncState, type YSyncState } from "./ysync.js";
|
|
4
|
+
export { yCommentsPlugin, yCommentsPluginKey, setThreads, selectThread, setPendingRange, getResolutions, getSelectedThreadId, getThreads, DEFAULT_HIGHLIGHT_CLASS, DEFAULT_ACTIVE_CLASS, DEFAULT_PENDING_CLASS, DEFAULT_DEBOUNCE_MS, type YCommentsState, type YCommentsPluginOptions, type PendingRange, } from "./plugin.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { anchorFromSelection, anchorFromRange, MAX_QUOTE_LENGTH, DEFAULT_CONTEXT_LENGTH, BLOCK_SEPARATOR, LEAF_TEXT, } from "./anchors.js";
|
|
2
|
+
export { createDocAdapter } from "./doc-adapter.js";
|
|
3
|
+
export { getYSyncState } from "./ysync.js";
|
|
4
|
+
export { yCommentsPlugin, yCommentsPluginKey, setThreads, selectThread, setPendingRange, getResolutions, getSelectedThreadId, getThreads, DEFAULT_HIGHLIGHT_CLASS, DEFAULT_ACTIVE_CLASS, DEFAULT_PENDING_CLASS, DEFAULT_DEBOUNCE_MS, } from "./plugin.js";
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { type Anchor, type Resolution, type Thread } from "@defensestation/y-comments-core";
|
|
2
|
+
import { Plugin, PluginKey, type EditorState } from "prosemirror-state";
|
|
3
|
+
import { DecorationSet, type EditorView } from "prosemirror-view";
|
|
4
|
+
export declare const DEFAULT_HIGHLIGHT_CLASS = "y-comments-highlight";
|
|
5
|
+
export declare const DEFAULT_ACTIVE_CLASS = "y-comments-active";
|
|
6
|
+
export declare const DEFAULT_PENDING_CLASS = "y-comments-pending";
|
|
7
|
+
export declare const DEFAULT_DEBOUNCE_MS = 150;
|
|
8
|
+
export interface PendingRange {
|
|
9
|
+
from: number;
|
|
10
|
+
to: number;
|
|
11
|
+
}
|
|
12
|
+
export interface YCommentsState {
|
|
13
|
+
threads: Thread[];
|
|
14
|
+
/** Bumped on every setThreads, so the view can detect thread changes. */
|
|
15
|
+
threadsVersion: number;
|
|
16
|
+
resolutions: Map<string, Resolution>;
|
|
17
|
+
selectedThreadId: string | null;
|
|
18
|
+
/** Range currently being commented on (composer open) — highlighted so
|
|
19
|
+
* the author can still see what they selected while typing. */
|
|
20
|
+
pendingRange: PendingRange | null;
|
|
21
|
+
decorations: DecorationSet;
|
|
22
|
+
}
|
|
23
|
+
export declare const yCommentsPluginKey: PluginKey<YCommentsState>;
|
|
24
|
+
export interface YCommentsPluginOptions {
|
|
25
|
+
/** Trailing debounce for full re-resolution after doc changes. Default 150 ms. */
|
|
26
|
+
debounceMs?: number;
|
|
27
|
+
/** Fired when a highlight is clicked (threadId) or the selection is cleared (null). */
|
|
28
|
+
onThreadSelect?: (threadId: string | null) => void;
|
|
29
|
+
/** Fired after every re-resolution pass with the fresh resolutions. */
|
|
30
|
+
onResolutionsChange?: (resolutions: Map<string, Resolution>) => void;
|
|
31
|
+
/**
|
|
32
|
+
* After a `reanchored` resolution whose text still equals the stored
|
|
33
|
+
* quote exactly, persist a fresh anchor via `store.updateAnchor`.
|
|
34
|
+
* Off by default: it writes to the thread store on read, which surprises
|
|
35
|
+
* audit logs and requires update permission on every reader.
|
|
36
|
+
*/
|
|
37
|
+
selfHeal?: boolean;
|
|
38
|
+
/** Store used for self-healing. Only `updateAnchor` is consulted. */
|
|
39
|
+
store?: {
|
|
40
|
+
updateAnchor?: (input: {
|
|
41
|
+
threadId: string;
|
|
42
|
+
anchor: Anchor;
|
|
43
|
+
}) => Promise<void>;
|
|
44
|
+
};
|
|
45
|
+
highlightClass?: string;
|
|
46
|
+
activeClass?: string;
|
|
47
|
+
pendingClass?: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* View-only comment highlights: threads live in an external store, anchors
|
|
51
|
+
* resolve through Yjs relative positions, and highlights render as inline
|
|
52
|
+
* decorations. Nothing here ever writes to the document or the Y.Doc.
|
|
53
|
+
*
|
|
54
|
+
* Decorations are mapped through `tr.mapping` on every transaction (so they
|
|
55
|
+
* track keystrokes instantly) and fully re-resolved on a trailing debounce
|
|
56
|
+
* (so they stay correct after structural edits).
|
|
57
|
+
*/
|
|
58
|
+
export declare function yCommentsPlugin(options?: YCommentsPluginOptions): Plugin<YCommentsState>;
|
|
59
|
+
/** Feed a fresh thread list into the plugin (dispatches a meta transaction). */
|
|
60
|
+
export declare function setThreads(view: EditorView, threads: Thread[]): void;
|
|
61
|
+
/** Select (highlight as active) a thread, or pass null to deselect. */
|
|
62
|
+
export declare function selectThread(view: EditorView, threadId: string | null): void;
|
|
63
|
+
/**
|
|
64
|
+
* Highlight the range currently being commented on (composer open), or
|
|
65
|
+
* pass null to clear. Keeps the author's selection visible while focus is
|
|
66
|
+
* in the composer.
|
|
67
|
+
*/
|
|
68
|
+
export declare function setPendingRange(view: EditorView, range: PendingRange | null): void;
|
|
69
|
+
/** Latest resolutions, keyed by thread id. Sidebars use this for ordering. */
|
|
70
|
+
export declare function getResolutions(state: EditorState): Map<string, Resolution>;
|
|
71
|
+
export declare function getSelectedThreadId(state: EditorState): string | null;
|
|
72
|
+
export declare function getThreads(state: EditorState): Thread[];
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { resolveThread, } from "@defensestation/y-comments-core";
|
|
2
|
+
import { Plugin, PluginKey } from "prosemirror-state";
|
|
3
|
+
import { Decoration, DecorationSet } from "prosemirror-view";
|
|
4
|
+
import { anchorFromRange, BLOCK_SEPARATOR, LEAF_TEXT } from "./anchors.js";
|
|
5
|
+
import { createDocAdapter } from "./doc-adapter.js";
|
|
6
|
+
export const DEFAULT_HIGHLIGHT_CLASS = "y-comments-highlight";
|
|
7
|
+
export const DEFAULT_ACTIVE_CLASS = "y-comments-active";
|
|
8
|
+
export const DEFAULT_PENDING_CLASS = "y-comments-pending";
|
|
9
|
+
export const DEFAULT_DEBOUNCE_MS = 150;
|
|
10
|
+
export const yCommentsPluginKey = new PluginKey("yComments");
|
|
11
|
+
function buildDecorations(doc, threads, resolutions, selectedThreadId, pendingRange, highlightClass, activeClass, pendingClass) {
|
|
12
|
+
const decorations = [];
|
|
13
|
+
if (pendingRange && pendingRange.from < pendingRange.to) {
|
|
14
|
+
decorations.push(Decoration.inline(Math.max(0, pendingRange.from), Math.min(doc.content.size, pendingRange.to), { class: pendingClass }));
|
|
15
|
+
}
|
|
16
|
+
for (const thread of threads) {
|
|
17
|
+
// Resolved threads get no persistent highlight (parity with BlockNote);
|
|
18
|
+
// container/orphaned threads render in the sidebar only.
|
|
19
|
+
if (thread.resolved) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const resolution = resolutions.get(thread.id);
|
|
23
|
+
if (!resolution || (resolution.kind !== "exact" && resolution.kind !== "reanchored")) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const from = Math.max(0, resolution.from.index);
|
|
27
|
+
const to = Math.min(doc.content.size, resolution.to.index);
|
|
28
|
+
if (from >= to) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const active = thread.id === selectedThreadId;
|
|
32
|
+
decorations.push(Decoration.inline(from, to, {
|
|
33
|
+
class: active ? `${highlightClass} ${activeClass}` : highlightClass,
|
|
34
|
+
"data-thread-id": thread.id,
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
return DecorationSet.create(doc, decorations);
|
|
38
|
+
}
|
|
39
|
+
function resolutionsEqual(a, b) {
|
|
40
|
+
if (a.size !== b.size) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
for (const [id, ra] of a) {
|
|
44
|
+
const rb = b.get(id);
|
|
45
|
+
if (!rb || ra.kind !== rb.kind) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
if ((ra.kind === "exact" || ra.kind === "reanchored") &&
|
|
49
|
+
(rb.kind === "exact" || rb.kind === "reanchored") &&
|
|
50
|
+
(ra.from.index !== rb.from.index || ra.to.index !== rb.to.index)) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* View-only comment highlights: threads live in an external store, anchors
|
|
58
|
+
* resolve through Yjs relative positions, and highlights render as inline
|
|
59
|
+
* decorations. Nothing here ever writes to the document or the Y.Doc.
|
|
60
|
+
*
|
|
61
|
+
* Decorations are mapped through `tr.mapping` on every transaction (so they
|
|
62
|
+
* track keystrokes instantly) and fully re-resolved on a trailing debounce
|
|
63
|
+
* (so they stay correct after structural edits).
|
|
64
|
+
*/
|
|
65
|
+
export function yCommentsPlugin(options = {}) {
|
|
66
|
+
const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
67
|
+
const highlightClass = options.highlightClass ?? DEFAULT_HIGHLIGHT_CLASS;
|
|
68
|
+
const activeClass = options.activeClass ?? DEFAULT_ACTIVE_CLASS;
|
|
69
|
+
const pendingClass = options.pendingClass ?? DEFAULT_PENDING_CLASS;
|
|
70
|
+
const rebuild = (state, doc) => buildDecorations(doc, state.threads, state.resolutions, state.selectedThreadId, state.pendingRange, highlightClass, activeClass, pendingClass);
|
|
71
|
+
return new Plugin({
|
|
72
|
+
key: yCommentsPluginKey,
|
|
73
|
+
state: {
|
|
74
|
+
init() {
|
|
75
|
+
return {
|
|
76
|
+
threads: [],
|
|
77
|
+
threadsVersion: 0,
|
|
78
|
+
resolutions: new Map(),
|
|
79
|
+
selectedThreadId: null,
|
|
80
|
+
pendingRange: null,
|
|
81
|
+
decorations: DecorationSet.empty,
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
apply(tr, prev, _oldState, newState) {
|
|
85
|
+
let next = prev;
|
|
86
|
+
if (tr.docChanged) {
|
|
87
|
+
// Map resolutions alongside the decorations: a meta rebuild (e.g.
|
|
88
|
+
// a thread-store poll landing inside the debounce window) must
|
|
89
|
+
// never rebuild from pre-edit positions.
|
|
90
|
+
const mappedResolutions = new Map();
|
|
91
|
+
for (const [id, res] of next.resolutions) {
|
|
92
|
+
if (res.kind === "exact" || res.kind === "reanchored") {
|
|
93
|
+
mappedResolutions.set(id, {
|
|
94
|
+
...res,
|
|
95
|
+
from: { ...res.from, index: tr.mapping.map(res.from.index, 1) },
|
|
96
|
+
to: { ...res.to, index: tr.mapping.map(res.to.index, -1) },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
mappedResolutions.set(id, res);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
next = {
|
|
104
|
+
...next,
|
|
105
|
+
resolutions: mappedResolutions,
|
|
106
|
+
pendingRange: next.pendingRange
|
|
107
|
+
? {
|
|
108
|
+
from: tr.mapping.map(next.pendingRange.from, 1),
|
|
109
|
+
to: tr.mapping.map(next.pendingRange.to, -1),
|
|
110
|
+
}
|
|
111
|
+
: null,
|
|
112
|
+
decorations: next.decorations.map(tr.mapping, tr.doc),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const meta = tr.getMeta(yCommentsPluginKey);
|
|
116
|
+
if (meta) {
|
|
117
|
+
switch (meta.type) {
|
|
118
|
+
case "setThreads":
|
|
119
|
+
next = { ...next, threads: meta.threads, threadsVersion: next.threadsVersion + 1 };
|
|
120
|
+
break;
|
|
121
|
+
case "setResolutions":
|
|
122
|
+
next = { ...next, resolutions: meta.resolutions };
|
|
123
|
+
break;
|
|
124
|
+
case "selectThread":
|
|
125
|
+
next = { ...next, selectedThreadId: meta.threadId };
|
|
126
|
+
break;
|
|
127
|
+
case "setPendingRange":
|
|
128
|
+
next = { ...next, pendingRange: meta.range };
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
next = { ...next, decorations: rebuild(next, newState.doc) };
|
|
132
|
+
}
|
|
133
|
+
return next;
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
view(editorView) {
|
|
137
|
+
let timer;
|
|
138
|
+
let destroyed = false;
|
|
139
|
+
const recompute = () => {
|
|
140
|
+
if (destroyed) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const state = editorView.state;
|
|
144
|
+
const pluginState = yCommentsPluginKey.getState(state);
|
|
145
|
+
if (!pluginState) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
let adapter;
|
|
149
|
+
try {
|
|
150
|
+
adapter = createDocAdapter(state);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// Editor not (yet) collaborative — nothing to resolve against.
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const resolutions = new Map();
|
|
157
|
+
for (const thread of pluginState.threads) {
|
|
158
|
+
resolutions.set(thread.id, resolveThread(thread, adapter));
|
|
159
|
+
}
|
|
160
|
+
// Skip the dispatch AND the host callback when nothing changed —
|
|
161
|
+
// otherwise every debounce tick re-renders the host UI for free.
|
|
162
|
+
if (!resolutionsEqual(pluginState.resolutions, resolutions)) {
|
|
163
|
+
editorView.dispatch(state.tr.setMeta(yCommentsPluginKey, { type: "setResolutions", resolutions }));
|
|
164
|
+
options.onResolutionsChange?.(resolutions);
|
|
165
|
+
}
|
|
166
|
+
if (options.selfHeal && options.store?.updateAnchor) {
|
|
167
|
+
for (const thread of pluginState.threads) {
|
|
168
|
+
const resolution = resolutions.get(thread.id);
|
|
169
|
+
if (resolution?.kind !== "reanchored") {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const text = state.doc.textBetween(resolution.from.index, resolution.to.index, BLOCK_SEPARATOR, LEAF_TEXT);
|
|
173
|
+
// Only heal when the re-resolved text equals the quote exactly.
|
|
174
|
+
if (text !== thread.anchor.quotedText) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const anchor = anchorFromRange(state, resolution.from.index, resolution.to.index);
|
|
179
|
+
void options.store.updateAnchor({ threadId: thread.id, anchor }).catch(() => {
|
|
180
|
+
/* healing is best-effort */
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
/* healing is best-effort */
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
const schedule = () => {
|
|
190
|
+
if (timer !== undefined) {
|
|
191
|
+
clearTimeout(timer);
|
|
192
|
+
}
|
|
193
|
+
timer = setTimeout(recompute, debounceMs);
|
|
194
|
+
};
|
|
195
|
+
schedule();
|
|
196
|
+
return {
|
|
197
|
+
update(view, prevState) {
|
|
198
|
+
const prev = yCommentsPluginKey.getState(prevState);
|
|
199
|
+
const current = yCommentsPluginKey.getState(view.state);
|
|
200
|
+
if (view.state.doc !== prevState.doc ||
|
|
201
|
+
(prev && current && prev.threadsVersion !== current.threadsVersion)) {
|
|
202
|
+
schedule();
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
destroy() {
|
|
206
|
+
destroyed = true;
|
|
207
|
+
if (timer !== undefined) {
|
|
208
|
+
clearTimeout(timer);
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
},
|
|
213
|
+
props: {
|
|
214
|
+
decorations(state) {
|
|
215
|
+
return yCommentsPluginKey.getState(state)?.decorations ?? null;
|
|
216
|
+
},
|
|
217
|
+
handleClick(view, _pos, event) {
|
|
218
|
+
const target = event.target;
|
|
219
|
+
const span = target?.closest?.("[data-thread-id]");
|
|
220
|
+
const threadId = span?.getAttribute("data-thread-id") ?? null;
|
|
221
|
+
const current = yCommentsPluginKey.getState(view.state)?.selectedThreadId ?? null;
|
|
222
|
+
if (threadId !== current) {
|
|
223
|
+
selectThread(view, threadId);
|
|
224
|
+
options.onThreadSelect?.(threadId);
|
|
225
|
+
}
|
|
226
|
+
return false;
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
/** Feed a fresh thread list into the plugin (dispatches a meta transaction). */
|
|
232
|
+
export function setThreads(view, threads) {
|
|
233
|
+
view.dispatch(view.state.tr.setMeta(yCommentsPluginKey, { type: "setThreads", threads }));
|
|
234
|
+
}
|
|
235
|
+
/** Select (highlight as active) a thread, or pass null to deselect. */
|
|
236
|
+
export function selectThread(view, threadId) {
|
|
237
|
+
view.dispatch(view.state.tr.setMeta(yCommentsPluginKey, { type: "selectThread", threadId }));
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Highlight the range currently being commented on (composer open), or
|
|
241
|
+
* pass null to clear. Keeps the author's selection visible while focus is
|
|
242
|
+
* in the composer.
|
|
243
|
+
*/
|
|
244
|
+
export function setPendingRange(view, range) {
|
|
245
|
+
view.dispatch(view.state.tr.setMeta(yCommentsPluginKey, { type: "setPendingRange", range }));
|
|
246
|
+
}
|
|
247
|
+
/** Latest resolutions, keyed by thread id. Sidebars use this for ordering. */
|
|
248
|
+
export function getResolutions(state) {
|
|
249
|
+
return yCommentsPluginKey.getState(state)?.resolutions ?? new Map();
|
|
250
|
+
}
|
|
251
|
+
export function getSelectedThreadId(state) {
|
|
252
|
+
return yCommentsPluginKey.getState(state)?.selectedThreadId ?? null;
|
|
253
|
+
}
|
|
254
|
+
export function getThreads(state) {
|
|
255
|
+
return yCommentsPluginKey.getState(state)?.threads ?? [];
|
|
256
|
+
}
|
package/dist/ysync.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Node as PMNode } from "prosemirror-model";
|
|
2
|
+
import type { EditorState } from "prosemirror-state";
|
|
3
|
+
import type * as Y from "yjs";
|
|
4
|
+
/** ProsemirrorMapping from y-prosemirror (Y type → PM node). */
|
|
5
|
+
export type YSyncMapping = Map<Y.AbstractType<any>, PMNode | PMNode[]>;
|
|
6
|
+
export interface YSyncState {
|
|
7
|
+
type: Y.XmlFragment;
|
|
8
|
+
doc: Y.Doc;
|
|
9
|
+
binding: {
|
|
10
|
+
mapping: YSyncMapping;
|
|
11
|
+
} | null;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Read the y-prosemirror sync plugin state, throwing a descriptive error if
|
|
15
|
+
* the editor is not collaborative. This package requires the y-prosemirror
|
|
16
|
+
* binding that BlockNote's collaboration mode installs — without it there
|
|
17
|
+
* are no Yjs relative positions to anchor to.
|
|
18
|
+
*/
|
|
19
|
+
export declare function getYSyncState(state: EditorState): YSyncState & {
|
|
20
|
+
binding: {
|
|
21
|
+
mapping: YSyncMapping;
|
|
22
|
+
};
|
|
23
|
+
};
|
package/dist/ysync.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ySyncPluginKey } from "y-prosemirror";
|
|
2
|
+
/**
|
|
3
|
+
* Read the y-prosemirror sync plugin state, throwing a descriptive error if
|
|
4
|
+
* the editor is not collaborative. This package requires the y-prosemirror
|
|
5
|
+
* binding that BlockNote's collaboration mode installs — without it there
|
|
6
|
+
* are no Yjs relative positions to anchor to.
|
|
7
|
+
*/
|
|
8
|
+
export function getYSyncState(state) {
|
|
9
|
+
const ystate = ySyncPluginKey.getState(state);
|
|
10
|
+
if (!ystate || !ystate.type || !ystate.doc || !ystate.binding) {
|
|
11
|
+
throw new Error("y-comments: y-prosemirror sync plugin state not found. " +
|
|
12
|
+
"Decoration-anchored comments require a collaborative editor bound to a Y.Doc " +
|
|
13
|
+
"(for BlockNote, pass the `collaboration` option when creating the editor).");
|
|
14
|
+
}
|
|
15
|
+
return ystate;
|
|
16
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@defensestation/y-comments-prosemirror",
|
|
3
|
+
"description": "ProseMirror adapter for decoration-anchored Yjs comments: anchor creation from selections, DocAdapter for the resolution chain, and a decorations plugin. No marks, no document writes.",
|
|
4
|
+
"keywords": [
|
|
5
|
+
"prosemirror",
|
|
6
|
+
"yjs",
|
|
7
|
+
"comments",
|
|
8
|
+
"decorations",
|
|
9
|
+
"y-prosemirror"
|
|
10
|
+
],
|
|
11
|
+
"version": "1.0.0-dev.84f89ec",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"main": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc -p tsconfig.build.json",
|
|
26
|
+
"test": "vitest run"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@defensestation/y-comments-core": "1.0.0-dev.84f89ec"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"prosemirror-model": "^1.19.0",
|
|
33
|
+
"prosemirror-state": "^1.4.3",
|
|
34
|
+
"prosemirror-view": "^1.31.0",
|
|
35
|
+
"y-prosemirror": "^1.2.0",
|
|
36
|
+
"yjs": "^13.6.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"prosemirror-model": "^1.25.4",
|
|
40
|
+
"prosemirror-state": "^1.4.4",
|
|
41
|
+
"prosemirror-view": "^1.41.4",
|
|
42
|
+
"y-prosemirror": "^1.3.7",
|
|
43
|
+
"y-protocols": "^1.0.6",
|
|
44
|
+
"yjs": "^13.6.31"
|
|
45
|
+
},
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "https://github.com/defensestation/y-comments",
|
|
49
|
+
"directory": "packages/y-comments-prosemirror"
|
|
50
|
+
},
|
|
51
|
+
"license": "MIT"
|
|
52
|
+
}
|