@tessera-editor/react 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/dist/index.js ADDED
@@ -0,0 +1,2006 @@
1
+ // src/Tessera.tsx
2
+ import { useCallback as useCallback3, useEffect as useEffect12, useMemo, useRef as useRef7, useState as useState13 } from "react";
3
+ import { EditorContent, useEditor } from "@tiptap/react";
4
+ import { DragHandle } from "@tiptap/extension-drag-handle-react";
5
+ import {
6
+ createTesseraExtensions,
7
+ createTesseraT
8
+ } from "@tessera-editor/core";
9
+ import { createAiController, aiSlashItems as aiSlashItems2 } from "@tessera-editor/ai";
10
+
11
+ // src/ImageNodeView.tsx
12
+ import { NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react";
13
+ import { ImageBlock } from "@tessera-editor/core";
14
+ import { useContext as useContext2, useEffect, useRef, useState } from "react";
15
+ import { createPortal } from "react-dom";
16
+
17
+ // src/context.ts
18
+ import { createContext, useContext } from "react";
19
+ var TesseraContext = createContext(null);
20
+ function useTesseraContext() {
21
+ const ctx = useContext(TesseraContext);
22
+ if (!ctx) {
23
+ throw new Error("Tessera: useTesseraContext outside provider");
24
+ }
25
+ return ctx;
26
+ }
27
+
28
+ // src/ImageNodeView.tsx
29
+ import { jsx, jsxs } from "react/jsx-runtime";
30
+ function AiImageNodeView({ node, updateAttributes, selected, decorations }) {
31
+ console.log("[imgview] decorations:", Array.isArray(decorations), decorations?.length);
32
+ const galleryAttrs = (() => {
33
+ for (const deco of decorations ?? []) {
34
+ const attrs = deco.type?.attrs;
35
+ if (attrs && "data-gallery" in attrs) {
36
+ return attrs;
37
+ }
38
+ }
39
+ return null;
40
+ })();
41
+ const { t } = useContext2(TesseraContext);
42
+ const imgRef = useRef(null);
43
+ const [preview, setPreview] = useState(null);
44
+ useEffect(() => {
45
+ if (!preview) return;
46
+ const close = (e) => {
47
+ if (e.key === "Escape") setPreview(null);
48
+ };
49
+ window.addEventListener("keydown", close);
50
+ return () => window.removeEventListener("keydown", close);
51
+ }, [preview]);
52
+ const { src, alt, width, align } = node.attrs;
53
+ const startResize = (event) => {
54
+ event.preventDefault();
55
+ event.stopPropagation();
56
+ const startX = event.clientX;
57
+ const startWidth = imgRef.current?.getBoundingClientRect().width ?? width ?? 400;
58
+ const onMove = (e) => {
59
+ const next = Math.round(Math.max(80, Math.min(startWidth + (e.clientX - startX), 1200)));
60
+ updateAttributes({ width: next });
61
+ };
62
+ const onUp = () => {
63
+ window.removeEventListener("pointermove", onMove);
64
+ window.removeEventListener("pointerup", onUp);
65
+ };
66
+ window.addEventListener("pointermove", onMove);
67
+ window.addEventListener("pointerup", onUp);
68
+ };
69
+ if (!src) {
70
+ return /* @__PURE__ */ jsx(NodeViewWrapper, { className: "tessera-image tessera-image-empty", "data-selected": selected, children: /* @__PURE__ */ jsx("span", { children: t("imageUploadFailed") }) });
71
+ }
72
+ return /* @__PURE__ */ jsxs(
73
+ NodeViewWrapper,
74
+ {
75
+ className: "tessera-image",
76
+ "data-align": align,
77
+ "data-selected": selected,
78
+ "data-gallery": galleryAttrs ? "true" : void 0,
79
+ "data-gallery-index": galleryAttrs?.["data-gallery-index"],
80
+ "data-gallery-size": galleryAttrs?.["data-gallery-size"],
81
+ children: [
82
+ /* @__PURE__ */ jsxs("div", { className: "tessera-image-frame", style: width ? { width: `${width}px` } : void 0, children: [
83
+ /* @__PURE__ */ jsx(
84
+ "img",
85
+ {
86
+ ref: imgRef,
87
+ src,
88
+ alt: alt ?? "",
89
+ draggable: false,
90
+ style: { cursor: "zoom-in" },
91
+ onClick: (e) => {
92
+ e.stopPropagation();
93
+ setPreview(src);
94
+ }
95
+ }
96
+ ),
97
+ /* @__PURE__ */ jsx("div", { className: "tessera-image-resize", onPointerDown: startResize, title: "\u2194" })
98
+ ] }),
99
+ preview ? createPortal(
100
+ /* @__PURE__ */ jsx("div", { className: "tessera-lightbox", onClick: () => setPreview(null), children: /* @__PURE__ */ jsx("img", { src: preview, alt: alt ?? "" }) }),
101
+ document.body
102
+ ) : null,
103
+ /* @__PURE__ */ jsxs("div", { className: "tessera-image-align", children: [
104
+ /* @__PURE__ */ jsx("button", { type: "button", title: t("imageAlignLeft"), "data-on": align === "left", onClick: () => updateAttributes({ align: "left" }), children: "\u2B70" }),
105
+ /* @__PURE__ */ jsx("button", { type: "button", title: t("imageAlignCenter"), "data-on": align === "center", onClick: () => updateAttributes({ align: "center" }), children: "\u2B64" }),
106
+ /* @__PURE__ */ jsx("button", { type: "button", title: t("imageAlignFull"), "data-on": align === "full", onClick: () => updateAttributes({ align: "full" }), children: "\u2B65" })
107
+ ] })
108
+ ]
109
+ }
110
+ );
111
+ }
112
+ var ImageBlockView = ImageBlock.extend({
113
+ addNodeView() {
114
+ return ReactNodeViewRenderer(AiImageNodeView);
115
+ }
116
+ });
117
+
118
+ // src/TableNodeViews.tsx
119
+ import { useContext as useContext3, useState as useState2 } from "react";
120
+ import { NodeViewWrapper as NodeViewWrapper2, NodeViewContent, ReactNodeViewRenderer as ReactNodeViewRenderer2 } from "@tiptap/react";
121
+ import {
122
+ AiTableCell,
123
+ AiTableHeader,
124
+ TABLE_COLUMN_KINDS,
125
+ tableToCsvAt
126
+ } from "@tessera-editor/core";
127
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
128
+ function kindLabel(t, kind) {
129
+ switch (kind) {
130
+ case "checkbox":
131
+ return t("colTypeCheckbox");
132
+ case "select":
133
+ return t("colTypeSelect");
134
+ case "multiSelect":
135
+ return t("colTypeMultiSelect");
136
+ case "number":
137
+ return t("colTypeNumber");
138
+ case "date":
139
+ return t("colTypeDate");
140
+ case "link":
141
+ return t("colTypeLink");
142
+ default:
143
+ return t("colTypeText");
144
+ }
145
+ }
146
+ function columnIndex(props) {
147
+ const pos = props.getPos();
148
+ if (typeof pos !== "number") {
149
+ return 0;
150
+ }
151
+ const $pos = props.editor.state.doc.resolve(pos);
152
+ return $pos.index($pos.depth);
153
+ }
154
+ function AiTableHeaderView(props) {
155
+ const { editor, t } = useContext3(TesseraContext);
156
+ const [open, setOpen] = useState2(false);
157
+ const index = columnIndex(props);
158
+ const run = (fn) => {
159
+ setOpen(false);
160
+ const pos = props.getPos();
161
+ if (typeof pos === "number") {
162
+ editor.commands.setTextSelection(pos + 2);
163
+ }
164
+ void fn();
165
+ };
166
+ return /* @__PURE__ */ jsxs2(NodeViewWrapper2, { as: "th", className: "tessera-th", "data-index": index, children: [
167
+ /* @__PURE__ */ jsx2(NodeViewContent, { className: "tessera-th-content" }),
168
+ /* @__PURE__ */ jsx2(
169
+ "button",
170
+ {
171
+ type: "button",
172
+ className: "tessera-col-menu-btn",
173
+ contentEditable: false,
174
+ title: t("colMenuTitle"),
175
+ onMouseDown: (e) => e.preventDefault(),
176
+ onClick: () => setOpen((v) => !v),
177
+ children: "\u2304"
178
+ }
179
+ ),
180
+ open ? /* @__PURE__ */ jsxs2("div", { className: "tessera-popover tessera-col-menu", contentEditable: false, onMouseDown: (e) => e.preventDefault(), children: [
181
+ /* @__PURE__ */ jsx2("div", { className: "tessera-menu-section", children: TABLE_COLUMN_KINDS.map((kind) => /* @__PURE__ */ jsx2("button", { type: "button", onClick: () => run(() => editor.commands.setColumnType(index, kind)), children: kindLabel(t, kind) }, kind)) }),
182
+ /* @__PURE__ */ jsx2("div", { className: "tessera-menu-sep" }),
183
+ /* @__PURE__ */ jsx2("button", { type: "button", onClick: () => run(() => editor.commands.sortTableByColumn(index, "asc")), children: t("colMenuSortAsc") }),
184
+ /* @__PURE__ */ jsx2("button", { type: "button", onClick: () => run(() => editor.commands.sortTableByColumn(index, "desc")), children: t("colMenuSortDesc") }),
185
+ /* @__PURE__ */ jsx2("div", { className: "tessera-menu-sep" }),
186
+ /* @__PURE__ */ jsx2("button", { type: "button", onClick: () => run(() => editor.commands.addColumnBefore()), children: t("colMenuInsertLeft") }),
187
+ /* @__PURE__ */ jsx2("button", { type: "button", onClick: () => run(() => editor.commands.addColumnAfter()), children: t("colMenuInsertRight") }),
188
+ /* @__PURE__ */ jsx2("button", { type: "button", className: "tessera-danger", onClick: () => run(() => editor.commands.deleteColumn()), children: t("colMenuDelete") }),
189
+ /* @__PURE__ */ jsx2("div", { className: "tessera-menu-sep" }),
190
+ /* @__PURE__ */ jsx2("button", { type: "button", onClick: () => run(() => editor.commands.toggleHeaderRow()), children: t("tableToggleHeader") }),
191
+ /* @__PURE__ */ jsx2(
192
+ "button",
193
+ {
194
+ type: "button",
195
+ onClick: () => run(() => {
196
+ const csv = tableToCsvAt(editor);
197
+ if (csv) {
198
+ void navigator.clipboard.writeText(csv);
199
+ }
200
+ }),
201
+ children: t("tableCopyCsv")
202
+ }
203
+ ),
204
+ /* @__PURE__ */ jsx2("button", { type: "button", className: "tessera-danger", onClick: () => run(() => editor.commands.deleteTable()), children: t("tableDelete") })
205
+ ] }) : null
206
+ ] });
207
+ }
208
+ function AiTableCellView(props) {
209
+ const { editor } = useContext3(TesseraContext);
210
+ const value = props.node.attrs.value;
211
+ const kind = (() => {
212
+ const pos = props.getPos();
213
+ if (typeof pos !== "number") {
214
+ return "text";
215
+ }
216
+ const $pos = editor.state.doc.resolve(pos);
217
+ let tableDepth = -1;
218
+ for (let d = $pos.depth; d >= 1; d--) {
219
+ if ($pos.node(d).type.name === "table") {
220
+ tableDepth = d;
221
+ break;
222
+ }
223
+ }
224
+ if (tableDepth < 1) {
225
+ return "text";
226
+ }
227
+ const table = $pos.node(tableDepth);
228
+ const rowDepth = tableDepth + 1;
229
+ const cellIndex = $pos.index(rowDepth);
230
+ const types = Array.isArray(table.attrs.types) ? table.attrs.types : [];
231
+ return types[cellIndex] ?? "text";
232
+ })();
233
+ if (kind === "text") {
234
+ return /* @__PURE__ */ jsx2(NodeViewWrapper2, { as: "td", className: "tessera-td", children: /* @__PURE__ */ jsx2(NodeViewContent, {}) });
235
+ }
236
+ const setValue = (next) => props.updateAttributes({ value: next });
237
+ return /* @__PURE__ */ jsxs2(NodeViewWrapper2, { as: "td", className: `tessera-td tessera-td--${kind}`, children: [
238
+ /* @__PURE__ */ jsx2(NodeViewContent, { className: "tessera-td-hidden" }),
239
+ kind === "checkbox" ? /* @__PURE__ */ jsx2(
240
+ "input",
241
+ {
242
+ type: "checkbox",
243
+ className: "tessera-cell-checkbox",
244
+ checked: value === true || value === "true",
245
+ onChange: (e) => setValue(e.target.checked),
246
+ contentEditable: false
247
+ }
248
+ ) : null,
249
+ kind === "number" ? /* @__PURE__ */ jsx2(
250
+ "input",
251
+ {
252
+ type: "number",
253
+ className: "tessera-cell-input",
254
+ value: typeof value === "number" ? value : "",
255
+ placeholder: "\u2014",
256
+ onChange: (e) => setValue(e.target.value === "" ? null : Number(e.target.value)),
257
+ contentEditable: false
258
+ }
259
+ ) : null,
260
+ kind === "date" ? /* @__PURE__ */ jsx2(
261
+ "input",
262
+ {
263
+ type: "date",
264
+ className: "tessera-cell-input",
265
+ value: typeof value === "string" ? value : "",
266
+ onChange: (e) => setValue(e.target.value || null),
267
+ contentEditable: false
268
+ }
269
+ ) : null,
270
+ kind === "select" || kind === "multiSelect" ? /* @__PURE__ */ jsx2(
271
+ TagEditor,
272
+ {
273
+ multi: kind === "multiSelect",
274
+ value: Array.isArray(value) ? value : value ? [String(value)] : [],
275
+ onChange: (tags) => setValue(kind === "multiSelect" ? tags : tags[0] ?? null)
276
+ }
277
+ ) : null,
278
+ kind === "link" ? /* @__PURE__ */ jsx2(
279
+ "input",
280
+ {
281
+ type: "url",
282
+ className: "tessera-cell-input",
283
+ placeholder: "https://\u2026",
284
+ value: typeof value === "string" ? value : "",
285
+ onChange: (e) => setValue(e.target.value || null),
286
+ contentEditable: false
287
+ }
288
+ ) : null
289
+ ] });
290
+ }
291
+ function TagEditor({ multi, value, onChange }) {
292
+ const [input, setInput] = useState2("");
293
+ const commit = () => {
294
+ const tag = input.trim();
295
+ if (!tag) {
296
+ return;
297
+ }
298
+ const next = multi ? Array.from(/* @__PURE__ */ new Set([...value, tag])) : [tag];
299
+ onChange(next);
300
+ setInput("");
301
+ };
302
+ return /* @__PURE__ */ jsxs2("div", { className: "tessera-cell-tags", contentEditable: false, children: [
303
+ value.map((tag) => /* @__PURE__ */ jsxs2("span", { className: "tessera-tag", children: [
304
+ tag,
305
+ /* @__PURE__ */ jsx2(
306
+ "button",
307
+ {
308
+ type: "button",
309
+ className: "tessera-tag-x",
310
+ onClick: () => onChange(value.filter((v) => v !== tag)),
311
+ children: "\xD7"
312
+ }
313
+ )
314
+ ] }, tag)),
315
+ /* @__PURE__ */ jsx2(
316
+ "input",
317
+ {
318
+ className: "tessera-cell-input tessera-cell-taginput",
319
+ value: input,
320
+ placeholder: multi ? "+ \u6807\u7B7E" : "+ \u6807\u7B7E",
321
+ onChange: (e) => setInput(e.target.value),
322
+ onKeyDown: (e) => {
323
+ if (e.key === "Enter") {
324
+ e.preventDefault();
325
+ commit();
326
+ }
327
+ },
328
+ onBlur: commit
329
+ }
330
+ )
331
+ ] });
332
+ }
333
+ var AiTableCellViewExtension = AiTableCell.extend({
334
+ addNodeView() {
335
+ return ReactNodeViewRenderer2(AiTableCellView);
336
+ }
337
+ });
338
+ var AiTableHeaderViewExtension = AiTableHeader.extend({
339
+ addNodeView() {
340
+ return ReactNodeViewRenderer2(AiTableHeaderView);
341
+ }
342
+ });
343
+
344
+ // src/EmbedTocViews.tsx
345
+ import { useContext as useContext4, useEffect as useEffect2, useState as useState3 } from "react";
346
+ import { NodeViewWrapper as NodeViewWrapper3, ReactNodeViewRenderer as ReactNodeViewRenderer3 } from "@tiptap/react";
347
+ import { EmbedBlock, TocBlock } from "@tessera-editor/core";
348
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
349
+ function EmbedView({ node, updateAttributes, selected }) {
350
+ const { t } = useContext4(TesseraContext);
351
+ const [url, setUrl] = useState3(typeof node.attrs.src === "string" ? node.attrs.src : "");
352
+ const src = typeof node.attrs.src === "string" ? node.attrs.src : "";
353
+ const height = typeof node.attrs.height === "number" ? node.attrs.height : 360;
354
+ if (!src) {
355
+ return /* @__PURE__ */ jsxs3(NodeViewWrapper3, { className: "tessera-embed tessera-embed-empty", "data-selected": selected, children: [
356
+ /* @__PURE__ */ jsx3(
357
+ "input",
358
+ {
359
+ className: "tessera-embed-input",
360
+ value: url,
361
+ placeholder: t("embedPlaceholder"),
362
+ onChange: (e) => setUrl(e.target.value),
363
+ onKeyDown: (e) => {
364
+ if (e.key === "Enter") {
365
+ apply();
366
+ }
367
+ },
368
+ contentEditable: false
369
+ }
370
+ ),
371
+ /* @__PURE__ */ jsx3("button", { type: "button", className: "tessera-embed-apply", onClick: apply, contentEditable: false, children: t("embedApply") })
372
+ ] });
373
+ }
374
+ function apply() {
375
+ const next = url.trim();
376
+ if (/^https?:\/\/.+/.test(next)) {
377
+ updateAttributes({ src: next });
378
+ }
379
+ }
380
+ return /* @__PURE__ */ jsxs3(NodeViewWrapper3, { className: "tessera-embed", "data-selected": selected, children: [
381
+ /* @__PURE__ */ jsx3("div", { className: "tessera-embed-frame", style: { height: `${height}px` }, contentEditable: false, children: /* @__PURE__ */ jsx3(
382
+ "iframe",
383
+ {
384
+ src,
385
+ title: node.attrs.title ?? src,
386
+ sandbox: "",
387
+ referrerPolicy: "no-referrer",
388
+ loading: "lazy"
389
+ }
390
+ ) }),
391
+ /* @__PURE__ */ jsxs3("a", { className: "tessera-embed-link", href: src, target: "_blank", rel: "noreferrer", contentEditable: false, children: [
392
+ t("embedOpen"),
393
+ " \u2197"
394
+ ] })
395
+ ] });
396
+ }
397
+ function TocView({ editor }) {
398
+ const { t } = useContext4(TesseraContext);
399
+ const [items, setItems] = useState3([]);
400
+ useEffect2(() => {
401
+ const scan = () => {
402
+ const found = [];
403
+ editor.state.doc.forEach((node, _offset, index) => {
404
+ void index;
405
+ if (node.type.name === "heading" && typeof node.attrs.id === "string") {
406
+ found.push({ id: node.attrs.id, level: Number(node.attrs.level), text: node.textContent });
407
+ }
408
+ });
409
+ setItems(found);
410
+ };
411
+ scan();
412
+ editor.on("transaction", scan);
413
+ return () => {
414
+ editor.off("transaction", scan);
415
+ };
416
+ }, [editor]);
417
+ return /* @__PURE__ */ jsxs3(NodeViewWrapper3, { className: "tessera-toc", children: [
418
+ /* @__PURE__ */ jsx3("div", { className: "tessera-toc-title", children: t("itemToc") }),
419
+ items.length === 0 ? /* @__PURE__ */ jsx3("div", { className: "tessera-toc-empty", children: t("tocEmpty") }) : /* @__PURE__ */ jsx3("div", { className: "tessera-toc-list", contentEditable: false, children: items.map((item) => /* @__PURE__ */ jsx3(
420
+ "button",
421
+ {
422
+ type: "button",
423
+ className: "tessera-toc-item",
424
+ "data-level": item.level,
425
+ onClick: () => {
426
+ document.querySelector(`[data-id="${item.id}"]`)?.scrollIntoView({ behavior: "smooth", block: "start" });
427
+ },
428
+ children: item.text
429
+ },
430
+ item.id
431
+ )) })
432
+ ] });
433
+ }
434
+ var EmbedBlockView = EmbedBlock.extend({
435
+ addNodeView() {
436
+ return ReactNodeViewRenderer3(EmbedView);
437
+ }
438
+ });
439
+ var TocBlockView = TocBlock.extend({
440
+ addNodeView() {
441
+ return ReactNodeViewRenderer3(TocView);
442
+ }
443
+ });
444
+
445
+ // src/SlashMenu.tsx
446
+ import { useEffect as useEffect3, useImperativeHandle, useRef as useRef2, useState as useState4, forwardRef } from "react";
447
+ import { ReactRenderer } from "@tiptap/react";
448
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
449
+ var SlashMenuView = forwardRef(
450
+ function SlashMenuView2({ items, command, clientRect, t }, ref) {
451
+ const [selectedIndex, setSelectedIndex] = useState4(0);
452
+ const listRef = useRef2(null);
453
+ const [rect, setRect] = useState4(null);
454
+ useEffect3(() => {
455
+ setRect(clientRect?.() ?? null);
456
+ }, [items, clientRect]);
457
+ useEffect3(() => {
458
+ setSelectedIndex(0);
459
+ }, [items]);
460
+ useEffect3(() => {
461
+ listRef.current?.querySelector('[data-selected="true"]')?.scrollIntoView({ block: "nearest" });
462
+ }, [selectedIndex]);
463
+ useImperativeHandle(ref, () => ({
464
+ onKeyDown: ({ event }) => {
465
+ if (event.key === "ArrowDown") {
466
+ setSelectedIndex((i) => (i + 1) % Math.max(items.length, 1));
467
+ return true;
468
+ }
469
+ if (event.key === "ArrowUp") {
470
+ setSelectedIndex((i) => (i - 1 + Math.max(items.length, 1)) % Math.max(items.length, 1));
471
+ return true;
472
+ }
473
+ if (event.key === "Enter") {
474
+ const item = items[selectedIndex];
475
+ if (item) {
476
+ command(item);
477
+ }
478
+ return true;
479
+ }
480
+ return false;
481
+ }
482
+ }));
483
+ if (items.length === 0) {
484
+ return null;
485
+ }
486
+ const groups = groupBy(items, (item) => item.group);
487
+ let flatIndex = -1;
488
+ const style = rect ? (() => {
489
+ const menuMax = 336;
490
+ const fitsBelow = rect.bottom + 8 + menuMax <= window.innerHeight;
491
+ const fitsAbove = rect.top - 8 - menuMax >= 0;
492
+ const flip = !fitsBelow && fitsAbove;
493
+ return {
494
+ position: "fixed",
495
+ left: `${Math.min(rect.left, window.innerWidth - 340)}px`,
496
+ top: flip ? void 0 : `${Math.min(rect.bottom + 8, window.innerHeight - menuMax)}px`,
497
+ bottom: flip ? `${window.innerHeight - rect.top + 8}px` : void 0,
498
+ width: 320
499
+ };
500
+ })() : { position: "fixed", left: -9999, top: -9999 };
501
+ return /* @__PURE__ */ jsx4("div", { className: "tessera-slash-menu", style, "data-testid": "slash-menu", children: Array.from(groups.entries()).map(([group, groupItems]) => /* @__PURE__ */ jsxs4("div", { className: "tessera-slash-group", children: [
502
+ /* @__PURE__ */ jsx4("div", { className: "tessera-slash-group-title", children: group === "ai" ? t("groupAi") : group === "advanced" ? t("groupAdvanced") : t("groupBasic") }),
503
+ groupItems.map((item) => {
504
+ flatIndex += 1;
505
+ const idx = flatIndex;
506
+ return /* @__PURE__ */ jsxs4(
507
+ "button",
508
+ {
509
+ type: "button",
510
+ className: "tessera-slash-item",
511
+ "data-selected": idx === selectedIndex,
512
+ onMouseEnter: () => setSelectedIndex(idx),
513
+ onClick: () => command(item),
514
+ children: [
515
+ /* @__PURE__ */ jsx4("span", { className: "tessera-slash-item-title", children: item.title }),
516
+ /* @__PURE__ */ jsx4("span", { className: "tessera-slash-item-desc", children: item.description }),
517
+ item.shortcut ? /* @__PURE__ */ jsx4("span", { className: "tessera-slash-item-shortcut", children: item.shortcut }) : null
518
+ ]
519
+ },
520
+ item.id
521
+ );
522
+ })
523
+ ] }, group)) });
524
+ }
525
+ );
526
+ function groupBy(items, key) {
527
+ const map = /* @__PURE__ */ new Map();
528
+ for (const item of items) {
529
+ const k = key(item);
530
+ const list = map.get(k) ?? [];
531
+ list.push(item);
532
+ map.set(k, list);
533
+ }
534
+ return map;
535
+ }
536
+ function createSlashRenderer(t) {
537
+ return () => {
538
+ let renderer = null;
539
+ let wrapper = null;
540
+ return {
541
+ onStart: (props) => {
542
+ renderer = new ReactRenderer(SlashMenuView, {
543
+ props: { ...props, t },
544
+ editor: props.editor
545
+ });
546
+ wrapper = document.createElement("div");
547
+ wrapper.className = "tessera-slash-wrapper";
548
+ wrapper.appendChild(renderer.element);
549
+ const host = props.editor.view.dom.closest(".tessera-root") ?? document.body;
550
+ host.appendChild(wrapper);
551
+ },
552
+ onUpdate: (props) => {
553
+ renderer?.updateProps({ ...props, t });
554
+ },
555
+ onKeyDown: (props) => {
556
+ if (props.event.key === "Escape") {
557
+ wrapper?.remove();
558
+ renderer?.destroy();
559
+ renderer = null;
560
+ wrapper = null;
561
+ return true;
562
+ }
563
+ return renderer?.ref?.onKeyDown?.(props) ?? false;
564
+ },
565
+ onExit: () => {
566
+ wrapper?.remove();
567
+ renderer?.destroy();
568
+ renderer = null;
569
+ wrapper = null;
570
+ }
571
+ };
572
+ };
573
+ }
574
+
575
+ // src/EmojiMenu.tsx
576
+ import { useEffect as useEffect4, useImperativeHandle as useImperativeHandle2, useRef as useRef3, useState as useState5, forwardRef as forwardRef2 } from "react";
577
+ import { ReactRenderer as ReactRenderer2 } from "@tiptap/react";
578
+ import { jsx as jsx5 } from "react/jsx-runtime";
579
+ var EmojiMenuView = forwardRef2(function EmojiMenuView2({ items, command, clientRect }, ref) {
580
+ const [selectedIndex, setSelectedIndex] = useState5(0);
581
+ const [rect, setRect] = useState5(null);
582
+ const gridRef = useRef3(null);
583
+ useEffect4(() => {
584
+ setRect(clientRect?.() ?? null);
585
+ }, [items, clientRect]);
586
+ useEffect4(() => {
587
+ setSelectedIndex(0);
588
+ }, [items]);
589
+ useEffect4(() => {
590
+ gridRef.current?.querySelectorAll(".tessera-emoji-item")[selectedIndex]?.scrollIntoView({ block: "nearest" });
591
+ }, [selectedIndex]);
592
+ useImperativeHandle2(ref, () => ({
593
+ onKeyDown: ({ event }) => {
594
+ if (event.key === "ArrowDown" || event.key === "ArrowRight") {
595
+ setSelectedIndex((i) => (i + 1) % Math.max(items.length, 1));
596
+ return true;
597
+ }
598
+ if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
599
+ setSelectedIndex((i) => (i - 1 + Math.max(items.length, 1)) % Math.max(items.length, 1));
600
+ return true;
601
+ }
602
+ if (event.key === "Enter") {
603
+ const item = items[selectedIndex];
604
+ if (item) {
605
+ command(item);
606
+ }
607
+ return true;
608
+ }
609
+ return false;
610
+ }
611
+ }));
612
+ if (items.length === 0) {
613
+ return null;
614
+ }
615
+ const menuMax = 264;
616
+ const style = rect ? (() => {
617
+ const fitsBelow = rect.bottom + 8 + menuMax <= window.innerHeight;
618
+ const fitsAbove = rect.top - 8 - menuMax >= 0;
619
+ const flip = !fitsBelow && fitsAbove;
620
+ return {
621
+ left: `${Math.min(rect.left, window.innerWidth - 300)}px`,
622
+ top: flip ? void 0 : `${Math.min(rect.bottom + 8, window.innerHeight - menuMax)}px`,
623
+ bottom: flip ? `${window.innerHeight - rect.top + 8}px` : void 0
624
+ };
625
+ })() : { left: -9999, top: -9999 };
626
+ return /* @__PURE__ */ jsx5("div", { ref: gridRef, className: "tessera-emoji-menu", style, "data-testid": "emoji-menu", children: items.map((item, i) => /* @__PURE__ */ jsx5(
627
+ "button",
628
+ {
629
+ type: "button",
630
+ className: "tessera-emoji-item",
631
+ title: `:${item.name}:`,
632
+ "data-selected": i === selectedIndex,
633
+ onMouseEnter: () => setSelectedIndex(i),
634
+ onClick: () => command(item),
635
+ children: item.char
636
+ },
637
+ item.name
638
+ )) });
639
+ });
640
+ function createEmojiRenderer() {
641
+ return () => {
642
+ let renderer = null;
643
+ let wrapper = null;
644
+ return {
645
+ onStart: (props) => {
646
+ renderer = new ReactRenderer2(EmojiMenuView, {
647
+ props: { ...props },
648
+ editor: props.editor
649
+ });
650
+ wrapper = document.createElement("div");
651
+ wrapper.className = "tessera-emoji-wrapper";
652
+ wrapper.appendChild(renderer.element);
653
+ const host = props.editor.view.dom.closest(".tessera-root") ?? document.body;
654
+ host.appendChild(wrapper);
655
+ },
656
+ onUpdate: (props) => {
657
+ renderer?.updateProps({ ...props });
658
+ },
659
+ onKeyDown: (props) => {
660
+ if (props.event.key === "Escape") {
661
+ wrapper?.remove();
662
+ renderer?.destroy();
663
+ renderer = null;
664
+ wrapper = null;
665
+ return true;
666
+ }
667
+ return renderer?.ref?.onKeyDown?.(props) ?? false;
668
+ },
669
+ onExit: () => {
670
+ wrapper?.remove();
671
+ renderer?.destroy();
672
+ renderer = null;
673
+ wrapper = null;
674
+ }
675
+ };
676
+ };
677
+ }
678
+
679
+ // src/EmptyLineToolbar.tsx
680
+ import { useCallback, useContext as useContext5, useEffect as useEffect6, useLayoutEffect, useRef as useRef4, useState as useState7 } from "react";
681
+ import { createPortal as createPortal2 } from "react-dom";
682
+ import { defaultSlashItems } from "@tessera-editor/core";
683
+ import { aiSlashItems } from "@tessera-editor/ai";
684
+
685
+ // src/portal.ts
686
+ import { useEffect as useEffect5, useState as useState6 } from "react";
687
+ function useTesseraPortalRoot(editor) {
688
+ const [target, setTarget] = useState6(null);
689
+ useEffect5(() => {
690
+ const resolve = () => {
691
+ setTarget((prev) => {
692
+ if (prev && prev !== document.body) {
693
+ return prev;
694
+ }
695
+ return editor.view.dom.closest(".tessera-root") ?? document.body;
696
+ });
697
+ };
698
+ resolve();
699
+ editor.on("focus", resolve);
700
+ editor.on("selectionUpdate", resolve);
701
+ return () => {
702
+ editor.off("focus", resolve);
703
+ editor.off("selectionUpdate", resolve);
704
+ };
705
+ }, [editor]);
706
+ return target;
707
+ }
708
+
709
+ // src/EmptyLineToolbar.tsx
710
+ import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
711
+ function buildItems(editor, t, hasAi) {
712
+ const base = defaultSlashItems(t);
713
+ if (!hasAi) {
714
+ return base;
715
+ }
716
+ return base;
717
+ }
718
+ function EmptyLineToolbar() {
719
+ const { editor, t, ai } = useContext5(TesseraContext);
720
+ const [style, setStyle] = useState7(null);
721
+ const [expanded, setExpanded] = useState7(false);
722
+ const [panelPlacement, setPanelPlacement] = useState7("bottom");
723
+ const [panelMaxHeight, setPanelMaxHeight] = useState7(void 0);
724
+ const composingRef = useRef4(false);
725
+ const rootRef = useRef4(null);
726
+ const panelRef = useRef4(null);
727
+ const portalRoot = useTesseraPortalRoot(editor);
728
+ const hideAll = useCallback(() => {
729
+ setStyle(null);
730
+ setExpanded(false);
731
+ editor.view.dom.removeAttribute("data-toolbar-line");
732
+ }, [editor]);
733
+ const reposition = useCallback(() => {
734
+ const dom = editor.view.dom;
735
+ if (composingRef.current || !editor.isFocused) {
736
+ setStyle(null);
737
+ dom.removeAttribute("data-toolbar-line");
738
+ return;
739
+ }
740
+ const { $from, empty } = editor.state.selection;
741
+ const isEmptyParagraph = empty && $from.parent.type.name === "paragraph" && $from.parent.content.size === 0;
742
+ if (!isEmptyParagraph) {
743
+ setStyle(null);
744
+ setExpanded(false);
745
+ dom.removeAttribute("data-toolbar-line");
746
+ return;
747
+ }
748
+ const coords = editor.view.coordsAtPos($from.pos);
749
+ const viewRect = dom.getBoundingClientRect();
750
+ if (coords.top < Math.max(viewRect.top, 0) - 2 || coords.top > Math.min(viewRect.bottom, window.innerHeight) + 2) {
751
+ hideAll();
752
+ return;
753
+ }
754
+ setStyle({
755
+ position: "fixed",
756
+ // Float just ABOVE the empty line so the caret stays visible; the
757
+ // toolbar bottom sits a couple of pixels over the line's top edge.
758
+ top: `${Math.max(2, coords.top - 38)}px`,
759
+ left: `${coords.left}px`
760
+ });
761
+ dom.setAttribute("data-toolbar-line", "true");
762
+ }, [editor, hideAll]);
763
+ useEffect6(() => {
764
+ const hide = () => {
765
+ setStyle(null);
766
+ setExpanded(false);
767
+ };
768
+ const onCompositionStart = () => {
769
+ composingRef.current = true;
770
+ setStyle(null);
771
+ };
772
+ const onCompositionEnd = () => {
773
+ composingRef.current = false;
774
+ requestAnimationFrame(reposition);
775
+ };
776
+ const onScroll = (event) => {
777
+ const node = rootRef.current;
778
+ if (node && event.target instanceof Node && node.contains(event.target)) {
779
+ return;
780
+ }
781
+ requestAnimationFrame(reposition);
782
+ };
783
+ const dom = editor.view.dom;
784
+ editor.on("selectionUpdate", reposition);
785
+ editor.on("focus", reposition);
786
+ editor.on("blur", hide);
787
+ dom.addEventListener("compositionstart", onCompositionStart);
788
+ dom.addEventListener("compositionend", onCompositionEnd);
789
+ window.addEventListener("scroll", onScroll, true);
790
+ return () => {
791
+ editor.off("selectionUpdate", reposition);
792
+ editor.off("focus", reposition);
793
+ editor.off("blur", hide);
794
+ dom.removeEventListener("compositionstart", onCompositionStart);
795
+ dom.removeEventListener("compositionend", onCompositionEnd);
796
+ window.removeEventListener("scroll", onScroll, true);
797
+ };
798
+ }, [editor, reposition]);
799
+ useLayoutEffect(() => {
800
+ const toolbar = rootRef.current;
801
+ if (!expanded || !toolbar) {
802
+ setPanelPlacement("bottom");
803
+ setPanelMaxHeight(void 0);
804
+ return;
805
+ }
806
+ const rect = toolbar.getBoundingClientRect();
807
+ const spaceBelow = window.innerHeight - rect.bottom;
808
+ const spaceAbove = rect.top;
809
+ const natural = panelRef.current?.offsetHeight ?? 320;
810
+ const flip = spaceBelow < natural + 12 && spaceAbove > spaceBelow;
811
+ setPanelPlacement(flip ? "top" : "bottom");
812
+ const avail = (flip ? spaceAbove : spaceBelow) - 12;
813
+ setPanelMaxHeight(avail > 120 && avail < natural ? avail : void 0);
814
+ }, [expanded, style]);
815
+ if (!style || !portalRoot) {
816
+ return null;
817
+ }
818
+ const items = buildItems(editor, t, !!ai);
819
+ const runItem = (item) => {
820
+ const { $from } = editor.state.selection;
821
+ item.command({ editor, range: { from: $from.pos, to: $from.pos } });
822
+ setExpanded(false);
823
+ };
824
+ return createPortal2(
825
+ /* @__PURE__ */ jsxs5(
826
+ "div",
827
+ {
828
+ ref: rootRef,
829
+ className: "tessera-emptyline-toolbar",
830
+ style,
831
+ "data-testid": "empty-line-toolbar",
832
+ onMouseDown: (e) => e.preventDefault(),
833
+ children: [
834
+ /* @__PURE__ */ jsx6("button", { type: "button", className: "tessera-tb-btn", title: t("itemText"), onClick: () => runItem(items.find((i) => i.id === "text")), children: "\xB6" }),
835
+ /* @__PURE__ */ jsx6("button", { type: "button", className: "tessera-tb-btn", title: t("itemH2"), onClick: () => runItem(items.find((i) => i.id === "h2")), children: "H2" }),
836
+ /* @__PURE__ */ jsx6("button", { type: "button", className: "tessera-tb-btn", title: t("itemBullet"), onClick: () => runItem(items.find((i) => i.id === "bulletList")), children: "\u2022" }),
837
+ /* @__PURE__ */ jsx6("button", { type: "button", className: "tessera-tb-btn", title: t("itemTask"), onClick: () => runItem(items.find((i) => i.id === "taskList")), children: "\u2611" }),
838
+ /* @__PURE__ */ jsx6("button", { type: "button", className: "tessera-tb-btn", title: t("itemQuote"), onClick: () => runItem(items.find((i) => i.id === "quote")), children: "\u275D" }),
839
+ /* @__PURE__ */ jsx6("button", { type: "button", className: "tessera-tb-btn", title: t("itemCode"), onClick: () => runItem(items.find((i) => i.id === "codeBlock")), children: "</>" }),
840
+ /* @__PURE__ */ jsx6(
841
+ "button",
842
+ {
843
+ type: "button",
844
+ className: "tessera-tb-btn tessera-emptyline-expand",
845
+ title: t("emptyLineExpand"),
846
+ "data-expanded": expanded,
847
+ onClick: () => setExpanded((v) => !v),
848
+ children: "\u203A"
849
+ }
850
+ ),
851
+ expanded ? /* @__PURE__ */ jsx6(
852
+ "div",
853
+ {
854
+ ref: panelRef,
855
+ className: "tessera-emptyline-panel",
856
+ "data-placement": panelPlacement,
857
+ style: panelMaxHeight ? { maxHeight: panelMaxHeight } : void 0,
858
+ "data-testid": "empty-line-panel",
859
+ children: items.map((item) => /* @__PURE__ */ jsxs5("button", { type: "button", className: "tessera-slash-item", onClick: () => runItem(item), children: [
860
+ /* @__PURE__ */ jsx6("span", { className: "tessera-slash-item-title", children: item.title }),
861
+ /* @__PURE__ */ jsx6("span", { className: "tessera-slash-item-desc", children: item.description })
862
+ ] }, item.id))
863
+ }
864
+ ) : null
865
+ ]
866
+ }
867
+ ),
868
+ portalRoot
869
+ );
870
+ }
871
+
872
+ // src/SelectionToolbar.tsx
873
+ import { useCallback as useCallback2, useContext as useContext7, useEffect as useEffect8, useLayoutEffect as useLayoutEffect2, useRef as useRef5, useState as useState9 } from "react";
874
+ import { createPortal as createPortal4 } from "react-dom";
875
+ import { docToMarkdown } from "@tessera-editor/core";
876
+ import { IMPROVE_PRESETS, improveSelection } from "@tessera-editor/ai";
877
+
878
+ // src/CommentPanel.tsx
879
+ import { useContext as useContext6, useEffect as useEffect7, useState as useState8 } from "react";
880
+ import { createPortal as createPortal3 } from "react-dom";
881
+ import { getCommentStore, getIdentityService, listCommentRanges } from "@tessera-editor/core";
882
+ import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
883
+ function uid(prefix) {
884
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
885
+ }
886
+ function CommentPanel() {
887
+ const { editor, t } = useContext6(TesseraContext);
888
+ const [visible, setVisible] = useState8(false);
889
+ const [threads, setThreads] = useState8([]);
890
+ const refresh = async () => {
891
+ const store2 = getCommentStore(editor);
892
+ if (!store2) {
893
+ return;
894
+ }
895
+ const all = await store2.list();
896
+ const ranges = listCommentRanges(editor.state);
897
+ const views = [];
898
+ for (const range of ranges) {
899
+ const thread = all.find((tr) => tr.id === range.threadId);
900
+ if (thread) {
901
+ views.push({ ...thread, from: range.from });
902
+ }
903
+ }
904
+ views.sort((a, b) => a.from - b.from);
905
+ setThreads(views);
906
+ };
907
+ useEffect7(() => {
908
+ const open = () => {
909
+ setVisible(true);
910
+ void refresh();
911
+ };
912
+ editor.on("tessera:commentPanel", open);
913
+ editor.on("transaction", refresh);
914
+ return () => {
915
+ editor.off("tessera:commentPanel", open);
916
+ editor.off("transaction", refresh);
917
+ };
918
+ }, [editor]);
919
+ if (!visible) {
920
+ return null;
921
+ }
922
+ const store = getCommentStore(editor);
923
+ const identity = getIdentityService(editor);
924
+ const me = identity?.getCurrentUser() ?? { id: "anonymous", name: "Anonymous" };
925
+ const toggleResolve = async (thread) => {
926
+ if (!store) {
927
+ return;
928
+ }
929
+ const next = { ...thread, resolved: !thread.resolved };
930
+ delete next.from;
931
+ await store.upsert(next);
932
+ editor.commands.setCommentResolved(thread.id, next.resolved);
933
+ await refresh();
934
+ };
935
+ const removeThread = async (thread) => {
936
+ if (!store) {
937
+ return;
938
+ }
939
+ await store.remove(thread.id);
940
+ editor.commands.removeCommentThread(thread.id);
941
+ await refresh();
942
+ };
943
+ return /* @__PURE__ */ jsxs6("div", { className: "tessera-comment-panel", "data-testid": "comment-panel", children: [
944
+ /* @__PURE__ */ jsxs6("div", { className: "tessera-ask-header", children: [
945
+ /* @__PURE__ */ jsxs6("span", { children: [
946
+ t("commentTitle"),
947
+ " \xB7 ",
948
+ threads.filter((x) => !x.resolved).length
949
+ ] }),
950
+ /* @__PURE__ */ jsxs6("div", { className: "tessera-history-actions", children: [
951
+ /* @__PURE__ */ jsx7("button", { type: "button", title: "\u2191", onClick: () => editor.commands.focusNextCommentThread(), children: "\u2191" }),
952
+ /* @__PURE__ */ jsx7("button", { type: "button", title: "\u2193", onClick: () => editor.commands.focusNextCommentThread(), children: "\u2193" }),
953
+ /* @__PURE__ */ jsx7("button", { type: "button", onClick: () => setVisible(false), children: "\xD7" })
954
+ ] })
955
+ ] }),
956
+ !store ? /* @__PURE__ */ jsx7("div", { className: "tessera-ask-error", children: "CommentStore not injected" }) : null,
957
+ /* @__PURE__ */ jsxs6("div", { className: "tessera-ask-body", children: [
958
+ threads.length === 0 ? /* @__PURE__ */ jsx7("div", { className: "tessera-toc-empty", children: t("commentEmpty") }) : null,
959
+ threads.map((thread) => /* @__PURE__ */ jsxs6("div", { className: `tessera-thread${thread.resolved ? " tessera-thread--resolved" : ""}`, children: [
960
+ /* @__PURE__ */ jsxs6("div", { className: "tessera-thread-quote", children: [
961
+ "\u201C",
962
+ thread.quote,
963
+ "\u201D"
964
+ ] }),
965
+ thread.entries.map((entry) => /* @__PURE__ */ jsxs6("div", { className: "tessera-thread-entry", children: [
966
+ /* @__PURE__ */ jsx7("span", { className: "tessera-thread-author", children: entry.authorName }),
967
+ /* @__PURE__ */ jsx7("span", { className: "tessera-thread-text", children: entry.text })
968
+ ] }, entry.id)),
969
+ /* @__PURE__ */ jsxs6("div", { className: "tessera-thread-actions", children: [
970
+ /* @__PURE__ */ jsx7("button", { type: "button", onClick: () => void toggleResolve(thread), children: thread.resolved ? t("commentReopen") : t("commentResolve") }),
971
+ /* @__PURE__ */ jsx7("button", { type: "button", className: "tessera-danger", onClick: () => void removeThread(thread), children: t("commentDelete") }),
972
+ thread.resolved ? /* @__PURE__ */ jsx7("span", { className: "tessera-thread-badge", children: t("commentResolvedBadge") }) : null
973
+ ] })
974
+ ] }, thread.id))
975
+ ] }),
976
+ /* @__PURE__ */ jsxs6("div", { className: "tessera-comment-hint", children: [
977
+ "\u2318\u2325M / \u{1F4AC} \xB7 ",
978
+ me.name
979
+ ] })
980
+ ] });
981
+ }
982
+ function CommentComposer({ onClose }) {
983
+ const { editor, t } = useContext6(TesseraContext);
984
+ const [text, setText] = useState8("");
985
+ const portalRoot = useTesseraPortalRoot(editor);
986
+ const quote = editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to, " ");
987
+ const submit = async () => {
988
+ if (!text.trim()) {
989
+ return;
990
+ }
991
+ const store = getCommentStore(editor);
992
+ if (!store) {
993
+ return;
994
+ }
995
+ const identity = getIdentityService(editor);
996
+ const me = identity?.getCurrentUser() ?? { id: "anonymous", name: "Anonymous" };
997
+ const thread = {
998
+ id: uid("thread"),
999
+ quote,
1000
+ resolved: false,
1001
+ createdAt: Date.now(),
1002
+ entries: [{ id: uid("c"), authorId: me.id, authorName: me.name, text: text.trim(), ts: Date.now() }]
1003
+ };
1004
+ await store.upsert(thread);
1005
+ editor.commands.addCommentThread(thread.id);
1006
+ onClose();
1007
+ editor.emit("tessera:commentPanel", {});
1008
+ };
1009
+ if (!portalRoot) {
1010
+ return null;
1011
+ }
1012
+ return createPortal3(
1013
+ /* @__PURE__ */ jsxs6("div", { className: "tessera-popover tessera-comment-composer", "data-testid": "comment-composer", children: [
1014
+ /* @__PURE__ */ jsxs6("div", { className: "tessera-thread-quote", children: [
1015
+ "\u201C",
1016
+ quote.slice(0, 60),
1017
+ quote.length > 60 ? "\u2026" : "",
1018
+ "\u201D"
1019
+ ] }),
1020
+ /* @__PURE__ */ jsx7(
1021
+ "textarea",
1022
+ {
1023
+ autoFocus: true,
1024
+ value: text,
1025
+ placeholder: t("commentPlaceholder"),
1026
+ onChange: (e) => setText(e.target.value),
1027
+ onKeyDown: (e) => {
1028
+ if (e.key === "Enter" && !e.shiftKey) {
1029
+ e.preventDefault();
1030
+ void submit();
1031
+ }
1032
+ }
1033
+ }
1034
+ ),
1035
+ /* @__PURE__ */ jsx7("div", { className: "tessera-comment-composer-actions", children: /* @__PURE__ */ jsx7("button", { type: "button", disabled: !text.trim(), onClick: () => void submit(), children: t("commentSend") }) })
1036
+ ] }),
1037
+ portalRoot
1038
+ );
1039
+ }
1040
+
1041
+ // src/SelectionToolbar.tsx
1042
+ import { jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
1043
+ var TEXT_COLORS = ["#262629", "#d9414f", "#e8912d", "#31a56f", "#2f9ea8", "#4f6df5", "#7a4fd8", "#98a0ab"];
1044
+ var HIGHLIGHT_COLORS = ["#fff2a8", "#d6eaff", "#d3f5df", "#fdd9e7", "#e6dcff"];
1045
+ function FlipPopover({
1046
+ posKey,
1047
+ className = "",
1048
+ testId,
1049
+ children
1050
+ }) {
1051
+ const ref = useRef5(null);
1052
+ const [placement, setPlacement] = useState9("bottom");
1053
+ const [maxHeight, setMaxHeight] = useState9(void 0);
1054
+ useLayoutEffect2(() => {
1055
+ const pop = ref.current;
1056
+ const toolbar = pop?.parentElement;
1057
+ if (!pop || !toolbar) {
1058
+ return;
1059
+ }
1060
+ const rect = toolbar.getBoundingClientRect();
1061
+ const spaceBelow = window.innerHeight - rect.bottom;
1062
+ const spaceAbove = rect.top;
1063
+ const natural = pop.offsetHeight;
1064
+ const flip = spaceBelow < natural + 12 && spaceAbove > spaceBelow;
1065
+ setPlacement(flip ? "top" : "bottom");
1066
+ const avail = (flip ? spaceAbove : spaceBelow) - 12;
1067
+ setMaxHeight(avail > 100 && avail < natural ? avail : void 0);
1068
+ }, [posKey]);
1069
+ return /* @__PURE__ */ jsx8(
1070
+ "div",
1071
+ {
1072
+ ref,
1073
+ className: `tessera-popover${className ? ` ${className}` : ""}`,
1074
+ "data-placement": placement,
1075
+ style: maxHeight ? { maxHeight, overflowY: "auto" } : void 0,
1076
+ "data-testid": testId,
1077
+ children
1078
+ }
1079
+ );
1080
+ }
1081
+ function SelectionToolbar({ onSession }) {
1082
+ const { editor, t, ai } = useContext7(TesseraContext);
1083
+ const [style, setStyle] = useState9(null);
1084
+ const [popover, setPopover] = useState9(null);
1085
+ const [linkValue, setLinkValue] = useState9("");
1086
+ const composingRef = useRef5(false);
1087
+ const portalRoot = useTesseraPortalRoot(editor);
1088
+ const reposition = useCallback2(() => {
1089
+ if (composingRef.current || !editor.isFocused) {
1090
+ setStyle(null);
1091
+ return;
1092
+ }
1093
+ const { from, to, empty } = editor.state.selection;
1094
+ if (empty) {
1095
+ setStyle(null);
1096
+ return;
1097
+ }
1098
+ if (!editor.state.doc.textBetween(from, to, " ").trim()) {
1099
+ setStyle(null);
1100
+ return;
1101
+ }
1102
+ const start = editor.view.coordsAtPos(from);
1103
+ const end = editor.view.coordsAtPos(to);
1104
+ const left = (Math.min(start.left, end.left) + Math.min(Math.max(start.left, end.left), window.innerWidth - 20)) / 2;
1105
+ const top = Math.max(8, start.top - 48);
1106
+ setStyle({
1107
+ position: "fixed",
1108
+ top: `${top}px`,
1109
+ left: `${Math.max(8, Math.min(left, window.innerWidth - 380))}px`
1110
+ });
1111
+ }, [editor]);
1112
+ useEffect8(() => {
1113
+ const hide = () => {
1114
+ setStyle(null);
1115
+ setPopover(null);
1116
+ };
1117
+ const onSelectionUpdate = () => {
1118
+ if (!editor.state.selection.empty) {
1119
+ const existing = editor.getAttributes("link").href;
1120
+ setLinkValue(typeof existing === "string" ? existing : "");
1121
+ } else {
1122
+ setPopover(null);
1123
+ }
1124
+ reposition();
1125
+ };
1126
+ const onCompositionStart = () => {
1127
+ composingRef.current = true;
1128
+ setStyle(null);
1129
+ setPopover(null);
1130
+ };
1131
+ const onCompositionEnd = () => {
1132
+ composingRef.current = false;
1133
+ requestAnimationFrame(reposition);
1134
+ };
1135
+ const dom = editor.view.dom;
1136
+ editor.on("selectionUpdate", onSelectionUpdate);
1137
+ editor.on("focus", reposition);
1138
+ editor.on("blur", hide);
1139
+ dom.addEventListener("compositionstart", onCompositionStart);
1140
+ dom.addEventListener("compositionend", onCompositionEnd);
1141
+ window.addEventListener("scroll", hide, true);
1142
+ return () => {
1143
+ editor.off("selectionUpdate", onSelectionUpdate);
1144
+ editor.off("focus", reposition);
1145
+ editor.off("blur", hide);
1146
+ dom.removeEventListener("compositionstart", onCompositionStart);
1147
+ dom.removeEventListener("compositionend", onCompositionEnd);
1148
+ window.removeEventListener("scroll", hide, true);
1149
+ };
1150
+ }, [editor, reposition]);
1151
+ const chain = () => editor.chain().focus();
1152
+ if (!style || !portalRoot) {
1153
+ return null;
1154
+ }
1155
+ const currentColor = editor.getAttributes("textStyle").color ?? null;
1156
+ return createPortal4(
1157
+ /* @__PURE__ */ jsxs7(
1158
+ "div",
1159
+ {
1160
+ className: "tessera-selection-toolbar",
1161
+ style,
1162
+ "data-testid": "selection-toolbar",
1163
+ onMouseDown: (e) => e.preventDefault(),
1164
+ children: [
1165
+ ai ? /* @__PURE__ */ jsx8(TbBtn, { title: t("tooltipImprove"), className: "tessera-tb-ai", onClick: () => setPopover((p) => p === "improve" ? null : "improve"), children: "\u2728" }) : null,
1166
+ /* @__PURE__ */ jsx8(TbBtn, { title: t("tooltipBold"), active: editor.isActive("bold"), onClick: () => chain().toggleBold().run(), children: /* @__PURE__ */ jsx8("b", { children: "B" }) }),
1167
+ /* @__PURE__ */ jsx8(TbBtn, { title: t("tooltipItalic"), active: editor.isActive("italic"), onClick: () => chain().toggleItalic().run(), children: /* @__PURE__ */ jsx8("i", { children: "I" }) }),
1168
+ /* @__PURE__ */ jsx8(TbBtn, { title: t("tooltipUnderline"), active: editor.isActive("underline"), onClick: () => chain().toggleUnderline().run(), children: /* @__PURE__ */ jsx8("u", { children: "U" }) }),
1169
+ /* @__PURE__ */ jsx8(TbBtn, { title: t("tooltipStrike"), active: editor.isActive("strike"), onClick: () => chain().toggleStrike().run(), children: /* @__PURE__ */ jsx8("s", { children: "S" }) }),
1170
+ /* @__PURE__ */ jsx8(TbBtn, { title: t("tooltipCode"), active: editor.isActive("code"), onClick: () => chain().toggleCode().run(), children: "</>" }),
1171
+ /* @__PURE__ */ jsx8(TbBtn, { title: t("tooltipColor"), active: !!currentColor, onClick: () => setPopover((p) => p === "color" ? null : "color"), children: /* @__PURE__ */ jsx8("span", { className: "tessera-tb-colorchip", style: { background: currentColor ?? "#262629" } }) }),
1172
+ /* @__PURE__ */ jsx8(TbBtn, { title: t("tooltipHighlight"), active: editor.isActive("highlight"), onClick: () => setPopover((p) => p === "highlight" ? null : "highlight"), children: /* @__PURE__ */ jsx8("span", { className: "tessera-tb-colorchip", style: { background: "#fff2a8" } }) }),
1173
+ /* @__PURE__ */ jsx8(TbBtn, { title: t("tooltipLink"), active: editor.isActive("link"), onClick: () => setPopover((p) => p === "link" ? null : "link"), children: "\u{1F517}" }),
1174
+ /* @__PURE__ */ jsx8(
1175
+ TbBtn,
1176
+ {
1177
+ title: t("tooltipCommentV11"),
1178
+ active: editor.isActive("comment"),
1179
+ onClick: () => setPopover((p) => p === "comment" ? null : "comment"),
1180
+ children: "\u{1F4AC}"
1181
+ }
1182
+ ),
1183
+ /* @__PURE__ */ jsx8(TbBtn, { title: t("tooltipTurnCollapsible"), onClick: () => chain().insertCollapsible().run(), children: "\u25B8" }),
1184
+ /* @__PURE__ */ jsx8(TbBtn, { title: t("tooltipMore"), onClick: () => setPopover((p) => p === "more" ? null : "more"), children: "\u22EF" }),
1185
+ popover === "color" ? /* @__PURE__ */ jsxs7(FlipPopover, { posKey: style, children: [
1186
+ /* @__PURE__ */ jsx8("button", { type: "button", className: "tessera-color-swatch tessera-color-none", title: t("colorDefault"), onClick: () => chain().unsetColor().run() }),
1187
+ TEXT_COLORS.map((color) => /* @__PURE__ */ jsx8("button", { type: "button", className: "tessera-color-swatch", style: { background: color }, onClick: () => chain().setColor(color).run() }, color))
1188
+ ] }) : null,
1189
+ popover === "highlight" ? /* @__PURE__ */ jsxs7(FlipPopover, { posKey: style, children: [
1190
+ /* @__PURE__ */ jsx8("button", { type: "button", className: "tessera-color-swatch tessera-color-none", title: t("highlightNone"), onClick: () => chain().unsetHighlight().run() }),
1191
+ HIGHLIGHT_COLORS.map((color) => /* @__PURE__ */ jsx8("button", { type: "button", className: "tessera-color-swatch", style: { background: color }, onClick: () => chain().toggleHighlight({ color }).run() }, color))
1192
+ ] }) : null,
1193
+ popover === "link" ? /* @__PURE__ */ jsxs7(FlipPopover, { posKey: style, className: "tessera-link-popover", children: [
1194
+ /* @__PURE__ */ jsx8(
1195
+ "input",
1196
+ {
1197
+ autoFocus: true,
1198
+ value: linkValue,
1199
+ placeholder: t("linkPlaceholder"),
1200
+ onChange: (e) => setLinkValue(e.target.value),
1201
+ onKeyDown: (e) => {
1202
+ if (e.key === "Enter" && linkValue.trim()) {
1203
+ chain().toggleLink({ href: linkValue.trim() }).run();
1204
+ setPopover(null);
1205
+ }
1206
+ }
1207
+ }
1208
+ ),
1209
+ /* @__PURE__ */ jsx8(
1210
+ "button",
1211
+ {
1212
+ type: "button",
1213
+ onClick: () => {
1214
+ if (linkValue.trim()) {
1215
+ chain().toggleLink({ href: linkValue.trim() }).run();
1216
+ }
1217
+ setPopover(null);
1218
+ },
1219
+ children: t("linkApply")
1220
+ }
1221
+ ),
1222
+ editor.isActive("link") ? /* @__PURE__ */ jsx8(
1223
+ "button",
1224
+ {
1225
+ type: "button",
1226
+ className: "tessera-danger",
1227
+ onClick: () => {
1228
+ chain().unsetLink().run();
1229
+ setPopover(null);
1230
+ },
1231
+ children: t("linkRemove")
1232
+ }
1233
+ ) : null
1234
+ ] }) : null,
1235
+ popover === "more" ? /* @__PURE__ */ jsx8(FlipPopover, { posKey: style, className: "tessera-popover-menu", children: /* @__PURE__ */ jsx8(
1236
+ "button",
1237
+ {
1238
+ type: "button",
1239
+ onClick: async () => {
1240
+ const { to } = editor.state.selection;
1241
+ const start = editor.state.selection.$from.before(1);
1242
+ const sliced = editor.state.doc.cut(start, to);
1243
+ await navigator.clipboard.writeText(docToMarkdown(sliced, editor.state.schema));
1244
+ setPopover(null);
1245
+ },
1246
+ children: t("tooltipCopyMarkdown")
1247
+ }
1248
+ ) }) : null,
1249
+ popover === "improve" ? /* @__PURE__ */ jsx8(ImprovePopover, { posKey: style, onSession, onClose: () => setPopover(null) }) : null,
1250
+ popover === "comment" ? /* @__PURE__ */ jsx8(CommentComposer, { onClose: () => setPopover(null) }) : null
1251
+ ]
1252
+ }
1253
+ ),
1254
+ portalRoot
1255
+ );
1256
+ }
1257
+ function TbBtn({
1258
+ title,
1259
+ active,
1260
+ className,
1261
+ onClick,
1262
+ children
1263
+ }) {
1264
+ return /* @__PURE__ */ jsx8(
1265
+ "button",
1266
+ {
1267
+ type: "button",
1268
+ title,
1269
+ "aria-label": title,
1270
+ className: `tessera-tb-btn${className ? ` ${className}` : ""}`,
1271
+ "data-active": active ?? false,
1272
+ onClick,
1273
+ children
1274
+ }
1275
+ );
1276
+ }
1277
+ function ImprovePopover({
1278
+ posKey,
1279
+ onSession,
1280
+ onClose
1281
+ }) {
1282
+ const { editor, t, locale, ai } = useContext7(TesseraContext);
1283
+ const [instruction, setInstruction] = useState9("");
1284
+ const [busy, setBusy] = useState9(false);
1285
+ const [error, setError] = useState9(null);
1286
+ const run = async (presetInstruction) => {
1287
+ if (!ai) {
1288
+ setError(t("aiRuntimeMissing"));
1289
+ return;
1290
+ }
1291
+ onClose();
1292
+ setBusy(true);
1293
+ try {
1294
+ const session = await improveSelection(editor, ai.runtime, { instruction: presetInstruction });
1295
+ onSession(session);
1296
+ } catch (err) {
1297
+ setError(String(err));
1298
+ } finally {
1299
+ setBusy(false);
1300
+ }
1301
+ };
1302
+ return /* @__PURE__ */ jsxs7(FlipPopover, { posKey, className: "tessera-improve-popover", testId: "improve-popover", children: [
1303
+ IMPROVE_PRESETS.map((preset) => /* @__PURE__ */ jsx8("button", { type: "button", disabled: busy, onClick: () => run(preset.instruction), children: locale === "zh-CN" ? preset.labelZh : preset.labelEn }, preset.id)),
1304
+ /* @__PURE__ */ jsxs7("div", { className: "tessera-improve-custom", children: [
1305
+ /* @__PURE__ */ jsx8(
1306
+ "input",
1307
+ {
1308
+ value: instruction,
1309
+ placeholder: t("aiImprovePrompt"),
1310
+ onChange: (e) => setInstruction(e.target.value),
1311
+ onKeyDown: (e) => {
1312
+ if (e.key === "Enter" && instruction.trim()) {
1313
+ run(instruction.trim());
1314
+ }
1315
+ }
1316
+ }
1317
+ ),
1318
+ /* @__PURE__ */ jsx8("button", { type: "button", disabled: busy || !instruction.trim(), onClick: () => run(instruction.trim()), children: t("aiImproveRun") })
1319
+ ] }),
1320
+ error ? /* @__PURE__ */ jsx8("div", { className: "tessera-popover-error", children: error }) : null
1321
+ ] });
1322
+ }
1323
+
1324
+ // src/Panels.tsx
1325
+ import { useContext as useContext8, useEffect as useEffect9, useRef as useRef6, useState as useState10 } from "react";
1326
+ import { findReplaceKey } from "@tessera-editor/core";
1327
+ import { jsx as jsx9, jsxs as jsxs8 } from "react/jsx-runtime";
1328
+ function FindReplacePanel() {
1329
+ const { editor, t } = useContext8(TesseraContext);
1330
+ const [visible, setVisible] = useState10(false);
1331
+ const [query, setQuery] = useState10("");
1332
+ const [replacement, setReplacement] = useState10("");
1333
+ const [count, setCount] = useState10(0);
1334
+ const [active, setActive] = useState10(0);
1335
+ const queryRef = useRef6(query);
1336
+ queryRef.current = query;
1337
+ useEffect9(() => {
1338
+ const open = () => {
1339
+ setVisible(true);
1340
+ requestAnimationFrame(() => {
1341
+ const input = document.querySelector(".tessera-find-input");
1342
+ input?.focus();
1343
+ });
1344
+ };
1345
+ editor.on("tessera:findPanel", open);
1346
+ const sync = () => {
1347
+ const s = findReplaceKey.getState(editor.state);
1348
+ if (!s) {
1349
+ return;
1350
+ }
1351
+ setVisible(s.visible);
1352
+ setCount(s.matches.length);
1353
+ setActive(s.active);
1354
+ if (s.query !== queryRef.current && !s.visible) {
1355
+ setQuery(s.query);
1356
+ }
1357
+ };
1358
+ editor.on("transaction", sync);
1359
+ return () => {
1360
+ editor.off("tessera:findPanel", open);
1361
+ editor.off("transaction", sync);
1362
+ };
1363
+ }, [editor]);
1364
+ if (!visible) {
1365
+ return null;
1366
+ }
1367
+ const updateQuery = (value) => {
1368
+ setQuery(value);
1369
+ editor.commands.setFindQuery(value);
1370
+ };
1371
+ return /* @__PURE__ */ jsxs8("div", { className: "tessera-find-panel", "data-testid": "find-panel", children: [
1372
+ /* @__PURE__ */ jsx9(
1373
+ "input",
1374
+ {
1375
+ className: "tessera-find-input",
1376
+ autoFocus: true,
1377
+ value: query,
1378
+ placeholder: t("findPlaceholder"),
1379
+ onChange: (e) => updateQuery(e.target.value),
1380
+ onKeyDown: (e) => {
1381
+ if (e.key === "Enter") {
1382
+ editor.commands.findNext();
1383
+ }
1384
+ if (e.key === "Escape") {
1385
+ editor.commands.closeFindPanel();
1386
+ }
1387
+ }
1388
+ }
1389
+ ),
1390
+ /* @__PURE__ */ jsx9("span", { className: "tessera-find-count", children: count > 0 ? `${active + 1}/${count}` : "0" }),
1391
+ /* @__PURE__ */ jsx9("button", { type: "button", title: t("findPrev"), onClick: () => editor.commands.findPrev(), children: "\u2191" }),
1392
+ /* @__PURE__ */ jsx9("button", { type: "button", title: t("findNext"), onClick: () => editor.commands.findNext(), children: "\u2193" }),
1393
+ /* @__PURE__ */ jsx9(
1394
+ "input",
1395
+ {
1396
+ value: replacement,
1397
+ placeholder: t("replacePlaceholder"),
1398
+ onChange: (e) => setReplacement(e.target.value)
1399
+ }
1400
+ ),
1401
+ /* @__PURE__ */ jsx9("button", { type: "button", onClick: () => editor.commands.replaceCurrent(replacement), children: t("replaceOne") }),
1402
+ /* @__PURE__ */ jsx9("button", { type: "button", onClick: () => editor.commands.replaceAll(replacement), children: t("replaceAll") }),
1403
+ /* @__PURE__ */ jsx9("button", { type: "button", className: "tessera-find-close", title: t("findClose"), onClick: () => editor.commands.closeFindPanel(), children: "\xD7" })
1404
+ ] });
1405
+ }
1406
+ function AskPanel() {
1407
+ const { editor, t, ai } = useContext8(TesseraContext);
1408
+ const [visible, setVisible] = useState10(false);
1409
+ const [question, setQuestion] = useState10("");
1410
+ const [answer, setAnswer] = useState10("");
1411
+ const [thinking, setThinking] = useState10(false);
1412
+ const [error, setError] = useState10(null);
1413
+ useEffect9(() => {
1414
+ const open = () => setVisible(true);
1415
+ editor.on("tessera:askPanel", open);
1416
+ return () => {
1417
+ editor.off("tessera:askPanel", open);
1418
+ };
1419
+ }, [editor]);
1420
+ if (!visible) {
1421
+ return null;
1422
+ }
1423
+ const ask = async () => {
1424
+ if (!ai || !question.trim()) {
1425
+ return;
1426
+ }
1427
+ setThinking(true);
1428
+ setAnswer("");
1429
+ setError(null);
1430
+ try {
1431
+ await ai.ask(question, { onToken: (token) => setAnswer((prev) => prev + token) });
1432
+ } catch (err) {
1433
+ setError(String(err));
1434
+ } finally {
1435
+ setThinking(false);
1436
+ }
1437
+ };
1438
+ return /* @__PURE__ */ jsxs8("div", { className: "tessera-ask-panel", "data-testid": "ask-panel", children: [
1439
+ /* @__PURE__ */ jsxs8("div", { className: "tessera-ask-header", children: [
1440
+ /* @__PURE__ */ jsx9("span", { children: t("aiTitle") }),
1441
+ /* @__PURE__ */ jsx9("button", { type: "button", onClick: () => setVisible(false), children: "\xD7" })
1442
+ ] }),
1443
+ ai ? null : /* @__PURE__ */ jsx9("div", { className: "tessera-ask-error", children: t("aiRuntimeMissing") }),
1444
+ /* @__PURE__ */ jsxs8("div", { className: "tessera-ask-body", children: [
1445
+ answer ? /* @__PURE__ */ jsx9("div", { className: "tessera-ask-answer", children: answer }) : null,
1446
+ thinking ? /* @__PURE__ */ jsx9("div", { className: "tessera-ask-thinking", children: t("aiThinking") }) : null,
1447
+ error ? /* @__PURE__ */ jsx9("div", { className: "tessera-ask-error", children: error }) : null
1448
+ ] }),
1449
+ /* @__PURE__ */ jsxs8("div", { className: "tessera-ask-input", children: [
1450
+ /* @__PURE__ */ jsx9(
1451
+ "input",
1452
+ {
1453
+ value: question,
1454
+ placeholder: t("aiAskPlaceholder"),
1455
+ onChange: (e) => setQuestion(e.target.value),
1456
+ onKeyDown: (e) => {
1457
+ if (e.key === "Enter") {
1458
+ ask();
1459
+ }
1460
+ }
1461
+ }
1462
+ ),
1463
+ /* @__PURE__ */ jsx9("button", { type: "button", disabled: !ai || thinking || !question.trim(), onClick: ask, children: t("aiSend") })
1464
+ ] })
1465
+ ] });
1466
+ }
1467
+ function SuggestionBar({
1468
+ session,
1469
+ onClear
1470
+ }) {
1471
+ const { t } = useContext8(TesseraContext);
1472
+ if (!session) {
1473
+ return null;
1474
+ }
1475
+ return /* @__PURE__ */ jsxs8("div", { className: "tessera-suggestion-bar", "data-testid": "suggestion-bar", children: [
1476
+ /* @__PURE__ */ jsxs8("span", { className: "tessera-suggestion-label", children: [
1477
+ "\u2728 ",
1478
+ t("aiStreaming")
1479
+ ] }),
1480
+ /* @__PURE__ */ jsx9(
1481
+ "button",
1482
+ {
1483
+ type: "button",
1484
+ className: "tessera-suggestion-accept",
1485
+ onClick: () => {
1486
+ session.accept();
1487
+ onClear();
1488
+ },
1489
+ children: t("aiAccept")
1490
+ }
1491
+ ),
1492
+ /* @__PURE__ */ jsx9(
1493
+ "button",
1494
+ {
1495
+ type: "button",
1496
+ className: "tessera-suggestion-reject",
1497
+ onClick: () => {
1498
+ session.reject();
1499
+ onClear();
1500
+ },
1501
+ children: t("aiReject")
1502
+ }
1503
+ )
1504
+ ] });
1505
+ }
1506
+
1507
+ // src/HistoryPanel.tsx
1508
+ import { useContext as useContext9, useEffect as useEffect10, useState as useState11 } from "react";
1509
+ import {
1510
+ diffDocs,
1511
+ diffSummary,
1512
+ getStorageService
1513
+ } from "@tessera-editor/core";
1514
+ import { jsx as jsx10, jsxs as jsxs9 } from "react/jsx-runtime";
1515
+ function HistoryPanel() {
1516
+ const { editor, t } = useContext9(TesseraContext);
1517
+ const [visible, setVisible] = useState11(false);
1518
+ const [snapshots, setSnapshots] = useState11([]);
1519
+ const [selected, setSelected] = useState11(null);
1520
+ const [entries, setEntries] = useState11(null);
1521
+ const [summary, setSummary] = useState11({ added: 0, removed: 0, changed: 0 });
1522
+ const refresh = async () => {
1523
+ const storage = getStorageService(editor);
1524
+ if (!storage) {
1525
+ return;
1526
+ }
1527
+ const list = await storage.listSnapshots();
1528
+ setSnapshots([...list].sort((a, b) => b.ts - a.ts));
1529
+ };
1530
+ useEffect10(() => {
1531
+ const open = () => {
1532
+ setVisible(true);
1533
+ void refresh();
1534
+ };
1535
+ editor.on("tessera:historyPanel", open);
1536
+ const saved = () => void refresh();
1537
+ editor.on("tessera:snapshotSaved", saved);
1538
+ return () => {
1539
+ editor.off("tessera:historyPanel", open);
1540
+ editor.off("tessera:snapshotSaved", saved);
1541
+ };
1542
+ }, [editor]);
1543
+ if (!visible) {
1544
+ return null;
1545
+ }
1546
+ const hasStorage = !!getStorageService(editor);
1547
+ const select = (snap) => {
1548
+ setSelected(snap);
1549
+ const result = diffDocs(snap.doc, editor.getJSON());
1550
+ setEntries(result);
1551
+ setSummary(diffSummary(result.filter((e) => e.kind !== "unchanged")));
1552
+ };
1553
+ const restore = (snap) => {
1554
+ if (!window.confirm(t("historyConfirmRestore"))) {
1555
+ return;
1556
+ }
1557
+ editor.commands.setContent(snap.doc);
1558
+ setVisible(false);
1559
+ };
1560
+ return /* @__PURE__ */ jsxs9("div", { className: "tessera-history-panel", "data-testid": "history-panel", children: [
1561
+ /* @__PURE__ */ jsxs9("div", { className: "tessera-ask-header", children: [
1562
+ /* @__PURE__ */ jsx10("span", { children: t("historyTitle") }),
1563
+ /* @__PURE__ */ jsxs9("div", { className: "tessera-history-actions", children: [
1564
+ hasStorage ? /* @__PURE__ */ jsx10("button", { type: "button", onClick: () => editor.commands.captureSnapshot(), children: t("historyCapture") }) : null,
1565
+ /* @__PURE__ */ jsx10("button", { type: "button", onClick: () => setVisible(false), children: "\xD7" })
1566
+ ] })
1567
+ ] }),
1568
+ !hasStorage ? /* @__PURE__ */ jsx10("div", { className: "tessera-ask-error", children: t("aiRuntimeMissing").replace("AI Runtime", "StorageService") }) : null,
1569
+ /* @__PURE__ */ jsxs9("div", { className: "tessera-history-body", children: [
1570
+ /* @__PURE__ */ jsxs9("div", { className: "tessera-history-list", children: [
1571
+ snapshots.length === 0 ? /* @__PURE__ */ jsx10("div", { className: "tessera-toc-empty", children: t("historyEmpty") }) : null,
1572
+ snapshots.map((snap) => /* @__PURE__ */ jsxs9(
1573
+ "button",
1574
+ {
1575
+ type: "button",
1576
+ className: "tessera-history-item",
1577
+ "data-selected": selected?.id === snap.id,
1578
+ onClick: () => select(snap),
1579
+ children: [
1580
+ /* @__PURE__ */ jsx10("span", { className: "tessera-history-time", children: formatTime(snap.ts) }),
1581
+ snap.label ? /* @__PURE__ */ jsx10("span", { className: "tessera-history-label", children: snap.label }) : null,
1582
+ /* @__PURE__ */ jsx10(
1583
+ "span",
1584
+ {
1585
+ className: "tessera-history-action",
1586
+ role: "button",
1587
+ tabIndex: 0,
1588
+ onClick: (e) => {
1589
+ e.stopPropagation();
1590
+ restore(snap);
1591
+ },
1592
+ onKeyDown: (e) => {
1593
+ if (e.key === "Enter") restore(snap);
1594
+ },
1595
+ children: t("historyRestore")
1596
+ }
1597
+ )
1598
+ ]
1599
+ },
1600
+ snap.id
1601
+ ))
1602
+ ] }),
1603
+ selected ? /* @__PURE__ */ jsxs9("div", { className: "tessera-history-diff", children: [
1604
+ /* @__PURE__ */ jsxs9("div", { className: "tessera-diff-summary", children: [
1605
+ "+",
1606
+ summary.added,
1607
+ " ",
1608
+ t("historyDiffAdded"),
1609
+ " \xB7 \u2212",
1610
+ summary.removed,
1611
+ " ",
1612
+ t("historyDiffRemoved"),
1613
+ " \xB7 ~",
1614
+ summary.changed,
1615
+ " ",
1616
+ t("historyDiffChanged")
1617
+ ] }),
1618
+ (entries ?? []).filter((e) => e.kind !== "unchanged").slice(0, 80).map((entry, i) => /* @__PURE__ */ jsxs9("div", { className: `tessera-diff-block tessera-diff-block--${entry.kind}`, children: [
1619
+ /* @__PURE__ */ jsx10("span", { className: "tessera-diff-badge", children: entry.kind === "added" ? t("historyDiffAdded") : entry.kind === "removed" ? t("historyDiffRemoved") : t("historyDiffChanged") }),
1620
+ entry.kind === "changed" && entry.wordDiff ? /* @__PURE__ */ jsx10("span", { className: "tessera-diff-text", children: entry.wordDiff.map((part, j) => /* @__PURE__ */ jsx10("span", { className: `tessera-diff-part tessera-diff-part--${part.type}`, children: part.text }, j)) }) : /* @__PURE__ */ jsx10("span", { className: "tessera-diff-text", children: blockText(entry) })
1621
+ ] }, entry.id ?? i))
1622
+ ] }) : null
1623
+ ] })
1624
+ ] });
1625
+ }
1626
+ function blockText(entry) {
1627
+ const json = entry.after ?? entry.before;
1628
+ const walk = (node) => {
1629
+ if (!node || typeof node !== "object") {
1630
+ return "";
1631
+ }
1632
+ const n = node;
1633
+ return (n.text ?? "") + (n.content ?? []).map(walk).join("");
1634
+ };
1635
+ return walk(json).trim().slice(0, 120);
1636
+ }
1637
+ function formatTime(ts) {
1638
+ const d = new Date(ts);
1639
+ const pad = (n) => String(n).padStart(2, "0");
1640
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
1641
+ }
1642
+
1643
+ // src/BlockMenu.tsx
1644
+ import { useContext as useContext10, useEffect as useEffect11, useState as useState12 } from "react";
1645
+ import { createPortal as createPortal5 } from "react-dom";
1646
+ import { tableToCsvAt as tableToCsvAt2 } from "@tessera-editor/core";
1647
+ import { Fragment, jsx as jsx11, jsxs as jsxs10 } from "react/jsx-runtime";
1648
+ function BlockContextMenuUI() {
1649
+ const { editor, t } = useContext10(TesseraContext);
1650
+ const [menu, setMenu] = useState12(null);
1651
+ const portalRoot = useTesseraPortalRoot(editor);
1652
+ useEffect11(() => {
1653
+ const onMenu = (payload) => {
1654
+ const found = findBlockPosById(editor, payload.blockId);
1655
+ if (found !== null) {
1656
+ editor.commands.setTextSelection(Math.min(found + 2, editor.state.doc.content.size));
1657
+ }
1658
+ setMenu(payload);
1659
+ };
1660
+ const close = () => setMenu(null);
1661
+ editor.on("tessera:blockMenu", onMenu);
1662
+ window.addEventListener("mousedown", close);
1663
+ window.addEventListener("scroll", close, true);
1664
+ return () => {
1665
+ editor.off("tessera:blockMenu", onMenu);
1666
+ window.removeEventListener("mousedown", close);
1667
+ window.removeEventListener("scroll", close, true);
1668
+ };
1669
+ }, [editor]);
1670
+ if (!menu || !portalRoot) {
1671
+ return null;
1672
+ }
1673
+ const inTable = menu.blockType === "table" || menu.blockType === "tableRow";
1674
+ const style = {
1675
+ position: "fixed",
1676
+ left: `${Math.min(menu.clientX, window.innerWidth - 220)}px`,
1677
+ top: `${Math.min(menu.clientY, window.innerHeight - 300)}px`
1678
+ };
1679
+ const copy = (text) => {
1680
+ void navigator.clipboard.writeText(text);
1681
+ setMenu(null);
1682
+ };
1683
+ return createPortal5(
1684
+ /* @__PURE__ */ jsxs10("div", { className: "tessera-block-menu", style, onMouseDown: (e) => e.stopPropagation(), children: [
1685
+ inTable ? /* @__PURE__ */ jsxs10(Fragment, { children: [
1686
+ /* @__PURE__ */ jsx11(MenuLabel, { children: t("rowMenuTitle") }),
1687
+ /* @__PURE__ */ jsx11(MenuItem, { onClick: () => {
1688
+ editor.commands.addRowBefore();
1689
+ setMenu(null);
1690
+ }, children: t("rowMenuInsertAbove") }),
1691
+ /* @__PURE__ */ jsx11(MenuItem, { onClick: () => {
1692
+ editor.commands.addRowAfter();
1693
+ setMenu(null);
1694
+ }, children: t("rowMenuInsertBelow") }),
1695
+ /* @__PURE__ */ jsx11(MenuItem, { danger: true, onClick: () => {
1696
+ editor.commands.deleteRow();
1697
+ setMenu(null);
1698
+ }, children: t("rowMenuDelete") }),
1699
+ /* @__PURE__ */ jsx11("div", { className: "tessera-menu-sep" }),
1700
+ /* @__PURE__ */ jsx11(MenuItem, { onClick: () => {
1701
+ const csv = tableToCsvAt2(editor);
1702
+ if (csv) copy(csv);
1703
+ }, children: t("tableCopyCsv") }),
1704
+ /* @__PURE__ */ jsx11(MenuItem, { onClick: () => {
1705
+ editor.commands.toggleHeaderRow();
1706
+ setMenu(null);
1707
+ }, children: t("tableToggleHeader") }),
1708
+ /* @__PURE__ */ jsx11(MenuItem, { danger: true, onClick: () => {
1709
+ editor.commands.deleteTable();
1710
+ setMenu(null);
1711
+ }, children: t("tableDelete") }),
1712
+ /* @__PURE__ */ jsx11("div", { className: "tessera-menu-sep" })
1713
+ ] }) : null,
1714
+ /* @__PURE__ */ jsx11(MenuItem, { onClick: () => copy(anchorUrl(menu.blockId)), children: t("menuCopyAnchor") }),
1715
+ /* @__PURE__ */ jsx11(MenuItem, { onClick: () => copy(menu.blockId), children: t("menuCopyBlockId") }),
1716
+ /* @__PURE__ */ jsx11(MenuItem, { danger: true, onClick: () => {
1717
+ deleteBlockById(editor, menu.blockId);
1718
+ setMenu(null);
1719
+ }, children: t("menuDeleteBlock") })
1720
+ ] }),
1721
+ portalRoot
1722
+ );
1723
+ }
1724
+ function anchorUrl(blockId) {
1725
+ const base = `${location.origin}${location.pathname}`;
1726
+ return `${base}#block-${blockId}`;
1727
+ }
1728
+ function findBlockPosById(editor, id) {
1729
+ let found = null;
1730
+ editor.state.doc.forEach((node, offset) => {
1731
+ if (found === null && node.attrs.id === id) {
1732
+ found = offset;
1733
+ }
1734
+ });
1735
+ return found;
1736
+ }
1737
+ function deleteBlockById(editor, id) {
1738
+ const pos = findBlockPosById(editor, id);
1739
+ if (pos === null) {
1740
+ return;
1741
+ }
1742
+ const node = editor.state.doc.nodeAt(pos);
1743
+ if (!node) {
1744
+ return;
1745
+ }
1746
+ const tr = editor.state.tr.delete(pos, pos + node.nodeSize);
1747
+ editor.view.dispatch(tr);
1748
+ }
1749
+ function MenuLabel({ children }) {
1750
+ return /* @__PURE__ */ jsx11("div", { className: "tessera-menu-label", children });
1751
+ }
1752
+ function MenuItem({
1753
+ children,
1754
+ onClick,
1755
+ danger
1756
+ }) {
1757
+ return /* @__PURE__ */ jsx11("button", { type: "button", className: `tessera-menu-item${danger ? " tessera-danger" : ""}`, onClick, children });
1758
+ }
1759
+
1760
+ // src/Tessera.tsx
1761
+ import "@tessera-editor/core/styles.css";
1762
+ import { jsx as jsx12, jsxs as jsxs11 } from "react/jsx-runtime";
1763
+ var DRAG_HANDLE_POSITION_CONFIG = { placement: "left", strategy: "absolute" };
1764
+ function Tessera({
1765
+ content,
1766
+ locale = "zh-CN",
1767
+ upload,
1768
+ storage,
1769
+ comments,
1770
+ identity,
1771
+ ai: runtime,
1772
+ historyIdleMs,
1773
+ docWidth,
1774
+ onUpdate,
1775
+ onCreate
1776
+ }) {
1777
+ const t = useMemo(() => createTesseraT(locale), [locale]);
1778
+ const [session, setSession] = useState13(null);
1779
+ const fileInputRef = useRef7(null);
1780
+ const dragNodeRef = useRef7(null);
1781
+ const handleDragNodeChange = useCallback3((data) => {
1782
+ dragNodeRef.current = data.node ? data : null;
1783
+ }, []);
1784
+ const onUpdateRef = useRef7(onUpdate);
1785
+ onUpdateRef.current = onUpdate;
1786
+ const onCreateRef = useRef7(onCreate);
1787
+ onCreateRef.current = onCreate;
1788
+ const runtimeRef = useRef7(runtime);
1789
+ runtimeRef.current = runtime;
1790
+ const initialContentRef = useRef7(content);
1791
+ const editorRef = useRef7(null);
1792
+ const extensions = useMemo(
1793
+ () => createTesseraExtensions({ locale, historyIdleMs }).map((ext) => {
1794
+ if (ext.name === "tesseraSlashMenu") {
1795
+ return ext.configure({
1796
+ render: createSlashRenderer(t),
1797
+ extraItems: (ctx) => {
1798
+ const rt = runtimeRef.current;
1799
+ return rt ? aiSlashItems2({ editor: ctx.editor, runtime: rt, t: ctx.t }) : [];
1800
+ }
1801
+ });
1802
+ }
1803
+ if (ext.name === "tesseraEmojiMenu") {
1804
+ return ext.configure({ render: createEmojiRenderer() });
1805
+ }
1806
+ if (ext.name === "imageBlock") {
1807
+ return ImageBlockView;
1808
+ }
1809
+ if (ext.name === "tableCell") {
1810
+ return AiTableCellViewExtension;
1811
+ }
1812
+ if (ext.name === "tableHeader") {
1813
+ return AiTableHeaderViewExtension;
1814
+ }
1815
+ if (ext.name === "embedBlock") {
1816
+ return EmbedBlockView;
1817
+ }
1818
+ if (ext.name === "tocBlock") {
1819
+ return TocBlockView;
1820
+ }
1821
+ return ext;
1822
+ }),
1823
+ // runtime is read through runtimeRef on purpose: rebuilding the extension
1824
+ // list after mount cannot apply anyway (extensions are only read when the
1825
+ // editor is created) and would only trigger the setOptions churn above
1826
+ [locale, t, historyIdleMs]
1827
+ );
1828
+ async function uploadAndInsert(file) {
1829
+ const ed = editorRef.current;
1830
+ if (!ed) {
1831
+ return;
1832
+ }
1833
+ const storage2 = ed.storage;
1834
+ const svc = storage2.tesseraServices?.upload;
1835
+ if (!svc) {
1836
+ return;
1837
+ }
1838
+ try {
1839
+ const asset = await svc.uploadImage(file);
1840
+ ed.chain().focus().setImage({ src: asset.url, alt: asset.name }).run();
1841
+ } catch (err) {
1842
+ console.error("[Tessera] image upload failed:", err);
1843
+ }
1844
+ }
1845
+ const uploadAndInsertRef = useRef7(uploadAndInsert);
1846
+ uploadAndInsertRef.current = uploadAndInsert;
1847
+ const editorProps = useMemo(
1848
+ () => ({
1849
+ attributes: {
1850
+ class: "tessera-doc",
1851
+ spellcheck: "false"
1852
+ },
1853
+ handlePaste: (_view, event) => {
1854
+ const file = Array.from(event.clipboardData?.files ?? []).find((f) => f.type.startsWith("image/"));
1855
+ if (file) {
1856
+ void uploadAndInsertRef.current(file);
1857
+ return true;
1858
+ }
1859
+ return false;
1860
+ },
1861
+ handleDrop: (_view, event, _slice, moved) => {
1862
+ if (moved) {
1863
+ return false;
1864
+ }
1865
+ const file = Array.from(event.dataTransfer?.files ?? []).find((f) => f.type.startsWith("image/"));
1866
+ if (file) {
1867
+ event.preventDefault();
1868
+ void uploadAndInsertRef.current(file);
1869
+ return true;
1870
+ }
1871
+ return false;
1872
+ }
1873
+ }),
1874
+ []
1875
+ );
1876
+ const handleUpdate = useCallback3(({ editor: e }) => onUpdateRef.current?.(e), []);
1877
+ const handleCreate = useCallback3(({ editor: e }) => onCreateRef.current?.(e), []);
1878
+ const editor = useEditor({
1879
+ extensions,
1880
+ content: initialContentRef.current,
1881
+ onUpdate: handleUpdate,
1882
+ onCreate: handleCreate,
1883
+ editorProps
1884
+ });
1885
+ editorRef.current = editor ?? null;
1886
+ useEffect12(() => {
1887
+ if (editor) {
1888
+ const bag = editor.storage.tesseraServices ?? {};
1889
+ editor.storage.tesseraServices = {
1890
+ ...bag,
1891
+ upload,
1892
+ storage,
1893
+ comments,
1894
+ identity
1895
+ };
1896
+ }
1897
+ }, [editor, upload, storage, comments, identity]);
1898
+ useEffect12(() => {
1899
+ if (!editor) {
1900
+ return;
1901
+ }
1902
+ const pickImage = () => fileInputRef.current?.click();
1903
+ const onSession = (payload) => setSession(payload.session);
1904
+ editor.on("tessera:insertImage", pickImage);
1905
+ editor.on("tessera:session", onSession);
1906
+ return () => {
1907
+ editor.off("tessera:insertImage", pickImage);
1908
+ editor.off("tessera:session", onSession);
1909
+ };
1910
+ }, [editor]);
1911
+ const ai = useMemo(
1912
+ () => editor && runtime ? createAiController(editor, runtime, locale) : null,
1913
+ [editor, runtime, locale]
1914
+ );
1915
+ if (!editor) {
1916
+ return null;
1917
+ }
1918
+ return /* @__PURE__ */ jsx12(TesseraContext.Provider, { value: { editor, locale, t, ai }, children: /* @__PURE__ */ jsxs11(
1919
+ "div",
1920
+ {
1921
+ className: "tessera-root",
1922
+ style: { "--te-doc-max-width": docWidth ? `${docWidth}px` : void 0 },
1923
+ children: [
1924
+ /* @__PURE__ */ jsx12(EditorContent, { editor }),
1925
+ /* @__PURE__ */ jsx12(
1926
+ DragHandle,
1927
+ {
1928
+ editor,
1929
+ pluginKey: "tesseraDragHandle",
1930
+ computePositionConfig: DRAG_HANDLE_POSITION_CONFIG,
1931
+ onNodeChange: handleDragNodeChange,
1932
+ children: /* @__PURE__ */ jsx12(
1933
+ "div",
1934
+ {
1935
+ className: "tessera-drag-handle",
1936
+ onClick: (event) => {
1937
+ const data = dragNodeRef.current;
1938
+ if (!data?.node) {
1939
+ return;
1940
+ }
1941
+ const id = data.node.attrs.id;
1942
+ if (!id) {
1943
+ return;
1944
+ }
1945
+ editor.emit("tessera:blockMenu", {
1946
+ blockId: id,
1947
+ blockType: data.node.type.name,
1948
+ clientX: event.clientX,
1949
+ clientY: event.clientY
1950
+ });
1951
+ },
1952
+ children: "\u283F"
1953
+ }
1954
+ )
1955
+ }
1956
+ ),
1957
+ /* @__PURE__ */ jsx12(EmptyLineToolbar, {}),
1958
+ /* @__PURE__ */ jsx12(SelectionToolbar, { onSession: setSession }),
1959
+ /* @__PURE__ */ jsx12(FindReplacePanel, {}),
1960
+ /* @__PURE__ */ jsx12(AskPanel, {}),
1961
+ /* @__PURE__ */ jsx12(HistoryPanel, {}),
1962
+ /* @__PURE__ */ jsx12(CommentPanel, {}),
1963
+ /* @__PURE__ */ jsx12(BlockContextMenuUI, {}),
1964
+ /* @__PURE__ */ jsx12(SuggestionBar, { session, onClear: () => setSession(null) }),
1965
+ /* @__PURE__ */ jsx12(
1966
+ "input",
1967
+ {
1968
+ ref: fileInputRef,
1969
+ type: "file",
1970
+ accept: "image/*",
1971
+ hidden: true,
1972
+ onChange: (e) => {
1973
+ const file = e.target.files?.[0];
1974
+ if (file) {
1975
+ void uploadAndInsert(file);
1976
+ }
1977
+ e.target.value = "";
1978
+ }
1979
+ }
1980
+ )
1981
+ ]
1982
+ }
1983
+ ) });
1984
+ }
1985
+ export {
1986
+ AiTableCellViewExtension,
1987
+ AiTableHeaderViewExtension,
1988
+ AskPanel,
1989
+ BlockContextMenuUI,
1990
+ CommentComposer,
1991
+ CommentPanel,
1992
+ EmbedBlockView,
1993
+ EmptyLineToolbar,
1994
+ FindReplacePanel,
1995
+ HistoryPanel,
1996
+ ImageBlockView,
1997
+ SelectionToolbar,
1998
+ SlashMenuView,
1999
+ SuggestionBar,
2000
+ Tessera,
2001
+ TesseraContext,
2002
+ TocBlockView,
2003
+ createSlashRenderer,
2004
+ useTesseraContext
2005
+ };
2006
+ //# sourceMappingURL=index.js.map