feat(plugins): add resource panels and embeds
This commit is contained in:
1 parent
dcbb0d3383
commit
cee98bc90b
31 files changed
+1172
-62
No files matched your search
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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()}`);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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] : [];
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()` 同样会经过这项保护。
|
||||
|
||||
## 桌面端插件入口
|
||||
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" });
|
||||
```
|
||||
Vendored
+59
-4
@@ -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>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
Vendored
+3
-2
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user