@agent-native/toolkit 0.6.0 → 0.7.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/README.md +17 -1
- package/dist/chat-history/ChatHistoryList.d.ts +103 -0
- package/dist/chat-history/ChatHistoryList.d.ts.map +1 -0
- package/dist/chat-history/ChatHistoryList.js +109 -0
- package/dist/chat-history/ChatHistoryList.js.map +1 -0
- package/dist/chat-history/ChatHistoryList.spec.d.ts +2 -0
- package/dist/chat-history/ChatHistoryList.spec.d.ts.map +1 -0
- package/dist/chat-history/ChatHistoryList.spec.js +283 -0
- package/dist/chat-history/ChatHistoryList.spec.js.map +1 -0
- package/dist/chat-history/index.d.ts +2 -0
- package/dist/chat-history/index.d.ts.map +1 -0
- package/dist/chat-history/index.js +2 -0
- package/dist/chat-history/index.js.map +1 -0
- package/dist/chat-history.css +314 -0
- package/package.json +17 -1
- package/src/app-shell/header-actions.tsx +84 -0
- package/src/app-shell/index.ts +1 -0
- package/src/chat-history/ChatHistoryList.spec.tsx +453 -0
- package/src/chat-history/ChatHistoryList.tsx +469 -0
- package/src/chat-history/index.ts +7 -0
- package/src/chat-history.css +314 -0
- package/src/collab-ui/AgentPresenceChip.tsx +69 -0
- package/src/collab-ui/LiveCursorOverlay.tsx +241 -0
- package/src/collab-ui/PresenceBar.tsx +346 -0
- package/src/collab-ui/RecentEditHighlights.tsx +225 -0
- package/src/collab-ui/RemoteSelectionRings.tsx +227 -0
- package/src/collab-ui/agent-identity.ts +16 -0
- package/src/collab-ui/index.ts +39 -0
- package/src/collab-ui/lead-client.ts +32 -0
- package/src/collab-ui/types.spec.ts +18 -0
- package/src/collab-ui/types.ts +96 -0
- package/src/composer/AgentComposerFrame.tsx +59 -0
- package/src/composer/ComposerPlusMenu.tsx +983 -0
- package/src/composer/MentionPopover.tsx +412 -0
- package/src/composer/PastedTextChip.tsx +150 -0
- package/src/composer/PromptComposer.spec.ts +73 -0
- package/src/composer/PromptComposer.tsx +718 -0
- package/src/composer/RealtimeVoiceMode.spec.tsx +771 -0
- package/src/composer/RealtimeVoiceMode.tsx +856 -0
- package/src/composer/TiptapComposer.spec.ts +390 -0
- package/src/composer/TiptapComposer.tsx +2936 -0
- package/src/composer/VoiceButton.spec.ts +33 -0
- package/src/composer/VoiceButton.tsx +255 -0
- package/src/composer/asset-picker-url.spec.ts +56 -0
- package/src/composer/asset-picker-url.ts +80 -0
- package/src/composer/attachment-accept.spec.ts +22 -0
- package/src/composer/attachment-accept.ts +71 -0
- package/src/composer/draft-key.spec.ts +19 -0
- package/src/composer/draft-key.ts +7 -0
- package/src/composer/extensions/FileReference.tsx +55 -0
- package/src/composer/extensions/MentionReference.tsx +93 -0
- package/src/composer/extensions/SkillReference.tsx +56 -0
- package/src/composer/index.ts +79 -0
- package/src/composer/pasted-text.spec.ts +162 -0
- package/src/composer/pasted-text.ts +160 -0
- package/src/composer/prompt-attachments.spec.ts +80 -0
- package/src/composer/prompt-attachments.ts +93 -0
- package/src/composer/realtime-voice-audio-level.spec.ts +32 -0
- package/src/composer/realtime-voice-audio-level.ts +64 -0
- package/src/composer/realtime-voice-transcript.spec.ts +95 -0
- package/src/composer/realtime-voice-transcript.ts +134 -0
- package/src/composer/runtime-adapters.tsx +343 -0
- package/src/composer/types.ts +66 -0
- package/src/composer/use-file-search.ts +64 -0
- package/src/composer/use-mention-search.ts +90 -0
- package/src/composer/use-skills.ts +45 -0
- package/src/composer/useRealtimeVoiceMode.spec.ts +818 -0
- package/src/composer/useRealtimeVoiceMode.tsx +2117 -0
- package/src/composer/useVoiceDictation.spec.ts +45 -0
- package/src/composer/useVoiceDictation.ts +1191 -0
- package/src/context-ui/ContextMeter.tsx +101 -0
- package/src/context-ui/ContextSegmentRow.tsx +184 -0
- package/src/context-ui/ContextTreemap.tsx +125 -0
- package/src/context-ui/ContextXRayPanel.tsx +377 -0
- package/src/context-ui/format.ts +42 -0
- package/src/context-ui/index.ts +15 -0
- package/src/context-ui/types.ts +43 -0
- package/src/design-tweaks/index.ts +1 -0
- package/src/design-tweaks/visual-style-controls.tsx +1084 -0
- package/src/editor/BubbleToolbar.tsx +253 -0
- package/src/editor/CodeBlockNode.tsx +254 -0
- package/src/editor/DragHandle.spec.ts +588 -0
- package/src/editor/DragHandle.ts +1437 -0
- package/src/editor/ImageExtension.roundtrip.spec.ts +117 -0
- package/src/editor/ImageExtension.ts +306 -0
- package/src/editor/RegistryBlockContext.tsx +107 -0
- package/src/editor/RegistryBlockNode.spec.ts +279 -0
- package/src/editor/RegistryBlockNode.tsx +652 -0
- package/src/editor/RichMarkdownEditor.collab.spec.ts +79 -0
- package/src/editor/RichMarkdownEditor.roundtrip.spec.ts +226 -0
- package/src/editor/RichMarkdownEditor.tsx +81 -0
- package/src/editor/RunId.ts +61 -0
- package/src/editor/SharedRichEditor.tsx +321 -0
- package/src/editor/SlashCommandMenu.tsx +310 -0
- package/src/editor/disableHistory.spec.ts +85 -0
- package/src/editor/extensions.ts +402 -0
- package/src/editor/gfmDoc.spec.ts +230 -0
- package/src/editor/gfmDoc.ts +92 -0
- package/src/editor/index.ts +93 -0
- package/src/editor/registrySlashCommands.ts +131 -0
- package/src/editor/structuralReorderUndo.spec.ts +144 -0
- package/src/editor/surgical-apply.spec.ts +279 -0
- package/src/editor/surgical-apply.ts +192 -0
- package/src/editor/useCollabReconcile.concurrent.spec.ts +422 -0
- package/src/editor/useCollabReconcile.idempotent.spec.ts +324 -0
- package/src/editor/useCollabReconcile.ts +589 -0
- package/src/editor.css +510 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/use-mobile.tsx +21 -0
- package/src/hooks/use-toast.ts +188 -0
- package/src/index.ts +8 -0
- package/src/onboarding/index.ts +1 -0
- package/src/provider.tsx +47 -0
- package/src/sharing/VisibilityBadge.spec.ts +96 -0
- package/src/sharing/VisibilityBadge.tsx +37 -0
- package/src/sharing/index.ts +4 -0
- package/src/styles.css +21 -0
- package/src/ui/accordion.tsx +56 -0
- package/src/ui/alert-dialog.tsx +141 -0
- package/src/ui/alert.tsx +59 -0
- package/src/ui/aspect-ratio.tsx +5 -0
- package/src/ui/avatar.tsx +48 -0
- package/src/ui/badge.tsx +37 -0
- package/src/ui/breadcrumb.tsx +115 -0
- package/src/ui/button.tsx +78 -0
- package/src/ui/calendar.tsx +214 -0
- package/src/ui/card.tsx +86 -0
- package/src/ui/carousel.tsx +260 -0
- package/src/ui/chart.tsx +375 -0
- package/src/ui/checkbox.tsx +28 -0
- package/src/ui/collapsible.tsx +9 -0
- package/src/ui/command.spec.tsx +51 -0
- package/src/ui/command.tsx +174 -0
- package/src/ui/context-menu.tsx +202 -0
- package/src/ui/date-picker.tsx +64 -0
- package/src/ui/dialog.spec.tsx +43 -0
- package/src/ui/dialog.tsx +146 -0
- package/src/ui/drawer.tsx +126 -0
- package/src/ui/dropdown-menu.tsx +218 -0
- package/src/ui/form.tsx +177 -0
- package/src/ui/hover-card.tsx +27 -0
- package/src/ui/index.ts +52 -0
- package/src/ui/input-otp.tsx +69 -0
- package/src/ui/input.tsx +22 -0
- package/src/ui/label.tsx +24 -0
- package/src/ui/logo.tsx +36 -0
- package/src/ui/menubar.tsx +235 -0
- package/src/ui/navigation-menu.tsx +128 -0
- package/src/ui/pagination.tsx +121 -0
- package/src/ui/popover.tsx +44 -0
- package/src/ui/progress.tsx +26 -0
- package/src/ui/radio-group.tsx +42 -0
- package/src/ui/resizable.tsx +43 -0
- package/src/ui/scroll-area.tsx +46 -0
- package/src/ui/select.tsx +158 -0
- package/src/ui/separator.tsx +29 -0
- package/src/ui/sheet.tsx +160 -0
- package/src/ui/sidebar.tsx +777 -0
- package/src/ui/skeleton.tsx +18 -0
- package/src/ui/slider.tsx +26 -0
- package/src/ui/sonner.tsx +58 -0
- package/src/ui/spinner.tsx +17 -0
- package/src/ui/switch.tsx +27 -0
- package/src/ui/table.tsx +117 -0
- package/src/ui/tabs.tsx +53 -0
- package/src/ui/textarea.tsx +23 -0
- package/src/ui/toast.tsx +127 -0
- package/src/ui/toaster.tsx +33 -0
- package/src/ui/toggle-group.tsx +59 -0
- package/src/ui/toggle.tsx +43 -0
- package/src/ui/tooltip.tsx +53 -0
- package/src/ui/use-toast.ts +3 -0
- package/src/utils.ts +8 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { Editor } from "@tiptap/core";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { createSharedEditorExtensions } from "./extensions.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* GFM image round-trip for the SHARED block-level image node.
|
|
10
|
+
*
|
|
11
|
+
* Plans turn on `features.image` and rely on the image serializing to standard
|
|
12
|
+
* markdown image syntax (``) so plan blocks stay source-syncable and
|
|
13
|
+
* byte-stable. The shared image node is built on `@tiptap/extension-image` (a
|
|
14
|
+
* node named `image`), so tiptap-markdown serializes it through its built-in
|
|
15
|
+
* `defaultMarkdownSerializer.nodes.image` fallback — NO custom `<img width>`
|
|
16
|
+
* HTML override (that would break the GFM `html:false` contract).
|
|
17
|
+
*
|
|
18
|
+
* This mounts an `Editor` from the same `createSharedEditorExtensions` factory
|
|
19
|
+
* the editor component uses, with `features.image: true` (the plan config), and
|
|
20
|
+
* pins:
|
|
21
|
+
* - markdown `` parses to an `image` node and re-serializes
|
|
22
|
+
* byte-identically, and
|
|
23
|
+
* - a programmatically inserted image node serializes to ``.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
function buildEditor(content: string): Editor {
|
|
27
|
+
return new Editor({
|
|
28
|
+
element: document.createElement("div"),
|
|
29
|
+
extensions: createSharedEditorExtensions({
|
|
30
|
+
dialect: "gfm",
|
|
31
|
+
features: { image: true },
|
|
32
|
+
}),
|
|
33
|
+
content,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getMarkdown(editor: Editor): string {
|
|
38
|
+
const storage = editor.storage as unknown as {
|
|
39
|
+
markdown?: { getMarkdown?: () => string };
|
|
40
|
+
};
|
|
41
|
+
return storage.markdown?.getMarkdown?.() ?? "";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function roundTrip(markdown: string): string {
|
|
45
|
+
const editor = buildEditor(markdown);
|
|
46
|
+
try {
|
|
47
|
+
return getMarkdown(editor);
|
|
48
|
+
} finally {
|
|
49
|
+
editor.destroy();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("shared image block — GFM markdown round-trip", () => {
|
|
54
|
+
it("round-trips a standalone image (byte-stable)", () => {
|
|
55
|
+
expect(roundTrip("")).toBe(
|
|
56
|
+
"",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("round-trips an image with no alt text", () => {
|
|
61
|
+
expect(roundTrip("")).toBe(
|
|
62
|
+
"",
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("round-trips an image embedded mid-body with a following paragraph (byte-stable)", () => {
|
|
67
|
+
// The shared node's block-aware markdown serializer calls `closeBlock`, so
|
|
68
|
+
// an image immediately followed by a paragraph keeps its blank-line
|
|
69
|
+
// separator — byte-stable, unlike tiptap-markdown's inline default.
|
|
70
|
+
const body = [
|
|
71
|
+
"# Title",
|
|
72
|
+
"",
|
|
73
|
+
"Intro paragraph.",
|
|
74
|
+
"",
|
|
75
|
+
"",
|
|
76
|
+
"",
|
|
77
|
+
"Closing paragraph.",
|
|
78
|
+
].join("\n");
|
|
79
|
+
expect(roundTrip(body)).toBe(body);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("round-trips an image as the final block (byte-stable)", () => {
|
|
83
|
+
const body = [
|
|
84
|
+
"# Title",
|
|
85
|
+
"",
|
|
86
|
+
"Intro paragraph.",
|
|
87
|
+
"",
|
|
88
|
+
"",
|
|
89
|
+
].join("\n");
|
|
90
|
+
expect(roundTrip(body)).toBe(body);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("parses `` markdown into an `image` node", () => {
|
|
94
|
+
const editor = buildEditor("");
|
|
95
|
+
try {
|
|
96
|
+
const json = editor.getJSON();
|
|
97
|
+
const flat = JSON.stringify(json);
|
|
98
|
+
expect(flat).toContain('"type":"image"');
|
|
99
|
+
expect(flat).toContain("https://cdn.example.com/cat.png");
|
|
100
|
+
} finally {
|
|
101
|
+
editor.destroy();
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("serializes a programmatically inserted image node to ``", () => {
|
|
106
|
+
const editor = buildEditor("");
|
|
107
|
+
try {
|
|
108
|
+
editor.commands.setImage({
|
|
109
|
+
src: "https://cdn.example.com/x.png",
|
|
110
|
+
alt: "X",
|
|
111
|
+
});
|
|
112
|
+
expect(getMarkdown(editor)).toBe("");
|
|
113
|
+
} finally {
|
|
114
|
+
editor.destroy();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import Image, { type ImageOptions } from "@tiptap/extension-image";
|
|
2
|
+
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
|
3
|
+
import type { EditorView } from "@tiptap/pm/view";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The injectable async upload contract for the shared image block.
|
|
7
|
+
*
|
|
8
|
+
* An app provides this to turn a picked / pasted / dropped {@link File} into a
|
|
9
|
+
* hosted image. It returns the final `src` (a hosted URL) plus an optional
|
|
10
|
+
* `alt`. Plans wire it to the framework `upload-image` action; Content keeps its
|
|
11
|
+
* own richer image block and does not use this contract.
|
|
12
|
+
*
|
|
13
|
+
* When NO upload function is supplied, the shared block still renders images and
|
|
14
|
+
* round-trips `` markdown — it just cannot ingest local files (paste
|
|
15
|
+
* of an image URL / markdown still works via the base node's input rules).
|
|
16
|
+
*/
|
|
17
|
+
export type ImageUploadFn = (
|
|
18
|
+
file: File,
|
|
19
|
+
) => Promise<{ src: string; alt?: string }>;
|
|
20
|
+
|
|
21
|
+
export interface SharedImageOptions extends ImageOptions {
|
|
22
|
+
/**
|
|
23
|
+
* App-injected uploader. When present, the shared block accepts pasted /
|
|
24
|
+
* dropped image files and the `/image` slash command, uploading each file
|
|
25
|
+
* through this function and patching the node's `src` on resolve.
|
|
26
|
+
*/
|
|
27
|
+
onImageUpload?: ImageUploadFn | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const sharedImageUploadPluginKey = new PluginKey("an-shared-image-upload");
|
|
31
|
+
|
|
32
|
+
/** A monotonically increasing id so concurrent uploads patch the right node. */
|
|
33
|
+
let uploadCounter = 0;
|
|
34
|
+
function nextUploadId(): string {
|
|
35
|
+
uploadCounter += 1;
|
|
36
|
+
return `an-img-${Date.now()}-${uploadCounter}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Image files only — never ingest non-image clipboard/drop payloads. */
|
|
40
|
+
function imageFilesFrom(data: DataTransfer | null | undefined): File[] {
|
|
41
|
+
if (!data) return [];
|
|
42
|
+
return Array.from(data.files ?? []).filter((file) =>
|
|
43
|
+
file.type.startsWith("image/"),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Insert a placeholder `image` node (empty `src`, transient `uploadId`) at
|
|
49
|
+
* `pos` for every file, then resolve each upload and patch the matching node's
|
|
50
|
+
* `src`. Mirrors Content's optimistic upload flow but is self-contained in the
|
|
51
|
+
* shared extension so it works for ANY editor wrapper (the plan's
|
|
52
|
+
* `SharedRichEditor` and Content's hand-rolled `useEditor` alike) without a new
|
|
53
|
+
* `editorProps` seam.
|
|
54
|
+
*/
|
|
55
|
+
function uploadAndInsertImages(
|
|
56
|
+
view: EditorView,
|
|
57
|
+
files: File[],
|
|
58
|
+
pos: number,
|
|
59
|
+
upload: ImageUploadFn,
|
|
60
|
+
): void {
|
|
61
|
+
if (files.length === 0) return;
|
|
62
|
+
|
|
63
|
+
const pending: Array<{ uploadId: string; file: File }> = [];
|
|
64
|
+
|
|
65
|
+
// Insert all placeholders first, top-down from `pos`, so multi-file
|
|
66
|
+
// paste/drop keeps source order.
|
|
67
|
+
let insertAt = pos;
|
|
68
|
+
for (const file of files) {
|
|
69
|
+
const uploadId = nextUploadId();
|
|
70
|
+
const node = view.state.schema.nodes.image?.create({
|
|
71
|
+
src: "",
|
|
72
|
+
alt: "",
|
|
73
|
+
uploadId,
|
|
74
|
+
});
|
|
75
|
+
if (!node) continue;
|
|
76
|
+
const tr = view.state.tr.insert(insertAt, node);
|
|
77
|
+
view.dispatch(tr);
|
|
78
|
+
pending.push({ uploadId, file });
|
|
79
|
+
// Advance past the inserted atom for the next placeholder.
|
|
80
|
+
insertAt += node.nodeSize;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const item of pending) {
|
|
84
|
+
void (async () => {
|
|
85
|
+
try {
|
|
86
|
+
const { src, alt } = await upload(item.file);
|
|
87
|
+
if (!view.dom.isConnected || view.isDestroyed) return;
|
|
88
|
+
patchUploadNode(view, item.uploadId, { src, alt: alt ?? "" });
|
|
89
|
+
} catch (error) {
|
|
90
|
+
console.error("Image upload failed:", error);
|
|
91
|
+
// Drop the placeholder so a failed upload does not leave an empty box.
|
|
92
|
+
if (view.dom.isConnected && !view.isDestroyed) {
|
|
93
|
+
removeUploadNode(view, item.uploadId);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
})();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Find a placeholder node by its transient `uploadId` and return its position. */
|
|
101
|
+
function findUploadNode(
|
|
102
|
+
view: EditorView,
|
|
103
|
+
uploadId: string,
|
|
104
|
+
): { pos: number; nodeSize: number } | null {
|
|
105
|
+
let found: { pos: number; nodeSize: number } | null = null;
|
|
106
|
+
view.state.doc.descendants((node, pos) => {
|
|
107
|
+
if (found) return false;
|
|
108
|
+
if (node.type.name === "image" && node.attrs.uploadId === uploadId) {
|
|
109
|
+
found = { pos, nodeSize: node.nodeSize };
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
return true;
|
|
113
|
+
});
|
|
114
|
+
return found;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Patch the resolved `src`/`alt` onto the placeholder and clear `uploadId`. */
|
|
118
|
+
function patchUploadNode(
|
|
119
|
+
view: EditorView,
|
|
120
|
+
uploadId: string,
|
|
121
|
+
attrs: { src: string; alt: string },
|
|
122
|
+
): void {
|
|
123
|
+
const target = findUploadNode(view, uploadId);
|
|
124
|
+
if (!target) return;
|
|
125
|
+
const node = view.state.doc.nodeAt(target.pos);
|
|
126
|
+
if (!node) return;
|
|
127
|
+
const tr = view.state.tr.setNodeMarkup(target.pos, undefined, {
|
|
128
|
+
...node.attrs,
|
|
129
|
+
src: attrs.src,
|
|
130
|
+
alt: attrs.alt,
|
|
131
|
+
uploadId: null,
|
|
132
|
+
});
|
|
133
|
+
view.dispatch(tr);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Remove a placeholder whose upload failed. */
|
|
137
|
+
function removeUploadNode(view: EditorView, uploadId: string): void {
|
|
138
|
+
const target = findUploadNode(view, uploadId);
|
|
139
|
+
if (!target) return;
|
|
140
|
+
const tr = view.state.tr.delete(target.pos, target.pos + target.nodeSize);
|
|
141
|
+
view.dispatch(tr);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The SHARED block-level image node. Built on `@tiptap/extension-image` (a
|
|
146
|
+
* node named `image`), so:
|
|
147
|
+
*
|
|
148
|
+
* - **GFM serialization** emits pure `` markdown via a block-aware
|
|
149
|
+
* `addStorage().markdown.serialize` (it calls `closeBlock` so a block image
|
|
150
|
+
* keeps its blank-line separator, byte-stable). It emits NO `<img width>`
|
|
151
|
+
* HTML, so plans stay source-syncable under the GFM `html:false` contract.
|
|
152
|
+
* - **Paste / drop** of local image files is handled by a self-contained
|
|
153
|
+
* ProseMirror plugin that calls the injected {@link ImageUploadFn}. Pasting
|
|
154
|
+
* an image URL or `` markdown works through the base node's input
|
|
155
|
+
* rules even when no uploader is supplied.
|
|
156
|
+
*
|
|
157
|
+
* The node adds one transient `uploadId` attribute (never parsed/rendered to
|
|
158
|
+
* HTML, never serialized to markdown) used to track an in-flight upload.
|
|
159
|
+
*
|
|
160
|
+
* Content keeps its own richer `ImageNode` (Assets picker, AI alt-text, resize,
|
|
161
|
+
* NFM `{color}` serialization) and does NOT use this shared node — both nodes
|
|
162
|
+
* are named `image` but never coexist in the same editor because Content leaves
|
|
163
|
+
* `features.image` off and injects its own via `extraExtensions`.
|
|
164
|
+
*/
|
|
165
|
+
export const SharedImage = Image.extend<SharedImageOptions>({
|
|
166
|
+
// Block-level atom (Content's image is also a block atom). The base extension
|
|
167
|
+
// is block when `inline:false`.
|
|
168
|
+
inline: false,
|
|
169
|
+
group: "block",
|
|
170
|
+
|
|
171
|
+
addOptions() {
|
|
172
|
+
return {
|
|
173
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
174
|
+
...this.parent!(),
|
|
175
|
+
onImageUpload: null,
|
|
176
|
+
};
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
addAttributes() {
|
|
180
|
+
return {
|
|
181
|
+
...this.parent?.(),
|
|
182
|
+
// Transient: marks an in-flight upload so the plugin can patch `src` on
|
|
183
|
+
// resolve. Never written to HTML or markdown.
|
|
184
|
+
uploadId: {
|
|
185
|
+
default: null,
|
|
186
|
+
parseHTML: () => null,
|
|
187
|
+
renderHTML: () => ({}),
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
addStorage() {
|
|
193
|
+
return {
|
|
194
|
+
...this.parent?.(),
|
|
195
|
+
// A BLOCK-aware markdown serializer. tiptap-markdown's built-in fallback
|
|
196
|
+
// for the `image` node is prosemirror-markdown's INLINE image serializer,
|
|
197
|
+
// which omits the trailing block separator — so an image immediately
|
|
198
|
+
// followed by a paragraph loses its blank line on round-trip. This
|
|
199
|
+
// node-level spec (merged OVER the fallback by tiptap-markdown's
|
|
200
|
+
// `getMarkdownSpec`) emits the same pure `` markdown but calls
|
|
201
|
+
// `closeBlock`, so a block image stays byte-stable and source-syncable.
|
|
202
|
+
// No `<img width>` / HTML is emitted, so the GFM `html:false` contract and
|
|
203
|
+
// the plan round-trip corpus are preserved.
|
|
204
|
+
markdown: {
|
|
205
|
+
serialize(
|
|
206
|
+
state: {
|
|
207
|
+
esc: (s: string) => string;
|
|
208
|
+
write: (s: string) => void;
|
|
209
|
+
closeBlock: (n: unknown) => void;
|
|
210
|
+
},
|
|
211
|
+
node: { attrs: { src?: string; alt?: string; title?: string } },
|
|
212
|
+
) {
|
|
213
|
+
const src = node.attrs.src ?? "";
|
|
214
|
+
const alt = node.attrs.alt ?? "";
|
|
215
|
+
const title = node.attrs.title ?? "";
|
|
216
|
+
const titleSuffix = title ? ` "${title.replace(/"/g, '\\"')}"` : "";
|
|
217
|
+
state.write(`}${titleSuffix})`);
|
|
218
|
+
state.closeBlock(node);
|
|
219
|
+
},
|
|
220
|
+
parse: {
|
|
221
|
+
// Parsing `` is handled by markdown-it + the base node's
|
|
222
|
+
// markdown input rule.
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
addProseMirrorPlugins() {
|
|
229
|
+
const upload = this.options.onImageUpload;
|
|
230
|
+
const parentPlugins = this.parent?.() ?? [];
|
|
231
|
+
if (!upload) return parentPlugins;
|
|
232
|
+
|
|
233
|
+
return [
|
|
234
|
+
...parentPlugins,
|
|
235
|
+
new Plugin({
|
|
236
|
+
key: sharedImageUploadPluginKey,
|
|
237
|
+
props: {
|
|
238
|
+
handlePaste(view, event) {
|
|
239
|
+
const files = imageFilesFrom(event.clipboardData);
|
|
240
|
+
if (files.length === 0) return false;
|
|
241
|
+
event.preventDefault();
|
|
242
|
+
uploadAndInsertImages(
|
|
243
|
+
view,
|
|
244
|
+
files,
|
|
245
|
+
view.state.selection.from,
|
|
246
|
+
upload,
|
|
247
|
+
);
|
|
248
|
+
return true;
|
|
249
|
+
},
|
|
250
|
+
handleDrop(view, event) {
|
|
251
|
+
const files = imageFilesFrom(event.dataTransfer);
|
|
252
|
+
if (files.length === 0) return false;
|
|
253
|
+
event.preventDefault();
|
|
254
|
+
const coords = view.posAtCoords({
|
|
255
|
+
left: event.clientX,
|
|
256
|
+
top: event.clientY,
|
|
257
|
+
});
|
|
258
|
+
const pos = coords?.pos ?? view.state.selection.from;
|
|
259
|
+
uploadAndInsertImages(view, files, pos, upload);
|
|
260
|
+
return true;
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
}),
|
|
264
|
+
];
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Build the shared image extension, optionally wired with an app uploader.
|
|
270
|
+
*
|
|
271
|
+
* @example
|
|
272
|
+
* createImageExtension({ onImageUpload: uploadEditorImage })
|
|
273
|
+
*/
|
|
274
|
+
export function createImageExtension(
|
|
275
|
+
options: { onImageUpload?: ImageUploadFn | null } = {},
|
|
276
|
+
) {
|
|
277
|
+
return SharedImage.configure({
|
|
278
|
+
onImageUpload: options.onImageUpload ?? null,
|
|
279
|
+
HTMLAttributes: { class: "an-rich-md-image" },
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Open a native file picker, then upload + insert the chosen image(s) through
|
|
285
|
+
* the same flow as paste/drop. Used by the `/image` slash command.
|
|
286
|
+
*/
|
|
287
|
+
export function pickAndInsertImage(
|
|
288
|
+
view: EditorView,
|
|
289
|
+
upload: ImageUploadFn,
|
|
290
|
+
): void {
|
|
291
|
+
const input = document.createElement("input");
|
|
292
|
+
input.type = "file";
|
|
293
|
+
input.accept = "image/*";
|
|
294
|
+
input.multiple = true;
|
|
295
|
+
input.style.display = "none";
|
|
296
|
+
input.addEventListener("change", () => {
|
|
297
|
+
const files = Array.from(input.files ?? []).filter((file) =>
|
|
298
|
+
file.type.startsWith("image/"),
|
|
299
|
+
);
|
|
300
|
+
input.remove();
|
|
301
|
+
if (files.length === 0) return;
|
|
302
|
+
uploadAndInsertImages(view, files, view.state.selection.from, upload);
|
|
303
|
+
});
|
|
304
|
+
document.body.appendChild(input);
|
|
305
|
+
input.click();
|
|
306
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface RegistryBlockSideMapBlock {
|
|
4
|
+
id: string;
|
|
5
|
+
title?: string;
|
|
6
|
+
summary?: string;
|
|
7
|
+
data: unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface RegistryBlockNestedBlock {
|
|
11
|
+
type: string;
|
|
12
|
+
id: string;
|
|
13
|
+
title?: string;
|
|
14
|
+
summary?: string;
|
|
15
|
+
data: unknown;
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RegistryBlockDataChangeMeta {
|
|
20
|
+
containerRegion?: {
|
|
21
|
+
regionId: string;
|
|
22
|
+
blocks: RegistryBlockNestedBlock[];
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RegistryBlockEditSurfaceOptions {
|
|
27
|
+
title: string;
|
|
28
|
+
open: boolean;
|
|
29
|
+
onOpenChange: (open: boolean) => void;
|
|
30
|
+
blockId: string;
|
|
31
|
+
blockType: string;
|
|
32
|
+
blockTitle?: string;
|
|
33
|
+
blockSummary?: string;
|
|
34
|
+
blockData: unknown;
|
|
35
|
+
trigger: ReactNode;
|
|
36
|
+
children: ReactNode;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RegistryBlockRenderOptions {
|
|
40
|
+
blockType: string;
|
|
41
|
+
editable: boolean;
|
|
42
|
+
selected: boolean;
|
|
43
|
+
shellHovered: boolean;
|
|
44
|
+
panelOpen: boolean;
|
|
45
|
+
setPanelOpen: (open: boolean) => void;
|
|
46
|
+
onChange: (nextData: unknown, meta?: RegistryBlockDataChangeMeta) => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface RegistryBlockRenderResult {
|
|
50
|
+
body: ReactNode;
|
|
51
|
+
editSurface?: ReactNode;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface RegistryBlockDataValue<
|
|
55
|
+
TBlock extends RegistryBlockSideMapBlock = RegistryBlockSideMapBlock,
|
|
56
|
+
> {
|
|
57
|
+
getBlock: (blockId: string) => TBlock | undefined;
|
|
58
|
+
onBlockDataChange: (
|
|
59
|
+
blockId: string,
|
|
60
|
+
nextData: unknown,
|
|
61
|
+
meta?: RegistryBlockDataChangeMeta,
|
|
62
|
+
) => void;
|
|
63
|
+
editable: boolean;
|
|
64
|
+
notionSync?: boolean;
|
|
65
|
+
isNotionIncompatibleType?: (blockType: string) => boolean;
|
|
66
|
+
renderLegacyBlock?: (
|
|
67
|
+
block: TBlock,
|
|
68
|
+
options: { editing: boolean },
|
|
69
|
+
) => ReactNode;
|
|
70
|
+
renderLegacyBlockEditor?: (
|
|
71
|
+
block: TBlock,
|
|
72
|
+
args: { onChange: (nextData: unknown) => void },
|
|
73
|
+
) => ReactNode;
|
|
74
|
+
legacyBlockSelfEdits?: (blockType: string) => boolean;
|
|
75
|
+
renderRegisteredBlock?: (
|
|
76
|
+
block: TBlock,
|
|
77
|
+
options: RegistryBlockRenderOptions,
|
|
78
|
+
) => RegistryBlockRenderResult | null;
|
|
79
|
+
renderEditSurface?: (options: RegistryBlockEditSurfaceOptions) => ReactNode;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const RegistryBlockDataContext =
|
|
83
|
+
createContext<RegistryBlockDataValue<any> | null>(null);
|
|
84
|
+
|
|
85
|
+
export function RegistryBlockDataProvider<
|
|
86
|
+
TBlock extends RegistryBlockSideMapBlock = RegistryBlockSideMapBlock,
|
|
87
|
+
>({
|
|
88
|
+
value,
|
|
89
|
+
children,
|
|
90
|
+
}: {
|
|
91
|
+
value: RegistryBlockDataValue<TBlock>;
|
|
92
|
+
children: ReactNode;
|
|
93
|
+
}) {
|
|
94
|
+
return (
|
|
95
|
+
<RegistryBlockDataContext.Provider value={value}>
|
|
96
|
+
{children}
|
|
97
|
+
</RegistryBlockDataContext.Provider>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function useRegistryBlockData<
|
|
102
|
+
TBlock extends RegistryBlockSideMapBlock = RegistryBlockSideMapBlock,
|
|
103
|
+
>(): RegistryBlockDataValue<TBlock> | null {
|
|
104
|
+
return useContext(
|
|
105
|
+
RegistryBlockDataContext,
|
|
106
|
+
) as RegistryBlockDataValue<TBlock> | null;
|
|
107
|
+
}
|