@ai-matrx/rich-editor 0.5.4 → 0.5.6

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.6
4
+
5
+ Automatic changed-only republish (docs/metadata drift since the last tag — see
6
+ `git diff npm/rich-editor/v0.5.5..npm/rich-editor/v0.5.6 -- apps/shared/rich-editor`).
7
+ No source changes intended and no consumer action required.
8
+
9
+ ## 0.5.5
10
+ ### Images in Write after a reload; image paste in textareas; checklist Enter and `- ` (2026-10-08)
11
+
12
+ - Write's uploaded image draws after a reload (needs rich-content 0.2.54: the image waits for the app's lazy host instead of swapping its hooks mid-life). Guard `src/visual/__tests__/write-image-island.test.tsx`.
13
+ - `useTextareaFormatting(el, enabled, { uploadImage })`: a pasted or dropped image uploads and `![name](url)` lands at the caret. `format/markdown-image-upload` — `useMarkdownImageUpload()` (through the host bindings; Write uses it), `createMarkdownImageUpload(upload, fileUrls)` (for a host that renders before the lazy host), `insertUploadedImages`, `imageFilesOf`. Guard `src/format/__tests__/textarea-image-paste.test.tsx`.
14
+ - An empty list item stores as its bare marker, task or not: `- [ ]` with nothing after it reloads as a bullet reading "[ ]" (GFM needs text after the box).
15
+ - `- ` / `* ` / `+ ` typed at the start of a checklist item turns it into a bullet item (Notion, Google Docs); typed mid-text it stays text. Guard `src/visual/__tests__/checklist-enter-and-dash.test.ts`.
16
+
17
+ Consumer action: a markdown textarea that should take pasted images passes `uploadImage`.
18
+
3
19
  ## 0.5.4
4
20
  ### Images in Write survive a reload (2026-10-08)
5
21
 
@@ -140,7 +140,7 @@ function serializeListItem(item, marker, ctx) {
140
140
  const task = attr(item, "mdTask") ?? "";
141
141
  const body = serializeChildren(item, "listItem", ctx);
142
142
  const head = lead + marker;
143
- if (body === "") return task ? `${head}${after || " "}${task}`.trimEnd() : head;
143
+ if (body === "") return head;
144
144
  const indent = attr(item, "mdIndent") ?? (lead + marker + (after || " ")).length;
145
145
  const pad = " ".repeat(indent);
146
146
  const lines = body.split("\n");
@@ -1,6 +1,8 @@
1
1
  import { Extension, InputRule } from "@tiptap/core";
2
2
  /** `[ ] ` / `[] ` / `[x] ` typed at the very start of a line. */
3
3
  export declare const TASK_TYPED: RegExp;
4
+ /** `- ` / `* ` / `+ ` typed at the very start of a line. */
5
+ export declare const BULLET_TYPED: RegExp;
4
6
  /** An address followed by the whitespace that ends it. */
5
7
  export declare const URL_TYPED: RegExp;
6
8
  /** `![alt](url)` followed by whitespace. */
@@ -2,6 +2,7 @@ import { Extension, InputRule } from "@tiptap/core";
2
2
  import { findWrapping } from "@tiptap/pm/transform";
3
3
  import { TASK_DONE, TASK_OPEN } from "./commands.js";
4
4
  const TASK_TYPED = /^\s*\[([ xX]?)\]\s$/;
5
+ const BULLET_TYPED = /^\s*([-+*])\s$/;
5
6
  const URL_TYPED = /(?:^|\s)((?:https?:\/\/|www\.)[^\s<>]+)(\s)$/;
6
7
  const IMAGE_TYPED = /!\[([^\]\n]*)\]\(([^()\s]+)\)(\s)$/;
7
8
  function trimUrl(url) {
@@ -52,6 +53,22 @@ const taskRule = new InputRule({
52
53
  return void 0;
53
54
  }
54
55
  });
