feat(plugins): add resource panels and embeds

This commit is contained in:
tianma-if committed 2026-09-01 21:35:25 +08:00
1 parent dcbb0d3383
commit cee98bc90b
31 files changed
+1172 -62

No files matched your search

+2
View File
@@ -130,6 +130,7 @@ import {
createAttachmentResource,
createImageResource,
getResourceRow,
replaceResourceContent,
} from "./resource-service";
// Compatibility aliases keep the existing SQL-heavy implementation small
@@ -359,6 +360,7 @@ registerResourceRoutes(app, {
uploadResourcePart: (...args) => uploadResourcePart(...args),
completeResourceUpload: (...args) => completeResourceUpload(...args),
abortResourceUpload: (...args) => abortResourceUpload(...args),
replaceResourceContent: (...args) => replaceResourceContent(...args),
});
app.post("/api/v1/demo/reset", async (c) => {
+34
View File
@@ -72,6 +72,7 @@ const createApp = (auth = agentAuth, getResourceRow = async () => null, override
uploadResourcePart: async () => { throw new Error("Unexpected multipart part"); },
completeResourceUpload: async () => { throw new Error("Unexpected multipart completion"); },
abortResourceUpload: async () => { throw new Error("Unexpected multipart abort"); },
replaceResourceContent: async () => { throw new Error("Unexpected resource replacement"); },
...overrides,
});
return app;
@@ -113,6 +114,39 @@ describe("resource route contracts", () => {
expect(await response.json()).toMatchObject({ error: { code: "forbidden" } });
});
test("replaces resource content with an explicit optimistic-concurrency baseline", async () => {
let replacement;
const form = new FormData();
form.append("file", new File(["scene"], "drawing.excalidraw", { type: "application/vnd.excalidraw+json" }));
form.append("expectedContentHash", "checksum");
form.append("mimeType", "application/vnd.excalidraw+json");
form.append("filename", "drawing.excalidraw");
const response = await createApp(
{ ...agentAuth, scopes: ["write:resources"] },
async () => resourceRow,
{
replaceResourceContent: async (_context, input) => {
replacement = input;
return { ...resourceRow, sha256: "next-checksum", byte_size: 5 };
},
},
).request(
"/api/v1/resources/res_1/blob",
{ method: "PUT", body: form },
createEnvironment(),
);
expect(response.status).toBe(200);
expect(replacement).toMatchObject({
resourceId: "res_1",
expectedContentHash: "checksum",
filename: "drawing.excalidraw",
mimeType: "application/vnd.excalidraw+json",
});
expect(new TextDecoder().decode(replacement.bytes)).toBe("scene");
expect(await response.json()).toMatchObject({ resource: { id: "res_1", sha256: "next-checksum" } });
});
test("initializes a bounded multipart upload after memo authorization", async () => {
let input;
const response = await createApp(
+49
View File
@@ -66,6 +66,14 @@ type ResourceRouteDependencies = {
actor: AuditActor,
) => Promise<Resource>;
abortResourceUpload: (context: AppContext, uploadId: string) => Promise<void>;
replaceResourceContent: (context: AppContext, input: {
resourceId: string;
expectedContentHash: string;
filename: string;
mimeType: string;
bytes: Uint8Array;
actor: AuditActor;
}) => Promise<Resource>;
};
const MAX_LEGACY_RESOURCE_UPLOAD_BYTES = 100 * 1024 * 1024;
@@ -314,6 +322,47 @@ export const registerResourceRoutes = (
return new Response(object.body, { headers, status: byteRange.kind === "range" ? 206 : 200 });
});
app.put("/api/v1/resources/:id/blob", async (context) => {
const denied = requireScopes(context, "write:resources");
if (denied) return denied;
const declaredRequestBytes = Number(context.req.header("Content-Length"));
if (
Number.isFinite(declaredRequestBytes)
&& declaredRequestBytes > MAX_LEGACY_RESOURCE_UPLOAD_BYTES + 1024 * 1024
) {
return apiError(context, "upload_too_large", "Resource replacements must be at most 100 MiB.", 413);
}
const form = await context.req.raw.formData();
const file = form.get("file");
const expectedContentHash = form.get("expectedContentHash");
const declaredMimeType = form.get("mimeType");
const declaredFilename = form.get("filename");
if (!(file instanceof File) || typeof expectedContentHash !== "string" || !expectedContentHash.trim()) {
return badRequest(context, "file and expectedContentHash are required.");
}
if (file.size > MAX_LEGACY_RESOURCE_UPLOAD_BYTES) {
return apiError(context, "upload_too_large", "Resource replacements must be at most 100 MiB.", 413);
}
try {
const resource = await dependencies.replaceResourceContent(context, {
resourceId: context.req.param("id"),
expectedContentHash,
filename: typeof declaredFilename === "string" && declaredFilename.trim() ? declaredFilename : file.name,
mimeType: typeof declaredMimeType === "string" && declaredMimeType.trim()
? declaredMimeType
: file.type || "application/octet-stream",
bytes: new Uint8Array(await file.arrayBuffer()),
actor: getAuditActor(context),
});
return context.json({ resource });
} catch (error) {
return appErrorResponse(context, error);
}
});
app.patch(
"/api/v1/resources/:id",
zValidator("json", ResourceUpdateSchema),
+86
View File
@@ -6,6 +6,7 @@ import {
inferImageExtension,
mapResourceListItem,
normalizeFilename,
replaceResourceContent,
validateAttachmentUpload,
validateImageUpload,
} from "./resource-service.ts";
@@ -74,4 +75,89 @@ describe("resource service contracts", () => {
expect(() => validateImageUpload("image/svg+xml", 128)).toThrow();
expect(() => validateAttachmentUpload(MAX_ATTACHMENT_UPLOAD_BYTES + 1)).toThrow();
});
test("replaces bytes through a conditional object-pointer swap", async () => {
let current = { ...resourceRow, kind: "attachment", mime_type: "application/octet-stream" };
const stored = [];
const deleted = [];
const database = {
prepare(sql) {
const statement = {
bind(...values) {
return {
first: async () => {
if (sql.includes("object_storage_configs")) return {
id: "builtin", provider: "builtin", display_name: "Builtin", endpoint: null,
region: null, bucket: null, access_key_id: null, secret_access_key_encrypted: null,
force_path_style: 0, object_prefix: "", is_active: 1,
};
if (sql.includes("FROM resources")) return current;
return null;
},
run: async () => {
if (sql.startsWith("UPDATE resources") && current.sha256 === values[7]) {
current = {
...current,
object_key: values[0],
mime_type: values[1],
filename: values[2],
byte_size: values[3],
sha256: values[4],
width: null,
height: null,
updated_at: values[5],
};
}
return { success: true, results: [], meta: {} };
},
};
},
first: async () => null,
run: async () => ({ success: true, results: [], meta: {} }),
};
return statement;
},
};
const context = {
get: () => ({ workspaceId: "ws_1" }),
env: {
storage: {
db: database,
resources: {
put: async (key, bytes) => stored.push({ key, bytes }),
delete: async (key) => deleted.push(key),
},
},
},
};
const updated = await replaceResourceContent(context, {
resourceId: "res_1",
expectedContentHash: "checksum",
filename: "drawing.excalidraw",
mimeType: "application/vnd.excalidraw+json",
bytes: new TextEncoder().encode("scene"),
actor: { actorType: "agent", actorId: "token_1" },
});
expect(updated).toMatchObject({
id: "res_1",
filename: "drawing.excalidraw",
mimeType: "application/vnd.excalidraw+json",
byteSize: 5,
});
expect(stored).toHaveLength(1);
expect(stored[0].key).not.toBe(resourceRow.object_key);
expect(deleted).toEqual([resourceRow.object_key]);
await expect(replaceResourceContent(context, {
resourceId: "res_1",
expectedContentHash: "stale",
filename: "drawing.excalidraw",
mimeType: "application/vnd.excalidraw+json",
bytes: new TextEncoder().encode("other"),
actor: { actorType: "agent", actorId: "token_1" },
})).rejects.toMatchObject({ code: "resource_conflict", status: 409 });
expect(stored).toHaveLength(1);
});
});
+84
View File
@@ -352,6 +352,90 @@ export const createAttachmentResource = async (
});
};
export const replaceResourceContent = async (
context: AppContext,
input: {
resourceId: string;
expectedContentHash: string;
filename: string;
mimeType: string;
bytes: Uint8Array;
actor: AuditActor;
},
): Promise<Resource> => {
const workspaceId = getWorkspaceId(context);
const current = await getResourceRow(context.env.storage.db, workspaceId, input.resourceId);
if (!current) throw new AppError("not_found", "Resource not found", 404);
if (!current.sha256 || current.sha256 !== input.expectedContentHash) {
throw new AppError("resource_conflict", "The resource changed after it was read.", 409);
}
if (current.kind === "image") validateImageUpload(input.mimeType, input.bytes.byteLength);
else validateAttachmentUpload(input.bytes.byteLength);
const filename = normalizeFilename(input.filename) || current.filename || input.resourceId;
const mimeType = input.mimeType || "application/octet-stream";
const contentHash = await sha256Bytes(input.bytes);
if (
contentHash === current.sha256
&& filename === current.filename
&& mimeType === current.mime_type
) return mapResource(current);
const source = await resolveObjectStorage(context.env, current.storage_config_id);
const nextObjectKey = `${current.object_key}.version-${createId("blob")}`;
await source.store.put(nextObjectKey, input.bytes, {
httpMetadata: { contentType: mimeType, cacheControl: "private, max-age=3600" },
customMetadata: { memoId: current.memo_id, resourceId: current.id, filename },
});
const now = isoNow();
let pointerSwapped = false;
try {
await context.env.storage.db.prepare(
`UPDATE resources
SET object_key = ?, mime_type = ?, filename = ?, byte_size = ?, sha256 = ?, width = NULL, height = NULL, updated_at = ?
WHERE id = ? AND sha256 = ? AND is_deleted = 0`,
).bind(
nextObjectKey,
mimeType,
filename,
input.bytes.byteLength,
contentHash,
now,
current.id,
input.expectedContentHash,
).run();
const updated = await getResourceRow(context.env.storage.db, workspaceId, current.id);
if (!updated || updated.object_key !== nextObjectKey) {
throw new AppError("resource_conflict", "The resource changed while it was being saved.", 409);
}
pointerSwapped = true;
await auditStatement(
context.env.storage.db,
input.actor.actorType,
input.actor.actorId,
"resource.content.update",
"resource",
current.id,
{
memoId: current.memo_id,
previousContentHash: current.sha256,
contentHash,
byteSize: input.bytes.byteLength,
mimeType,
},
).run();
await source.store.delete(current.object_key).catch(() => undefined);
return mapResource(updated);
} catch (error) {
if (!pointerSwapped) await source.store.delete(nextObjectKey).catch(() => undefined);
throw error;
}
};
const encodeRfc5987Value = (value: string) => encodeURIComponent(
value.replace(/[\uD800-\uDFFF]/gu, "\uFFFD"),
).replace(/['()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
+22
View File
@@ -107,6 +107,8 @@ import {
MEMO_CONTENT_STYLE,
markdownToDoc,
MergeDivider,
PLUGIN_EMBED_NODE_TYPE,
pluginEmbedToMarkdown,
isPdfAttachment,
resolveMemoContentDoc,
type Notebook,
@@ -204,6 +206,7 @@ import {
import { ImageViewer } from "./editor/ImageViewer";
import { PdfAttachment } from "./editor/PdfAttachment";
import { FileAttachment } from "./editor/FileAttachment";
import { createPluginEmbedExtension } from "./editor/PluginEmbed";
import { getEditorScrollProgress, restoreEditorScrollProgress } from "./editor/editor-mode-scroll";
import { useEditorSaveStatus } from "./editor/useEditorSaveStatus";
import { useEditorNoteSearchController } from "./editor/useEditorNoteSearchController";
@@ -1122,6 +1125,7 @@ const RichEditorPane = ({
});
}, [queryClient, repository, resourceInsertionLimit, t]);
const pluginEmbedExtension = useMemo(() => createPluginEmbedExtension(pluginHost), [pluginHost]);
const editor = useEditor({
extensions: [
StarterKit.configure({
@@ -1132,6 +1136,7 @@ const RichEditorPane = ({
TaskItem.configure({ nested: true }),
EdgeEverCodeBlock.configure({ lowlight: codeBlockLowlight, defaultLanguage: "plaintext" }),
MergeDivider,
pluginEmbedExtension,
PdfAttachment,
FileAttachment,
...createEdgeEverMathematics(),
@@ -2937,6 +2942,23 @@ const RichEditorPane = ({
else editor.commands.setContent(markdownToDoc(contentMarkdown));
markDirty();
},
insertEmbed: (embed) => {
const attributes = {
id: embed.id,
pluginId: embed.pluginId,
type: embed.type,
resourceId: embed.resourceId,
previewResourceId: embed.previewResourceId,
title: embed.title,
dataJson: JSON.stringify(embed.data),
};
if (isMarkdownMode) {
setMarkdownSource((current) => `${current.trimEnd()}${current.trim() ? "\n\n" : ""}${pluginEmbedToMarkdown(attributes)}\n`);
} else {
editor.chain().focus().insertContent({ type: PLUGIN_EMBED_NODE_TYPE, attrs: attributes }).run();
}
markDirty();
},
replaceSelection: (contentMarkdown) => {
const { selection, doc } = editor.state;
const context = getRichTextAiSelectionContext(doc, selection);
@@ -10,7 +10,7 @@ import { TaskItem, TaskList } from "@tiptap/extension-list";
import { mergeAttributes } from "@tiptap/core";
import Placeholder from "@tiptap/extension-placeholder";
import { TableKit } from "@tiptap/extension-table";
import { createExcerpt, docToMarkdown, docToText, emptyDoc, getImageReferrerPolicy, isPdfAttachment, MergeDivider, type MemoDetail, type MemoEditSession, type Notebook, type TagSummary, type TiptapDoc } from "@edgeever/shared";
import { createExcerpt, docToMarkdown, docToText, emptyDoc, getImageReferrerPolicy, isPdfAttachment, MergeDivider, PluginEmbed, type MemoDetail, type MemoEditSession, type Notebook, type TagSummary, type TiptapDoc } from "@edgeever/shared";
import { createEdgeEverMathematics } from "@edgeever/shared/mathematics";
import { getMobileEditorInputAttributes, getMobileEditorPlaceholder } from "@edgeever/shared/mobile-editor";
import {
@@ -202,6 +202,7 @@ export const MobileStandaloneTiptapEditor = ({
TaskList,
TaskItem.configure({ nested: true }),
MergeDivider,
PluginEmbed,
...createEdgeEverMathematics(),
ThemeBlock,
ProtectedExternalImage.configure({
@@ -17,6 +17,7 @@ import {
parseImageWidth,
getImageReferrerPolicy,
MergeDivider,
PluginEmbed,
resolveMemoContentDoc,
rewriteMemoResourcesForShare,
type PublicMemoShare,
@@ -91,6 +92,7 @@ const SharedDocument = ({ share, token }: { share: PublicMemoShare; token: strin
TaskItem.configure({ nested: true }),
EdgeEverCodeBlock.configure({ lowlight: codeBlockLowlight, defaultLanguage: "plaintext" }),
MergeDivider,
PluginEmbed,
PdfAttachment,
FileAttachment,
...createEdgeEverMathematics(),
+15 -5
View File
@@ -13,6 +13,7 @@ import {
type PointerEvent as ReactPointerEvent,
} from "react";
import { useInfiniteQuery, useQuery, useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
import type { PluginPanelOpenOptions } from "@edgeever/plugin-api";
import { Home, Search, UserRound, Plus, ChevronDown, ChevronRight, RefreshCw, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import * as m from "motion/react-m";
@@ -735,10 +736,14 @@ export const WorkspaceApp = ({
queryClient.invalidateQueries({ queryKey: ["notebooks"] }),
queryClient.invalidateQueries({ queryKey: ["tags"] }),
queryClient.invalidateQueries({ queryKey: ["templates"] }),
queryClient.invalidateQueries({ queryKey: ["resources"] }),
]);
},
}), [localDataScope, queryClient, repository, t]);
const [requestedPluginPanel, setRequestedPluginPanel] = useState<RegisteredPluginPanel | null>(null);
const [requestedPluginPanel, setRequestedPluginPanel] = useState<{
panel: RegisteredPluginPanel;
options?: PluginPanelOpenOptions;
} | null>(null);
const pluginNavigationRequestIdRef = useRef(0);
const [pluginNavigationRequest, setPluginNavigationRequest] = useState<{ id: number; noteId: string; search: string } | null>(null);
@@ -750,18 +755,18 @@ export const WorkspaceApp = ({
}, [pluginHost]);
useEffect(() => pluginHost.setPanelAdapter({
openPanel(pluginId, panelId) {
openPanel(pluginId, panelId, options) {
const panel = pluginHost.getSnapshot().panels.find((candidate) =>
candidate.pluginId === pluginId && candidate.id === panelId);
if (!panel) throw new Error("Plugin panel is not registered.");
setRequestedPluginPanel(panel);
setRequestedPluginPanel({ panel, options });
},
}), [pluginHost]);
useEffect(() => {
const unsubscribe = pluginHost.subscribe(() => {
setRequestedPluginPanel((current) => current && pluginHost.getSnapshot().panels.some(
(panel) => panel.pluginId === current.pluginId && panel.id === current.id,
(panel) => panel.pluginId === current.panel.pluginId && panel.id === current.panel.id,
) ? current : null);
});
return () => {
@@ -3385,7 +3390,12 @@ export const WorkspaceApp = ({
onConfirm={() => resetDemoMutation.mutate()}
/>
)}
<PluginPanelDialog host={pluginHost} panel={requestedPluginPanel} onClose={() => setRequestedPluginPanel(null)} />
<PluginPanelDialog
host={pluginHost}
panel={requestedPluginPanel?.panel ?? null}
options={requestedPluginPanel?.options}
onClose={() => setRequestedPluginPanel(null)}
/>
{visibleActivePane !== "editor" && !memoSelectionModeActive && (
<MobileBottomNav
activeItem={mobileBottomNavActive}
@@ -0,0 +1,74 @@
import {
PluginEmbed as BasePluginEmbed,
normalizePluginEmbedAttributes,
type PluginEmbedAttributes,
} from "@edgeever/shared";
import type { PluginEmbedInstance } from "@edgeever/plugin-api";
import { NodeViewWrapper, ReactNodeViewRenderer, type NodeViewProps } from "@tiptap/react";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import type { EdgeEverPluginHost } from "@/lib/plugins/plugin-host";
const toPluginEmbedInstance = (attributes: PluginEmbedAttributes): PluginEmbedInstance => ({
id: attributes.id,
pluginId: attributes.pluginId,
type: attributes.type,
resourceId: attributes.resourceId,
previewResourceId: attributes.previewResourceId,
title: attributes.title,
data: JSON.parse(attributes.dataJson) as PluginEmbedInstance["data"],
});
const PluginEmbedNodeView = ({ node, host }: NodeViewProps & { host: EdgeEverPluginHost }) => {
const containerRef = useRef<HTMLDivElement>(null);
const [mountError, setMountError] = useState<string | null>(null);
const snapshot = useSyncExternalStore(host.subscribe, host.getSnapshot, host.getSnapshot);
const attributes = normalizePluginEmbedAttributes(node.attrs);
const registered = attributes
? snapshot.embeds.some((embed) => embed.pluginId === attributes.pluginId && embed.type === attributes.type)
: false;
useEffect(() => {
const container = containerRef.current;
if (!container || !attributes || !registered) return;
let disposed = false;
let disposeEmbed: (() => void) | null = null;
setMountError(null);
container.replaceChildren();
void host.mountEmbed(
attributes.pluginId,
attributes.type,
container,
toPluginEmbedInstance(attributes),
).then((dispose) => {
if (disposed) dispose();
else disposeEmbed = dispose;
}).catch((error: unknown) => {
if (!disposed) setMountError(error instanceof Error ? error.message : String(error));
});
return () => {
disposed = true;
disposeEmbed?.();
container.replaceChildren();
};
}, [attributes?.dataJson, attributes?.id, attributes?.pluginId, attributes?.previewResourceId, attributes?.resourceId, attributes?.title, attributes?.type, host, registered]);
const fallbackTitle = attributes?.title || attributes?.type || "Plugin embed";
return (
<NodeViewWrapper className="edgeever-plugin-embed-node" contentEditable={false}>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white">
{registered ? <div ref={containerRef} className="min-h-32" /> : (
<div className="flex min-h-24 items-center justify-center bg-slate-50 px-4 text-sm font-medium text-slate-500">
{fallbackTitle}
</div>
)}
{mountError ? <div role="alert" className="border-t border-rose-100 bg-rose-50 px-3 py-2 text-xs text-rose-700">{mountError}</div> : null}
</div>
</NodeViewWrapper>
);
};
export const createPluginEmbedExtension = (host: EdgeEverPluginHost) => BasePluginEmbed.extend({
addNodeView() {
return ReactNodeViewRenderer((props) => <PluginEmbedNodeView {...props} host={host} />);
},
});
@@ -1,16 +1,22 @@
import { useEffect, useRef, useState } from "react";
import type { PluginPanelCloseDecision, PluginPanelOpenOptions } from "@edgeever/plugin-api";
import { useTranslation } from "react-i18next";
import { AppConfirmDialog } from "@/components/dialogs/ConfirmDialogs";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import type { EdgeEverPluginHost, RegisteredPluginPanel } from "@/lib/plugins/plugin-host";
import { cn } from "@/lib/utils";
export const PluginPanelDialog = ({ host, panel, onClose }: {
export const PluginPanelDialog = ({ host, panel, options, onClose }: {
host: EdgeEverPluginHost;
panel: RegisteredPluginPanel | null;
options?: PluginPanelOpenOptions;
onClose: () => void;
}) => {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const [mountError, setMountError] = useState<string | null>(null);
const [closeConfirmation, setCloseConfirmation] = useState<Exclude<PluginPanelCloseDecision, boolean> | null>(null);
const [checkingClose, setCheckingClose] = useState(false);
const panelPluginId = panel?.pluginId ?? null;
const panelId = panel?.id ?? null;
@@ -21,7 +27,7 @@ export const PluginPanelDialog = ({ host, panel, onClose }: {
let disposePanel: (() => void) | null = null;
setMountError(null);
container.replaceChildren();
void host.mountPanel(panelPluginId, panelId, container).then((dispose) => {
void host.mountPanel(panelPluginId, panelId, container, options, requestClose).then((dispose) => {
if (disposed) dispose();
else disposePanel = dispose;
}).catch((error: unknown) => {
@@ -32,18 +38,54 @@ export const PluginPanelDialog = ({ host, panel, onClose }: {
disposePanel?.();
container.replaceChildren();
};
}, [host, panelId, panelPluginId]);
}, [host, options, panelId, panelPluginId]);
const requestClose = async () => {
if (!panelPluginId || !panelId || checkingClose) return;
setCheckingClose(true);
try {
const decision = await host.getPanelCloseDecision(panelPluginId, panelId);
if (decision === true) onClose();
else if (decision && typeof decision === "object") setCloseConfirmation(decision);
} catch (error) {
setMountError(error instanceof Error ? error.message : String(error));
} finally {
setCheckingClose(false);
}
};
return (
<Dialog open={Boolean(panel)} onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>{panel?.title ?? t("plugins.panel")}</DialogTitle>
<DialogDescription>{t("plugins.panelDescription")}</DialogDescription>
</DialogHeader>
{mountError ? <div role="alert" className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">{mountError}</div> : null}
<div ref={containerRef} className="min-h-40 rounded-lg border border-slate-200 bg-white p-4 text-sm text-slate-700" />
</DialogContent>
</Dialog>
<>
<Dialog open={Boolean(panel)} onOpenChange={(open) => { if (!open) void requestClose(); }}>
<DialogContent className={cn(
panel?.presentation === "fullscreen"
? "flex h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-none flex-col overflow-hidden p-5"
: "max-h-[85vh] max-w-3xl overflow-y-auto",
)}>
<DialogHeader>
<DialogTitle>{panel?.title ?? t("plugins.panel")}</DialogTitle>
<DialogDescription>{t("plugins.panelDescription")}</DialogDescription>
</DialogHeader>
{mountError ? <div role="alert" className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">{mountError}</div> : null}
<div ref={containerRef} className={cn(
"min-h-40 rounded-lg border border-slate-200 bg-white p-4 text-sm text-slate-700",
panel?.presentation === "fullscreen" && "min-h-0 flex-1 overflow-hidden",
)} />
</DialogContent>
</Dialog>
{closeConfirmation ? (
<AppConfirmDialog
title={closeConfirmation.title}
description={closeConfirmation.message}
confirmLabel={closeConfirmation.confirmLabel ?? t("common.close")}
tone="neutral"
onCancel={() => setCloseConfirmation(null)}
onConfirm={() => {
setCloseConfirmation(null);
onClose();
}}
/>
) : null}
</>
);
};
+11
View File
@@ -133,6 +133,17 @@ export const createDesktopRepository = (): EdgeEverRepository => ({
return { resource: { ...result.resource, url: toDesktopResourceUrl(result.resource.url) } };
},
readResource: async (resourceId) => (
await api.getResourceResponse(`/api/v1/resources/${encodeURIComponent(resourceId)}/blob`, { cache: "no-store" })
).blob(),
updateResource: async (resourceId, file, expectedContentHash) => {
const result = await api.updateResourceContent(resourceId, file, expectedContentHash);
await request("resource.delete", { resourceId });
await request("resource.cache", { resource: result.resource });
return { resource: { ...result.resource, url: toDesktopResourceUrl(result.resource.url) } };
},
listResources: async () => {
const local = await request("resource.list", { limit: 500 });
const staged = await listStagedResources();
@@ -9,7 +9,12 @@ export default {
const pinned = await context.notes.pin(["note-1"], true);
const revisions = await context.notes.revisions.list("note-1");
const resources = await context.resources.list("note-1");
const resourceBlob = await context.resources.read("resource-1");
const uploaded = await context.resources.upload("note-1", new File(["hello"], "hello.txt", { type: "text/plain" }));
const updatedResource = await context.resources.update("resource-1", {
file: new File(["updated"], "drawing.excalidraw", { type: "application/vnd.excalidraw+json" }),
expectedContentHash: resources[0].contentHash,
});
const endpoint = await context.settings.get("endpoint");
const note = await context.notes.get("note-1");
const edited = await context.notes.editMarkdown("note-1", {
@@ -25,18 +30,43 @@ export default {
await context.templates.delete(updatedTemplate.id);
const editorDocument = await context.editor.getDocument();
const editedEditorDocument = await context.editor.editMarkdown([{ from: 0, to: 5, insert: "Live" }]);
const insertedEmbed = await context.editor.insertEmbed({
type: "drawing",
resourceId: updatedResource.id,
title: "Architecture",
data: { mode: "edit" },
});
await context.ui.openNote("note-1", { search: "Updated note" });
await context.ui.panels.open("capability-panel");
await context.ui.panels.open("capability-panel", { state: { resourceId: updatedResource.id } });
globalThis.edgeeverPluginCapabilityResult = {
notebook, moved, pinned, revisions, resources, uploaded, endpoint, note, edited,
notebook, moved, pinned, revisions, resources, resourceText: await resourceBlob.text(), uploaded, updatedResource, endpoint, note, edited,
contentQuery, templates, createdTemplate, updatedTemplate, templatedNote, editorDocument, editedEditorDocument,
insertedEmbed,
};
},
});
const disposePanel = context.ui.panels.register({
id: "capability-panel",
title: "Capability panel",
mount() {},
presentation: "fullscreen",
mount(container, panelContext) {
container.panelState = panelContext.state;
container.requestPanelClose = panelContext.requestClose;
return () => {
delete container.panelState;
delete container.requestPanelClose;
};
},
beforeClose() {
return { title: "Unsaved drawing", message: "Close without saving?", confirmLabel: "Close drawing" };
},
});
const disposeEmbed = context.editor.embeds.register({
type: "drawing",
mount(container, embed) {
container.dataset.embedId = embed.id;
return () => delete container.dataset.embedId;
},
});
const disposeStaleEdit = context.commands.register({
id: "edit-stale-note",
@@ -53,6 +83,7 @@ export default {
disposeExercise();
disposeStaleEdit();
disposePanel();
disposeEmbed();
};
},
};
@@ -6,9 +6,13 @@ export default {
const disposeTemplate = context.events.on("template.created", ({ template }) => {
globalThis.edgeeverPluginObservedTemplate = template;
});
const disposeResource = context.events.on("resource.updated", ({ resource }) => {
globalThis.edgeeverPluginObservedResource = resource;
});
return () => {
disposeNote();
disposeTemplate();
disposeResource();
};
},
};
+68 -6
View File
@@ -98,6 +98,7 @@ describe("EdgeEverPluginHost", () => {
getSelection: () => ({ noteId: "note-1", from: 1, to: 6, empty: false, text: "hello", contentMarkdown: "hello" }),
getDocument: () => ({ noteId: "note-1", contentMarkdown: "hello", hasUnsavedChanges: false }),
replaceDocument: () => undefined,
insertEmbed: () => undefined,
replaceSelection: (value) => { replacement = value; },
insertAtCursor: () => undefined,
});
@@ -123,7 +124,7 @@ describe("EdgeEverPluginHost", () => {
await host.runCommand("org.edgeever.test-plugin", "hello");
expect(host.getSnapshot().commands).toHaveLength(7);
expect(host.getSnapshot().panels).toHaveLength(1);
expect(host.getSnapshot().panels).toEqual([{ pluginId: "org.edgeever.test-plugin", id: "fixture", title: "Fixture panel", presentation: "dialog" }]);
expect(notices).toEqual(["hello from plugin"]);
expect(host.getSnapshot().recentActions[0]).toMatchObject({ id: "hello", type: "command" });
await expect(host.runCommand("org.edgeever.test-plugin", "read-without-permission")).rejects.toThrow("notes:read");
@@ -301,7 +302,7 @@ describe("EdgeEverPluginHost", () => {
mimeType: "text/plain",
filename: "hello.txt",
byteSize: 5,
sha256: null,
sha256: "resource-hash",
width: null,
height: null,
createdAt: "2026-09-01T00:00:00.000Z",
@@ -362,7 +363,9 @@ describe("EdgeEverPluginHost", () => {
createdAt: "2026-09-01T00:00:00.000Z",
}] }),
listResources: async () => ({ resources: [{ ...resource, memoTitle: "Note", memoExcerpt: "", memoDeleted: false }], summary: {} }),
readResource: async () => new Blob(["resource bytes"], { type: "text/plain" }),
uploadMemoResource: async () => ({ resource }),
updateResource: async (_resourceId, file) => ({ resource: { ...resource, filename: file.name, mimeType: file.type, sha256: "updated-resource-hash" } }),
listTemplates: async () => ({ templates: [template] }),
createTemplate: async (input) => ({ template: { ...template, name: input.name } }),
updateTemplate: async (_templateId, input) => ({ template: { ...template, name: input.name ?? template.name } }),
@@ -373,14 +376,16 @@ describe("EdgeEverPluginHost", () => {
const host = new EdgeEverPluginHost({ repository: repositoryWithCapabilities, scope: "capabilities", packageStorage });
const opened = [];
const openedPanels = [];
const insertedEmbeds = [];
let liveDocument = "First note";
let liveDocumentDirty = false;
host.setNavigationAdapter({ openNote: (noteId, notebookId, options) => opened.push({ noteId, notebookId, options }) });
host.setPanelAdapter({ openPanel: (pluginId, panelId) => openedPanels.push({ pluginId, panelId }) });
host.setPanelAdapter({ openPanel: (pluginId, panelId, options) => openedPanels.push({ pluginId, panelId, options }) });
host.setEditorAdapter({
getSelection: () => ({ noteId: "note-1", from: 0, to: 0, empty: true, text: "", contentMarkdown: "" }),
getDocument: () => ({ noteId: "note-1", contentMarkdown: liveDocument, hasUnsavedChanges: liveDocumentDirty }),
replaceDocument: (contentMarkdown) => { liveDocument = contentMarkdown; liveDocumentDirty = true; },
insertEmbed: (embed) => insertedEmbeds.push(embed),
replaceSelection: () => undefined,
insertAtCursor: () => undefined,
});
@@ -395,7 +400,7 @@ describe("EdgeEverPluginHost", () => {
permissions: [
"notes:read", "notes:write", "metadata:write", "resources:read", "resources:write",
"templates:read", "templates:write", "editor:read", "editor:write",
"ui:commands", "ui:navigation", "ui:panels",
"ui:commands", "ui:navigation", "ui:panels", "ui:embeds",
],
settings: { fields: [{ key: "endpoint", type: "text", label: "Endpoint", default: "https://api.example" }] },
}, "https://plugins.example/capabilities/manifest.json");
@@ -409,7 +414,9 @@ describe("EdgeEverPluginHost", () => {
pinned: 1,
revisions: [{ id: "revision-1", noteId: "note-1", contentMarkdown: "First" }],
resources: [{ id: "resource-1", noteId: "note-1", filename: "hello.txt" }],
resourceText: "resource bytes",
uploaded: { id: "resource-1", noteId: "note-1" },
updatedResource: { id: "resource-1", filename: "drawing.excalidraw", contentHash: "updated-resource-hash" },
endpoint: "https://api.example",
note: { id: "note-1", revision: 3, contentHash: "first-hash" },
edited: { contentMarkdown: "Updated note" },
@@ -420,6 +427,7 @@ describe("EdgeEverPluginHost", () => {
templatedNote: { id: "templated-note", contentMarkdown: "Template" },
editorDocument: { noteId: "note-1", contentMarkdown: "First note", hasUnsavedChanges: false },
editedEditorDocument: { noteId: "note-1", contentMarkdown: "Live note", hasUnsavedChanges: true },
insertedEmbed: { pluginId: "org.edgeever.capabilities", type: "drawing", resourceId: "resource-1", data: { mode: "edit" } },
});
expect(updateInput).toMatchObject({
expectedRevision: 3,
@@ -427,13 +435,45 @@ describe("EdgeEverPluginHost", () => {
contentMarkdown: "Updated note",
});
expect(opened).toEqual([{ noteId: "note-1", notebookId: "notebook-1", options: { search: "Updated note" } }]);
expect(openedPanels).toEqual([{ pluginId: "org.edgeever.capabilities", panelId: "capability-panel" }]);
expect(openedPanels).toEqual([{
pluginId: "org.edgeever.capabilities",
panelId: "capability-panel",
options: { state: { resourceId: "resource-1" } },
}]);
expect(insertedEmbeds).toHaveLength(1);
expect(host.getSnapshot().embeds).toEqual([{ pluginId: "org.edgeever.capabilities", type: "drawing" }]);
const embedContainer = { dataset: {} };
const disposeEmbed = await host.mountEmbed("org.edgeever.capabilities", "drawing", embedContainer, insertedEmbeds[0]);
expect(embedContainer.dataset.embedId).toBe(insertedEmbeds[0].id);
disposeEmbed();
expect(embedContainer.dataset.embedId).toBeUndefined();
expect(host.getSnapshot().panels[0]).toMatchObject({ presentation: "fullscreen" });
const panelContainer = {};
let requestedPanelClose = false;
const disposePanel = await host.mountPanel(
"org.edgeever.capabilities",
"capability-panel",
panelContainer,
{ state: { resourceId: "resource-1" } },
() => { requestedPanelClose = true; },
);
expect(panelContainer.panelState).toEqual({ resourceId: "resource-1" });
await panelContainer.requestPanelClose();
expect(requestedPanelClose).toBe(true);
disposePanel();
expect(panelContainer.panelState).toBeUndefined();
await expect(host.getPanelCloseDecision("org.edgeever.capabilities", "capability-panel")).resolves.toEqual({
title: "Unsaved drawing",
message: "Close without saving?",
confirmLabel: "Close drawing",
});
await expect(host.runCommand("org.edgeever.capabilities", "edit-stale-note"))
.rejects.toMatchObject({ code: "NOTE_CONFLICT" });
host.setEditorAdapter({
getSelection: () => ({ noteId: "note-1", from: 0, to: 0, empty: true, text: "", contentMarkdown: "" }),
getDocument: () => ({ noteId: "note-1", contentMarkdown: "Draft", hasUnsavedChanges: true }),
replaceDocument: () => undefined,
insertEmbed: () => undefined,
replaceSelection: () => undefined,
insertAtCursor: () => undefined,
});
@@ -489,10 +529,26 @@ describe("EdgeEverPluginHost", () => {
createdAt: "2026-09-01T00:00:00.000Z",
updatedAt: "2026-09-01T00:00:00.000Z",
};
const updatedResource = {
id: "resource-events",
memoId: "note-events",
originalMemoId: null,
kind: "attachment",
mimeType: "application/vnd.excalidraw+json",
filename: "drawing.excalidraw",
byteSize: 5,
sha256: "resource-event-hash",
width: null,
height: null,
createdAt: "2026-09-01T00:00:00.000Z",
updatedAt: "2026-09-01T00:01:00.000Z",
url: "/api/v1/resources/resource-events/blob",
};
const repositoryWithEvents = withRepositoryMutationEvents({
...repository,
updateMemo: async () => ({ memo: updatedMemo, queued: true }),
createTemplate: async () => ({ template: createdTemplate, queued: true }),
updateResource: async () => ({ resource: updatedResource }),
}, "event-workspace");
const packageStorage = { get: async () => null, put: async () => undefined, remove: async () => undefined };
const host = new EdgeEverPluginHost({ repository: repositoryWithEvents, scope: "event-workspace", packageStorage });
@@ -503,13 +559,14 @@ describe("EdgeEverPluginHost", () => {
version: "1.0.0",
apiVersion: "1",
entry: new URL("./plugin-host-events.fixture.mjs", import.meta.url).href,
permissions: ["notes:read", "templates:read"],
permissions: ["notes:read", "templates:read", "resources:read"],
}, "https://plugins.example/events/manifest.json");
await host.setEnabled("org.edgeever.events", true);
await host.activateEnabled();
await repositoryWithEvents.updateMemo(updatedMemo, {});
await repositoryWithEvents.createTemplate({ name: "Event template" });
await repositoryWithEvents.updateResource("resource-events", new File(["scene"], "drawing.excalidraw"), "old-hash");
expect(globalThis.edgeeverPluginObservedNote).toMatchObject({
id: "note-events",
@@ -519,8 +576,13 @@ describe("EdgeEverPluginHost", () => {
id: "template-events",
contentMarkdown: "Template",
});
expect(globalThis.edgeeverPluginObservedResource).toMatchObject({
id: "resource-events",
contentHash: "resource-event-hash",
});
delete globalThis.edgeeverPluginObservedNote;
delete globalThis.edgeeverPluginObservedTemplate;
delete globalThis.edgeeverPluginObservedResource;
await host.dispose();
});
});
+179 -10
View File
@@ -7,6 +7,8 @@ import {
type PluginContext,
type PluginEventMap,
type PluginEditorDocument,
type PluginEmbedInstance,
type PluginEmbedRenderer,
type PluginManifest,
type PluginMarkdownEdit,
type PluginNote,
@@ -14,6 +16,8 @@ import {
type PluginNoteSummary,
type PluginPanel,
type PluginOpenNoteOptions,
type PluginPanelCloseDecision,
type PluginPanelOpenOptions,
type PluginPermission,
type PluginApiErrorCode,
type PluginResource,
@@ -122,6 +126,12 @@ export interface RegisteredPluginPanel {
pluginId: string;
id: string;
title: string;
presentation: "dialog" | "fullscreen";
}
export interface RegisteredPluginEmbed {
pluginId: string;
type: string;
}
export interface RegisteredPluginAction {
@@ -135,6 +145,7 @@ export interface PluginEditorAdapter {
getSelection(): PluginEditorSelection | null;
getDocument(): PluginEditorDocument | null;
replaceDocument(contentMarkdown: string): void;
insertEmbed(embed: PluginEmbedInstance): void;
replaceSelection(contentMarkdown: string): void;
insertAtCursor(contentMarkdown: string): void;
}
@@ -144,13 +155,14 @@ export interface PluginNavigationAdapter {
}
export interface PluginPanelAdapter {
openPanel(pluginId: string, panelId: string): void | Promise<void>;
openPanel(pluginId: string, panelId: string, options?: PluginPanelOpenOptions): void | Promise<void>;
}
export interface PluginHostSnapshot {
extensions: InstalledExtension[];
commands: RegisteredPluginCommand[];
panels: RegisteredPluginPanel[];
embeds: RegisteredPluginEmbed[];
recentActions: RegisteredPluginAction[];
activeThemeId: string | null;
}
@@ -213,6 +225,7 @@ const toPluginResource = (resource: Awaited<ReturnType<EdgeEverRepository["uploa
mimeType: resource.mimeType,
filename: resource.filename,
byteSize: resource.byteSize,
contentHash: resource.sha256,
width: resource.width,
height: resource.height,
createdAt: resource.createdAt,
@@ -322,6 +335,9 @@ const EVENT_PERMISSIONS: Partial<Record<keyof PluginEventMap, PluginPermission>>
"template.created": "templates:read",
"template.updated": "templates:read",
"template.deleted": "templates:read",
"resource.created": "resources:read",
"resource.updated": "resources:read",
"resource.deleted": "resources:read",
};
const isAllowedNetworkHost = (hostname: string, allowedHosts: string[]) =>
@@ -370,6 +386,20 @@ const validateSettingValue = (field: PluginSettingField, value: PluginSettingVal
return value;
};
const normalizePanelState = (state: PluginPanelOpenOptions["state"] | undefined) => {
if (state === undefined) return null;
let serialized: string;
try {
serialized = JSON.stringify(state);
} catch {
throw new Error("Plugin panel state must be JSON-serializable.");
}
if (serialized === undefined || serialized.length > 64 * 1024) {
throw new Error("Plugin panel state must be JSON-serializable and at most 64 KiB.");
}
return JSON.parse(serialized) as NonNullable<PluginPanelOpenOptions["state"]>;
};
export class EdgeEverPluginHost {
private readonly repository: EdgeEverRepository;
private readonly scope: string;
@@ -382,10 +412,12 @@ export class EdgeEverPluginHost {
private readonly commands = new Map<string, PluginCommand & { pluginId: string }>();
private readonly panels = new Map<string, PluginPanel & { pluginId: string }>();
private readonly mountedPanels = new Map<string, Set<() => void>>();
private readonly embeds = new Map<string, PluginEmbedRenderer & { pluginId: string }>();
private readonly mountedEmbeds = new Map<string, Set<() => void>>();
private readonly eventListeners = new Map<keyof PluginEventMap, Set<(payload: never) => void>>();
private extensions = readInstalledExtensions();
private activeThemeId = readStorageItem(ACTIVE_THEME_STORAGE_KEY);
private snapshot: PluginHostSnapshot = { extensions: [], commands: [], panels: [], recentActions: [], activeThemeId: null };
private snapshot: PluginHostSnapshot = { extensions: [], commands: [], panels: [], embeds: [], recentActions: [], activeThemeId: null };
private recentActions: RegisteredPluginAction[];
private editorAdapter: PluginEditorAdapter | null = null;
private navigationAdapter: PluginNavigationAdapter | null = null;
@@ -661,13 +693,24 @@ export class EdgeEverPluginHost {
this.recordRecentAction({ pluginId, id: commandId, title: command.title, type: "command" });
}
async mountPanel(pluginId: string, panelId: string, container: HTMLElement) {
async mountPanel(
pluginId: string,
panelId: string,
container: HTMLElement,
options?: PluginPanelOpenOptions,
onRequestClose?: () => void | Promise<void>,
) {
const key = `${pluginId}:${panelId}`;
const panel = this.panels.get(key);
if (!panel) throw new Error("Plugin panel is not registered.");
const mounted = this.mountedPanels.get(key) ?? new Set<() => void>();
this.mountedPanels.set(key, mounted);
const pluginDispose = await panel.mount(container);
const pluginDispose = await panel.mount(container, {
state: normalizePanelState(options?.state),
requestClose: async () => {
await onRequestClose?.();
},
});
if (this.panels.get(key) !== panel) {
if (typeof pluginDispose === "function") pluginDispose();
throw new Error("Plugin panel was closed while mounting.");
@@ -685,6 +728,35 @@ export class EdgeEverPluginHost {
return dispose;
}
async getPanelCloseDecision(pluginId: string, panelId: string): Promise<PluginPanelCloseDecision> {
const panel = this.panels.get(`${pluginId}:${panelId}`);
if (!panel) return true;
return panel.beforeClose ? panel.beforeClose() : true;
}
async mountEmbed(pluginId: string, type: string, container: HTMLElement, embed: PluginEmbedInstance) {
const key = `${pluginId}:${type}`;
const renderer = this.embeds.get(key);
if (!renderer) throw new Error("Plugin embed renderer is not registered.");
const mounted = this.mountedEmbeds.get(key) ?? new Set<() => void>();
this.mountedEmbeds.set(key, mounted);
const pluginDispose = await renderer.mount(container, embed);
if (this.embeds.get(key) !== renderer) {
if (typeof pluginDispose === "function") pluginDispose();
throw new Error("Plugin embed renderer was removed while mounting.");
}
let active = true;
const dispose = () => {
if (!active) return;
active = false;
mounted.delete(dispose);
if (mounted.size === 0) this.mountedEmbeds.delete(key);
if (typeof pluginDispose === "function") pluginDispose();
};
mounted.add(dispose);
return dispose;
}
async dispose() {
this.started = false;
window.removeEventListener("edgeever:sync-queue-changed", this.handleSyncQueueChanged);
@@ -721,6 +793,9 @@ export class EdgeEverPluginHost {
if (event.type === "template.created") return this.emit(event.type, { template: toPluginTemplate(event.template) });
if (event.type === "template.updated") return this.emit(event.type, { template: toPluginTemplate(event.template) });
if (event.type === "template.deleted") return this.emit(event.type, { templateId: event.templateId });
if (event.type === "resource.created") return this.emit(event.type, { resource: toPluginResource(event.resource) });
if (event.type === "resource.updated") return this.emit(event.type, { resource: toPluginResource(event.resource) });
if (event.type === "resource.deleted") return this.emit(event.type, { resourceId: event.resourceId });
this.emit("workspace.synced", { bootstrapped: event.bootstrapped, changed: event.changed });
};
@@ -797,12 +872,21 @@ export class EdgeEverPluginHost {
for (const key of [...this.panels.keys()]) {
if (key.startsWith(`${pluginId}:`)) this.panels.delete(key);
}
for (const key of [...this.embeds.keys()]) {
if (key.startsWith(`${pluginId}:`)) this.embeds.delete(key);
}
for (const [key, disposers] of [...this.mountedPanels]) {
if (!key.startsWith(`${pluginId}:`)) continue;
for (const dispose of [...disposers]) {
try { dispose(); } catch { /* Panel cleanup must not strand the plugin. */ }
}
}
for (const [key, embedDisposers] of [...this.mountedEmbeds]) {
if (!key.startsWith(`${pluginId}:`)) continue;
for (const dispose of [...embedDisposers]) {
try { dispose(); } catch { /* Embed cleanup must not strand the plugin. */ }
}
}
this.refreshSnapshot();
}
@@ -1104,6 +1188,49 @@ export class EdgeEverPluginHost {
if (contentMarkdown !== current.contentMarkdown) this.editorAdapter.replaceDocument(contentMarkdown);
return { ...current, contentMarkdown, hasUnsavedChanges: contentMarkdown !== current.contentMarkdown || current.hasUnsavedChanges };
},
insertEmbed: async (input) => {
assertPermission(manifest, "editor:write");
assertPermission(manifest, "ui:embeds");
if (!this.editorAdapter) throw new Error("No note editor is currently active.");
const type = input?.type?.trim();
const resourceId = input?.resourceId?.trim();
if (!type || !/^[a-z0-9][a-z0-9._-]*$/i.test(type) || !resourceId) {
throw new Error("Plugin embeds require a valid type and resource id.");
}
if (!this.embeds.has(`${manifest.id}:${type}`)) {
throw new Error("Register the plugin embed renderer before inserting an embed.");
}
const data = normalizePanelState(input.data);
const embed: PluginEmbedInstance = {
id: `embed_${crypto.randomUUID().replace(/-/g, "")}`,
pluginId: manifest.id,
type,
resourceId,
previewResourceId: input.previewResourceId?.trim() ?? "",
title: input.title?.trim().slice(0, 500) ?? "",
data,
};
this.editorAdapter.insertEmbed(embed);
return embed;
},
embeds: {
register: (renderer) => {
assertPermission(manifest, "ui:embeds");
const type = renderer.type?.trim();
if (!type || !/^[a-z0-9][a-z0-9._-]*$/i.test(type)) throw new Error("Plugin embed type is invalid.");
const key = `${manifest.id}:${type}`;
if (this.embeds.has(key)) throw new Error(`Plugin embed renderer already exists: ${type}`);
this.embeds.set(key, { ...renderer, type, pluginId: manifest.id });
this.refreshSnapshot();
const dispose = () => {
this.embeds.delete(key);
for (const mountedDispose of [...(this.mountedEmbeds.get(key) ?? [])]) mountedDispose();
this.refreshSnapshot();
};
disposers.push(dispose);
return dispose;
},
},
replaceSelection: async (contentMarkdown) => {
assertPermission(manifest, "editor:write");
if (!this.editorAdapter) throw new Error("No note editor is currently active.");
@@ -1121,18 +1248,48 @@ export class EdgeEverPluginHost {
const { resources } = await this.repository.listResources();
return resources.filter((resource) => !noteId || resource.memoId === noteId).map(toPluginResource);
},
read: async (resourceId) => {
assertPermission(manifest, "resources:read");
return this.repository.readResource(resourceId);
},
upload: async (noteId, file) => {
assertPermission(manifest, "resources:write");
return toPluginResource((await this.repository.uploadMemoResource(noteId, file)).resource);
const resource = toPluginResource((await this.repository.uploadMemoResource(noteId, file)).resource);
await this.onWorkspaceChanged?.();
return resource;
},
update: async (resourceId, input) => {
assertPermission(manifest, "resources:read");
assertPermission(manifest, "resources:write");
if (!(input?.file instanceof File) || !input.expectedContentHash?.trim()) {
throw new Error("A file and expected resource content hash are required.");
}
try {
const resource = toPluginResource((await this.repository.updateResource(
resourceId,
input.file,
input.expectedContentHash,
)).resource);
await this.onWorkspaceChanged?.();
return resource;
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "resource_conflict") {
throw createPluginApiError("RESOURCE_CONFLICT", "The resource changed after the plugin read it. Reload it before saving.");
}
throw error;
}
},
rename: async (resourceId, filename) => {
assertPermission(manifest, "resources:write");
assertPermission(manifest, "resources:read");
return toPluginResource((await this.repository.renameResource(resourceId, filename)).resource);
const resource = toPluginResource((await this.repository.renameResource(resourceId, filename)).resource);
await this.onWorkspaceChanged?.();
return resource;
},
delete: async (resourceId) => {
assertPermission(manifest, "resources:write");
await this.repository.deleteResource(resourceId);
await this.onWorkspaceChanged?.();
},
},
settings: {
@@ -1175,7 +1332,11 @@ export class EdgeEverPluginHost {
if (!panel.title.trim()) throw new Error("Plugin panel title is required.");
const key = `${manifest.id}:${panel.id}`;
if (this.panels.has(key)) throw new Error(`Plugin panel already exists: ${panel.id}`);
this.panels.set(key, { ...panel, pluginId: manifest.id });
this.panels.set(key, {
...panel,
presentation: panel.presentation === "fullscreen" ? "fullscreen" : "dialog",
pluginId: manifest.id,
});
this.refreshSnapshot();
const dispose = () => {
this.panels.delete(key);
@@ -1184,11 +1345,13 @@ export class EdgeEverPluginHost {
disposers.push(dispose);
return dispose;
},
open: async (panelId) => {
open: async (panelId, options) => {
assertPermission(manifest, "ui:panels");
if (!this.panels.has(`${manifest.id}:${panelId}`)) throw new Error("Plugin panel is not registered.");
if (!this.panelAdapter) throw new Error("Plugin panel presentation is unavailable in this host.");
await this.panelAdapter.openPanel(manifest.id, panelId);
await this.panelAdapter.openPanel(manifest.id, panelId, {
state: normalizePanelState(options?.state),
});
},
},
},
@@ -1297,7 +1460,13 @@ export class EdgeEverPluginHost {
this.snapshot = {
extensions: this.extensions.map((item) => ({ ...item, manifest: { ...item.manifest } })),
commands: [...this.commands.values()].map(({ pluginId, id, title }) => ({ pluginId, id, title })),
panels: [...this.panels.values()].map(({ pluginId, id, title }) => ({ pluginId, id, title })),
panels: [...this.panels.values()].map(({ pluginId, id, title, presentation }) => ({
pluginId,
id,
title,
presentation: presentation === "fullscreen" ? "fullscreen" : "dialog",
})),
embeds: [...this.embeds.values()].map(({ pluginId, type }) => ({ pluginId, type })),
recentActions: this.recentActions.flatMap((action) => {
const registered = registeredActions.get(`${action.type}:${action.pluginId}:${action.id}`);
return registered ? [registered] : [];
+24 -1
View File
@@ -1,4 +1,4 @@
import type { MemoDetail, MemoTemplate } from "@edgeever/shared";
import type { MemoDetail, MemoTemplate, Resource } from "@edgeever/shared";
import type { EdgeEverRepository } from "@/lib/repository";
export type RepositoryMutationEvent =
@@ -9,6 +9,9 @@ export type RepositoryMutationEvent =
| { type: "template.created"; template: MemoTemplate }
| { type: "template.updated"; template: MemoTemplate }
| { type: "template.deleted"; templateId: string }
| { type: "resource.created"; resource: Resource }
| { type: "resource.updated"; resource: Resource }
| { type: "resource.deleted"; resourceId: string }
| { type: "workspace.synced"; bootstrapped: boolean; changed: number };
type RepositoryMutationListener = (event: RepositoryMutationEvent) => void;
@@ -61,6 +64,26 @@ export const withRepositoryMutationEvents = (repository: EdgeEverRepository, sco
notifyRepositoryMutation(scope, { type: "note.created", note: result.memo });
return result;
},
async uploadMemoResource(memoId, file) {
const result = await repository.uploadMemoResource(memoId, file);
notifyRepositoryMutation(scope, { type: "resource.created", resource: result.resource });
return result;
},
async updateResource(resourceId, file, expectedContentHash) {
const result = await repository.updateResource(resourceId, file, expectedContentHash);
notifyRepositoryMutation(scope, { type: "resource.updated", resource: result.resource });
return result;
},
async renameResource(resourceId, filename) {
const result = await repository.renameResource(resourceId, filename);
notifyRepositoryMutation(scope, { type: "resource.updated", resource: result.resource });
return result;
},
async deleteResource(resourceId) {
const result = await repository.deleteResource(resourceId);
notifyRepositoryMutation(scope, { type: "resource.deleted", resourceId });
return result;
},
async createTemplate(input) {
const result = await repository.createTemplate(input);
notifyRepositoryMutation(scope, { type: "template.created", template: result.template });
+31
View File
@@ -46,6 +46,7 @@ import { createDesktopRepository } from "@/lib/desktop-repository";
import { isBrowserOffline } from "@/lib/network-status";
import { notifySyncQueueDeferred } from "@/lib/sync-events";
import { isActiveLocalMemoUpdateStatus, shouldAcceptRemoteMemoDetail } from "@/lib/memo-detail-freshness";
import { cacheLocalResourceBytes, getCachedLocalResourceBytes } from "@/lib/local-resource-cache";
import { withRepositoryMutationEvents } from "@/lib/repository-events";
export type EdgeEverRepository = {
@@ -60,6 +61,8 @@ export type EdgeEverRepository = {
deleteTemplate(templateId: string): Promise<{ ok: true }>;
useTemplate(templateId: string, notebookId: string): Promise<{ memo: MemoDetail }>;
uploadMemoResource(memoId: string, file: File): Promise<{ resource: Resource }>;
readResource(resourceId: string): Promise<Blob>;
updateResource(resourceId: string, file: File, expectedContentHash: string): Promise<{ resource: Resource }>;
listResources(): Promise<{ resources: ResourceListItem[]; summary: ResourceStorageSummary }>;
renameResource(resourceId: string, filename: string): Promise<{ resource: Resource }>;
deleteResource(resourceId: string): Promise<{ ok: true }>;
@@ -303,6 +306,34 @@ export const createWebRepository = (scope: string): EdgeEverRepository => {
await putLocalResource(scope, { ...result.resource, memoTitle: null, memoExcerpt: null, memoDeleted: false });
return result;
},
async readResource(resourceId) {
const local = (await listLocalResources(scope)).resources.find((resource) => resource.id === resourceId);
if (isOffline()) {
if (!local?.url) throw new Error("The resource is not available offline.");
const cached = await getCachedLocalResourceBytes(local.url);
if (cached) return cached;
throw new Error("The resource is not available offline.");
}
const url = local?.url ?? `/api/v1/resources/${encodeURIComponent(resourceId)}/blob`;
const blob = await (await api.getResourceResponse(url, { cache: "no-store" })).blob();
await cacheLocalResourceBytes(url, blob).catch(() => undefined);
return blob;
},
async updateResource(resourceId, file, expectedContentHash) {
if (isOffline() || resourceId.startsWith("local_resource_")) {
throw new Error("Attachments can only be updated after they are synced.");
}
const existing = (await listLocalResources(scope)).resources.find((resource) => resource.id === resourceId);
const result = await api.updateResourceContent(resourceId, file, expectedContentHash);
await putLocalResource(scope, {
...result.resource,
memoTitle: existing?.memoTitle ?? null,
memoExcerpt: existing?.memoExcerpt ?? null,
memoDeleted: existing?.memoDeleted ?? false,
});
await cacheLocalResourceBytes(result.resource.url, file);
return result;
},
async listResources() {
const local = await listLocalResources(scope);
if (local.resources.length > 0 || isOffline()) {
+42 -6
View File
@@ -1,6 +1,6 @@
# EdgeEver Plugin Development (P0 Preview)
EdgeEver's P0 extension API supports trusted client plugins and no-code theme packages. Users can install extensions from the verified marketplace, a public GitHub repository, or a manifest URL. Extensions are installed per device and run only while EdgeEver is open. Scheduled/background jobs, webhooks, custom editor blocks, and a hard JavaScript sandbox are not part of this preview.
EdgeEver's P0 extension API supports trusted client plugins and no-code theme packages. Users can install extensions from the verified marketplace, a public GitHub repository, or a manifest URL. Extensions are installed per device and run only while EdgeEver is open. Scheduled/background jobs, webhooks, unrestricted TipTap extensions, and a hard JavaScript sandbox are not part of this preview.
## Security model
@@ -108,6 +108,7 @@ Supported permissions:
- `ui:navigation`
- `ui:notices`
- `ui:panels`
- `ui:embeds`
Network access through `context.network.fetch()` also requires a `networkHosts` allowlist in the manifest.
@@ -211,14 +212,18 @@ Attachments use their own permissions and still flow through the same Web/Deskto
```ts
context.resources.list(noteId); // resources:read
const blob = await context.resources.read(resourceId); // resources:read
context.resources.upload(noteId, file); // resources:write
context.resources.update(resourceId, { file, expectedContentHash });
context.resources.rename(resourceId, filename);
context.resources.delete(resourceId);
```
Subscribing to `note.*` events requires `notes:read`, subscribing to `tag.changed` requires `metadata:read`, and subscribing to `template.*` requires `templates:read`. The sync-queue status event carries no note or metadata content and requires no additional read permission.
`resources.update()` requires both resource permissions and uses the `contentHash` returned by `resources.list()` as an optimistic-concurrency baseline. A stale baseline throws `PluginApiError` with `code: "RESOURCE_CONFLICT"`. Replacements are currently limited to 100 MiB and require the resource to be synchronized and online. The host stores new bytes under a new object key and switches the database pointer conditionally, so a rejected update does not damage the previous object.
Successful note, tag, and template changes made through EdgeEver's normal repository layer—including user actions and plugin actions—feed the same plugin event stream. `workspace.synced` reports completed repository sync passes. Failed mutations do not emit success events.
Subscribing to `note.*` events requires `notes:read`, subscribing to `tag.changed` requires `metadata:read`, subscribing to `template.*` requires `templates:read`, and subscribing to `resource.*` requires `resources:read`. The sync-queue status event carries no note or metadata content and requires no additional read permission.
Successful note, tag, template, and resource changes made through EdgeEver's normal repository layer—including user actions and plugin actions—feed the same plugin event stream. `workspace.synced` reports completed repository sync passes. Failed mutations do not emit success events.
## Templates API
@@ -318,6 +323,31 @@ if (document) {
Reading returns `null` when no editable note is open; writes throw an error. `editor.editMarkdown()` uses the same range validation as `notes.editMarkdown()`, but operates on the current in-memory document so it can safely include unsaved user changes. Plugin edits use normal editor transactions and the autosave flow.
### Plugin embeds
`ui:embeds` allows a plugin to register a renderer for its own constrained, block-level embed type. Inserting an embed additionally requires `editor:write`:
```ts
const disposeEmbed = context.editor.embeds.register({
type: "drawing",
async mount(container, embed) {
const scene = await context.resources.read(embed.resourceId);
// Render a framework-independent preview into container.
return () => container.replaceChildren();
}
});
await context.editor.insertEmbed({
type: "drawing",
resourceId: sceneResource.id,
previewResourceId: previewResource.id,
title: "Architecture",
data: { mode: "view" }
});
```
The host assigns the embed ID and plugin ID, so a plugin cannot impersonate another renderer. Embed metadata is limited to JSON-compatible values and 64 KiB. EdgeEver persists the generic node as an `edgeever-plugin-embed` fenced block in Markdown. When the plugin is disabled or unavailable, Web and public-share views show a stable fallback, while native editors preserve the original node through their unsupported-content compatibility path. Plugins do not receive the raw TipTap editor or schema.
## Note navigation
With `ui:navigation` declared, a plugin can open an existing note from a task, calendar, index, or search panel:
@@ -336,18 +366,24 @@ Plugins can register framework-independent DOM panels. Users open them from the
context.ui.panels.register({
id: "dashboard",
title: "Dashboard",
mount(container) {
presentation: "fullscreen",
mount(container, { state, requestClose }) {
const heading = document.createElement("h2");
heading.textContent = "Plugin dashboard";
container.append(heading);
return () => heading.remove();
},
beforeClose() {
return hasUnsavedDrawing
? { title: "Unsaved drawing", message: "Close without saving?", confirmLabel: "Close drawing" }
: true;
}
});
await context.ui.panels.open("dashboard");
await context.ui.panels.open("dashboard", { state: { resourceId } });
```
`panels.open()` can only open a panel registered by the calling plugin and is useful for commands, onboarding, and deep links into a plugin workflow.
`presentation` accepts `dialog` (the default) or `fullscreen`. `panels.open()` can only open a panel registered by the calling plugin; its optional JSON state is limited to 64 KiB and is delivered through the mount context. `beforeClose()` may return `true` to close, `false` to stay open, or confirmation copy for a host-rendered dialog. The mount context's `requestClose()` follows the same guard.
## Desktop plugin entry
+42 -6
View File
@@ -1,6 +1,6 @@
# EdgeEver 插件开发(P0 预览版)
EdgeEver P0 扩展 API 支持受信任的客户端插件和无代码主题包。用户可以从已验证插件市场、公开 GitHub 仓库或 Manifest 地址安装扩展;扩展安装在当前设备,并且只在 EdgeEver 打开期间运行。当前预览版不包含定时或后台任务、Webhook、自定义编辑器 Block 和严格的 JavaScript 沙箱。
EdgeEver P0 扩展 API 支持受信任的客户端插件和无代码主题包。用户可以从已验证插件市场、公开 GitHub 仓库或 Manifest 地址安装扩展;扩展安装在当前设备,并且只在 EdgeEver 打开期间运行。当前预览版不包含定时或后台任务、Webhook、不受约束的 TipTap 扩展和严格的 JavaScript 沙箱。
## 安全模型
@@ -108,6 +108,7 @@ Registry 格式:
- `ui:navigation`
- `ui:notices`
- `ui:panels`
- `ui:embeds`
通过 `context.network.fetch()` 访问网络时,还必须在 Manifest 的 `networkHosts` 中声明目标域名。
@@ -211,14 +212,18 @@ await context.notes.editMarkdown(noteId, {
```ts
context.resources.list(noteId); // resources:read
const blob = await context.resources.read(resourceId); // resources:read
context.resources.upload(noteId, file); // resources:write
context.resources.update(resourceId, { file, expectedContentHash });
context.resources.rename(resourceId, filename);
context.resources.delete(resourceId);
```
订阅 `note.*` 事件需要 `notes:read`,订阅 `tag.changed` 需要 `metadata:read`,订阅 `template.*` 需要 `templates:read`。同步队列状态事件不包含笔记或元数据,因此无需额外读取权限。
`resources.update()` 同时需要两项资源权限,并使用 `resources.list()` 返回的 `contentHash` 做乐观并发检查。基线过期时会抛出 `code: "RESOURCE_CONFLICT"` 的 `PluginApiError`。当前替换上限为 100 MiB,并要求资源已同步且设备在线。宿主会把新内容写入新的对象键,再有条件地切换数据库指针,因此被拒绝的更新不会损坏旧对象。
通过 EdgeEver 正常 Repository 层成功完成的笔记、标签和模板变更——无论来自用户操作还是插件操作——都会进入同一条插件事件流。`workspace.synced` 用于报告已完成的 Repository 同步;失败的变更不会发送成功事件。
订阅 `note.*` 事件需要 `notes:read`,订阅 `tag.changed` 需要 `metadata:read`,订阅 `template.*` 需要 `templates:read`,订阅 `resource.*` 需要 `resources:read`。同步队列状态事件不包含笔记或元数据,因此无需额外读取权限。
通过 EdgeEver 正常 Repository 层成功完成的笔记、标签、模板和资源变更——无论来自用户操作还是插件操作——都会进入同一条插件事件流。`workspace.synced` 用于报告已完成的 Repository 同步;失败的变更不会发送成功事件。
## 模板 API
@@ -318,6 +323,31 @@ if (document) {
没有打开可编辑笔记时,读取返回 `null`,写入会抛出错误。`editor.editMarkdown()` 使用与 `notes.editMarkdown()` 相同的区间校验,但操作当前内存中的文档,因此可以安全保留用户尚未保存的修改。插件修改会进入正常的编辑器事务和自动保存流程。
### 插件 Embed
`ui:embeds` 允许插件为自己的受约束块级 Embed 类型注册渲染器。插入 Embed 还需要 `editor:write`:
```ts
const disposeEmbed = context.editor.embeds.register({
type: "drawing",
async mount(container, embed) {
const scene = await context.resources.read(embed.resourceId);
// 在 container 中渲染与框架无关的预览。
return () => container.replaceChildren();
}
});
await context.editor.insertEmbed({
type: "drawing",
resourceId: sceneResource.id,
previewResourceId: previewResource.id,
title: "架构图",
data: { mode: "view" }
});
```
宿主会分配 Embed ID 和插件 ID,因此插件不能冒充其他渲染器。Embed 元数据只能使用兼容 JSON 的值,且上限为 64 KiB。EdgeEver 会在 Markdown 中将通用节点保存为 `edgeever-plugin-embed` 围栏块。插件停用或不可用时,Web 和公开分享页面会显示稳定的降级内容,原生编辑器则通过“不支持内容”兼容路径无损保留原节点。插件不会获得原始 TipTap 编辑器或 Schema。
## 笔记导航
声明 `ui:navigation` 后,插件可以从任务、日历、索引或搜索面板打开一篇现有笔记:
@@ -336,18 +366,24 @@ await context.ui.openNote(noteId, { search: "- [ ] 发布版本" });
context.ui.panels.register({
id: "dashboard",
title: "Dashboard",
mount(container) {
presentation: "fullscreen",
mount(container, { state, requestClose }) {
const heading = document.createElement("h2");
heading.textContent = "Plugin dashboard";
container.append(heading);
return () => heading.remove();
},
beforeClose() {
return hasUnsavedDrawing
? { title: "绘图尚未保存", message: "仍要关闭吗?", confirmLabel: "关闭绘图" }
: true;
}
});
await context.ui.panels.open("dashboard");
await context.ui.panels.open("dashboard", { state: { resourceId } });
```
`panels.open()` 只能打开调用插件自己注册的面板,适合命令、首次引导和插件工作流的深度跳转。
`presentation` 可以使用 `dialog`(默认)或 `fullscreen`。`panels.open()` 只能打开调用插件自己注册的面板;可选 JSON 状态上限为 64 KiB,并通过挂载上下文传入。`beforeClose()` 可以返回 `true` 关闭、返回 `false` 保持打开,或返回由宿主显示确认框所需的文案。挂载上下文中的 `requestClose()` 同样会经过这项保护。
## 桌面端插件入口
+22
View File
@@ -232,6 +232,28 @@ describe("EdgeEver client HTTP contract", () => {
expect(await calls[3].body.text()).toBe("ij");
});
test("replaces resource content with a multipart concurrency baseline", async () => {
let request;
const client = createEdgeEverClient({
fetch: async (input, init) => {
request = { path: String(input), init };
return jsonResponse({ resource: { id: "res_1", sha256: "next" } });
},
});
const file = new File(["scene"], "drawing.excalidraw", { type: "application/vnd.excalidraw+json" });
await client.updateResourceContent("res/1", file, "previous");
expect(request.path).toBe("/api/v1/resources/res%2F1/blob");
expect(request.init.method).toBe("PUT");
expect(request.init.body).toBeInstanceOf(FormData);
expect(request.init.body.get("file")).toBeInstanceOf(File);
expect(await request.init.body.get("file").text()).toBe("scene");
expect(request.init.body.get("expectedContentHash")).toBe("previous");
expect(request.init.body.get("mimeType")).toBe("application/vnd.excalidraw+json");
expect(request.init.body.get("filename")).toBe("drawing.excalidraw");
});
test("uploads a file-backed source without requesting more than one part at a time", async () => {
const ranges = [];
const bodies = [];
+12
View File
@@ -956,6 +956,18 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
body: JSON.stringify({ filename }),
}),
updateResourceContent: (resourceId: string, file: Blob, expectedContentHash: string) => {
const form = new FormData();
form.append("file", file);
form.append("expectedContentHash", expectedContentHash);
form.append("mimeType", file.type || "application/octet-stream");
if (file instanceof File) form.append("filename", file.name);
return request<ResourceResponse>(`/api/v1/resources/${encodeURIComponent(resourceId)}/blob`, {
method: "PUT",
body: form,
});
},
deleteResource: (resourceId: string) =>
request<{ ok: true }>(`/api/v1/resources/${encodeURIComponent(resourceId)}`, {
method: "DELETE",
+14
View File
@@ -31,3 +31,17 @@ await context.ui.openNote(noteId, { search: "New task" }); // requires ui:naviga
```
Bulk-indexing plugins can page through `context.notes.queryContent()`. The API also exposes workspace templates, full live-editor reads and range edits, template mutation events, and programmatic opening of a plugin's own registered panels.
Canvas-style plugins can read and conflict-safely replace resource bytes, open full-screen guarded panels with JSON state, and register constrained block embed renderers:
```ts
const [resource] = await context.resources.list(noteId);
const scene = await context.resources.read(resource.id);
await context.resources.update(resource.id, {
file: new File([scene], "drawing.excalidraw"),
expectedContentHash: resource.contentHash!,
});
context.editor.embeds.register({ type: "drawing", mount(container, embed) {} });
await context.editor.insertEmbed({ type: "drawing", resourceId: resource.id, title: "Drawing" });
```
+59 -4
View File
@@ -1,6 +1,6 @@
export declare const PLUGIN_API_VERSION: "1";
export declare const THEME_API_VERSION: "1";
export declare const PLUGIN_PERMISSIONS: readonly ["notes:read", "notes:write", "notes:delete", "metadata:read", "metadata:write", "resources:read", "resources:write", "templates:read", "templates:write", "network", "storage", "secrets", "editor:read", "editor:write", "ui:commands", "ui:navigation", "ui:notices", "ui:panels"];
export declare const PLUGIN_PERMISSIONS: readonly ["notes:read", "notes:write", "notes:delete", "metadata:read", "metadata:write", "resources:read", "resources:write", "templates:read", "templates:write", "network", "storage", "secrets", "editor:read", "editor:write", "ui:commands", "ui:navigation", "ui:notices", "ui:panels", "ui:embeds"];
export type PluginPermission = (typeof PLUGIN_PERMISSIONS)[number];
export type ExtensionPlatform = "web" | "desktop" | "android" | "ios";
export interface PluginManifest {
@@ -51,7 +51,7 @@ export interface PluginSettingsSchema {
fields: PluginSettingField[];
}
export type PluginSettingValue = string | number | boolean;
export declare const PLUGIN_API_ERROR_CODES: readonly ["NOTE_CONFLICT", "INVALID_MARKDOWN_EDIT"];
export declare const PLUGIN_API_ERROR_CODES: readonly ["NOTE_CONFLICT", "RESOURCE_CONFLICT", "INVALID_MARKDOWN_EDIT"];
export type PluginApiErrorCode = (typeof PLUGIN_API_ERROR_CODES)[number];
export interface PluginApiError extends Error {
code: PluginApiErrorCode;
@@ -183,6 +183,7 @@ export interface PluginResource {
mimeType: string | null;
filename: string | null;
byteSize: number;
contentHash: string | null;
width: number | null;
height: number | null;
createdAt: string;
@@ -227,6 +228,15 @@ export type PluginEventMap = {
"template.deleted": {
templateId: string;
};
"resource.created": {
resource: PluginResource;
};
"resource.updated": {
resource: PluginResource;
};
"resource.deleted": {
resourceId: string;
};
"workspace.sync-queue-changed": Record<string, never>;
"workspace.synced": {
bootstrapped: boolean;
@@ -255,10 +265,46 @@ export interface PluginOpenNoteOptions {
/** Opens in-note search and reveals the first exact text match. */
search?: string;
}
export type PluginJsonValue = null | boolean | number | string | PluginJsonValue[] | {
[key: string]: PluginJsonValue;
};
export type PluginPanelPresentation = "dialog" | "fullscreen";
export interface PluginPanelOpenOptions {
state?: PluginJsonValue;
}
export interface PluginPanelMountContext {
state: PluginJsonValue | null;
requestClose(): Promise<void>;
}
export type PluginPanelCloseDecision = boolean | {
title: string;
message: string;
confirmLabel?: string;
};
export interface PluginEmbedInput {
type: string;
resourceId: string;
previewResourceId?: string;
title?: string;
data?: PluginJsonValue;
}
export interface PluginEmbedInstance extends PluginEmbedInput {
id: string;
pluginId: string;
previewResourceId: string;
title: string;
data: PluginJsonValue;
}
export interface PluginEmbedRenderer {
type: string;
mount(container: HTMLElement, embed: PluginEmbedInstance): void | (() => void) | Promise<void | (() => void)>;
}
export interface PluginPanel {
id: string;
title: string;
mount(container: HTMLElement): void | (() => void) | Promise<void | (() => void)>;
presentation?: PluginPanelPresentation;
mount(container: HTMLElement, context: PluginPanelMountContext): void | (() => void) | Promise<void | (() => void)>;
beforeClose?(): PluginPanelCloseDecision | Promise<PluginPanelCloseDecision>;
}
export interface PluginContext {
pluginId: string;
@@ -338,12 +384,21 @@ export interface PluginContext {
getSelection(): Promise<PluginEditorSelection | null>;
getDocument(): Promise<PluginEditorDocument | null>;
editMarkdown(edits: PluginMarkdownEdit[]): Promise<PluginEditorDocument>;
insertEmbed(input: PluginEmbedInput): Promise<PluginEmbedInstance>;
embeds: {
register(renderer: PluginEmbedRenderer): () => void;
};
replaceSelection(contentMarkdown: string): Promise<void>;
insertAtCursor(contentMarkdown: string): Promise<void>;
};
resources: {
list(noteId?: string): Promise<PluginResource[]>;
read(resourceId: string): Promise<Blob>;
upload(noteId: string, file: File): Promise<PluginResource>;
update(resourceId: string, input: {
file: File;
expectedContentHash: string;
}): Promise<PluginResource>;
rename(resourceId: string, filename: string): Promise<PluginResource>;
delete(resourceId: string): Promise<void>;
};
@@ -360,7 +415,7 @@ export interface PluginContext {
openNote(noteId: string, options?: PluginOpenNoteOptions): Promise<void>;
panels: {
register(panel: PluginPanel): () => void;
open(panelId: string): Promise<void>;
open(panelId: string, options?: PluginPanelOpenOptions): Promise<void>;
};
};
}
+3 -2
View File
@@ -19,9 +19,10 @@ var PLUGIN_PERMISSIONS = [
"ui:commands",
"ui:navigation",
"ui:notices",
"ui:panels"
"ui:panels",
"ui:embeds"
];
var PLUGIN_API_ERROR_CODES = ["NOTE_CONFLICT", "INVALID_MARKDOWN_EDIT"];
var PLUGIN_API_ERROR_CODES = ["NOTE_CONFLICT", "RESOURCE_CONFLICT", "INVALID_MARKDOWN_EDIT"];
var THEME_TOKEN_NAMES = [
"color.background",
"color.surface",
+2 -2
View File
@@ -10,8 +10,8 @@ describe("extension manifests", () => {
version: "1.0.0",
apiVersion: "1",
entry: "./main.js",
permissions: ["notes:read", "notes:read", "templates:read", "templates:write", "ui:commands", "ui:navigation"],
})).toMatchObject({ permissions: ["notes:read", "templates:read", "templates:write", "ui:commands", "ui:navigation"] });
permissions: ["notes:read", "notes:read", "templates:read", "templates:write", "ui:commands", "ui:navigation", "ui:embeds"],
})).toMatchObject({ permissions: ["notes:read", "templates:read", "templates:write", "ui:commands", "ui:navigation", "ui:embeds"] });
});
test("rejects undeclared permissions", () => {
+55 -3
View File
@@ -20,6 +20,7 @@ export const PLUGIN_PERMISSIONS = [
"ui:navigation",
"ui:notices",
"ui:panels",
"ui:embeds",
] as const;
export type PluginPermission = (typeof PLUGIN_PERMISSIONS)[number];
@@ -60,7 +61,7 @@ export interface PluginSettingsSchema {
export type PluginSettingValue = string | number | boolean;
export const PLUGIN_API_ERROR_CODES = ["NOTE_CONFLICT", "INVALID_MARKDOWN_EDIT"] as const;
export const PLUGIN_API_ERROR_CODES = ["NOTE_CONFLICT", "RESOURCE_CONFLICT", "INVALID_MARKDOWN_EDIT"] as const;
export type PluginApiErrorCode = (typeof PLUGIN_API_ERROR_CODES)[number];
export interface PluginApiError extends Error {
code: PluginApiErrorCode;
@@ -226,6 +227,7 @@ export interface PluginResource {
mimeType: string | null;
filename: string | null;
byteSize: number;
contentHash: string | null;
width: number | null;
height: number | null;
createdAt: string;
@@ -257,6 +259,9 @@ export type PluginEventMap = {
"template.created": { template: PluginTemplate };
"template.updated": { template: PluginTemplate };
"template.deleted": { templateId: string };
"resource.created": { resource: PluginResource };
"resource.updated": { resource: PluginResource };
"resource.deleted": { resourceId: string };
"workspace.sync-queue-changed": Record<string, never>;
"workspace.synced": { bootstrapped: boolean; changed: number };
};
@@ -287,10 +292,51 @@ export interface PluginOpenNoteOptions {
search?: string;
}
export type PluginJsonValue = null | boolean | number | string | PluginJsonValue[] | { [key: string]: PluginJsonValue };
export type PluginPanelPresentation = "dialog" | "fullscreen";
export interface PluginPanelOpenOptions {
state?: PluginJsonValue;
}
export interface PluginPanelMountContext {
state: PluginJsonValue | null;
requestClose(): Promise<void>;
}
export type PluginPanelCloseDecision = boolean | {
title: string;
message: string;
confirmLabel?: string;
};
export interface PluginEmbedInput {
type: string;
resourceId: string;
previewResourceId?: string;
title?: string;
data?: PluginJsonValue;
}
export interface PluginEmbedInstance extends PluginEmbedInput {
id: string;
pluginId: string;
previewResourceId: string;
title: string;
data: PluginJsonValue;
}
export interface PluginEmbedRenderer {
type: string;
mount(container: HTMLElement, embed: PluginEmbedInstance): void | (() => void) | Promise<void | (() => void)>;
}
export interface PluginPanel {
id: string;
title: string;
mount(container: HTMLElement): void | (() => void) | Promise<void | (() => void)>;
presentation?: PluginPanelPresentation;
mount(container: HTMLElement, context: PluginPanelMountContext): void | (() => void) | Promise<void | (() => void)>;
beforeClose?(): PluginPanelCloseDecision | Promise<PluginPanelCloseDecision>;
}
export interface PluginContext {
@@ -349,12 +395,18 @@ export interface PluginContext {
getSelection(): Promise<PluginEditorSelection | null>;
getDocument(): Promise<PluginEditorDocument | null>;
editMarkdown(edits: PluginMarkdownEdit[]): Promise<PluginEditorDocument>;
insertEmbed(input: PluginEmbedInput): Promise<PluginEmbedInstance>;
embeds: {
register(renderer: PluginEmbedRenderer): () => void;
};
replaceSelection(contentMarkdown: string): Promise<void>;
insertAtCursor(contentMarkdown: string): Promise<void>;
};
resources: {
list(noteId?: string): Promise<PluginResource[]>;
read(resourceId: string): Promise<Blob>;
upload(noteId: string, file: File): Promise<PluginResource>;
update(resourceId: string, input: { file: File; expectedContentHash: string }): Promise<PluginResource>;
rename(resourceId: string, filename: string): Promise<PluginResource>;
delete(resourceId: string): Promise<void>;
};
@@ -371,7 +423,7 @@ export interface PluginContext {
openNote(noteId: string, options?: PluginOpenNoteOptions): Promise<void>;
panels: {
register(panel: PluginPanel): () => void;
open(panelId: string): Promise<void>;
open(panelId: string, options?: PluginPanelOpenOptions): Promise<void>;
};
};
}
+22
View File
@@ -8,6 +8,8 @@ import {
MERGE_DIVIDER_NODE_TYPE,
FILE_ATTACHMENT_NODE_TYPE,
PDF_ATTACHMENT_NODE_TYPE,
PLUGIN_EMBED_NODE_TYPE,
pluginEmbedToMarkdown,
mergeMemoDocs,
resolvePdfDisplayMode,
resolveMemoContentDoc,
@@ -453,3 +455,23 @@ describe("merge divider", () => {
expect(resolved.content.some((node) => node.type === MERGE_DIVIDER_NODE_TYPE)).toBe(true);
});
});
describe("plugin embed Markdown compatibility", () => {
test("round-trips a generic plugin embed without requiring the plugin renderer", () => {
const attributes = {
id: "embed_1",
pluginId: "org.edgeever.excalidraw",
type: "drawing",
resourceId: "res_scene",
previewResourceId: "res_preview",
title: "Architecture",
dataJson: JSON.stringify({ mode: "view" }),
};
const markdown = pluginEmbedToMarkdown(attributes);
const parsed = markdownToDoc(markdown);
expect(parsed.content[0]).toMatchObject({ type: PLUGIN_EMBED_NODE_TYPE, attrs: attributes });
expect(docToMarkdown(parsed)).toBe(markdown);
expect(resolveMemoContentDoc(parsed, "fallback").content[0].type).toBe(PLUGIN_EMBED_NODE_TYPE);
});
});
+6
View File
@@ -12,6 +12,10 @@ import {
INLINE_MATH_NODE_TYPE,
} from "./mathematics-markdown";
import { projectNativeUnknownContentForMarkdown } from "./mobile-content-compatibility";
import { PluginEmbed, PLUGIN_EMBED_NODE_TYPE } from "./plugin-embed";
export { PluginEmbed, PLUGIN_EMBED_NODE_TYPE, pluginEmbedToMarkdown, normalizePluginEmbedAttributes } from "./plugin-embed";
export type { PluginEmbedAttributes } from "./plugin-embed";
export {
BLOCK_MATH_NODE_TYPE,
@@ -98,6 +102,7 @@ const markdownManager = new MarkdownManager({
PdfAttachment,
FileAttachment,
MergeDivider,
PluginEmbed,
...createEdgeEverMarkdownMathematics(),
Markdown.configure({
markedOptions: { gfm: true },
@@ -140,6 +145,7 @@ export const resolveMemoContentDoc = (
docContainsNodeType(currentDoc, "taskList") ||
docContainsNodeType(currentDoc, "edgeeverThemeBlock") ||
docContainsNodeType(currentDoc, MERGE_DIVIDER_NODE_TYPE) ||
docContainsNodeType(currentDoc, PLUGIN_EMBED_NODE_TYPE) ||
docContainsNodeType(currentDoc, BLOCK_MATH_NODE_TYPE) ||
docContainsNodeType(currentDoc, INLINE_MATH_NODE_TYPE)
|| docContainsNodeType(currentDoc, PDF_ATTACHMENT_NODE_TYPE)
@@ -2,6 +2,7 @@ import { Mark, Node } from "@tiptap/core";
import type { TiptapDoc, TiptapMark, TiptapNode, TiptapTextNode } from "./content";
import { FILE_ATTACHMENT_NODE_TYPE } from "./file-attachment";
import { PDF_ATTACHMENT_NODE_TYPE } from "./pdf-attachment";
import { PLUGIN_EMBED_NODE_TYPE } from "./plugin-embed";
export const UNSUPPORTED_BLOCK_NODE_TYPE = "edgeeverUnsupportedBlock" as const;
export const UNSUPPORTED_INLINE_NODE_TYPE = "edgeeverUnsupportedInline" as const;
@@ -52,6 +53,7 @@ const MARKDOWN_EDITOR_NODE_TYPES = new Set<string>([
...NATIVE_EDITOR_NODE_TYPES,
FILE_ATTACHMENT_NODE_TYPE,
PDF_ATTACHMENT_NODE_TYPE,
PLUGIN_EMBED_NODE_TYPE,
]);
const INLINE_PARENT_TYPES = new Set<string>(["paragraph", "heading"]);
+115
View File
@@ -0,0 +1,115 @@
import { mergeAttributes, Node } from "@tiptap/core";
export const PLUGIN_EMBED_NODE_TYPE = "edgeeverPluginEmbed" as const;
export const PLUGIN_EMBED_MARKDOWN_LANGUAGE = "edgeever-plugin-embed" as const;
export type PluginEmbedAttributes = {
id: string;
pluginId: string;
type: string;
resourceId: string;
previewResourceId: string;
title: string;
dataJson: string;
};
const normalizeString = (value: unknown, limit = 500) =>
typeof value === "string" ? value.trim().slice(0, limit) : "";
export const normalizePluginEmbedAttributes = (value: unknown): PluginEmbedAttributes | null => {
if (!value || typeof value !== "object") return null;
const input = value as Record<string, unknown>;
const id = normalizeString(input.id, 160);
const pluginId = normalizeString(input.pluginId, 160);
const type = normalizeString(input.type, 120);
const resourceId = normalizeString(input.resourceId, 200);
if (!id || !pluginId || !type || !resourceId) return null;
const dataJson = typeof input.dataJson === "string" ? input.dataJson : "null";
try {
JSON.parse(dataJson);
} catch {
return null;
}
return {
id,
pluginId,
type,
resourceId,
previewResourceId: normalizeString(input.previewResourceId, 200),
title: normalizeString(input.title, 500),
dataJson,
};
};
export const pluginEmbedToMarkdown = (attributes: PluginEmbedAttributes) =>
`\`\`\`${PLUGIN_EMBED_MARKDOWN_LANGUAGE}\n${JSON.stringify(attributes)}\n\`\`\``;
const FENCE_PATTERN = /^```edgeever-plugin-embed[ \t]*\n([^\n]+)\n```(?:\n|$)/;
export const PluginEmbed = Node.create({
name: PLUGIN_EMBED_NODE_TYPE,
group: "block",
atom: true,
selectable: true,
draggable: true,
addAttributes() {
return {
id: { default: "" },
pluginId: { default: "" },
type: { default: "" },
resourceId: { default: "" },
previewResourceId: { default: "" },
title: { default: "" },
dataJson: { default: "null" },
};
},
parseHTML() {
return [{ tag: 'figure[data-type="edgeever-plugin-embed"]' }];
},
renderHTML({ HTMLAttributes }) {
const title = typeof HTMLAttributes.title === "string" && HTMLAttributes.title
? HTMLAttributes.title
: "Plugin embed";
return [
"figure",
mergeAttributes(HTMLAttributes, {
"data-type": "edgeever-plugin-embed",
class: "edgeever-plugin-embed",
contenteditable: "false",
}),
["figcaption", {}, title],
];
},
parseMarkdown: (token) => ({
type: PLUGIN_EMBED_NODE_TYPE,
attrs: token.attrs,
}),
renderMarkdown: (node) => {
const attributes = normalizePluginEmbedAttributes(node.attrs);
return attributes ? pluginEmbedToMarkdown(attributes) : "";
},
markdownTokenizer: {
name: PLUGIN_EMBED_NODE_TYPE,
level: "block",
start(source: string) {
return source.indexOf(`\`\`\`${PLUGIN_EMBED_MARKDOWN_LANGUAGE}`);
},
tokenize(source: string) {
const match = FENCE_PATTERN.exec(source);
if (!match) return undefined;
try {
const attrs = normalizePluginEmbedAttributes(JSON.parse(match[1]));
if (!attrs) return undefined;
return { type: PLUGIN_EMBED_NODE_TYPE, raw: match[0], attrs };
} catch {
return undefined;
}
},
},
});