@k2b/editor 0.0.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +32 -1
- package/package.json +116 -4
- package/src/BubbleMenu.tsx +126 -0
- package/src/Editor.tsx +32 -0
- package/src/SlashMenu.tsx +13 -0
- package/src/Toolbar.tsx +205 -0
- package/src/actions.ts +102 -0
- package/src/chart-schema.ts +274 -0
- package/src/charts.ts +710 -0
- package/src/collab/history.ts +39 -0
- package/src/collab/index.ts +32 -0
- package/src/collab/relative-cursor.ts +18 -0
- package/src/collab/remote-cursors.ts +358 -0
- package/src/editor-state.ts +199 -0
- package/src/formula.ts +1283 -0
- package/src/index.ts +16 -0
- package/src/markdown/_lib/cursor-zone-field.ts +338 -0
- package/src/markdown/_lib/inline-syntax.ts +9 -0
- package/src/markdown/_lib/link-opener.ts +50 -0
- package/src/markdown/_lib/list-item-line.ts +34 -0
- package/src/markdown/_lib/list-structure.ts +227 -0
- package/src/markdown/_lib/math-syntax.ts +3 -0
- package/src/markdown/_lib/table-cell.ts +74 -0
- package/src/markdown/basic.ts +37 -0
- package/src/markdown/block-previews.tsx +453 -0
- package/src/markdown/chart-block-messages.ts +5 -0
- package/src/markdown/chart-copy.ts +20 -0
- package/src/markdown/code-fence-snippets.ts +167 -0
- package/src/markdown/code-font.ts +54 -0
- package/src/markdown/completion-icon.ts +9 -0
- package/src/markdown/data-block-template.ts +11 -0
- package/src/markdown/data-blocks.ts +214 -0
- package/src/markdown/editor-scope.ts +92 -0
- package/src/markdown/fade-scroll-margins.browser-harness.ts +78 -0
- package/src/markdown/fade-scroll-margins.ts +25 -0
- package/src/markdown/image-syntax.ts +26 -0
- package/src/markdown/images.ts +192 -0
- package/src/markdown/index.ts +54 -0
- package/src/markdown/info-block-snippets.ts +159 -0
- package/src/markdown/info-blocks.ts +156 -0
- package/src/markdown/katex.ts +303 -0
- package/src/markdown/lib/chart-blocks.ts +511 -0
- package/src/markdown/lib/heading-anchors.ts +40 -0
- package/src/markdown/lib/ligatures.ts +107 -0
- package/src/markdown/lib/markdown-context.ts +72 -0
- package/src/markdown/lib/markdown-fences.ts +22 -0
- package/src/markdown/lib/markdown-image.fixtures.ts +20 -0
- package/src/markdown/lib/markdown-image.ts +36 -0
- package/src/markdown/lib/named-blocks.ts +403 -0
- package/src/markdown/lib/query-blocks.ts +145 -0
- package/src/markdown/ligatures.ts +223 -0
- package/src/markdown/links.ts +158 -0
- package/src/markdown/list-commands.ts +1124 -0
- package/src/markdown/lists.ts +290 -0
- package/src/markdown/mark.ts +103 -0
- package/src/markdown/markdown.ts +78 -0
- package/src/markdown/markup.ts +385 -0
- package/src/markdown/mermaid-export.ts +78 -0
- package/src/markdown/mermaid-preview.tsx +76 -0
- package/src/markdown/mermaid-render.ts +22 -0
- package/src/markdown/mermaid-viewport.tsx +78 -0
- package/src/markdown/mermaid.ts +242 -0
- package/src/markdown/messages.ts +17 -0
- package/src/markdown/named-blocks.ts +84 -0
- package/src/markdown/note-scroller.browser-harness.ts +84 -0
- package/src/markdown/note-scroller.ts +57 -0
- package/src/markdown/notebook-charts.tsx +142 -0
- package/src/markdown/pretty-table-messages.ts +33 -0
- package/src/markdown/pretty-table.ts +220 -0
- package/src/markdown/search-theme.ts +98 -0
- package/src/markdown/shared-tables.ts +286 -0
- package/src/markdown/sub-sup.ts +115 -0
- package/src/markdown/surface-press.ts +17 -0
- package/src/markdown/tab-indent.ts +104 -0
- package/src/markdown/table-columns.ts +188 -0
- package/src/markdown/table-formulas.ts +178 -0
- package/src/markdown/tables.ts +703 -0
- package/src/markdown/theme.ts +243 -0
- package/src/markdown/urls.ts +29 -0
- package/src/messages.ts +45 -0
- package/src/slash-menu.ts +220 -0
- package/src/styles.css +376 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { isolateHistory } from "@codemirror/commands";
|
|
2
|
+
import { EditorState, type Extension, type Transaction } from "@codemirror/state";
|
|
3
|
+
import { EditorView } from "@codemirror/view";
|
|
4
|
+
import { ySyncAnnotation } from "y-codemirror.next";
|
|
5
|
+
import type { UndoManager } from "yjs";
|
|
6
|
+
|
|
7
|
+
/** A transaction's own history boundary; a local line break, such as Enter, is an undo step of its own. */
|
|
8
|
+
const isolation = (tr: Transaction) => {
|
|
9
|
+
const explicit = tr.annotation(isolateHistory);
|
|
10
|
+
if (explicit || tr.annotation(ySyncAnnotation) || !tr.isUserEvent("input") || tr.isUserEvent("input.paste")) return explicit;
|
|
11
|
+
let lineBreak = false;
|
|
12
|
+
tr.changes.iterChanges((_from, _to, _fromB, _toB, text) => {
|
|
13
|
+
if (text.lines > 1) lineBreak = true;
|
|
14
|
+
});
|
|
15
|
+
return lineBreak ? "full" : undefined;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Honor CodeMirror's history boundaries in the Yjs undo stack: "before" and
|
|
20
|
+
* "full" close the open undo item before yCollab sends the edit to Yjs, and
|
|
21
|
+
* "after" and "full" close it once the edit is in Yjs.
|
|
22
|
+
*/
|
|
23
|
+
export const isolateYjsHistory = (undoManager: UndoManager): Extension => [
|
|
24
|
+
EditorState.transactionExtender.of((tr) => {
|
|
25
|
+
const isolate = isolation(tr);
|
|
26
|
+
if (isolate === "before" || isolate === "full") undoManager.stopCapturing();
|
|
27
|
+
return null;
|
|
28
|
+
}),
|
|
29
|
+
// Update listeners run after yCollab's view plugin wrote the transaction to Yjs.
|
|
30
|
+
EditorView.updateListener.of((update) => {
|
|
31
|
+
if (
|
|
32
|
+
update.transactions.some((tr) => {
|
|
33
|
+
const value = isolation(tr);
|
|
34
|
+
return value === "after" || value === "full";
|
|
35
|
+
})
|
|
36
|
+
)
|
|
37
|
+
undoManager.stopCapturing();
|
|
38
|
+
}),
|
|
39
|
+
];
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Extension } from "@codemirror/state";
|
|
2
|
+
import { yCollab } from "y-codemirror.next";
|
|
3
|
+
import type { Awareness } from "y-protocols/awareness";
|
|
4
|
+
import type * as Y from "yjs";
|
|
5
|
+
import { isolateYjsHistory } from "./history";
|
|
6
|
+
import { remoteCursors } from "./remote-cursors";
|
|
7
|
+
|
|
8
|
+
export { isolateYjsHistory } from "./history";
|
|
9
|
+
export { resolveTextCursor } from "./relative-cursor";
|
|
10
|
+
export { REMOTE_CARET_IDLE_CLASS, REMOTE_CARET_LABEL_IDLE_MS, remoteCursors } from "./remote-cursors";
|
|
11
|
+
|
|
12
|
+
export type CollabOptions = {
|
|
13
|
+
/** The shared text the editor shows; the application owns its document and transport. */
|
|
14
|
+
text: Y.Text;
|
|
15
|
+
/** The Yjs undo stack of this editor; without it, undo stays CodeMirror's own. */
|
|
16
|
+
undoManager?: Y.UndoManager;
|
|
17
|
+
/**
|
|
18
|
+
* Presence of the other writers. The editor publishes its own cursor as `{ anchor, head }` relative positions in
|
|
19
|
+
* the `cursor` field and draws the others' cursors with `user.name` and `user.color`.
|
|
20
|
+
*/
|
|
21
|
+
awareness?: Awareness;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Collaborative editing for `@k2b/editor`: the Yjs binding, undo steps that keep CodeMirror's history boundaries,
|
|
26
|
+
* and the other writers' cursors. The transport, such as a WebSocket provider, stays in the application.
|
|
27
|
+
*/
|
|
28
|
+
export const collab = (options: CollabOptions): Extension => [
|
|
29
|
+
yCollab(options.text, null, { undoManager: options.undoManager ?? false }),
|
|
30
|
+
options.undoManager ? isolateYjsHistory(options.undoManager) : [],
|
|
31
|
+
options.awareness ? remoteCursors(options.text, options.awareness) : [],
|
|
32
|
+
];
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import * as Y from "yjs";
|
|
2
|
+
|
|
3
|
+
/** Resolve only the already mounted text root; Yjs may create unknown named roots. */
|
|
4
|
+
export const resolveTextCursor = (ytext: Y.Text, value: unknown): Y.AbsolutePosition | null => {
|
|
5
|
+
const doc = ytext.doc;
|
|
6
|
+
if (!doc) return null;
|
|
7
|
+
try {
|
|
8
|
+
const position = Y.createRelativePositionFromJSON(value);
|
|
9
|
+
if (typeof position.tname !== "string" || position.type !== null) return null;
|
|
10
|
+
const root = doc.share.get(position.tname);
|
|
11
|
+
if (!(root instanceof Y.Text) || root !== ytext) return null;
|
|
12
|
+
const absolute = Y.createAbsolutePositionFromRelativePosition(position, doc);
|
|
13
|
+
return absolute?.type === ytext ? absolute : null;
|
|
14
|
+
} catch {
|
|
15
|
+
// Pending references and malformed transient cursor states have no position.
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { Annotation, EditorSelection, type Extension } from "@codemirror/state";
|
|
2
|
+
import { BlockType, EditorView, type LayerMarker, layer, RectangleMarker, ViewPlugin, type ViewUpdate } from "@codemirror/view";
|
|
3
|
+
import type { Awareness } from "y-protocols/awareness";
|
|
4
|
+
import * as Y from "yjs";
|
|
5
|
+
import { resolveTextCursor } from "./relative-cursor";
|
|
6
|
+
|
|
7
|
+
// Long enough to read a name after the caret stops, short enough that a pause in typing clears the text it covers.
|
|
8
|
+
export const REMOTE_CARET_LABEL_IDLE_MS = 3_000;
|
|
9
|
+
export const REMOTE_CARET_IDLE_CLASS = "cm-remoteCaret-idle";
|
|
10
|
+
|
|
11
|
+
const hasCursor = (cursor: unknown): cursor is { anchor: object; head: object } =>
|
|
12
|
+
typeof cursor === "object" &&
|
|
13
|
+
cursor !== null &&
|
|
14
|
+
"anchor" in cursor &&
|
|
15
|
+
typeof cursor.anchor === "object" &&
|
|
16
|
+
cursor.anchor !== null &&
|
|
17
|
+
"head" in cursor &&
|
|
18
|
+
typeof cursor.head === "object" &&
|
|
19
|
+
cursor.head !== null;
|
|
20
|
+
|
|
21
|
+
const remoteCursorsAnnotation = Annotation.define<boolean>();
|
|
22
|
+
|
|
23
|
+
class RemoteSelectionMarker implements LayerMarker {
|
|
24
|
+
constructor(
|
|
25
|
+
readonly rect: RectangleMarker,
|
|
26
|
+
readonly color: string,
|
|
27
|
+
) {}
|
|
28
|
+
|
|
29
|
+
eq(other: LayerMarker) {
|
|
30
|
+
return other instanceof RemoteSelectionMarker && this.rect.eq(other.rect) && this.color === other.color;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
draw() {
|
|
34
|
+
const dom = this.rect.draw();
|
|
35
|
+
dom.style.backgroundColor = this.color;
|
|
36
|
+
return dom;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
update(dom: HTMLElement, old: LayerMarker) {
|
|
40
|
+
if (!(old instanceof RemoteSelectionMarker)) return false;
|
|
41
|
+
this.rect.update(dom, old.rect);
|
|
42
|
+
dom.style.backgroundColor = this.color;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
class RemoteCaretMarker implements LayerMarker {
|
|
48
|
+
constructor(
|
|
49
|
+
readonly rect: RectangleMarker,
|
|
50
|
+
readonly color: string,
|
|
51
|
+
readonly name: string,
|
|
52
|
+
readonly idle: boolean,
|
|
53
|
+
) {}
|
|
54
|
+
|
|
55
|
+
eq(other: LayerMarker) {
|
|
56
|
+
return (
|
|
57
|
+
other instanceof RemoteCaretMarker &&
|
|
58
|
+
this.rect.eq(other.rect) &&
|
|
59
|
+
this.color === other.color &&
|
|
60
|
+
this.name === other.name &&
|
|
61
|
+
this.idle === other.idle
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
draw() {
|
|
66
|
+
const dom = this.rect.draw();
|
|
67
|
+
const dot = document.createElement("div");
|
|
68
|
+
dot.className = "cm-remoteCaretDot";
|
|
69
|
+
const label = document.createElement("div");
|
|
70
|
+
label.className = "cm-remoteCaretLabel";
|
|
71
|
+
dom.append(dot, label);
|
|
72
|
+
this.patch(dom);
|
|
73
|
+
return dom;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
update(dom: HTMLElement, old: LayerMarker) {
|
|
77
|
+
if (!(old instanceof RemoteCaretMarker)) return false;
|
|
78
|
+
this.rect.update(dom, old.rect);
|
|
79
|
+
this.patch(dom);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private patch(dom: HTMLElement) {
|
|
84
|
+
dom.style.setProperty("--remote-color", this.color);
|
|
85
|
+
dom.classList.toggle(REMOTE_CARET_IDLE_CLASS, this.idle);
|
|
86
|
+
const label = dom.querySelector(".cm-remoteCaretLabel");
|
|
87
|
+
if (label) label.textContent = this.name;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const remoteCursorTheme = EditorView.theme({
|
|
92
|
+
".cm-remoteCaretLayer, .cm-remoteSelectionLayer": { pointerEvents: "none" },
|
|
93
|
+
".cm-remoteCaret": { position: "absolute", width: "2px", marginLeft: "-1px", backgroundColor: "var(--remote-color)" },
|
|
94
|
+
// The name flag meets the top of the caret and continues its left edge.
|
|
95
|
+
".cm-remoteCaretLabel": {
|
|
96
|
+
position: "absolute",
|
|
97
|
+
left: "0",
|
|
98
|
+
bottom: "100%",
|
|
99
|
+
padding: "0 4px",
|
|
100
|
+
borderRadius: "4px 4px 4px 0",
|
|
101
|
+
backgroundColor: "var(--remote-color)",
|
|
102
|
+
color: "#fff",
|
|
103
|
+
font: "500 11px/15px var(--font-sans)",
|
|
104
|
+
whiteSpace: "nowrap",
|
|
105
|
+
userSelect: "none",
|
|
106
|
+
opacity: "1",
|
|
107
|
+
transition: "none",
|
|
108
|
+
},
|
|
109
|
+
// An idle caret keeps a dot as the hover target for its name.
|
|
110
|
+
".cm-remoteCaretDot": {
|
|
111
|
+
position: "absolute",
|
|
112
|
+
left: "-2px",
|
|
113
|
+
top: "-3px",
|
|
114
|
+
width: "6px",
|
|
115
|
+
height: "6px",
|
|
116
|
+
borderRadius: "50%",
|
|
117
|
+
backgroundColor: "var(--remote-color)",
|
|
118
|
+
opacity: "0",
|
|
119
|
+
},
|
|
120
|
+
// Only a visible dot takes the pointer; an invisible one would swallow presses on the text below it.
|
|
121
|
+
".cm-remoteCaret-idle .cm-remoteCaretDot": { opacity: "1", pointerEvents: "auto" },
|
|
122
|
+
".cm-remoteCaret-idle:not(:hover) .cm-remoteCaretLabel": { opacity: "0", transition: "opacity 300ms ease-out" },
|
|
123
|
+
"@media (prefers-reduced-motion: reduce)": { ".cm-remoteCaret-idle:not(:hover) .cm-remoteCaretLabel": { transition: "none" } },
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
export const remoteCursors = (ytext: Y.Text, awareness: Awareness): Extension => {
|
|
127
|
+
if (!ytext.doc) throw new Error("Remote cursors require a Y.Text attached to a document");
|
|
128
|
+
const doc = ytext.doc;
|
|
129
|
+
|
|
130
|
+
const plugin = ViewPlugin.fromClass(
|
|
131
|
+
class {
|
|
132
|
+
lastActive = new Map<number, number>();
|
|
133
|
+
cursorKeys = new Map<number, string>();
|
|
134
|
+
nullCursors = new Set<number>();
|
|
135
|
+
deletions: { position: Y.RelativePosition; time: number }[] = [];
|
|
136
|
+
timer: ReturnType<typeof setTimeout> | undefined;
|
|
137
|
+
|
|
138
|
+
constructor(readonly view: EditorView) {
|
|
139
|
+
this.trackCursors([...awareness.getStates().keys()]);
|
|
140
|
+
awareness.on("change", this.onAwarenessChange);
|
|
141
|
+
ytext.observe(this.onTextChange);
|
|
142
|
+
doc.on("afterTransaction", this.onTransaction);
|
|
143
|
+
this.apply();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
trackCursors(clients: number[]) {
|
|
147
|
+
for (const client of clients) {
|
|
148
|
+
if (client === doc.clientID) continue;
|
|
149
|
+
const cursor: unknown = awareness.getStates().get(client)?.cursor;
|
|
150
|
+
if (!hasCursor(cursor)) {
|
|
151
|
+
this.nullCursors.add(client);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (this.nullCursors.delete(client)) {
|
|
155
|
+
const now = Date.now();
|
|
156
|
+
this.pruneDeletions(now);
|
|
157
|
+
const position = Y.createRelativePositionFromJSON(cursor.head);
|
|
158
|
+
const head = resolveTextCursor(ytext, position);
|
|
159
|
+
const deletion = this.deletions.findIndex(({ position: deleted }) => {
|
|
160
|
+
if (Y.compareRelativePositions(position, deleted)) return true;
|
|
161
|
+
const absolute = resolveTextCursor(ytext, deleted);
|
|
162
|
+
return head?.type === ytext && absolute?.type === ytext && head.index === absolute.index;
|
|
163
|
+
});
|
|
164
|
+
if (deletion !== -1) {
|
|
165
|
+
this.lastActive.set(client, now);
|
|
166
|
+
this.deletions.splice(deletion, 1);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const key = JSON.stringify([cursor.anchor, cursor.head]);
|
|
170
|
+
if (this.cursorKeys.get(client) === key) continue;
|
|
171
|
+
this.cursorKeys.set(client, key);
|
|
172
|
+
this.lastActive.set(client, Date.now());
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
onAwarenessChange = ({ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] }) => {
|
|
177
|
+
this.trackCursors([...added, ...updated]);
|
|
178
|
+
// Keep the key when a cursor is nulled: republishing it is not activity.
|
|
179
|
+
for (const client of removed) {
|
|
180
|
+
this.lastActive.delete(client);
|
|
181
|
+
this.cursorKeys.delete(client);
|
|
182
|
+
this.nullCursors.delete(client);
|
|
183
|
+
}
|
|
184
|
+
if ([...added, ...updated, ...removed].some((client) => client !== doc.clientID)) {
|
|
185
|
+
this.apply();
|
|
186
|
+
this.redraw();
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
pruneDeletions(now: number) {
|
|
191
|
+
this.deletions = this.deletions.filter(({ time }) => now - time < REMOTE_CARET_LABEL_IDLE_MS);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
onTextChange = (event: Y.YTextEvent, transaction: Y.Transaction) => {
|
|
195
|
+
if (transaction.local) return;
|
|
196
|
+
const now = Date.now();
|
|
197
|
+
let index = 0;
|
|
198
|
+
for (const op of event.delta) {
|
|
199
|
+
if (op.retain !== undefined) index += op.retain;
|
|
200
|
+
if (op.insert !== undefined) index += typeof op.insert === "string" ? op.insert.length : 1;
|
|
201
|
+
if (op.delete === undefined) continue;
|
|
202
|
+
let matched = false;
|
|
203
|
+
for (const [client, state] of awareness.getStates()) {
|
|
204
|
+
if (client === doc.clientID || !hasCursor(state.cursor)) continue;
|
|
205
|
+
const head = resolveTextCursor(ytext, state.cursor.head);
|
|
206
|
+
if (head?.type !== ytext || head.index !== index) continue;
|
|
207
|
+
this.lastActive.set(client, now);
|
|
208
|
+
matched = true;
|
|
209
|
+
}
|
|
210
|
+
if (!matched) {
|
|
211
|
+
// The deletion can arrive before an idle sender republishes its cursor.
|
|
212
|
+
this.pruneDeletions(now);
|
|
213
|
+
this.deletions.push({ position: Y.createRelativePositionFromTypeIndex(ytext, index), time: now });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
onTransaction = (transaction: Y.Transaction) => {
|
|
219
|
+
if (transaction.local) return;
|
|
220
|
+
// Document writers and server-owned awareness envelopes have separate IDs.
|
|
221
|
+
for (const [client, state] of awareness.getStates()) {
|
|
222
|
+
const writer: unknown = state.writerId;
|
|
223
|
+
if (typeof writer !== "number") continue;
|
|
224
|
+
if ((transaction.afterState.get(writer) ?? 0) > (transaction.beforeState.get(writer) ?? 0))
|
|
225
|
+
this.lastActive.set(client, Date.now());
|
|
226
|
+
}
|
|
227
|
+
// Yjs observers have already dispatched the view update before this event.
|
|
228
|
+
this.apply();
|
|
229
|
+
this.redraw();
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
apply() {
|
|
233
|
+
if (this.timer !== undefined) clearTimeout(this.timer);
|
|
234
|
+
this.timer = undefined;
|
|
235
|
+
const now = Date.now();
|
|
236
|
+
let nextDeadline = Infinity;
|
|
237
|
+
for (const lastActive of this.lastActive.values()) {
|
|
238
|
+
const deadline = lastActive + REMOTE_CARET_LABEL_IDLE_MS;
|
|
239
|
+
if (deadline > now) nextDeadline = Math.min(nextDeadline, deadline);
|
|
240
|
+
}
|
|
241
|
+
if (nextDeadline !== Infinity)
|
|
242
|
+
this.timer = setTimeout(() => {
|
|
243
|
+
this.apply();
|
|
244
|
+
this.redraw();
|
|
245
|
+
}, nextDeadline - now);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
redraw() {
|
|
249
|
+
this.view.dispatch({ annotations: remoteCursorsAnnotation.of(true) });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
update(update: ViewUpdate) {
|
|
253
|
+
if (!update.selectionSet && !update.docChanged && !update.focusChanged) return;
|
|
254
|
+
const localState = awareness.getLocalState();
|
|
255
|
+
if (localState !== null) {
|
|
256
|
+
const hasFocus = update.view.hasFocus && update.view.dom.ownerDocument.hasFocus();
|
|
257
|
+
const selection = hasFocus ? update.state.selection.main : null;
|
|
258
|
+
const cursor: unknown = localState.cursor;
|
|
259
|
+
const currentAnchor = hasCursor(cursor) ? Y.createRelativePositionFromJSON(cursor.anchor) : null;
|
|
260
|
+
const currentHead = hasCursor(cursor) ? Y.createRelativePositionFromJSON(cursor.head) : null;
|
|
261
|
+
if (selection !== null) {
|
|
262
|
+
const anchor = Y.createRelativePositionFromTypeIndex(ytext, selection.anchor);
|
|
263
|
+
const head = Y.createRelativePositionFromTypeIndex(ytext, selection.head);
|
|
264
|
+
if (
|
|
265
|
+
!currentAnchor ||
|
|
266
|
+
!currentHead ||
|
|
267
|
+
!Y.compareRelativePositions(currentAnchor, anchor) ||
|
|
268
|
+
!Y.compareRelativePositions(currentHead, head)
|
|
269
|
+
) {
|
|
270
|
+
awareness.setLocalStateField("cursor", { anchor, head });
|
|
271
|
+
}
|
|
272
|
+
} else if (cursor != null && hasFocus) {
|
|
273
|
+
awareness.setLocalStateField("cursor", null);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
destroy() {
|
|
279
|
+
awareness.off("change", this.onAwarenessChange);
|
|
280
|
+
ytext.unobserve(this.onTextChange);
|
|
281
|
+
doc.off("afterTransaction", this.onTransaction);
|
|
282
|
+
if (this.timer !== undefined) clearTimeout(this.timer);
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
const clients = (view: EditorView) => {
|
|
288
|
+
const result: { anchor: number; head: number; color: string; name: string; idle: boolean }[] = [];
|
|
289
|
+
for (const [client, state] of awareness.getStates()) {
|
|
290
|
+
const cursor: unknown = state.cursor;
|
|
291
|
+
if (client === doc.clientID || !hasCursor(cursor)) continue;
|
|
292
|
+
const anchor = resolveTextCursor(ytext, cursor.anchor);
|
|
293
|
+
const head = resolveTextCursor(ytext, cursor.head);
|
|
294
|
+
if (anchor?.type !== ytext || head?.type !== ytext) continue;
|
|
295
|
+
const user: unknown = state.user;
|
|
296
|
+
const color = typeof user === "object" && user !== null && "color" in user && typeof user.color === "string" ? user.color : "#30bced";
|
|
297
|
+
const name = typeof user === "object" && user !== null && "name" in user && typeof user.name === "string" ? user.name : "Anonymous";
|
|
298
|
+
const lastActive = view.plugin(plugin)?.lastActive.get(client);
|
|
299
|
+
result.push({
|
|
300
|
+
anchor: anchor.index,
|
|
301
|
+
head: head.index,
|
|
302
|
+
color,
|
|
303
|
+
name,
|
|
304
|
+
idle: lastActive === undefined || Date.now() - lastActive >= REMOTE_CARET_LABEL_IDLE_MS,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
return result;
|
|
308
|
+
};
|
|
309
|
+
const update = (update: ViewUpdate) =>
|
|
310
|
+
update.docChanged ||
|
|
311
|
+
update.viewportChanged ||
|
|
312
|
+
update.geometryChanged ||
|
|
313
|
+
update.transactions.some((tr) => tr.annotation(remoteCursorsAnnotation));
|
|
314
|
+
|
|
315
|
+
return [
|
|
316
|
+
plugin,
|
|
317
|
+
layer({
|
|
318
|
+
above: false,
|
|
319
|
+
class: "cm-remoteSelectionLayer",
|
|
320
|
+
update,
|
|
321
|
+
markers: (view) =>
|
|
322
|
+
clients(view).flatMap(({ anchor, head, color }) =>
|
|
323
|
+
anchor === head
|
|
324
|
+
? []
|
|
325
|
+
: RectangleMarker.forRange(view, "cm-remoteSelection", EditorSelection.range(anchor, head)).map(
|
|
326
|
+
(rect) => new RemoteSelectionMarker(rect, color + "33"),
|
|
327
|
+
),
|
|
328
|
+
),
|
|
329
|
+
}),
|
|
330
|
+
layer({
|
|
331
|
+
above: true,
|
|
332
|
+
class: "cm-remoteCaretLayer",
|
|
333
|
+
update,
|
|
334
|
+
markers: (view) =>
|
|
335
|
+
clients(view).flatMap(({ anchor, head, color, name, idle }) => {
|
|
336
|
+
const block = view.lineBlockAt(head);
|
|
337
|
+
if (block.type === BlockType.WidgetRange) {
|
|
338
|
+
if (head < view.viewport.from || head > view.viewport.to) return [];
|
|
339
|
+
const base = view.scrollDOM.getBoundingClientRect();
|
|
340
|
+
const edge = view.contentDOM.getBoundingClientRect();
|
|
341
|
+
const padding = parseFloat(getComputedStyle(view.contentDOM).paddingLeft);
|
|
342
|
+
const rect = new RectangleMarker(
|
|
343
|
+
"cm-remoteCaret",
|
|
344
|
+
edge.left + padding - base.left + view.scrollDOM.scrollLeft,
|
|
345
|
+
view.documentTop + block.top - base.top + view.scrollDOM.scrollTop,
|
|
346
|
+
null,
|
|
347
|
+
view.defaultLineHeight,
|
|
348
|
+
);
|
|
349
|
+
return [new RemoteCaretMarker(rect, color, name, idle)];
|
|
350
|
+
}
|
|
351
|
+
return RectangleMarker.forRange(view, "cm-remoteCaret", EditorSelection.cursor(head, head > anchor ? -1 : 1)).map(
|
|
352
|
+
(rect) => new RemoteCaretMarker(rect, color, name, idle),
|
|
353
|
+
);
|
|
354
|
+
}),
|
|
355
|
+
}),
|
|
356
|
+
remoteCursorTheme,
|
|
357
|
+
];
|
|
358
|
+
};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { isolateHistory } from "@codemirror/commands";
|
|
2
|
+
import { Compartment, type EditorState, type Extension, StateEffect } from "@codemirror/state";
|
|
3
|
+
import { EditorView } from "@codemirror/view";
|
|
4
|
+
import type { TextEditorController, TextEditorControls } from "@k2b/ui/text-editor";
|
|
5
|
+
import { type Accessor, createEffect, createSignal, getOwner, onCleanup, untrack } from "solid-js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One command a person can run on the editor. The toolbar, the bubble menu and the slash menu render the same
|
|
9
|
+
* actions; each place gets its own list. `run` receives the view after the place restored the selection it acted on.
|
|
10
|
+
*/
|
|
11
|
+
export type EditorAction = {
|
|
12
|
+
id: string;
|
|
13
|
+
/** Tabler icon class without the leading `ti `, such as `ti-bold`. */
|
|
14
|
+
icon: string;
|
|
15
|
+
label: string;
|
|
16
|
+
/** CodeMirror key spec, such as `Mod-b`; places only show it, the keymap binds it. */
|
|
17
|
+
shortcut?: string;
|
|
18
|
+
run: (view: EditorView) => void | Promise<void>;
|
|
19
|
+
/** Pressed state, for toggles such as bold. */
|
|
20
|
+
isActive?: (state: EditorState) => boolean;
|
|
21
|
+
/** `false` greys the action out and ignores presses. Every action is off in a read-only editor. */
|
|
22
|
+
isEnabled?: (state: EditorState) => boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Keep a command's edits apart from the surrounding typing in the undo history: close the open undo item before it
|
|
27
|
+
* and again after its last edit, also when the command waits for a picker first. Edits inside the command group as
|
|
28
|
+
* the history groups them; a command that should undo at once dispatches one transaction. A view destroyed meanwhile
|
|
29
|
+
* ignores the closing transaction.
|
|
30
|
+
*/
|
|
31
|
+
export const asUndoStep = (view: EditorView, run: () => void | Promise<void>) => {
|
|
32
|
+
view.dispatch({ annotations: isolateHistory.of("before") });
|
|
33
|
+
const close = () => view.dispatch({ annotations: isolateHistory.of("after") });
|
|
34
|
+
let result: void | Promise<void>;
|
|
35
|
+
try {
|
|
36
|
+
result = run();
|
|
37
|
+
} catch (error) {
|
|
38
|
+
close();
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
if (result) return result.finally(close);
|
|
42
|
+
close();
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type Slot = { compartment: Compartment; value: Extension; joined: boolean };
|
|
46
|
+
|
|
47
|
+
export type EditorHandle = {
|
|
48
|
+
/** The mounted view; `undefined` before `<Editor>` mounted and after it unmounted. */
|
|
49
|
+
view: Accessor<EditorView | undefined>;
|
|
50
|
+
/** The view's latest state; every document, selection or focus change updates it. */
|
|
51
|
+
state: Accessor<EditorState | undefined>;
|
|
52
|
+
/** Whether the editor itself or one of its places has focus. */
|
|
53
|
+
focused: Accessor<boolean>;
|
|
54
|
+
/** Table controls of the shared text editor, for toolbars that edit tables. */
|
|
55
|
+
controls: Accessor<TextEditorControls | undefined>;
|
|
56
|
+
controller: Accessor<TextEditorController | undefined>;
|
|
57
|
+
/**
|
|
58
|
+
* Add an extension to the view, also before it mounts. A function is tracked: its later values reconfigure only
|
|
59
|
+
* its own slot. The slot leaves the view again when the calling owner is disposed. Changes reach a mounted view one
|
|
60
|
+
* microtask later, so a factory may read signals that change during an editor update.
|
|
61
|
+
*/
|
|
62
|
+
use: (extension: Extension | (() => Extension)) => void;
|
|
63
|
+
/** Run an action apart from surrounding edits in the undo history and keep focus in the editor; disabled actions do nothing. */
|
|
64
|
+
run: (action: Pick<EditorAction, "run" | "isEnabled">) => void | Promise<void>;
|
|
65
|
+
isActive: (action: Pick<EditorAction, "isActive">) => boolean;
|
|
66
|
+
isEnabled: (action: Pick<EditorAction, "isEnabled">) => boolean;
|
|
67
|
+
/** Everything `<Editor>` passes to the view. Hosts mounting `TextEditor` themselves add this to its extensions. */
|
|
68
|
+
extension: () => Extension;
|
|
69
|
+
/**
|
|
70
|
+
* Hand a mounted view to the handle; `<Editor>` does this from `onReady`. A host that mounts the view itself also
|
|
71
|
+
* passes the text editor's controller and table controls when it has them.
|
|
72
|
+
*/
|
|
73
|
+
attach: (view: EditorView, parts?: { controller?: TextEditorController; controls?: TextEditorControls }) => void;
|
|
74
|
+
/** Forget a view that is being destroyed; a newer attached view stays. */
|
|
75
|
+
detach: (view: EditorView) => void;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Shared state of one editor. `<Editor>` mounts the view; `<Toolbar>`, `<BubbleMenu>` and `<SlashMenu>` read and
|
|
80
|
+
* change it from wherever the host places them. There is no second document model: the state is CodeMirror's.
|
|
81
|
+
*/
|
|
82
|
+
export function createEditor(): EditorHandle {
|
|
83
|
+
const [view, setView] = createSignal<EditorView>();
|
|
84
|
+
const [state, setState] = createSignal<EditorState>();
|
|
85
|
+
const [focused, setFocused] = createSignal(false);
|
|
86
|
+
const [controller, setController] = createSignal<TextEditorController>();
|
|
87
|
+
const [controls, setControls] = createSignal<TextEditorControls>();
|
|
88
|
+
const slots = new Set<Slot>();
|
|
89
|
+
// CodeMirror refuses a dispatch while it updates the view, and a plugin that throws there is switched off for good,
|
|
90
|
+
// such as the Yjs binding. A slot's factory may run inside such an update when it reads a signal that a plugin or
|
|
91
|
+
// listener wrote, so slots reach the view one microtask later, together, after the update finished.
|
|
92
|
+
const pending = new Set<Slot>();
|
|
93
|
+
const flush = () => {
|
|
94
|
+
const current = untrack(view);
|
|
95
|
+
if (!current) return;
|
|
96
|
+
const ready = [...pending];
|
|
97
|
+
pending.clear();
|
|
98
|
+
if (!current.dom.isConnected) return;
|
|
99
|
+
const effects = ready.map((slot) => {
|
|
100
|
+
if (slot.joined) return slot.compartment.reconfigure(slot.value);
|
|
101
|
+
slot.joined = true;
|
|
102
|
+
return StateEffect.appendConfig.of(slot.compartment.of(slot.value));
|
|
103
|
+
});
|
|
104
|
+
if (effects.length) current.dispatch({ effects });
|
|
105
|
+
};
|
|
106
|
+
const schedule = (slot: Slot) => {
|
|
107
|
+
if (!pending.size) queueMicrotask(flush);
|
|
108
|
+
pending.add(slot);
|
|
109
|
+
};
|
|
110
|
+
// Listeners run inside the dispatch of whoever changed the view, maybe a host effect. They only write signals: a
|
|
111
|
+
// read here would subscribe that effect to every editor update, and its next dispatch would run it again.
|
|
112
|
+
const tracker = EditorView.updateListener.of((update) => {
|
|
113
|
+
setState(update.state);
|
|
114
|
+
if (update.focusChanged) setFocused(update.view.hasFocus);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const use = (extension: Extension | (() => Extension)) => {
|
|
118
|
+
const slot: Slot = { compartment: new Compartment(), value: [], joined: false };
|
|
119
|
+
slots.add(slot);
|
|
120
|
+
if (typeof extension === "function")
|
|
121
|
+
createEffect(() => {
|
|
122
|
+
slot.value = extension();
|
|
123
|
+
schedule(slot);
|
|
124
|
+
});
|
|
125
|
+
else {
|
|
126
|
+
slot.value = extension;
|
|
127
|
+
// A place added after the view mounted joins its configuration.
|
|
128
|
+
if (untrack(view)) schedule(slot);
|
|
129
|
+
}
|
|
130
|
+
if (getOwner())
|
|
131
|
+
onCleanup(() => {
|
|
132
|
+
slots.delete(slot);
|
|
133
|
+
// A whole editor that unmounts disposes its places in the same pass; a view torn down meanwhile keeps its
|
|
134
|
+
// configuration, so no listener reads a field that just left. A place that leaves a living editor goes.
|
|
135
|
+
slot.value = [];
|
|
136
|
+
if (slot.joined) schedule(slot);
|
|
137
|
+
else pending.delete(slot);
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const run = (action: Pick<EditorAction, "run" | "isEnabled">) => {
|
|
142
|
+
const current = view();
|
|
143
|
+
if (!current || !isEnabled(action)) return;
|
|
144
|
+
const returnFocus = () => {
|
|
145
|
+
// A command may already have handed focus into a table cell or a picker.
|
|
146
|
+
if (current.dom.isConnected && !current.dom.contains(current.dom.ownerDocument.activeElement)) current.focus();
|
|
147
|
+
};
|
|
148
|
+
return asUndoStep(current, () => {
|
|
149
|
+
const result = action.run(current);
|
|
150
|
+
if (!result) return returnFocus();
|
|
151
|
+
return result.finally(returnFocus);
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const isActive = (action: Pick<EditorAction, "isActive">) => {
|
|
156
|
+
const current = state();
|
|
157
|
+
return !!current && !!action.isActive?.(current);
|
|
158
|
+
};
|
|
159
|
+
const isEnabled = (action: Pick<EditorAction, "isEnabled">) => {
|
|
160
|
+
const current = state();
|
|
161
|
+
// CodeMirror's read-only facet stops typing, not dispatched changes, so actions check it here.
|
|
162
|
+
return !!current && !current.readOnly && action.isEnabled?.(current) !== false;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
view,
|
|
167
|
+
state,
|
|
168
|
+
focused,
|
|
169
|
+
controls,
|
|
170
|
+
controller,
|
|
171
|
+
use,
|
|
172
|
+
run,
|
|
173
|
+
isActive,
|
|
174
|
+
isEnabled,
|
|
175
|
+
extension: () => [
|
|
176
|
+
tracker,
|
|
177
|
+
[...slots].map((slot) => {
|
|
178
|
+
slot.joined = true;
|
|
179
|
+
return slot.compartment.of(slot.value);
|
|
180
|
+
}),
|
|
181
|
+
],
|
|
182
|
+
attach(next, parts) {
|
|
183
|
+
setController(parts?.controller);
|
|
184
|
+
setControls(parts?.controls);
|
|
185
|
+
setView(next);
|
|
186
|
+
setState(next.state);
|
|
187
|
+
setFocused(next.hasFocus);
|
|
188
|
+
// Values that changed before the view existed reach it now.
|
|
189
|
+
if (pending.size) queueMicrotask(flush);
|
|
190
|
+
},
|
|
191
|
+
detach(old) {
|
|
192
|
+
if (untrack(view) !== old) return;
|
|
193
|
+
setView(undefined);
|
|
194
|
+
setController(undefined);
|
|
195
|
+
setControls(undefined);
|
|
196
|
+
setFocused(false);
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|