@ai-matrx/rich-editor 0.5.4 → 0.5.5
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 +10 -0
- package/dist/core/markdown-serialize.js +1 -1
- package/dist/core/typing-rules.d.ts +2 -0
- package/dist/core/typing-rules.js +19 -1
- package/dist/editor/RichEditorImpl.js +2 -13
- package/dist/format/markdown-image-upload.d.ts +34 -0
- package/dist/format/markdown-image-upload.js +60 -0
- package/dist/format/useTextareaFormatting.d.ts +9 -1
- package/dist/format/useTextareaFormatting.js +37 -2
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.5
|
|
4
|
+
### Images in Write after a reload; image paste in textareas; checklist Enter and `- ` (2026-10-08)
|
|
5
|
+
|
|
6
|
+
- 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`.
|
|
7
|
+
- `useTextareaFormatting(el, enabled, { uploadImage })`: a pasted or dropped image uploads and `` 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`.
|
|
8
|
+
- 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).
|
|
9
|
+
- `- ` / `* ` / `+ ` 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`.
|
|
10
|
+
|
|
11
|
+
Consumer action: a markdown textarea that should take pasted images passes `uploadImage`.
|
|
12
|
+
|
|
3
13
|
## 0.5.4
|
|
4
14
|
### Images in Write survive a reload (2026-10-08)
|
|
5
15
|
|
|
@@ -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
|
|
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
|
/** `` 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 {
|
|
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 =
|
|
266
|
-
try {
|
|
267
|
-
const uploaded = await upload({ kind: "file", file }, { folderPath: "Editor images" });
|
|
268
|
-
const alt = file.name.replace(/\.[^.]+$/, "").replace(/[[\]]/g, "");
|
|
269
|
-
return `.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 `.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 `` 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.
|
|
3
|
+
"version": "0.5.5",
|
|
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",
|
|
94
|
+
"@ai-matrx/alchemy": "latest",
|
|
95
95
|
"@ai-matrx/content-ir": "latest",
|
|
96
96
|
"@ai-matrx/design-system": "latest",
|
|
97
|
-
"@ai-matrx/print": "latest",
|
|
98
97
|
"@ai-matrx/kit": "latest",
|
|
98
|
+
"@ai-matrx/print": "latest",
|
|
99
99
|
"@ai-matrx/rich-content": "latest"
|
|
100
100
|
},
|
|
101
101
|
"peerDependencies": {
|