diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index abbbaf1b..d63df012 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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) => { diff --git a/apps/api/src/resource-routes.test.mjs b/apps/api/src/resource-routes.test.mjs index a4649fe5..ba00115c 100644 --- a/apps/api/src/resource-routes.test.mjs +++ b/apps/api/src/resource-routes.test.mjs @@ -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( diff --git a/apps/api/src/resource-routes.ts b/apps/api/src/resource-routes.ts index 75a23ea8..1a27f067 100644 --- a/apps/api/src/resource-routes.ts +++ b/apps/api/src/resource-routes.ts @@ -66,6 +66,14 @@ type ResourceRouteDependencies = { actor: AuditActor, ) => Promise; abortResourceUpload: (context: AppContext, uploadId: string) => Promise; + replaceResourceContent: (context: AppContext, input: { + resourceId: string; + expectedContentHash: string; + filename: string; + mimeType: string; + bytes: Uint8Array; + actor: AuditActor; + }) => Promise; }; 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), diff --git a/apps/api/src/resource-service.test.mjs b/apps/api/src/resource-service.test.mjs index fd0b31c9..474b451a 100644 --- a/apps/api/src/resource-service.test.mjs +++ b/apps/api/src/resource-service.test.mjs @@ -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); + }); }); diff --git a/apps/api/src/resource-service.ts b/apps/api/src/resource-service.ts index abfe7a27..b953e76f 100644 --- a/apps/api/src/resource-service.ts +++ b/apps/api/src/resource-service.ts @@ -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 => { + 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()}`); diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index 06b0d35d..67018ec4 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -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); diff --git a/apps/web/src/components/MobileStandaloneTiptapEditor.tsx b/apps/web/src/components/MobileStandaloneTiptapEditor.tsx index 11162eac..f60f955b 100644 --- a/apps/web/src/components/MobileStandaloneTiptapEditor.tsx +++ b/apps/web/src/components/MobileStandaloneTiptapEditor.tsx @@ -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({ diff --git a/apps/web/src/components/PublicSharePage.tsx b/apps/web/src/components/PublicSharePage.tsx index ce3ee803..06b99c34 100644 --- a/apps/web/src/components/PublicSharePage.tsx +++ b/apps/web/src/components/PublicSharePage.tsx @@ -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(), diff --git a/apps/web/src/components/WorkspaceApp.tsx b/apps/web/src/components/WorkspaceApp.tsx index f3605eb1..62cf6edb 100644 --- a/apps/web/src/components/WorkspaceApp.tsx +++ b/apps/web/src/components/WorkspaceApp.tsx @@ -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(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()} /> )} - setRequestedPluginPanel(null)} /> + setRequestedPluginPanel(null)} + /> {visibleActivePane !== "editor" && !memoSelectionModeActive && ( ({ + 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(null); + const [mountError, setMountError] = useState(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 ( + +
+ {registered ?
: ( +
+ {fallbackTitle} +
+ )} + {mountError ?
{mountError}
: null} +
+ + ); +}; + +export const createPluginEmbedExtension = (host: EdgeEverPluginHost) => BasePluginEmbed.extend({ + addNodeView() { + return ReactNodeViewRenderer((props) => ); + }, +}); diff --git a/apps/web/src/components/plugins/PluginPanelDialog.tsx b/apps/web/src/components/plugins/PluginPanelDialog.tsx index c26e7af0..36134842 100644 --- a/apps/web/src/components/plugins/PluginPanelDialog.tsx +++ b/apps/web/src/components/plugins/PluginPanelDialog.tsx @@ -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(null); const [mountError, setMountError] = useState(null); + const [closeConfirmation, setCloseConfirmation] = useState | 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 ( - { if (!open) onClose(); }}> - - - {panel?.title ?? t("plugins.panel")} - {t("plugins.panelDescription")} - - {mountError ?
{mountError}
: null} -
- -
+ <> + { if (!open) void requestClose(); }}> + + + {panel?.title ?? t("plugins.panel")} + {t("plugins.panelDescription")} + + {mountError ?
{mountError}
: null} +
+ +
+ {closeConfirmation ? ( + setCloseConfirmation(null)} + onConfirm={() => { + setCloseConfirmation(null); + onClose(); + }} + /> + ) : null} + ); }; diff --git a/apps/web/src/lib/desktop-repository.ts b/apps/web/src/lib/desktop-repository.ts index 95be554b..6ab09fa1 100644 --- a/apps/web/src/lib/desktop-repository.ts +++ b/apps/web/src/lib/desktop-repository.ts @@ -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(); diff --git a/apps/web/src/lib/plugins/plugin-host-capabilities.fixture.mjs b/apps/web/src/lib/plugins/plugin-host-capabilities.fixture.mjs index a841e9e6..683e0164 100644 --- a/apps/web/src/lib/plugins/plugin-host-capabilities.fixture.mjs +++ b/apps/web/src/lib/plugins/plugin-host-capabilities.fixture.mjs @@ -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(); }; }, }; diff --git a/apps/web/src/lib/plugins/plugin-host-events.fixture.mjs b/apps/web/src/lib/plugins/plugin-host-events.fixture.mjs index 1d4d5a54..045f49c3 100644 --- a/apps/web/src/lib/plugins/plugin-host-events.fixture.mjs +++ b/apps/web/src/lib/plugins/plugin-host-events.fixture.mjs @@ -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(); }; }, }; diff --git a/apps/web/src/lib/plugins/plugin-host.test.mjs b/apps/web/src/lib/plugins/plugin-host.test.mjs index 35145675..4688e450 100644 --- a/apps/web/src/lib/plugins/plugin-host.test.mjs +++ b/apps/web/src/lib/plugins/plugin-host.test.mjs @@ -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(); }); }); diff --git a/apps/web/src/lib/plugins/plugin-host.ts b/apps/web/src/lib/plugins/plugin-host.ts index d1d69995..1a070caf 100644 --- a/apps/web/src/lib/plugins/plugin-host.ts +++ b/apps/web/src/lib/plugins/plugin-host.ts @@ -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; + openPanel(pluginId: string, panelId: string, options?: PluginPanelOpenOptions): void | Promise; } export interface PluginHostSnapshot { extensions: InstalledExtension[]; commands: RegisteredPluginCommand[]; panels: RegisteredPluginPanel[]; + embeds: RegisteredPluginEmbed[]; recentActions: RegisteredPluginAction[]; activeThemeId: string | null; } @@ -213,6 +225,7 @@ const toPluginResource = (resource: Awaited> "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; +}; + export class EdgeEverPluginHost { private readonly repository: EdgeEverRepository; private readonly scope: string; @@ -382,10 +412,12 @@ export class EdgeEverPluginHost { private readonly commands = new Map(); private readonly panels = new Map(); private readonly mountedPanels = new Map void>>(); + private readonly embeds = new Map(); + private readonly mountedEmbeds = new Map void>>(); private readonly eventListeners = new Map 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, + ) { 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 { + 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] : []; diff --git a/apps/web/src/lib/repository-events.ts b/apps/web/src/lib/repository-events.ts index 686f4d52..a422c489 100644 --- a/apps/web/src/lib/repository-events.ts +++ b/apps/web/src/lib/repository-events.ts @@ -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 }); diff --git a/apps/web/src/lib/repository.ts b/apps/web/src/lib/repository.ts index 1b3d5cab..08420084 100644 --- a/apps/web/src/lib/repository.ts +++ b/apps/web/src/lib/repository.ts @@ -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; + 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()) { diff --git a/docs/plugin-development.md b/docs/plugin-development.md index 5e1c7830..92e86e52 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -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 diff --git a/docs/plugin-development.zh-CN.md b/docs/plugin-development.zh-CN.md index 23dd9db5..adc22462 100644 --- a/docs/plugin-development.zh-CN.md +++ b/docs/plugin-development.zh-CN.md @@ -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()` 同样会经过这项保护。 ## 桌面端插件入口 diff --git a/packages/client/src/index.test.mjs b/packages/client/src/index.test.mjs index 953a3bf9..c16b5289 100644 --- a/packages/client/src/index.test.mjs +++ b/packages/client/src/index.test.mjs @@ -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 = []; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 4c84782b..a1f5ccb2 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -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(`/api/v1/resources/${encodeURIComponent(resourceId)}/blob`, { + method: "PUT", + body: form, + }); + }, + deleteResource: (resourceId: string) => request<{ ok: true }>(`/api/v1/resources/${encodeURIComponent(resourceId)}`, { method: "DELETE", diff --git a/packages/plugin-api/README.md b/packages/plugin-api/README.md index 78856fcd..d2adf3f3 100644 --- a/packages/plugin-api/README.md +++ b/packages/plugin-api/README.md @@ -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" }); +``` diff --git a/packages/plugin-api/dist/index.d.ts b/packages/plugin-api/dist/index.d.ts index a46bec48..620b4e21 100644 --- a/packages/plugin-api/dist/index.d.ts +++ b/packages/plugin-api/dist/index.d.ts @@ -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; "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; +} +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)>; +} export interface PluginPanel { id: string; title: string; - mount(container: HTMLElement): void | (() => void) | Promise void)>; + presentation?: PluginPanelPresentation; + mount(container: HTMLElement, context: PluginPanelMountContext): void | (() => void) | Promise void)>; + beforeClose?(): PluginPanelCloseDecision | Promise; } export interface PluginContext { pluginId: string; @@ -338,12 +384,21 @@ export interface PluginContext { getSelection(): Promise; getDocument(): Promise; editMarkdown(edits: PluginMarkdownEdit[]): Promise; + insertEmbed(input: PluginEmbedInput): Promise; + embeds: { + register(renderer: PluginEmbedRenderer): () => void; + }; replaceSelection(contentMarkdown: string): Promise; insertAtCursor(contentMarkdown: string): Promise; }; resources: { list(noteId?: string): Promise; + read(resourceId: string): Promise; upload(noteId: string, file: File): Promise; + update(resourceId: string, input: { + file: File; + expectedContentHash: string; + }): Promise; rename(resourceId: string, filename: string): Promise; delete(resourceId: string): Promise; }; @@ -360,7 +415,7 @@ export interface PluginContext { openNote(noteId: string, options?: PluginOpenNoteOptions): Promise; panels: { register(panel: PluginPanel): () => void; - open(panelId: string): Promise; + open(panelId: string, options?: PluginPanelOpenOptions): Promise; }; }; } diff --git a/packages/plugin-api/dist/index.js b/packages/plugin-api/dist/index.js index 8ae71e13..a252dddf 100644 --- a/packages/plugin-api/dist/index.js +++ b/packages/plugin-api/dist/index.js @@ -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", diff --git a/packages/plugin-api/src/index.test.mjs b/packages/plugin-api/src/index.test.mjs index ca3e7b90..8b66c163 100644 --- a/packages/plugin-api/src/index.test.mjs +++ b/packages/plugin-api/src/index.test.mjs @@ -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", () => { diff --git a/packages/plugin-api/src/index.ts b/packages/plugin-api/src/index.ts index 0ab73e5d..ef18d703 100644 --- a/packages/plugin-api/src/index.ts +++ b/packages/plugin-api/src/index.ts @@ -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; "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; +} + +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)>; +} + export interface PluginPanel { id: string; title: string; - mount(container: HTMLElement): void | (() => void) | Promise void)>; + presentation?: PluginPanelPresentation; + mount(container: HTMLElement, context: PluginPanelMountContext): void | (() => void) | Promise void)>; + beforeClose?(): PluginPanelCloseDecision | Promise; } export interface PluginContext { @@ -349,12 +395,18 @@ export interface PluginContext { getSelection(): Promise; getDocument(): Promise; editMarkdown(edits: PluginMarkdownEdit[]): Promise; + insertEmbed(input: PluginEmbedInput): Promise; + embeds: { + register(renderer: PluginEmbedRenderer): () => void; + }; replaceSelection(contentMarkdown: string): Promise; insertAtCursor(contentMarkdown: string): Promise; }; resources: { list(noteId?: string): Promise; + read(resourceId: string): Promise; upload(noteId: string, file: File): Promise; + update(resourceId: string, input: { file: File; expectedContentHash: string }): Promise; rename(resourceId: string, filename: string): Promise; delete(resourceId: string): Promise; }; @@ -371,7 +423,7 @@ export interface PluginContext { openNote(noteId: string, options?: PluginOpenNoteOptions): Promise; panels: { register(panel: PluginPanel): () => void; - open(panelId: string): Promise; + open(panelId: string, options?: PluginPanelOpenOptions): Promise; }; }; } diff --git a/packages/shared/src/content.test.mjs b/packages/shared/src/content.test.mjs index 572080e4..65febb40 100644 --- a/packages/shared/src/content.test.mjs +++ b/packages/shared/src/content.test.mjs @@ -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); + }); +}); diff --git a/packages/shared/src/content.ts b/packages/shared/src/content.ts index 42351f7f..6a9f860c 100644 --- a/packages/shared/src/content.ts +++ b/packages/shared/src/content.ts @@ -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) diff --git a/packages/shared/src/mobile-content-compatibility.ts b/packages/shared/src/mobile-content-compatibility.ts index 3439d124..0c027e0e 100644 --- a/packages/shared/src/mobile-content-compatibility.ts +++ b/packages/shared/src/mobile-content-compatibility.ts @@ -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([ ...NATIVE_EDITOR_NODE_TYPES, FILE_ATTACHMENT_NODE_TYPE, PDF_ATTACHMENT_NODE_TYPE, + PLUGIN_EMBED_NODE_TYPE, ]); const INLINE_PARENT_TYPES = new Set(["paragraph", "heading"]); diff --git a/packages/shared/src/plugin-embed.ts b/packages/shared/src/plugin-embed.ts new file mode 100644 index 00000000..b33a0eda --- /dev/null +++ b/packages/shared/src/plugin-embed.ts @@ -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; + 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; + } + }, + }, +});