@ai-matrx/rich-editor 0.4.3 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.1
4
+ ### The toolbar measures its layout width (2026-10-08)
5
+
6
+ - `FormatButtons` folds by the row's layout width (`clientWidth`), not its painted box: in a dialog that opens with a scale animation it measured a shrunken rect and kept only Bold and Italic.
7
+
8
+ Consumer action: none.
9
+
10
+ ## 0.5.0
11
+ ### Write behaves like a notes app (2026-10-08)
12
+
13
+ - **Typed markdown converts** (`core/typing-rules.ts`, in the schema extensions): `[] `, `[ ] `, `[x] ` at a line's start make a checklist item; `- [ ] ` / `* [ ] ` turn the bullet into a task; an address followed by a space (`https://…`, `www.…`) becomes a link stored bare; `![alt](url) ` becomes an image. Only on the key press that completes the pattern — load, paste and drags never convert.
14
+ - **Table menu**: while the caret is in a table, a handle on its top-right corner opens add row above/below, add column left/right, Header row (on/off — off stores GFM's empty header line), delete row/column/table (`visual/table-menu.ts`, `visual/TableHandle.tsx`). `core/commands.ts` adds `toggleTableHeaderRow` / `tableHeaderRowShown`.
15
+ - **Image**: a new `image` format command. `FormatButtons` always has More, and More always holds **Image**. In Write it opens the image prompt — paste an address, or upload a file (uploaded to the platform's files as before); text engines insert `![alt](https://)` with the address selected. The `/image` slash item opens the same prompt.
16
+ - **`format/editor-keys`**: `editorOwnsKeyEvent(event)` — true inside a format host, a contenteditable or a CodeMirror view. App-wide shortcuts (the shell's ⌘K bar) stand down there so ⌘K is the editor's link prompt.
17
+
18
+ Consumer action: an app-wide ⌘K (or other chord) listener calls `editorOwnsKeyEvent(e)` and skips when true. A host with an exhaustive `Record<FormatCommandId, …>` adds `image`.
19
+
3
20
  ## 0.4.3
4
21
  ### `chrome="format"`: the toolbar for hosts with no row of their own (2026-10-08)
5
22
 
@@ -63,3 +63,6 @@ export type ColumnAlignment = "left" | "center" | "right" | null;
63
63
  * rewritten and nothing else in the table moves.
64
64
  */
65
65
  export declare function setColumnAlign(editor: Editor, align: ColumnAlignment): boolean;
66
+ /** Is the caret's table showing a header row (a first row with any text)? */
67
+ export declare function tableHeaderRowShown(editor: Editor): boolean;
68
+ export declare function toggleTableHeaderRow(editor: Editor): boolean;
@@ -269,6 +269,55 @@ function setColumnAlign(editor, align) {
269
269
  return true;
270
270
  });
271
271
  }
272
+ function tableAt(editor) {
273
+ const { $from } = editor.state.selection;
274
+ for (let depth = $from.depth; depth > 0; depth -= 1) {
275
+ const node = $from.node(depth);
276
+ if (node.type.name === "table") return { table: node, pos: $from.before(depth) };
277
+ }
278
+ return null;
279
+ }
280
+ function tableHeaderRowShown(editor) {
281
+ const found = tableAt(editor);
282
+ return Boolean(found?.table.firstChild && found.table.firstChild.textContent.trim().length > 0);
283
+ }
284
+ function toggleTableHeaderRow(editor) {
285
+ const found = tableAt(editor);
286
+ if (!found) return false;
287
+ const { table, pos } = found;
288
+ const { schema } = editor.state;
289
+ const headerType = schema.nodes.tableHeader;
290
+ const cellType = schema.nodes.tableCell;
291
+ const rowType = schema.nodes.tableRow;
292
+ const paragraph = schema.nodes.paragraph;
293
+ const first = table.firstChild;
294
+ if (!headerType || !cellType || !rowType || !paragraph || !first) return false;
295
+ const shown = first.textContent.trim().length > 0;
296
+ if (!shown && table.childCount < 2) return false;
297
+ return editor.chain().focus().command(({ tr }) => {
298
+ const rowPos = pos + 1;
299
+ if (shown) {
300
+ let cellPos = rowPos + 1;
301
+ first.forEach((cell) => {
302
+ tr.setNodeMarkup(cellPos, cellType, cell.attrs);
303
+ cellPos += cell.nodeSize;
304
+ });
305
+ const cells = [];
306
+ first.forEach((cell) => cells.push(headerType.create({ align: cell.attrs.align ?? null }, paragraph.create())));
307
+ tr.insert(rowPos, rowType.create(null, cells));
308
+ } else {
309
+ tr.delete(rowPos, rowPos + first.nodeSize);
310
+ const promoted = tr.doc.nodeAt(rowPos);
311
+ if (!promoted) return false;
312
+ let cellPos = rowPos + 1;
313
+ promoted.forEach((cell) => {
314
+ tr.setNodeMarkup(cellPos, headerType, cell.attrs);
315
+ cellPos += cell.nodeSize;
316
+ });
317
+ }
318
+ return true;
319
+ }).run();
320
+ }
272
321
  export {
273
322
  CALLOUT_TYPES,
274
323
  TASK_DONE,
@@ -292,6 +341,8 @@ export {
292
341
  replaceIslandRaw,
293
342
  setCallout,
294
343
  setColumnAlign,
344
+ tableHeaderRowShown,
345
+ toggleTableHeaderRow,
295
346
  toggleTaskChecked,
296
347
  toggleTaskList,
297
348
  topLevelAt,
@@ -21,6 +21,7 @@ import { Table, TableCell, TableHeader, TableRow } from "@tiptap/extension-table
21
21
  import { Dropcursor, Focus, Gapcursor, Placeholder, UndoRedo } from "@tiptap/extensions";
22
22
  import { MarkdownTextPaste } from "./paste-markdown.js";
23
23
  import { ClipboardPlainText } from "./clipboard-text.js";
24
+ import { MarkdownTypingRules } from "./typing-rules.js";
24
25
  const mdAttr = () => ({ default: null, rendered: false, keepOnSplit: false });
25
26
  const MD_ID_NODE_TYPES = [
26
27
  "paragraph",
@@ -310,7 +311,8 @@ function createRichEditorExtensions(options = {}) {
310
311
  Dropcursor,
311
312
  Placeholder.configure({ placeholder: options.placeholder ?? "Write\u2026" }),
312
313
  MarkdownTextPaste.configure({ onNotice: options.onPasteNotice ?? null }),
313
- ClipboardPlainText
314
+ ClipboardPlainText,
315
+ MarkdownTypingRules
314
316
  ];
315
317
  return list.map((extension) => extension.extend({ addPasteRules: () => [] }));
316
318
  }
@@ -1,5 +1,5 @@
1
1
  import type { SourceEditResult } from "./source-format.js";
2
- export type FormatCommandId = "bold" | "italic" | "strike" | "code" | "link" | "heading1" | "heading2" | "heading3" | "bulletList" | "orderedList" | "taskList" | "quote" | "codeBlock" | "paragraph" | "table" | "horizontalRule" | "undo" | "redo";
2
+ export type FormatCommandId = "bold" | "italic" | "strike" | "code" | "link" | "heading1" | "heading2" | "heading3" | "bulletList" | "orderedList" | "taskList" | "quote" | "codeBlock" | "paragraph" | "table" | "horizontalRule" | "image" | "undo" | "redo";
3
3
  export declare const FORMAT_COMMAND_IDS: readonly FormatCommandId[];
4
4
  export declare function isFormatCommandId(id: string): id is FormatCommandId;
5
5
  /** Run one formatting command on markdown text. Returns the minimal change. */
@@ -16,6 +16,7 @@ const FORMAT_COMMAND_IDS = [
16
16
  "paragraph",
17
17
  "table",
18
18
  "horizontalRule",
19
+ "image",
19
20
  "undo",
20
21
  "redo"
21
22
  ];
@@ -390,11 +391,19 @@ function formatMarkdown(text, from, to, command) {
390
391
  return insertBlockAfter(text, a, b, "| Column 1 | Column 2 | Column 3 |\n| --- | --- | --- |\n| | | |\n| | | |", 2, 8);
391
392
  case "horizontalRule":
392
393
  return insertBlockAfter(text, a, b, "---", null, 0);
394
+ case "image":
395
+ return imageCommand(text, a, b);
393
396
  case "undo":
394
397
  case "redo":
395
398
  return { changes: [], anchor: a, head: b };
396
399
  }
397
400
  }
401
+ function imageCommand(text, from, to) {
402
+ const alt = text.slice(from, to).replace(/[[\]\n]/g, " ").trim() || "image";
403
+ const insert = `![${alt}](https://)`;
404
+ const urlStart = from + 2 + alt.length + 2;
405
+ return { changes: [{ from, to, insert }], anchor: urlStart, head: urlStart + "https://".length };
406
+ }
398
407
  function paragraphCommand(text, from, to) {
399
408
  const changes = [];
400
409
  for (const line of linesTouched(text, from, to)) {
@@ -455,6 +464,7 @@ function isFormatActive(text, from, to, command) {
455
464
  case "codeBlock":
456
465
  case "table":
457
466
  case "horizontalRule":
467
+ case "image":
458
468
  case "undo":
459
469
  case "redo":
460
470
  return false;
@@ -486,6 +496,7 @@ const FORMAT_COMMAND_KEYS = {
486
496
  paragraph: ["Mod-Alt-0"],
487
497
  table: [],
488
498
  horizontalRule: [],
499
+ image: [],
489
500
  // The engine's own history answers ⌘Z / ⌘⇧Z; a host never rebinds them.
490
501
  undo: [],
491
502
  redo: []
@@ -0,0 +1,11 @@
1
+ import { Extension } from "@tiptap/core";
2
+ /** `[ ] ` / `[] ` / `[x] ` typed at the very start of a line. */
3
+ export declare const TASK_TYPED: RegExp;
4
+ /** An address followed by the whitespace that ends it. */
5
+ export declare const URL_TYPED: RegExp;
6
+ /** `![alt](url)` followed by whitespace. */
7
+ export declare const IMAGE_TYPED: RegExp;
8
+ /** Trailing punctuation is sentence, not address (GFM's extended-autolink rule). */
9
+ export declare function trimUrl(url: string): string;
10
+ /** The typed-markdown conversions (header). Image first: its URL must never be autolinked. */
11
+ export declare const MarkdownTypingRules: Extension<any, any>;
@@ -0,0 +1,96 @@
1
+ import { Extension, InputRule } from "@tiptap/core";
2
+ import { findWrapping } from "@tiptap/pm/transform";
3
+ import { TASK_DONE, TASK_OPEN } from "./commands.js";
4
+ const TASK_TYPED = /^\s*\[([ xX]?)\]\s$/;
5
+ const URL_TYPED = /(?:^|\s)((?:https?:\/\/|www\.)[^\s<>]+)(\s)$/;
6
+ const IMAGE_TYPED = /!\[([^\]\n]*)\]\(([^()\s]+)\)(\s)$/;
7
+ function trimUrl(url) {
8
+ let out = url.replace(/[.,;:!?'"*_~]+$/, "");
9
+ while (out.endsWith(")") && (out.match(/\(/g)?.length ?? 0) < (out.match(/\)/g)?.length ?? 0)) {
10
+ out = out.slice(0, -1).replace(/[.,;:!?'"*_~]+$/, "");
11
+ }
12
+ return out;
13
+ }
14
+ function listItemDepth($from) {
15
+ for (let depth = $from.depth; depth > 0; depth -= 1) {
16
+ if ($from.node(depth).type.name === "listItem") return depth;
17
+ }
18
+ return -1;
19
+ }
20
+ const taskRule = new InputRule({
21
+ find: TASK_TYPED,
22
+ handler: ({ state, range, match }) => {
23
+ const { tr } = state;
24
+ const mdTask = /[xX]/.test(match[1] ?? "") ? TASK_DONE : TASK_OPEN;
25
+ const $from = state.doc.resolve(range.from);
26
+ const textblock = $from.parent;
27
+ if (textblock.type.name !== "paragraph") return null;
28
+ const itemDepth = listItemDepth($from);
29
+ tr.delete(range.from, range.to);
30
+ if (itemDepth !== -1 && $from.index(itemDepth) === 0) {
31
+ const itemPos = $from.before(itemDepth);
32
+ const item = tr.doc.nodeAt(itemPos);
33
+ if (!item) return null;
34
+ tr.setNodeMarkup(itemPos, void 0, { ...item.attrs, mdTask });
35
+ return void 0;
36
+ }
37
+ const bulletList = state.schema.nodes.bulletList;
38
+ const listItem = state.schema.nodes.listItem;
39
+ if (!bulletList || !listItem) return null;
40
+ const $start = tr.doc.resolve(tr.mapping.map(range.from));
41
+ const blockRange = $start.blockRange();
42
+ const wrapping = blockRange && findWrapping(blockRange, bulletList);
43
+ if (!blockRange || !wrapping) return null;
44
+ tr.wrap(
45
+ blockRange,
46
+ wrapping.map((wrap) => wrap.type === listItem ? { type: listItem, attrs: { ...wrap.attrs, mdTask } } : wrap)
47
+ );
48
+ return void 0;
49
+ }
50
+ });
51
+ const imageRule = new InputRule({
52
+ find: IMAGE_TYPED,
53
+ handler: ({ state, range, match }) => {
54
+ const island = state.schema.nodes.inlineIsland;
55
+ if (!island || state.doc.resolve(range.from).parent.type.spec.code) return null;
56
+ const alt = (match[1] ?? "").replace(/[[\]]/g, "");
57
+ const url = match[2] ?? "";
58
+ const space = match[3] ?? " ";
59
+ const { tr } = state;
60
+ tr.replaceWith(range.from, range.to, island.create({ raw: `![${alt}](${url})`, islandType: "md_image" }));
61
+ tr.insertText(space, tr.mapping.map(range.to));
62
+ return void 0;
63
+ }
64
+ });
65
+ const urlRule = new InputRule({
66
+ find: URL_TYPED,
67
+ handler: ({ state, range, match }) => {
68
+ const link = state.schema.marks.link;
69
+ const typed = match[1] ?? "";
70
+ const url = trimUrl(typed);
71
+ if (!link || url.length < 5 || /^www\.$/i.test(url)) return null;
72
+ const start = range.from + match[0].indexOf(typed);
73
+ const end = start + url.length;
74
+ if (state.doc.rangeHasMark(start, end, link)) return null;
75
+ const $start = state.doc.resolve(start);
76
+ if ($start.parent.type.spec.code || $start.marks().some((mark) => mark.type.name === "code")) return null;
77
+ const href = /^www\./i.test(url) ? `https://${url}` : url;
78
+ const { tr } = state;
79
+ tr.addMark(start, end, link.create({ href, mdForm: "bare", mdTailHref: href, mdText: url }));
80
+ tr.removeStoredMark(link);
81
+ return void 0;
82
+ }
83
+ });
84
+ const MarkdownTypingRules = Extension.create({
85
+ name: "richEditorTypingRules",
86
+ addInputRules() {
87
+ return [taskRule, imageRule, urlRule];
88
+ }
89
+ });
90
+ export {
91
+ IMAGE_TYPED,
92
+ MarkdownTypingRules,
93
+ TASK_TYPED,
94
+ URL_TYPED,
95
+ trimUrl
96
+ };
@@ -135,6 +135,7 @@ function RichEditorImpl({
135
135
  const [saving, setSaving] = useState(false);
136
136
  const [saveState, setSaveState] = useState(null);
137
137
  const [linkDialog, setLinkDialog] = useState({ open: false, href: "" });
138
+ const [imageDialogOpen, setImageDialogOpen] = useState(false);
138
139
  const [kindRequest, setKindRequest] = useState(null);
139
140
  const approved = useRef(/* @__PURE__ */ new Set());
140
141
  const handle = useRef(null);
@@ -256,7 +257,11 @@ function RichEditorImpl({
256
257
  }
257
258
  void doSave(plan);
258
259
  };
259
- const pickImage = () => imageInput.current?.click();
260
+ const pickImage = () => setImageDialogOpen(true);
261
+ const uploadImageFile = () => {
262
+ setImageDialogOpen(false);
263
+ imageInput.current?.click();
264
+ };
260
265
  const uploadImage = async (file) => {
261
266
  try {
262
267
  const uploaded = await upload({ kind: "file", file }, { folderPath: "Editor images" });
@@ -695,6 +700,36 @@ function RichEditorImpl({
695
700
  }
696
701
  }
697
702
  ),
703
+ /* @__PURE__ */ jsx(
704
+ TextInputDialog,
705
+ {
706
+ open: imageDialogOpen,
707
+ onOpenChange: setImageDialogOpen,
708
+ title: "Image",
709
+ description: /* @__PURE__ */ jsxs("span", { children: [
710
+ "Paste the image address, or",
711
+ " ",
712
+ /* @__PURE__ */ jsx("button", { type: "button", "data-image-upload": "", className: "font-medium text-primary underline-offset-2 hover:underline", onClick: uploadImageFile, children: "upload a file" }),
713
+ "."
714
+ ] }),
715
+ placeholder: "https://",
716
+ defaultValue: "",
717
+ confirmLabel: "Insert",
718
+ validate: (value2) => /^(https?:\/\/|\/)\S+$/i.test(value2.trim()) ? null : "Paste an address that starts with https://",
719
+ onConfirm: (href) => {
720
+ const url = href.trim();
721
+ const last = url.split(/[?#]/)[0].split("/").pop() ?? "";
722
+ let name = last;
723
+ try {
724
+ name = decodeURIComponent(last);
725
+ } catch {
726
+ }
727
+ const alt = name.replace(/\.[^.]+$/, "").replace(/[[\]]/g, "") || "image";
728
+ handle.current?.replaceSelection(`![${alt}](${url})`);
729
+ setImageDialogOpen(false);
730
+ }
731
+ }
732
+ ),
698
733
  /* @__PURE__ */ jsx(
699
734
  "input",
700
735
  {
@@ -11,6 +11,8 @@ export interface FormatButtonSpec {
11
11
  export declare const BLOCK_STYLES: readonly FormatButtonSpec[];
12
12
  /** The toolbar, in its groups (a divider between each). The text-style menu sits after history. */
13
13
  export declare const FORMAT_GROUPS: readonly (readonly FormatButtonSpec[])[];
14
+ /** Always under More (never a strip button): inserts people reach for less often. */
15
+ export declare const MORE_ONLY: readonly FormatButtonSpec[];
14
16
  /** Every button the toolbar shows, in order (the text-style menu aside). */
15
17
  export declare const FORMAT_BUTTONS: readonly FormatButtonSpec[];
16
18
  /** The phone set (Arman, 2026-10-07): text style, B, I, list, checklist, link — the rest under More. */
@@ -10,6 +10,7 @@ import {
10
10
  Heading2,
11
11
  Heading3,
12
12
  Italic,
13
+ Image as ImageIcon,
13
14
  Link2,
14
15
  List,
15
16
  ListChecks,
@@ -52,6 +53,7 @@ const FORMAT_GROUPS = [
52
53
  [spec("link", "Link", Link2)],
53
54
  [spec("codeBlock", "Code block", SquareCode), spec("table", "Table", Table), spec("horizontalRule", "Horizontal rule", Minus)]
54
55
  ];
56
+ const MORE_ONLY = [spec("image", "Image", ImageIcon)];
55
57
  const FORMAT_BUTTONS = FORMAT_GROUPS.flat();
56
58
  const ESSENTIAL_COMMANDS = ["bold", "italic", "bulletList", "taskList", "link"];
57
59
  const KEEP = {
@@ -131,7 +133,7 @@ function FormatButtons({ resolve, disabled = false, className, size = "sm", vari
131
133
  React.useEffect(() => {
132
134
  const el = rootRef.current;
133
135
  if (!el || typeof ResizeObserver === "undefined") return void 0;
134
- const ro = new ResizeObserver(() => setWidth(el.getBoundingClientRect().width));
136
+ const ro = new ResizeObserver(() => setWidth(el.clientWidth || el.getBoundingClientRect().width));
135
137
  ro.observe(el);
136
138
  return () => ro.disconnect();
137
139
  }, []);
@@ -155,7 +157,7 @@ function FormatButtons({ resolve, disabled = false, className, size = "sm", vari
155
157
  let kept = all;
156
158
  let folded = variant === "essential" ? slotsOf("full").filter((s) => s.kind === "button" && !ESSENTIAL_COMMANDS.includes(s.spec.command)) : [];
157
159
  const rowWidth = (slots, more) => withDividers(slots).reduce((sum, s) => sum + widthOf(s), 0) + (more ? button : 0);
158
- if (width > 0 && rowWidth(kept, folded.length > 0) > width + 4) {
160
+ if (width > 0 && rowWidth(kept, true) > width + 4) {
159
161
  const byKeep = [...all].sort((x, y) => keepOf(x) - keepOf(y));
160
162
  const dropped = /* @__PURE__ */ new Set();
161
163
  for (const slot of byKeep) {
@@ -234,7 +236,7 @@ function FormatButtons({ resolve, disabled = false, className, size = "sm", vari
234
236
  command
235
237
  );
236
238
  }),
237
- (foldedButtons.length > 0 || styleFolded) && /* @__PURE__ */ jsxs(DropdownMenu, { children: [
239
+ (foldedButtons.length > 0 || styleFolded || MORE_ONLY.length > 0) && /* @__PURE__ */ jsxs(DropdownMenu, { children: [
238
240
  /* @__PURE__ */ jsx(DropdownMenuTrigger, { asChild: true, children: /* @__PURE__ */ jsx("button", { type: "button", "aria-label": "More formatting", title: "More formatting", "data-format-more": "", "data-matrx-button": "", className: buttonClass, children: /* @__PURE__ */ jsx(MoreHorizontal, { className: iconClass }) }) }),
239
241
  /* @__PURE__ */ jsxs(DropdownMenuContent, { align: "end", className: "w-56", onCloseAutoFocus: keepEditorFocus, children: [
240
242
  styleFolded && BLOCK_STYLES.map((style) => /* @__PURE__ */ jsxs(DropdownMenuItem, { "data-format-command": style.command, onSelect: () => run(style.command), children: [
@@ -247,6 +249,11 @@ function FormatButtons({ resolve, disabled = false, className, size = "sm", vari
247
249
  /* @__PURE__ */ jsx(b.icon, { className: "mr-2 h-4 w-4" }),
248
250
  /* @__PURE__ */ jsx("span", { className: "flex-1", children: b.label }),
249
251
  b.command !== "undo" && b.command !== "redo" && isActive(b.command) && /* @__PURE__ */ jsx(Check, { className: "ml-2 h-4 w-4 text-primary" })
252
+ ] }, b.command)),
253
+ (styleFolded || foldedButtons.length > 0) && /* @__PURE__ */ jsx(DropdownMenuSeparator, {}),
254
+ MORE_ONLY.map((b) => /* @__PURE__ */ jsxs(DropdownMenuItem, { "data-format-command": b.command, onSelect: () => run(b.command), children: [
255
+ /* @__PURE__ */ jsx(b.icon, { className: "mr-2 h-4 w-4" }),
256
+ /* @__PURE__ */ jsx("span", { className: "flex-1", children: b.label })
250
257
  ] }, b.command))
251
258
  ] })
252
259
  ] })
@@ -261,5 +268,6 @@ export {
261
268
  FORMAT_BUTTONS,
262
269
  FORMAT_GROUPS,
263
270
  FormatButtons,
271
+ MORE_ONLY,
264
272
  FormatButtons_default as default
265
273
  };
@@ -0,0 +1,4 @@
1
+ /** True when `target` (else the focused element) sits inside an editor. */
2
+ export declare function insideEditor(target: EventTarget | null | undefined): boolean;
3
+ /** True when a key press belongs to the editor it was pressed in (shell shortcuts skip it). */
4
+ export declare function editorOwnsKeyEvent(event: Pick<KeyboardEvent, "target">): boolean;
@@ -0,0 +1,14 @@
1
+ const EDITOR_SELECTOR = "[data-format-host], [contenteditable=''], [contenteditable='true'], [contenteditable='plaintext-only'], .cm-editor";
2
+ function insideEditor(target) {
3
+ const element = target && typeof target.closest === "function" ? target : typeof document !== "undefined" ? document.activeElement : null;
4
+ if (!element || typeof element.closest !== "function") return false;
5
+ if (element.closest(EDITOR_SELECTOR)) return true;
6
+ return element.isContentEditable === true;
7
+ }
8
+ function editorOwnsKeyEvent(event) {
9
+ return insideEditor(event.target);
10
+ }
11
+ export {
12
+ editorOwnsKeyEvent,
13
+ insideEditor
14
+ };
@@ -0,0 +1,6 @@
1
+ import { type RefObject } from "react";
2
+ import type { Editor } from "@tiptap/core";
3
+ export declare function TableHandle({ editor, container }: {
4
+ editor: Editor | null;
5
+ container: RefObject<HTMLDivElement | null>;
6
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,129 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useState } from "react";
4
+ import {
5
+ cn,
6
+ DropdownMenu,
7
+ DropdownMenuContent,
8
+ DropdownMenuItem,
9
+ DropdownMenuSeparator,
10
+ DropdownMenuTrigger
11
+ } from "@ai-matrx/design-system";
12
+ import {
13
+ ArrowDownToLine,
14
+ ArrowLeftToLine,
15
+ ArrowRightToLine,
16
+ ArrowUpToLine,
17
+ Check,
18
+ Columns3,
19
+ Heading,
20
+ Rows3,
21
+ Table2,
22
+ Trash2
23
+ } from "lucide-react";
24
+ import { useRichEditorContext } from "../editor/RichEditorContext.js";
25
+ import { TABLE_MENU } from "./table-menu.js";
26
+ const ICONS = {
27
+ "row-above": ArrowUpToLine,
28
+ "row-below": ArrowDownToLine,
29
+ "col-left": ArrowLeftToLine,
30
+ "col-right": ArrowRightToLine,
31
+ "header-row": Heading,
32
+ "del-row": Rows3,
33
+ "del-col": Columns3,
34
+ "del-table": Trash2
35
+ };
36
+ function anchorOf(editor, host) {
37
+ if (editor.isDestroyed || !editor.isEditable || !editor.isActive("table")) return null;
38
+ const { $from } = editor.state.selection;
39
+ for (let depth = $from.depth; depth > 0; depth -= 1) {
40
+ if ($from.node(depth).type.name !== "table") continue;
41
+ const dom = editor.view.nodeDOM($from.before(depth));
42
+ const element = dom instanceof HTMLElement ? dom.closest(".tableWrapper") ?? dom : null;
43
+ if (!element) return null;
44
+ const box = element.getBoundingClientRect();
45
+ const hostBox = host.getBoundingClientRect();
46
+ return { top: box.top - hostBox.top + host.scrollTop, right: hostBox.right - box.right };
47
+ }
48
+ return null;
49
+ }
50
+ function TableHandle({ editor, container }) {
51
+ const context = useRichEditorContext();
52
+ const [anchor, setAnchor] = useState(null);
53
+ const [menuOpen, setMenuOpen] = useState(false);
54
+ useEffect(() => {
55
+ const host = container.current;
56
+ if (!editor || !host || context.readOnly) return void 0;
57
+ const update = () => {
58
+ if (menuOpen) return;
59
+ setAnchor(editor.isFocused ? anchorOf(editor, host) : null);
60
+ };
61
+ update();
62
+ editor.on("selectionUpdate", update);
63
+ editor.on("transaction", update);
64
+ editor.on("focus", update);
65
+ editor.on("blur", update);
66
+ return () => {
67
+ editor.off("selectionUpdate", update);
68
+ editor.off("transaction", update);
69
+ editor.off("focus", update);
70
+ editor.off("blur", update);
71
+ };
72
+ }, [editor, container, context.readOnly, menuOpen]);
73
+ if (!editor || !anchor || context.readOnly) return null;
74
+ let section = null;
75
+ return /* @__PURE__ */ jsx("div", { className: "absolute z-10", style: { top: Math.max(0, anchor.top - 14), right: Math.max(0, anchor.right - 10) }, children: /* @__PURE__ */ jsxs(
76
+ DropdownMenu,
77
+ {
78
+ open: menuOpen,
79
+ onOpenChange: (open) => {
80
+ setMenuOpen(open);
81
+ if (!open) editor.commands.focus();
82
+ },
83
+ children: [
84
+ /* @__PURE__ */ jsx(DropdownMenuTrigger, { asChild: true, children: /* @__PURE__ */ jsx(
85
+ "button",
86
+ {
87
+ type: "button",
88
+ "aria-label": "Table options",
89
+ title: "Table options",
90
+ "data-table-handle": "",
91
+ onMouseDown: (event) => event.preventDefault(),
92
+ className: cn(
93
+ "flex h-6 w-6 items-center justify-center rounded-md border border-border bg-background text-muted-foreground shadow-sm",
94
+ "hover:bg-accent hover:text-foreground"
95
+ ),
96
+ children: /* @__PURE__ */ jsx(Table2, { className: "h-3.5 w-3.5" })
97
+ }
98
+ ) }),
99
+ /* @__PURE__ */ jsx(DropdownMenuContent, { align: "end", className: "w-52", onCloseAutoFocus: (event) => event.preventDefault(), children: TABLE_MENU.map((item) => {
100
+ const Icon = ICONS[item.id] ?? Table2;
101
+ const divider = section !== null && section !== item.section;
102
+ section = item.section;
103
+ const checked = item.checked?.(editor) ?? false;
104
+ return /* @__PURE__ */ jsxs("div", { children: [
105
+ divider && /* @__PURE__ */ jsx(DropdownMenuSeparator, {}),
106
+ /* @__PURE__ */ jsxs(
107
+ DropdownMenuItem,
108
+ {
109
+ "data-table-action": item.id,
110
+ className: cn(item.destructive && "text-destructive focus:text-destructive"),
111
+ onSelect: () => {
112
+ item.run(editor);
113
+ },
114
+ children: [
115
+ /* @__PURE__ */ jsx(Icon, { className: "mr-2 h-4 w-4" }),
116
+ /* @__PURE__ */ jsx("span", { className: "flex-1", children: item.label }),
117
+ item.checked && checked && /* @__PURE__ */ jsx(Check, { className: "ml-2 h-4 w-4 text-primary" })
118
+ ]
119
+ }
120
+ )
121
+ ] }, item.id);
122
+ }) })
123
+ ]
124
+ }
125
+ ) });
126
+ }
127
+ export {
128
+ TableHandle
129
+ };
@@ -21,6 +21,7 @@ import { contextAtIndex, locateCaret } from "../core/caret-context.js";
21
21
  import { createVisualExtensions } from "./visual-extensions.js";
22
22
  import { findHighlightKey } from "./decorations.js";
23
23
  import { BlockHandle } from "./BlockHandle.js";
24
+ import { TableHandle } from "./TableHandle.js";
24
25
  import { useSelectionZone } from "@ai-matrx/rich-content/selection-toolbar/selection-zones";
25
26
  import { registerFormatElement } from "../format/format-target.js";
26
27
  import { RICH_EDITOR_HOST_KEY, editorFormatTarget, islandSelectionOf, tableAnchorOf } from "./format-actions.js";
@@ -153,7 +154,7 @@ function VisualEditor({
153
154
  };
154
155
  const shellRef = useRef(shell);
155
156
  shellRef.current = shell;
156
- const formatTarget = useMemo(() => editor ? editorFormatTarget(editor, () => shellRef.current.editLink()) : null, [editor]);
157
+ const formatTarget = useMemo(() => editor ? editorFormatTarget(editor, () => shellRef.current.editLink(), () => shellRef.current.pickImage()) : null, [editor]);
157
158
  const [formatZone, setFormatZone] = useState(null);
158
159
  useEffect(() => {
159
160
  if (!formatZone || !formatTarget || context.readOnly) return void 0;
@@ -286,6 +287,7 @@ function VisualEditor({
286
287
  kind: "rich-editor",
287
288
  editor,
288
289
  onEditLink: shell.editLink,
290
+ onInsertImage: shell.pickImage,
289
291
  offerVariables: context.variables !== null,
290
292
  inTable: () => !editor.isDestroyed && editor.isActive("table")
291
293
  } : null;
@@ -327,6 +329,7 @@ function VisualEditor({
327
329
  "data-testid": "rich-editor-visual",
328
330
  children: [
329
331
  /* @__PURE__ */ jsx(BlockHandle, { editor, container }),
332
+ /* @__PURE__ */ jsx(TableHandle, { editor, container }),
330
333
  /* @__PURE__ */ jsx(EditorContent, { editor, className: "mx-auto max-w-3xl pb-[40dvh]" })
331
334
  ]
332
335
  }
@@ -6,6 +6,8 @@ export interface RichEditorSelectionHost {
6
6
  kind: "rich-editor";
7
7
  editor: Editor;
8
8
  onEditLink: () => void;
9
+ /** Open the image prompt (upload a file or paste an address). */
10
+ onInsertImage?: () => void;
9
11
  offerVariables: boolean;
10
12
  /** The caret or selection is inside a table (its table actions show). */
11
13
  inTable: () => boolean;
@@ -22,6 +24,6 @@ export declare function tableAnchorOf(editor: Editor): DOMRect | null;
22
24
  * (link field, checklist, code-block island) — the selection toolbar and the
23
25
  * visible toolbar (`FormatButtons`, through `registerFormatElement`) share it.
24
26
  */
25
- export declare function editorFormatTarget(editor: Editor, onEditLink: () => void): FormatTarget;
27
+ export declare function editorFormatTarget(editor: Editor, onEditLink: () => void, onInsertImage?: () => void): FormatTarget;
26
28
  /** The formatting provider (declared on load; the toolbar root registers it). */
27
29
  export declare const richEditorFormatProvider: ActionProvider;
@@ -44,11 +44,12 @@ function editorOf(target) {
44
44
  }
45
45
  function visualTargetOf(target) {
46
46
  const visual = editorOf(target);
47
- return visual ? editorFormatTarget(visual.editor, visual.onEditLink) : null;
47
+ return visual ? editorFormatTarget(visual.editor, visual.onEditLink, visual.onInsertImage) : null;
48
48
  }
49
- function editorFormatTarget(editor, onEditLink) {
49
+ function editorFormatTarget(editor, onEditLink, onInsertImage) {
50
50
  return visualFormatTarget(editor, {
51
51
  link: () => (onEditLink(), true),
52
+ image: () => onInsertImage ? (onInsertImage(), true) : false,
52
53
  taskList: () => toggleTaskList(editor),
53
54
  codeBlock: () => {
54
55
  markAutoEdit(editor, insertCodeBlock(editor));
@@ -0,0 +1,13 @@
1
+ import type { Editor } from "@tiptap/core";
2
+ export interface TableMenuItem {
3
+ id: string;
4
+ label: string;
5
+ /** A divider goes before this item. */
6
+ section: "rows" | "columns" | "header" | "delete";
7
+ destructive?: boolean;
8
+ run: (editor: Editor) => boolean;
9
+ /** A toggle's state (the header row). */
10
+ checked?: (editor: Editor) => boolean;
11
+ }
12
+ export declare const TABLE_MENU: readonly TableMenuItem[];
13
+ export declare function tableMenuItem(id: string): TableMenuItem;
@@ -0,0 +1,20 @@
1
+ import { tableHeaderRowShown, toggleTableHeaderRow } from "../core/commands.js";
2
+ const TABLE_MENU = [
3
+ { id: "row-above", section: "rows", label: "Add row above", run: (e) => e.chain().focus().addRowBefore().run() },
4
+ { id: "row-below", section: "rows", label: "Add row below", run: (e) => e.chain().focus().addRowAfter().run() },
5
+ { id: "col-left", section: "columns", label: "Add column left", run: (e) => e.chain().focus().addColumnBefore().run() },
6
+ { id: "col-right", section: "columns", label: "Add column right", run: (e) => e.chain().focus().addColumnAfter().run() },
7
+ { id: "header-row", section: "header", label: "Header row", run: (e) => toggleTableHeaderRow(e), checked: tableHeaderRowShown },
8
+ { id: "del-row", section: "delete", label: "Delete row", destructive: true, run: (e) => e.chain().focus().deleteRow().run() },
9
+ { id: "del-col", section: "delete", label: "Delete column", destructive: true, run: (e) => e.chain().focus().deleteColumn().run() },
10
+ { id: "del-table", section: "delete", label: "Delete table", destructive: true, run: (e) => e.chain().focus().deleteTable().run() }
11
+ ];
12
+ function tableMenuItem(id) {
13
+ const item = TABLE_MENU.find((candidate) => candidate.id === id);
14
+ if (!item) throw new Error(`No table menu item "${id}"`);
15
+ return item;
16
+ }
17
+ export {
18
+ TABLE_MENU,
19
+ tableMenuItem
20
+ };
@@ -17,6 +17,8 @@ const VISUAL = {
17
17
  paragraph: { run: (e) => e.chain().focus().setParagraph().run(), active: (e) => e.isActive("paragraph") },
18
18
  table: { run: (e) => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(), active: (e) => e.isActive("table") },
19
19
  horizontalRule: { run: (e) => e.chain().focus().setHorizontalRule().run(), active: () => false },
20
+ // The image prompt (upload or address) is the host shell's — passed in as an override.
21
+ image: { run: () => false, active: () => false },
20
22
  undo: { run: (e) => e.chain().focus().undo().run(), active: () => false },
21
23
  redo: { run: (e) => e.chain().focus().redo().run(), active: () => false }
22
24
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-matrx/rich-editor",
3
- "version": "0.4.3",
3
+ "version": "0.5.1",
4
4
  "description": "The AI Matrx editor: Visual (Tiptap), Source (CodeMirror), Preview, the markdown formatting command layer, and edit-in-place — over @ai-matrx/rich-content. Consumers that only render never install it.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -91,12 +91,12 @@
91
91
  "github-slugger": "^2.0.0",
92
92
  "lucide-react": "^1.48.0",
93
93
  "@ai-matrx/agents": "latest",
94
- "@ai-matrx/alchemy": "latest",
95
94
  "@ai-matrx/content-ir": "latest",
96
95
  "@ai-matrx/design-system": "latest",
97
96
  "@ai-matrx/kit": "latest",
98
97
  "@ai-matrx/print": "latest",
99
- "@ai-matrx/rich-content": "latest"
98
+ "@ai-matrx/rich-content": "latest",
99
+ "@ai-matrx/alchemy": "latest"
100
100
  },
101
101
  "peerDependencies": {
102
102
  "react": ">=19.0.0",