56
+ const bulletInTaskRule = new InputRule({
57
+ find: BULLET_TYPED,
58
+ handler: ({ state, range }) => {
59
+ const $from = state.doc.resolve(range.from);
60
+ if ($from.parent.type.name !== "paragraph") return null;
61
+ const itemDepth = listItemDepth($from);
62
+ if (itemDepth === -1 || $from.index(itemDepth) !== 0) return null;
63
+ const itemPos = $from.before(itemDepth);
64
+ const item = state.doc.nodeAt(itemPos);
65
+ if (!item || typeof item.attrs.mdTask !== "string") return null;
66
+ const { tr } = state;
67
+ tr.delete(range.from, range.to);
68
+ tr.setNodeMarkup(itemPos, void 0, { ...item.attrs, mdTask: null });
69
+ return void 0;
70
+ }
71
+ });
55
72
  const imageRule = new InputRule({
56
73
  find: IMAGE_TYPED,
57
74
  handler: ({ state, range, match }) => {
@@ -86,7 +103,7 @@ const urlRule = new InputRule({
86
103
  return void 0;
87
104
  }
88
105
  });
89
- const TYPING_RULES = [taskRule, imageRule, urlRule];
106
+ const TYPING_RULES = [taskRule, bulletInTaskRule, imageRule, urlRule];
90
107
  const MarkdownTypingRules = Extension.create({
91
108
  name: "richEditorTypingRules",
92
109
  addInputRules() {
@@ -94,6 +111,7 @@ const MarkdownTypingRules = Extension.create({
94
111
  }
95
112
  });
96
113
  export {
114
+ BULLET_TYPED,
97
115
  IMAGE_TYPED,
98
116
  MarkdownTypingRules,
99
117
  TASK_TYPED,
@@ -40,8 +40,7 @@ import { cn } from "@ai-matrx/design-system";
40
40
  import { EditableContextMenu } from "@ai-matrx/rich-content/host/app-bindings";
41
41
  import { buildApplicationScopeFromMenuContext } from "@ai-matrx/rich-content/host/app-bindings";
42
42
  import { useMicField } from "@ai-matrx/rich-content/host/app-bindings";
43
- import { useFileUpload } from "@ai-matrx/rich-content/host/app-bindings";
44
- import { fileUrls } from "@ai-matrx/rich-content/host/app-bindings";
43
+ import { useMarkdownImageUpload } from "../format/markdown-image-upload.js";
45
44
  import { RichDocument } from "@ai-matrx/rich-content/rich-document/RichDocument";
46
45
  import { planSave } from "../core/save-plan.js";
47
46
  import { reconcileHostValue } from "../core/host-value.js";
@@ -141,7 +140,6 @@ function RichEditorImpl({
141
140
  const handle = useRef(null);
142
141
  const pendingCaret = useRef(null);
143
142
  const imageInput = useRef(null);
144
- const { upload } = useFileUpload();
145
143
  const lastValue = useRef(value);
146
144
  const ownSaves = useRef(/* @__PURE__ */ new Set());
147
145
  useEffect(() => {
@@ -262,16 +260,7 @@ function RichEditorImpl({
262
260
  setImageDialogOpen(false);
263
261
  imageInput.current?.click();
264
262
  };
265
- const uploadImage = async (file) => {
266
- try {
267
- const uploaded = await upload({ kind: "file", file }, { folderPath: "Editor images" });
268
- const alt = file.name.replace(/\.[^.]+$/, "").replace(/[[\]]/g, "");
269
- return `![${alt}](${fileUrls(uploaded.fileId).inline})`;
270
- } catch (error) {
271
- toast.error(`The image did not upload: ${error instanceof Error ? error.message : String(error)}`);
272
- return null;
273
- }
274
- };
263
+ const uploadImage = useMarkdownImageUpload();
275
264
  const shellRef = useRef(null);
276
265
  const liveShell = {
277
266
  save,
@@ -0,0 +1,34 @@
1
+ export type MarkdownImageUpload = (file: File) => Promise<string | null>;
2
+ /** The alt text for an uploaded file: its name without the extension, brackets removed. */
3
+ export declare function imageAltOf(fileName: string): string;
4
+ type Upload = (input: {
5
+ kind: "file";
6
+ file: File;
7
+ }, options: {
8
+ folderPath: string;
9
+ }) => Promise<{
10
+ fileId: string;
11
+ }>;
12
+ type FileUrls = (fileId: string) => {
13
+ inline: string;
14
+ };
15
+ /**
16
+ * The uploader over a host's own upload + file-URL functions — for a host that renders before the
17
+ * app's lazy rich-content host has loaded (a hook read through the host bindings would change its
18
+ * hook list when the host lands).
19
+ */
20
+ export declare function createMarkdownImageUpload(upload: Upload, urls: FileUrls): MarkdownImageUpload;
21
+ /** Upload one image through the host bindings; the markdown that draws it, or null (announced). */
22
+ export declare function useMarkdownImageUpload(): MarkdownImageUpload;
23
+ /** The image files a paste or drop carries (screenshots arrive as files on the clipboard). */
24
+ export declare function imageFilesOf(data: DataTransfer | null | undefined): File[];
25
+ /**
26
+ * Upload each image and write its markdown into the textarea where the caret was when it was
27
+ * pasted or dropped (later edits move that point with them only if they were before it — the
28
+ * insert lands at the recorded offset clamped to the text). React hears it as an input event.
29
+ */
30
+ export declare function insertUploadedImages(el: HTMLTextAreaElement, files: readonly File[], at: {
31
+ from: number;
32
+ to: number;
33
+ }, uploadImage: MarkdownImageUpload): Promise<void>;
34
+ export {};
@@ -0,0 +1,60 @@
1
+ "use client";
2
+ import { fileUrls, useFileUpload } from "@ai-matrx/rich-content/host/app-bindings";
3
+ import { toast } from "@ai-matrx/rich-content/host/toast";
4
+ function imageAltOf(fileName) {
5
+ return fileName.replace(/\.[^.]+$/, "").replace(/[[\]]/g, "") || "image";
6
+ }
7
+ function createMarkdownImageUpload(upload, urls) {
8
+ return async (file) => {
9
+ try {
10
+ const uploaded = await upload({ kind: "file", file }, { folderPath: "Editor images" });
11
+ return `![${imageAltOf(file.name)}](${urls(uploaded.fileId).inline})`;
12
+ } catch (error) {
13
+ toast.error(`The image did not upload: ${error instanceof Error ? error.message : String(error)}`);
14
+ return null;
15
+ }
16
+ };
17
+ }
18
+ function useMarkdownImageUpload() {
19
+ const { upload } = useFileUpload();
20
+ return createMarkdownImageUpload(upload, fileUrls);
21
+ }
22
+ function imageFilesOf(data) {
23
+ return Array.from(data?.files ?? []).filter((file) => file.type.startsWith("image/"));
24
+ }
25
+ async function insertUploadedImages(el, files, at, uploadImage) {
26
+ let from = at.from;
27
+ let to = at.to;
28
+ for (const file of files) {
29
+ const markdown = await uploadImage(file);
30
+ if (!markdown || !el.isConnected) continue;
31
+ const text = el.value;
32
+ const start = Math.min(from, text.length);
33
+ const end = Math.min(Math.max(to, start), text.length);
34
+ const before = text.slice(0, start);
35
+ const lead = before === "" || /\s$/.test(before) ? "" : " ";
36
+ const insert = `${lead}${markdown}`;
37
+ el.focus({ preventScroll: true });
38
+ el.setSelectionRange(start, end);
39
+ let done = false;
40
+ try {
41
+ done = typeof document.execCommand === "function" && document.execCommand("insertText", false, insert);
42
+ } catch {
43
+ done = false;
44
+ }
45
+ if (!done || el.value === text) {
46
+ el.setRangeText(insert, start, end, "end");
47
+ el.dispatchEvent(new Event("input", { bubbles: true }));
48
+ }
49
+ from = start + insert.length;
50
+ to = from;
51
+ el.setSelectionRange(from, from);
52
+ }
53
+ }
54
+ export {
55
+ createMarkdownImageUpload,
56
+ imageAltOf,
57
+ imageFilesOf,
58
+ insertUploadedImages,
59
+ useMarkdownImageUpload
60
+ };
@@ -1,4 +1,12 @@
1
+ import { type MarkdownImageUpload } from "./markdown-image-upload.js";
2
+ export interface TextareaFormattingOptions {
3
+ /**
4
+ * Pasting or dropping an image uploads it and writes `![name](url)` at the caret
5
+ * (useMarkdownImageUpload). Absent: images are left to the browser / the host.
6
+ */
7
+ uploadImage?: MarkdownImageUpload | null;
8
+ }
1
9
  export declare function isApplePlatform(): boolean;
2
10
  /** Run the chord's command on this element; true when the chord was a formatting command. */
3
11
  export declare function handleFormatChord(el: HTMLTextAreaElement, event: KeyboardEvent): boolean;
4
- export declare function useTextareaFormatting(element: HTMLTextAreaElement | null, enabled?: boolean): void;
12
+ export declare function useTextareaFormatting(element: HTMLTextAreaElement | null, enabled?: boolean, options?: TextareaFormattingOptions): void;
@@ -1,9 +1,10 @@
1
1
  "use client";
2
- import { useEffect } from "react";
2
+ import { useEffect, useRef } from "react";
3
3
  import { useSelectionZone } from "@ai-matrx/rich-content/selection-toolbar/selection-zones";
4
4
  import { formatCommandForKey, keyNameOf } from "../core/markdown-format.js";
5
5
  import { markdownFormatHost, registerFormatElement, textareaFormatTarget } from "./format-target.js";
6
6
  import { ensureFormatActions } from "./format-actions.js";
7
+ import { imageFilesOf, insertUploadedImages } from "./markdown-image-upload.js";
7
8
  function isApplePlatform() {
8
9
  if (typeof navigator === "undefined") return false;
9
10
  const platform = navigator.userAgentData?.platform ?? navigator.platform ?? "";
@@ -16,7 +17,41 @@ function handleFormatChord(el, event) {
16
17
  textareaFormatTarget(el).run(command);
17
18
  return true;
18
19
  }
19
- function useTextareaFormatting(element, enabled = true) {
20
+ function useTextareaFormatting(element, enabled = true, options = {}) {
21
+ const uploadRef = useRef(options.uploadImage);
22
+ useEffect(() => {
23
+ uploadRef.current = options.uploadImage;
24
+ });
25
+ const uploads = Boolean(options.uploadImage);
26
+ useEffect(() => {
27
+ if (!element || !enabled || !uploads) return;
28
+ const take = (event, data) => {
29
+ if (element.readOnly || element.disabled) return;
30
+ const images = imageFilesOf(data);
31
+ const uploadImage = uploadRef.current;
32
+ if (images.length === 0 || !uploadImage) return;
33
+ event.preventDefault();
34
+ void insertUploadedImages(element, images, { from: element.selectionStart, to: element.selectionEnd }, uploadImage);
35
+ };
36
+ const onPaste = (event) => take(event, event.clipboardData);
37
+ const onDragOver = (event) => {
38
+ if (Array.from(event.dataTransfer?.items ?? []).some((item) => item.kind === "file" && item.type.startsWith("image/"))) {
39
+ event.preventDefault();
40
+ }
41
+ };
42
+ const onDrop = (event) => {
43
+ if (imageFilesOf(event.dataTransfer).length) element.focus({ preventScroll: true });
44
+ take(event, event.dataTransfer);
45
+ };
46
+ element.addEventListener("paste", onPaste);
47
+ element.addEventListener("dragover", onDragOver);
48
+ element.addEventListener("drop", onDrop);
49
+ return () => {
50
+ element.removeEventListener("paste", onPaste);
51
+ element.removeEventListener("dragover", onDragOver);
52
+ element.removeEventListener("drop", onDrop);
53
+ };
54
+ }, [element, enabled, uploads]);
20
55
  useEffect(() => {
21
56
  if (!element || !enabled) return;
22
57
  ensureFormatActions();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-matrx/rich-editor",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
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",
@@ -90,12 +90,12 @@
90
90
  "@tiptap/suggestion": "^3.31.3",
91
91
  "github-slugger": "^2.0.0",
92
92
  "lucide-react": "^1.48.0",
93
- "@ai-matrx/alchemy": "latest",
94
93
  "@ai-matrx/agents": "latest",
95
94
  "@ai-matrx/content-ir": "latest",
96
95
  "@ai-matrx/design-system": "latest",
97
- "@ai-matrx/print": "latest",
98
96
  "@ai-matrx/kit": "latest",
97
+ "@ai-matrx/print": "latest",
98
+ "@ai-matrx/alchemy": "latest",
99
99
  "@ai-matrx/rich-content": "latest"
100
100
  },
101
101
  "peerDependencies": {