@lark-apaas/coding-template-nestjs-react-fullstack 0.1.54 → 0.1.55-alpha.20261030081308
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/package.json +1 -1
- package/template/client/src/components/business-ui/README.md +10 -0
- package/template/client/src/components/business-ui/api/user-profiles/queries.ts +1 -7
- package/template/client/src/components/business-ui/comment/README.md +177 -0
- package/template/client/src/components/business-ui/comment/ccm-document-source.ts +252 -0
- package/template/client/src/components/business-ui/comment/comment-content.tsx +231 -0
- package/template/client/src/components/business-ui/comment/comment-input.tsx +536 -0
- package/template/client/src/components/business-ui/comment/comment-provider.tsx +213 -0
- package/template/client/src/components/business-ui/comment/content.ts +261 -0
- package/template/client/src/components/business-ui/comment/dom-editor.ts +582 -0
- package/template/client/src/components/business-ui/comment/image-attachments.tsx +375 -0
- package/template/client/src/components/business-ui/comment/mention-provider.tsx +168 -0
- package/template/client/src/components/business-ui/comment/mention-suggestions.tsx +411 -0
- package/template/client/src/components/business-ui/comment/mention-view.tsx +171 -0
- package/template/client/src/components/business-ui/comment/types.ts +149 -0
- package/template/client/src/components/business-ui/user-display/user-display.tsx +0 -13
- package/template/client/src/components/business-ui/user-profile/user-profile.tsx +146 -397
- package/template/client/src/components/business-ui/user-select/user-pill.tsx +0 -13
- package/template/client/src/hooks/use-example.ts +45 -36
- package/template/client/src/pages/ExamplePage/ExamplePage.tsx +7 -8
- package/template/client/src/tailwind-theme.css +5 -6
- package/template/eslint.config.js +0 -6
- package/template/package-lock.json +94 -73
- package/template/package.json +5 -4
- package/template/server/modules/hello/hello.controller.ts +4 -8
- package/template/server/modules/hello/hello.service.ts +3 -5
- package/template/shared/api.interface.ts +1 -8
- package/template/client/src/components/business-ui/user-profile/lark-card-pool.ts +0 -217
- package/template/client/src/lib/api-error.ts +0 -36
- package/template/server/modules/hello/hello.dto.ts +0 -21
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeValue,
|
|
3
|
+
collectMentionRefs,
|
|
4
|
+
mentionLabel,
|
|
5
|
+
safeUrl,
|
|
6
|
+
} from './content';
|
|
7
|
+
import {
|
|
8
|
+
documentEmoji,
|
|
9
|
+
documentIconSvg,
|
|
10
|
+
INPUT_USER_MENTION_CLASS,
|
|
11
|
+
INPUT_USER_MENTION_LABEL_CLASS,
|
|
12
|
+
atUserLabel,
|
|
13
|
+
DOCUMENT_MENTION_CLASS,
|
|
14
|
+
DOCUMENT_MENTION_UNREADABLE_CLASS,
|
|
15
|
+
} from './mention-view';
|
|
16
|
+
import type { EntityStore } from './mention-provider';
|
|
17
|
+
import type { CommentPart, CommentValue } from './types';
|
|
18
|
+
|
|
19
|
+
type Selection = { anchor: number; focus: number };
|
|
20
|
+
type Snapshot = { value: CommentValue; selection: Selection };
|
|
21
|
+
type Bounds = { start: number; end: number; children?: number[] };
|
|
22
|
+
const length = (part: CommentPart) =>
|
|
23
|
+
part.type === 'text' ? part.text.length : 1;
|
|
24
|
+
export const contentLength = (value: CommentValue) =>
|
|
25
|
+
value.content.reduce((sum, part) => sum + length(part), 0);
|
|
26
|
+
const equal = (a: CommentValue, b: CommentValue) =>
|
|
27
|
+
JSON.stringify(a) === JSON.stringify(b);
|
|
28
|
+
|
|
29
|
+
function graphemes(text: string): { index: number; segment: string }[] {
|
|
30
|
+
const Segmenter = (
|
|
31
|
+
Intl as typeof Intl & {
|
|
32
|
+
Segmenter?: new (
|
|
33
|
+
locale?: string,
|
|
34
|
+
options?: { granularity: string },
|
|
35
|
+
) => {
|
|
36
|
+
segment: (text: string) => Iterable<{ index: number; segment: string }>;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
).Segmenter;
|
|
40
|
+
if (Segmenter)
|
|
41
|
+
return [
|
|
42
|
+
...new Segmenter(undefined, { granularity: 'grapheme' }).segment(text),
|
|
43
|
+
];
|
|
44
|
+
let offset = 0;
|
|
45
|
+
return Array.from(text, (segment) => {
|
|
46
|
+
const index = offset;
|
|
47
|
+
offset += segment.length;
|
|
48
|
+
return { index, segment };
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Offsets count a whole reference as one unit, independently of its display label. */
|
|
53
|
+
export function sliceContent(
|
|
54
|
+
value: CommentValue,
|
|
55
|
+
from: number,
|
|
56
|
+
to: number,
|
|
57
|
+
): CommentValue {
|
|
58
|
+
const content: CommentPart[] = [];
|
|
59
|
+
let offset = 0;
|
|
60
|
+
for (const part of value.content) {
|
|
61
|
+
const end = offset + length(part);
|
|
62
|
+
if (end > from && offset < to)
|
|
63
|
+
content.push(
|
|
64
|
+
part.type === 'text'
|
|
65
|
+
? {
|
|
66
|
+
type: 'text',
|
|
67
|
+
text: part.text.slice(Math.max(0, from - offset), to - offset),
|
|
68
|
+
}
|
|
69
|
+
: part,
|
|
70
|
+
);
|
|
71
|
+
offset = end;
|
|
72
|
+
}
|
|
73
|
+
return normalizeValue({ version: 1, content });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** DOM ownership stays here; React never reconciles editable children. */
|
|
77
|
+
export class DivEditor {
|
|
78
|
+
value: CommentValue;
|
|
79
|
+
composing = false;
|
|
80
|
+
private atoms = new WeakMap<Node, CommentPart>();
|
|
81
|
+
private bounds = new Map<Node, Bounds>();
|
|
82
|
+
private undoStack: Snapshot[] = [];
|
|
83
|
+
private redoStack: Snapshot[] = [];
|
|
84
|
+
private selection: Selection = { anchor: 0, focus: 0 };
|
|
85
|
+
private captured?: Snapshot;
|
|
86
|
+
private group = '';
|
|
87
|
+
private changedAt = 0;
|
|
88
|
+
private unsubscribe: () => void;
|
|
89
|
+
private destroyed = false;
|
|
90
|
+
|
|
91
|
+
constructor(
|
|
92
|
+
readonly root: HTMLDivElement,
|
|
93
|
+
value: CommentValue,
|
|
94
|
+
private store: EntityStore,
|
|
95
|
+
private strict: boolean,
|
|
96
|
+
private onChange: (value: CommentValue) => void,
|
|
97
|
+
) {
|
|
98
|
+
this.value = normalizeValue(value);
|
|
99
|
+
this.render();
|
|
100
|
+
this.unsubscribe = store.subscribe(() => {
|
|
101
|
+
if (!this.composing) this.refreshEntities();
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
destroy() {
|
|
106
|
+
this.destroyed = true;
|
|
107
|
+
this.unsubscribe();
|
|
108
|
+
}
|
|
109
|
+
private render(selection?: Selection) {
|
|
110
|
+
const nodes = this.value.content.map((part) => {
|
|
111
|
+
if (part.type === 'text') return document.createTextNode(part.text);
|
|
112
|
+
if (part.type === 'break') return document.createElement('br');
|
|
113
|
+
const span = document.createElement('span');
|
|
114
|
+
span.contentEditable = 'false';
|
|
115
|
+
span.dataset.commentAtom = '';
|
|
116
|
+
this.atoms.set(span, part);
|
|
117
|
+
this.paint(span, part);
|
|
118
|
+
return span;
|
|
119
|
+
});
|
|
120
|
+
// An ignored caret landing point also makes an empty or newline-ending body editable.
|
|
121
|
+
const tail = document.createElement('br');
|
|
122
|
+
tail.dataset.commentTail = '';
|
|
123
|
+
this.root.replaceChildren(...nodes, tail);
|
|
124
|
+
this.read();
|
|
125
|
+
if (selection) this.restore(selection);
|
|
126
|
+
this.store.ensure(collectMentionRefs(this.value));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private paint(dom: HTMLElement, part: CommentPart) {
|
|
130
|
+
if (part.type === 'unresolved-mention') {
|
|
131
|
+
dom.className = 'text-subtle-foreground';
|
|
132
|
+
dom.textContent = '引用信息缺失';
|
|
133
|
+
dom.title = '原评论缺少被提及对象的信息';
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (part.type === 'link') {
|
|
137
|
+
dom.className = 'break-words text-primary no-underline hover:underline';
|
|
138
|
+
const link = document.createElement('a');
|
|
139
|
+
link.textContent = part.url;
|
|
140
|
+
const url = safeUrl(part.url);
|
|
141
|
+
if (url) {
|
|
142
|
+
link.href = url;
|
|
143
|
+
link.target = '_blank';
|
|
144
|
+
link.rel = 'noopener noreferrer';
|
|
145
|
+
}
|
|
146
|
+
dom.replaceChildren(link);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (part.type !== 'mention') return;
|
|
150
|
+
const result = this.store.peek(part.ref);
|
|
151
|
+
const label = mentionLabel(part.ref, part.label, result, this.strict);
|
|
152
|
+
dom.setAttribute(
|
|
153
|
+
'aria-label',
|
|
154
|
+
part.ref.kind === 'user' ? atUserLabel(label) : label,
|
|
155
|
+
);
|
|
156
|
+
dom.title = label;
|
|
157
|
+
if (part.ref.kind === 'user') {
|
|
158
|
+
dom.className = INPUT_USER_MENTION_CLASS;
|
|
159
|
+
const name = document.createElement('span');
|
|
160
|
+
name.className = INPUT_USER_MENTION_LABEL_CLASS;
|
|
161
|
+
name.textContent = atUserLabel(label);
|
|
162
|
+
dom.replaceChildren(name);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const resolved = result?.status === 'resolved' ? result : undefined;
|
|
166
|
+
const href = safeUrl(
|
|
167
|
+
resolved?.href || (!this.strict ? part.href : undefined),
|
|
168
|
+
);
|
|
169
|
+
const metadata =
|
|
170
|
+
resolved?.document || (!this.strict ? part.document : undefined);
|
|
171
|
+
const title =
|
|
172
|
+
resolved ||
|
|
173
|
+
part.label ||
|
|
174
|
+
this.strict ||
|
|
175
|
+
result?.status === 'forbidden' ||
|
|
176
|
+
result?.status === 'not-found'
|
|
177
|
+
? label
|
|
178
|
+
: '';
|
|
179
|
+
dom.className = '';
|
|
180
|
+
const link = document.createElement(href ? 'a' : 'span');
|
|
181
|
+
link.className =
|
|
182
|
+
result?.status === 'forbidden' || resolved?.readable === false
|
|
183
|
+
? DOCUMENT_MENTION_UNREADABLE_CLASS
|
|
184
|
+
: DOCUMENT_MENTION_CLASS;
|
|
185
|
+
link.dataset.doclink = 'true';
|
|
186
|
+
if (href) {
|
|
187
|
+
link.setAttribute('href', href);
|
|
188
|
+
link.setAttribute('target', '_blank');
|
|
189
|
+
link.setAttribute('rel', 'noopener noreferrer');
|
|
190
|
+
}
|
|
191
|
+
if (title) {
|
|
192
|
+
const icon = document.createElement('span');
|
|
193
|
+
icon.className = '-mb-0.5 mr-0.5 inline-block text-center leading-none';
|
|
194
|
+
icon.style.width = icon.style.height = icon.style.fontSize = '14px';
|
|
195
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
196
|
+
const emoji = documentEmoji(metadata?.iconInfo);
|
|
197
|
+
if (emoji) icon.textContent = emoji;
|
|
198
|
+
else icon.innerHTML = documentIconSvg(part.ref, metadata); // Bundled SVG only.
|
|
199
|
+
link.append(icon);
|
|
200
|
+
}
|
|
201
|
+
link.append(document.createTextNode(title || href || '文档'));
|
|
202
|
+
dom.replaceChildren(link);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private refreshEntities() {
|
|
206
|
+
for (const element of this.root.querySelectorAll<HTMLElement>(
|
|
207
|
+
'[data-comment-atom]',
|
|
208
|
+
)) {
|
|
209
|
+
const part = this.atoms.get(element);
|
|
210
|
+
if (part?.type === 'mention') this.paint(element, part);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Normalize native text/BR/block edits; arbitrary HTML cannot manufacture reference IDs. */
|
|
215
|
+
read(): CommentValue {
|
|
216
|
+
const content: CommentPart[] = [];
|
|
217
|
+
let offset = 0;
|
|
218
|
+
this.bounds.clear();
|
|
219
|
+
const append = (part: CommentPart) => {
|
|
220
|
+
content.push(part);
|
|
221
|
+
offset += length(part);
|
|
222
|
+
};
|
|
223
|
+
const walk = (node: Node) => {
|
|
224
|
+
const start = offset;
|
|
225
|
+
const atom = this.atoms.get(node);
|
|
226
|
+
const element = node instanceof HTMLElement ? node : undefined;
|
|
227
|
+
if (atom) append(atom);
|
|
228
|
+
else if (node.nodeType === Node.TEXT_NODE) {
|
|
229
|
+
if (node.textContent)
|
|
230
|
+
append({
|
|
231
|
+
type: 'text',
|
|
232
|
+
text: node.textContent.replace(/\u00a0/g, ' '),
|
|
233
|
+
});
|
|
234
|
+
} else if (element?.hasAttribute('data-comment-tail')) {
|
|
235
|
+
// Not a user newline.
|
|
236
|
+
} else if (element?.tagName === 'BR') append({ type: 'break' });
|
|
237
|
+
else if (
|
|
238
|
+
!element ||
|
|
239
|
+
!['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(element.tagName)
|
|
240
|
+
) {
|
|
241
|
+
const children: number[] = [];
|
|
242
|
+
const block =
|
|
243
|
+
element !== this.root &&
|
|
244
|
+
element &&
|
|
245
|
+
['DIV', 'P', 'LI'].includes(element.tagName);
|
|
246
|
+
const filler =
|
|
247
|
+
block &&
|
|
248
|
+
node.childNodes.length === 1 &&
|
|
249
|
+
node.firstChild instanceof HTMLBRElement;
|
|
250
|
+
let previous: Node | undefined;
|
|
251
|
+
const isBlock = (item?: Node) =>
|
|
252
|
+
item instanceof HTMLElement &&
|
|
253
|
+
['DIV', 'P', 'LI'].includes(item.tagName);
|
|
254
|
+
for (const child of node.childNodes) {
|
|
255
|
+
children.push(offset);
|
|
256
|
+
if (
|
|
257
|
+
child instanceof HTMLElement &&
|
|
258
|
+
child.hasAttribute('data-comment-tail')
|
|
259
|
+
)
|
|
260
|
+
continue;
|
|
261
|
+
const emptyPrevious =
|
|
262
|
+
isBlock(previous) &&
|
|
263
|
+
(!previous!.childNodes.length ||
|
|
264
|
+
(previous!.childNodes.length === 1 &&
|
|
265
|
+
previous!.firstChild instanceof HTMLBRElement));
|
|
266
|
+
if (
|
|
267
|
+
previous &&
|
|
268
|
+
(isBlock(previous) || isBlock(child)) &&
|
|
269
|
+
(emptyPrevious || content.at(-1)?.type !== 'break')
|
|
270
|
+
)
|
|
271
|
+
append({ type: 'break' });
|
|
272
|
+
if (!filler) walk(child);
|
|
273
|
+
previous = child;
|
|
274
|
+
}
|
|
275
|
+
children.push(offset);
|
|
276
|
+
this.bounds.set(node, { start, end: offset, children });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
this.bounds.set(node, { start, end: offset });
|
|
280
|
+
};
|
|
281
|
+
walk(this.root);
|
|
282
|
+
return normalizeValue({ version: 1, content });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private offset(node: Node, index: number): number {
|
|
286
|
+
const bounds = this.bounds.get(node);
|
|
287
|
+
if (bounds)
|
|
288
|
+
return node.nodeType === Node.TEXT_NODE
|
|
289
|
+
? Math.min(bounds.end, bounds.start + index)
|
|
290
|
+
: (bounds.children?.[index] ?? (index ? bounds.end : bounds.start));
|
|
291
|
+
const parent = node.parentNode;
|
|
292
|
+
if (parent && this.atoms.has(parent)) {
|
|
293
|
+
const atom = this.bounds.get(parent)!;
|
|
294
|
+
return index ? atom.end : atom.start;
|
|
295
|
+
}
|
|
296
|
+
return parent && this.root.contains(parent) ? this.offset(parent, 0) : 0;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
readSelection(): Selection | undefined {
|
|
300
|
+
const selection = window.getSelection();
|
|
301
|
+
if (
|
|
302
|
+
!selection?.anchorNode ||
|
|
303
|
+
!selection.focusNode ||
|
|
304
|
+
!this.root.contains(selection.anchorNode) ||
|
|
305
|
+
!this.root.contains(selection.focusNode)
|
|
306
|
+
)
|
|
307
|
+
return;
|
|
308
|
+
this.read();
|
|
309
|
+
return {
|
|
310
|
+
anchor: this.offset(selection.anchorNode, selection.anchorOffset),
|
|
311
|
+
focus: this.offset(selection.focusNode, selection.focusOffset),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private point(position: number): { node: Node; offset: number } {
|
|
316
|
+
for (const [node, bounds] of this.bounds) {
|
|
317
|
+
if (
|
|
318
|
+
node.nodeType === Node.TEXT_NODE &&
|
|
319
|
+
position >= bounds.start &&
|
|
320
|
+
position <= bounds.end
|
|
321
|
+
)
|
|
322
|
+
return { node, offset: position - bounds.start };
|
|
323
|
+
if (
|
|
324
|
+
node !== this.root &&
|
|
325
|
+
position === bounds.start &&
|
|
326
|
+
(this.atoms.has(node) || node instanceof HTMLBRElement)
|
|
327
|
+
) {
|
|
328
|
+
const parent = node.parentNode!;
|
|
329
|
+
return {
|
|
330
|
+
node: parent,
|
|
331
|
+
offset: Array.prototype.indexOf.call(parent.childNodes, node),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
node: this.root,
|
|
337
|
+
offset: Math.max(0, this.root.childNodes.length - 1),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
restore(selection: Selection) {
|
|
342
|
+
const max = contentLength(this.value);
|
|
343
|
+
const anchor = this.point(Math.min(max, Math.max(0, selection.anchor)));
|
|
344
|
+
const focus = this.point(Math.min(max, Math.max(0, selection.focus)));
|
|
345
|
+
window
|
|
346
|
+
.getSelection()
|
|
347
|
+
?.setBaseAndExtent(anchor.node, anchor.offset, focus.node, focus.offset);
|
|
348
|
+
this.selection = selection;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
focus() {
|
|
352
|
+
this.root.focus();
|
|
353
|
+
this.read();
|
|
354
|
+
const end = contentLength(this.value);
|
|
355
|
+
this.restore({ anchor: end, focus: end });
|
|
356
|
+
}
|
|
357
|
+
repair() {
|
|
358
|
+
this.render(this.selection);
|
|
359
|
+
}
|
|
360
|
+
private snapshot(): Snapshot {
|
|
361
|
+
return {
|
|
362
|
+
value: this.value,
|
|
363
|
+
selection: this.readSelection() || this.selection,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
capture() {
|
|
367
|
+
this.captured = this.snapshot();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** One shared history for native input and programmatic edits; never mixes native undo. */
|
|
371
|
+
private commit(
|
|
372
|
+
value: CommentValue,
|
|
373
|
+
selection: Selection,
|
|
374
|
+
kind: string,
|
|
375
|
+
render: boolean,
|
|
376
|
+
) {
|
|
377
|
+
value = normalizeValue(value);
|
|
378
|
+
const before = this.captured || this.snapshot();
|
|
379
|
+
this.captured = undefined;
|
|
380
|
+
if (!equal(this.value, value)) {
|
|
381
|
+
const merge =
|
|
382
|
+
kind === 'insertText' &&
|
|
383
|
+
this.group === kind &&
|
|
384
|
+
Date.now() - this.changedAt < 700 &&
|
|
385
|
+
before.selection.anchor === before.selection.focus &&
|
|
386
|
+
before.selection.focus === this.selection.focus;
|
|
387
|
+
if (!merge) {
|
|
388
|
+
this.undoStack.push(before);
|
|
389
|
+
if (this.undoStack.length > 100) this.undoStack.shift();
|
|
390
|
+
}
|
|
391
|
+
this.redoStack = [];
|
|
392
|
+
this.group = kind;
|
|
393
|
+
this.changedAt = Date.now();
|
|
394
|
+
this.value = value;
|
|
395
|
+
this.selection = selection;
|
|
396
|
+
if (render) this.render(selection);
|
|
397
|
+
this.onChange(value);
|
|
398
|
+
}
|
|
399
|
+
this.selection = selection;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
input(kind = 'input') {
|
|
403
|
+
if (this.composing || this.destroyed) return;
|
|
404
|
+
const value = this.read();
|
|
405
|
+
const selection = this.readSelection() || this.selection;
|
|
406
|
+
this.commit(value, selection, kind, false);
|
|
407
|
+
this.store.ensure(collectMentionRefs(value));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
startComposition() {
|
|
411
|
+
this.capture();
|
|
412
|
+
this.composing = true;
|
|
413
|
+
this.group = '';
|
|
414
|
+
}
|
|
415
|
+
endComposition() {
|
|
416
|
+
this.composing = false;
|
|
417
|
+
this.input('composition');
|
|
418
|
+
this.refreshEntities();
|
|
419
|
+
}
|
|
420
|
+
external(value: CommentValue) {
|
|
421
|
+
value = normalizeValue(value);
|
|
422
|
+
if (equal(this.value, value)) return;
|
|
423
|
+
const selection = this.readSelection();
|
|
424
|
+
this.value = value;
|
|
425
|
+
this.undoStack = [];
|
|
426
|
+
this.redoStack = [];
|
|
427
|
+
this.group = '';
|
|
428
|
+
this.captured = undefined;
|
|
429
|
+
this.render(selection);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
replace(from: number, to: number, inserted: CommentValue, kind = 'replace') {
|
|
433
|
+
if (this.composing) return;
|
|
434
|
+
this.capture();
|
|
435
|
+
const value = {
|
|
436
|
+
version: 1 as const,
|
|
437
|
+
content: [
|
|
438
|
+
...sliceContent(this.value, 0, from).content,
|
|
439
|
+
...inserted.content,
|
|
440
|
+
...sliceContent(this.value, to, contentLength(this.value)).content,
|
|
441
|
+
],
|
|
442
|
+
};
|
|
443
|
+
const end = from + contentLength(normalizeValue(inserted));
|
|
444
|
+
this.commit(value, { anchor: end, focus: end }, kind, true);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
insert(value: CommentValue) {
|
|
448
|
+
const selection = this.readSelection() || this.selection;
|
|
449
|
+
this.replace(
|
|
450
|
+
Math.min(selection.anchor, selection.focus),
|
|
451
|
+
Math.max(selection.anchor, selection.focus),
|
|
452
|
+
value,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
clear() {
|
|
457
|
+
this.replace(
|
|
458
|
+
0,
|
|
459
|
+
contentLength(this.value),
|
|
460
|
+
{ version: 1, content: [] },
|
|
461
|
+
'clear',
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
selected() {
|
|
465
|
+
const selection = this.readSelection();
|
|
466
|
+
return selection && selection.anchor !== selection.focus
|
|
467
|
+
? sliceContent(
|
|
468
|
+
this.value,
|
|
469
|
+
Math.min(selection.anchor, selection.focus),
|
|
470
|
+
Math.max(selection.anchor, selection.focus),
|
|
471
|
+
)
|
|
472
|
+
: undefined;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
erase(backward: boolean) {
|
|
476
|
+
const selection = this.readSelection();
|
|
477
|
+
if (!selection) return;
|
|
478
|
+
let from = Math.min(selection.anchor, selection.focus);
|
|
479
|
+
let to = Math.max(selection.anchor, selection.focus);
|
|
480
|
+
if (from === to) {
|
|
481
|
+
const text = this.value.content
|
|
482
|
+
.map((part) =>
|
|
483
|
+
part.type === 'text'
|
|
484
|
+
? part.text
|
|
485
|
+
: part.type === 'break'
|
|
486
|
+
? '\n'
|
|
487
|
+
: '\ufffc',
|
|
488
|
+
)
|
|
489
|
+
.join('');
|
|
490
|
+
const segments = graphemes(text);
|
|
491
|
+
if (backward)
|
|
492
|
+
from =
|
|
493
|
+
segments.filter((segment) => segment.index < from).at(-1)?.index ??
|
|
494
|
+
from;
|
|
495
|
+
else {
|
|
496
|
+
const next = segments.find((segment) => segment.index >= to);
|
|
497
|
+
to = next ? next.index + next.segment.length : to;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
this.replace(from, to, { version: 1, content: [] }, 'delete');
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
undo(redo = false) {
|
|
504
|
+
const source = redo ? this.redoStack : this.undoStack;
|
|
505
|
+
const next = source.pop();
|
|
506
|
+
if (!next || this.composing) return;
|
|
507
|
+
(redo ? this.undoStack : this.redoStack).push(this.snapshot());
|
|
508
|
+
this.value = next.value;
|
|
509
|
+
this.group = '';
|
|
510
|
+
this.captured = undefined;
|
|
511
|
+
this.render(next.selection);
|
|
512
|
+
this.onChange(this.value);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
query(): { from: number; to: number; query: string } | undefined {
|
|
516
|
+
const selection = this.readSelection();
|
|
517
|
+
if (!selection || selection.anchor !== selection.focus || this.composing)
|
|
518
|
+
return;
|
|
519
|
+
const before = sliceContent(this.value, 0, selection.focus)
|
|
520
|
+
.content.map((part) =>
|
|
521
|
+
part.type === 'text'
|
|
522
|
+
? part.text
|
|
523
|
+
: part.type === 'break'
|
|
524
|
+
? '\n'
|
|
525
|
+
: '\ufffc',
|
|
526
|
+
)
|
|
527
|
+
.join('');
|
|
528
|
+
const match = /@([^\s@\ufffc]*)$/.exec(before);
|
|
529
|
+
if (!match || (match.index && /[a-zA-Z0-9_]/.test(before[match.index - 1])))
|
|
530
|
+
return;
|
|
531
|
+
return { from: match.index, to: selection.focus, query: match[1] };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
caretRect(): DOMRect | null {
|
|
535
|
+
const selection = window.getSelection();
|
|
536
|
+
if (
|
|
537
|
+
!selection?.rangeCount ||
|
|
538
|
+
!selection.focusNode ||
|
|
539
|
+
!this.root.contains(selection.focusNode)
|
|
540
|
+
)
|
|
541
|
+
return null;
|
|
542
|
+
const range = selection.getRangeAt(0).cloneRange();
|
|
543
|
+
range.collapse(false);
|
|
544
|
+
const rect = range.getClientRects()[0];
|
|
545
|
+
return rect?.height ? rect : this.root.getBoundingClientRect();
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export function clipboardText(data: Pick<DataTransfer, 'getData'>): string {
|
|
550
|
+
const plain = data.getData('text/plain');
|
|
551
|
+
if (plain) return plain;
|
|
552
|
+
const doc = new DOMParser().parseFromString(
|
|
553
|
+
data.getData('text/html'),
|
|
554
|
+
'text/html',
|
|
555
|
+
);
|
|
556
|
+
let text = '';
|
|
557
|
+
const visit = (node: Node) => {
|
|
558
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
559
|
+
text += node.textContent || '';
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
if (
|
|
563
|
+
!(node instanceof Element) ||
|
|
564
|
+
['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(node.tagName)
|
|
565
|
+
)
|
|
566
|
+
return;
|
|
567
|
+
if (node.tagName === 'BR') {
|
|
568
|
+
text += '\n';
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
node.childNodes.forEach(visit);
|
|
572
|
+
if (
|
|
573
|
+
['P', 'DIV', 'LI', 'TR', 'H1', 'H2', 'H3', 'BLOCKQUOTE'].includes(
|
|
574
|
+
node.tagName,
|
|
575
|
+
) &&
|
|
576
|
+
!text.endsWith('\n')
|
|
577
|
+
)
|
|
578
|
+
text += '\n';
|
|
579
|
+
};
|
|
580
|
+
doc.body.childNodes.forEach(visit);
|
|
581
|
+
return text.replace(/\n$/, '');
|
|
582
|
+
}
|