diff --git a/examples/playground/package.json b/examples/playground/package.json index 11dccab4..ded3c12c 100644 --- a/examples/playground/package.json +++ b/examples/playground/package.json @@ -5,15 +5,16 @@ "type": "module", "scripts": { "dev": "vite", - "typecheck": "tsc -p tsconfig.json --noEmit", - "build": "vite build", + "typecheck": "pnpm -C ../../packages/treecrdt-content run build && tsc -p tsconfig.json --noEmit", + "build": "pnpm -C ../../packages/treecrdt-content run build && vite build", "preview": "vite preview", - "test:e2e": "pnpm -C ../../packages/discovery run build && pnpm -C ../../packages/sync-protocol/protocol run build && pnpm -C ../../packages/sync-protocol/server/core run build && pnpm -C ../../packages/treecrdt-auth run build && pnpm -C ../../packages/sync-protocol/material/sqlite run build && pnpm -C ../../packages/treecrdt-wa-sqlite-vendor run build && pnpm -C ../../packages/treecrdt-wa-sqlite run build:ts && playwright test", + "test:e2e": "pnpm -C ../../packages/treecrdt-content run build && pnpm -C ../../packages/discovery run build && pnpm -C ../../packages/sync-protocol/protocol run build && pnpm -C ../../packages/sync-protocol/server/core run build && pnpm -C ../../packages/treecrdt-auth run build && pnpm -C ../../packages/sync-protocol/material/sqlite run build && pnpm -C ../../packages/treecrdt-wa-sqlite-vendor run build && pnpm -C ../../packages/treecrdt-wa-sqlite run build:ts && playwright test", "deploy": "pnpm run build && gh-pages -d dist" }, "dependencies": { "@tanstack/react-virtual": "^3.13.13", "@treecrdt/auth": "workspace:*", + "@treecrdt/content": "workspace:*", "@treecrdt/crypto": "workspace:*", "@treecrdt/discovery": "workspace:*", "@treecrdt/interface": "workspace:*", diff --git a/examples/playground/src/App.tsx b/examples/playground/src/App.tsx index f680266b..5cf41078 100644 --- a/examples/playground/src/App.tsx +++ b/examples/playground/src/App.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; +import { encodeImageFileContent, encodeTextContent } from "@treecrdt/content"; import { type Operation } from "@treecrdt/interface"; import type { BoundTreecrdtEngineLocal, MaterializationEvent } from "@treecrdt/interface/engine"; import { bytesToHex } from "@treecrdt/interface/ids"; @@ -9,7 +10,6 @@ import { hexToBytes16 } from "./sync-v0"; import { useVirtualizer } from "./virtualizer"; import { MAX_COMPOSER_NODE_COUNT, ROOT_ID } from "./playground/constants"; -import { ComposerPanel } from "./playground/components/ComposerPanel"; import { OpsPanel } from "./playground/components/OpsPanel"; import { PlaygroundHeader } from "./playground/components/PlaygroundHeader"; import { ShareSubtreeDialog } from "./playground/components/ShareSubtreeDialog"; @@ -32,7 +32,7 @@ import { persistOpfsKey, persistStorage, } from "./playground/persist"; -import { getPlaygroundProfileId, prefixPlaygroundStorageKey } from "./playground/storage"; +import { getPlaygroundProfileId } from "./playground/storage"; import { applyChildrenLoaded, flattenForSelectState } from "./playground/treeState"; import type { BulkAddProgress, @@ -93,28 +93,16 @@ export default function App() { const [sessionKey, setSessionKey] = useState(() => initialStorage() === "opfs" ? ensureOpfsKey() : makeSessionKey() ); - const [parentChoice, setParentChoice] = useState(ROOT_ID); const [collapse, setCollapse] = useState(() => ({ defaultCollapsed: true, overrides: new Set([ROOT_ID]), })); const [busy, setBusy] = useState(false); const [bulkAddProgress, setBulkAddProgress] = useState(null); - const [nodeCount, setNodeCount] = useState(1); - const [fanout, setFanout] = useState(10); - const [newNodeValue, setNewNodeValue] = useState(""); const [showOpsPanel, setShowOpsPanel] = useState(false); const [showPeersPanel, setShowPeersPanel] = useState(false); const [syncServerUrl, setSyncServerUrl] = useState(() => initialSyncServerUrl()); const [syncTransportMode, setSyncTransportMode] = useState(() => initialSyncTransportMode()); - const [composerOpen, setComposerOpen] = useState(() => { - if (typeof window === "undefined") return true; - const key = prefixPlaygroundStorageKey("treecrdt-playground-ui-composer-open"); - const stored = window.localStorage.getItem(key); - if (stored === "0") return false; - if (stored === "1") return true; - return false; - }); const [online, setOnline] = useState(true); const joinMode = @@ -123,12 +111,6 @@ export default function App() { typeof window !== "undefined" && new URLSearchParams(window.location.search).get("autosync") === "1"; const profileId = useMemo(() => getPlaygroundProfileId(), []); - useEffect(() => { - if (typeof window === "undefined") return; - const key = prefixPlaygroundStorageKey("treecrdt-playground-ui-composer-open"); - window.localStorage.setItem(key, composerOpen ? "1" : "0"); - }, [composerOpen]); - useEffect(() => { if (typeof window === "undefined") return; const next = syncServerUrl.trim(); @@ -258,7 +240,6 @@ export default function App() { refreshDocPayloadKey, }); - const textEncoder = useMemo(() => new TextEncoder(), []); const { ops, recordOps, resetOps } = usePlaygroundOpsLog({ client, status, @@ -527,7 +508,6 @@ export default function App() { overrides.add(viewRootId); return { ...prev, overrides }; }); - setParentChoice((prev) => (prev === ROOT_ID ? viewRootId : prev)); void ensureChildrenLoaded(viewRootId); }, [ensureChildrenLoaded, viewRootId]); @@ -570,8 +550,8 @@ export default function App() { while (stack.length > 0) { const entry = stack.pop(); if (!entry) break; - const { label, value } = payloadDisplayForNode(entry.id); - acc.push({ node: { id: entry.id, label, value }, depth: entry.depth }); + const payload = payloadDisplayForNode(entry.id); + acc.push({ node: { id: entry.id, label: payload.label, value: payload.value, payload }, depth: entry.depth }); if (isCollapsed(entry.id)) continue; const kids = childrenByParent[entry.id] ?? []; for (let i = kids.length - 1; i >= 0; i--) { @@ -699,8 +679,6 @@ export default function App() { lamportRef.current = 0; setHeadLamport(0); setTotalNodes(null); - setParentChoice(ROOT_ID); - setNewNodeValue(""); setBulkAddProgress(null); setError(null); const closingClient = clientRef.current; @@ -727,11 +705,16 @@ export default function App() { } }; - const handleAddNodes = async (parentId: string, count: number, opts: { fanout?: number } = {}) => { + const handleAddNodes = async ( + parentId: string, + count: number, + opts: { fanout?: number; imageFile?: File | null; value?: string } = {} + ) => { const localWriter = getLocalWriter(); if (!localWriter) return; if (authEnabled && !canWriteStructure) return; - const normalizedCount = Math.max(0, Math.min(MAX_COMPOSER_NODE_COUNT, Math.floor(count))); + if (opts.imageFile && !canWritePayload) return; + const normalizedCount = opts.imageFile ? 1 : Math.max(0, Math.min(MAX_COMPOSER_NODE_COUNT, Math.floor(count))); if (normalizedCount <= 0) return; setBusy(true); const startedAtMs = Date.now(); @@ -740,15 +723,16 @@ export default function App() { const ops: Operation[] = []; let opsRecorded = false; try { - const fanoutLimit = Math.max(0, Math.floor(opts.fanout ?? fanout)); - const valueBase = canWritePayload ? newNodeValue.trim() : ""; + const fanoutLimit = opts.imageFile ? 0 : Math.max(0, Math.floor(opts.fanout ?? 0)); + const imagePayload = opts.imageFile ? await encodeImageFileContent(opts.imageFile) : null; + const valueBase = canWritePayload && !opts.imageFile ? (opts.value ?? "").trim() : ""; const shouldSetValue = canWritePayload && valueBase.length > 0; if (fanoutLimit <= 0) { for (let i = 0; i < normalizedCount; i++) { const nodeId = makeNodeId(); const value = normalizedCount > 1 ? `${valueBase} ${i + 1}` : valueBase; - const payload = shouldSetValue ? textEncoder.encode(value) : null; + const payload = imagePayload ?? (shouldSetValue ? encodeTextContent(value) : null); const encryptedPayload = await encryptPayloadBytes(payload); ops.push(await localWriter.insert(parentId, nodeId, { type: "last" }, encryptedPayload)); const completed = i + 1; @@ -795,7 +779,7 @@ export default function App() { const nodeId = makeNodeId(); const value = normalizedCount > 1 ? `${valueBase} ${i + 1}` : valueBase; - const payload = shouldSetValue ? textEncoder.encode(value) : null; + const payload = shouldSetValue ? encodeTextContent(value) : null; const encryptedPayload = await encryptPayloadBytes(payload); ops.push(await localWriter.insert(targetParent, nodeId, { type: "last" }, encryptedPayload)); @@ -820,21 +804,21 @@ export default function App() { } catch (err) { if (!opsRecorded && ops.length > 0) handleCommittedLocalOps(ops); console.error("Failed to add nodes", err); - setError("Failed to add nodes (see console)"); + setError(err instanceof Error ? err.message : "Failed to add nodes (see console)"); } finally { setBulkAddProgress(null); setBusy(false); } }; - const handleInsert = async (parentId: string) => { + const handleInsert = async (parentId: string, value = "") => { const localWriter = getLocalWriter(); if (!localWriter) return; if (authEnabled && !canWriteStructure) return; setBusy(true); try { - const valueBase = canWritePayload ? newNodeValue.trim() : ""; - const payload = valueBase.length > 0 ? textEncoder.encode(valueBase) : null; + const valueBase = canWritePayload ? value.trim() : ""; + const payload = valueBase.length > 0 ? encodeTextContent(valueBase) : null; const encryptedPayload = await encryptPayloadBytes(payload); const nodeId = makeNodeId(); const op = await localWriter.insert(parentId, nodeId, { type: "last" }, encryptedPayload); @@ -851,6 +835,14 @@ export default function App() { } }; + const handleAddImageNode = async (parentId: string, file: File) => { + await handleAddNodes(parentId, 1, { imageFile: file }); + }; + + const handleAddBulkNodes = async (parentId: string, count: number, fanout: number, value: string) => { + await handleAddNodes(parentId, count, { fanout, value }); + }; + const handleSetValue = (nodeId: string, value: string): Promise => { const run = payloadWriteQueueRef.current .catch(() => undefined) @@ -859,7 +851,7 @@ export default function App() { const localWriter = getLocalWriter(); if (!localWriter) return; try { - const payload = value.trim().length === 0 ? null : textEncoder.encode(value); + const payload = value.trim().length === 0 ? null : encodeTextContent(value); const encryptedPayload = await encryptPayloadBytes(payload); const op = await localWriter.payload(nodeId, encryptedPayload); handleCommittedLocalOps([op]); @@ -872,6 +864,46 @@ export default function App() { return run; }; + const handleSetImagePayload = (nodeId: string, file: File): Promise => { + const run = payloadWriteQueueRef.current + .catch(() => undefined) + .then(async () => { + if (nodeId === ROOT_ID || !canWritePayload) return; + const localWriter = getLocalWriter(); + if (!localWriter) return; + try { + const payload = await encodeImageFileContent(file); + const encryptedPayload = await encryptPayloadBytes(payload); + const op = await localWriter.payload(nodeId, encryptedPayload); + handleCommittedLocalOps([op]); + } catch (err) { + console.error("Failed to write image payload", err); + setError(err instanceof Error ? err.message : "Failed to write image payload (see console)"); + } + }); + payloadWriteQueueRef.current = run.catch(() => undefined); + return run; + }; + + const handleClearPayload = (nodeId: string): Promise => { + const run = payloadWriteQueueRef.current + .catch(() => undefined) + .then(async () => { + if (nodeId === ROOT_ID || !canWritePayload) return; + const localWriter = getLocalWriter(); + if (!localWriter) return; + try { + const op = await localWriter.payload(nodeId, null); + handleCommittedLocalOps([op]); + } catch (err) { + console.error("Failed to clear payload", err); + setError("Failed to clear payload (see console)"); + } + }); + payloadWriteQueueRef.current = run.catch(() => undefined); + return run; + }; + const handleDelete = async (nodeId: string) => { const localWriter = getLocalWriter(); if (nodeId === ROOT_ID || !localWriter) return; @@ -1023,27 +1055,6 @@ export default function App() {
- - void (authCanSyncAll ? handleSync({ all: {} }) : handleScopedSync())} + bulkAddProgress={bulkAddProgress} liveAllEnabled={liveAllEnabled} setLiveAllEnabled={setLiveAllEnabled} showPeersPanel={showPeersPanel} @@ -1128,11 +1140,12 @@ export default function App() { toggleCollapse={toggleCollapse} openShareForNode={openShareForNode} grantSubtreeToReplicaPubkey={grantSubtreeToReplicaPubkey} + onAddTextNode={handleInsert} + onAddImageNode={handleAddImageNode} + onAddBulkNodes={handleAddBulkNodes} onSetValue={handleSetValue} - onAddChild={(id) => { - setParentChoice(id); - void handleInsert(id); - }} + onSetImagePayload={handleSetImagePayload} + onClearPayload={handleClearPayload} onDelete={handleDelete} onMove={handleMove} onMoveToRoot={handleMoveToRoot} @@ -1150,6 +1163,7 @@ export default function App() { canWritePayload={canWritePayload} canWriteStructure={canWriteStructure} canDelete={canDelete} + maxNodeCount={MAX_COMPOSER_NODE_COUNT} liveChildrenParents={liveChildrenParents} meta={index} childrenByParent={childrenByParent} @@ -1172,9 +1186,7 @@ export default function App() { { - void (authCanSyncAll ? handleSync({ all: {} }) : handleScopedSync()); - }} + onSync={() => void (authCanSyncAll ? handleSync({ all: {} }) : handleScopedSync())} canSync={status === "ready" && !busy && !syncBusy && peers.length > 0 && online} onDetails={() => setShowAuthPanel(true)} /> diff --git a/examples/playground/src/playground/components/AddNodeMenu.tsx b/examples/playground/src/playground/components/AddNodeMenu.tsx new file mode 100644 index 00000000..e44824d1 --- /dev/null +++ b/examples/playground/src/playground/components/AddNodeMenu.tsx @@ -0,0 +1,447 @@ +import React from "react"; +import { formatContentBytes, SUPPORTED_IMAGE_MIME_TYPES, validateImageContentFile } from "@treecrdt/content"; +import { createPortal } from "react-dom"; +import { MdAdd, MdImage, MdShuffle, MdTextFields, MdAccountTree } from "react-icons/md"; + +type MenuLayout = { + top: number; + left: number; + width: number; + maxHeight: number; +}; + +const RANDOM_IMAGE_SIZES = [ + { key: "640", label: "640 x 420", width: 640, height: 420 }, + { key: "1024", label: "1024 x 1024", width: 1024, height: 1024 }, + { key: "2048", label: "2048 x 2048", width: 2048, height: 2048 }, +] as const; + +type RandomImageSizeKey = (typeof RANDOM_IMAGE_SIZES)[number]["key"]; +type AddMode = "menu" | "text" | "random" | "bulk"; + +export function AddNodeMenu({ + parentId, + parentLabel, + variant = "header", + ready, + busy, + canWritePayload, + canWriteStructure, + maxNodeCount, + onAddText, + onAddImage, + onAddBulk, +}: { + parentId: string; + parentLabel?: string; + variant?: "header" | "row"; + ready: boolean; + busy: boolean; + canWritePayload: boolean; + canWriteStructure: boolean; + maxNodeCount: number; + onAddText: (parentId: string, value: string) => void | Promise; + onAddImage: (parentId: string, file: File) => void | Promise; + onAddBulk: (parentId: string, count: number, fanout: number, value: string) => void | Promise; +}) { + const [mode, setMode] = React.useState(null); + const [layout, setLayout] = React.useState(null); + const [textValue, setTextValue] = React.useState(""); + const [bulkValue, setBulkValue] = React.useState(""); + const [bulkCount, setBulkCount] = React.useState(100); + const [bulkFanout, setBulkFanout] = React.useState(10); + const [randomImageSize, setRandomImageSize] = React.useState("1024"); + const [selectedImage, setSelectedImage] = React.useState(null); + const [error, setError] = React.useState(null); + const [randomBusy, setRandomBusy] = React.useState(false); + const buttonRef = React.useRef(null); + const menuRef = React.useRef(null); + const fileInputRef = React.useRef(null); + const textInputRef = React.useRef(null); + const isOpen = mode !== null; + const disabled = !ready || busy || !canWriteStructure; + + const updateLayout = React.useCallback(() => { + const button = buttonRef.current; + if (!button || typeof window === "undefined") return; + const rect = button.getBoundingClientRect(); + const width = variant === "header" ? 320 : 300; + const margin = 12; + const left = Math.min(Math.max(margin, rect.right - width), window.innerWidth - width - margin); + const top = Math.min(rect.bottom + 8, window.innerHeight - 160); + setLayout({ + top, + left, + width, + maxHeight: Math.max(180, window.innerHeight - top - margin), + }); + }, [variant]); + + const close = React.useCallback(() => { + setMode(null); + setError(null); + setSelectedImage(null); + if (fileInputRef.current) fileInputRef.current.value = ""; + }, []); + + const open = React.useCallback(() => { + if (disabled) return; + setMode("menu"); + setError(null); + updateLayout(); + }, [disabled, updateLayout]); + + React.useEffect(() => { + if (!isOpen) return; + updateLayout(); + const onPointerDown = (event: PointerEvent) => { + const target = event.target as Node | null; + if (target && (buttonRef.current?.contains(target) || menuRef.current?.contains(target))) return; + close(); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") close(); + }; + window.addEventListener("resize", updateLayout); + window.addEventListener("scroll", updateLayout, true); + window.addEventListener("pointerdown", onPointerDown); + window.addEventListener("keydown", onKeyDown); + return () => { + window.removeEventListener("resize", updateLayout); + window.removeEventListener("scroll", updateLayout, true); + window.removeEventListener("pointerdown", onPointerDown); + window.removeEventListener("keydown", onKeyDown); + }; + }, [close, isOpen, updateLayout]); + + React.useEffect(() => { + if (mode !== "text") return; + const id = window.requestAnimationFrame(() => textInputRef.current?.focus()); + return () => window.cancelAnimationFrame(id); + }, [mode]); + + const runCreate = React.useCallback( + async (create: () => void | Promise) => { + setError(null); + try { + await create(); + setTextValue(""); + setBulkValue(""); + setSelectedImage(null); + close(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, + [close], + ); + + const submitImage = React.useCallback( + async (file: File | null) => { + if (!file) return; + try { + validateImageContentFile(file); + setSelectedImage(file); + await runCreate(() => onAddImage(parentId, file)); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + if (fileInputRef.current) fileInputRef.current.value = ""; + } + }, + [onAddImage, parentId, runCreate], + ); + + const fetchRandomImage = React.useCallback(async () => { + setRandomBusy(true); + setError(null); + try { + const size = RANDOM_IMAGE_SIZES.find((entry) => entry.key === randomImageSize) ?? RANDOM_IMAGE_SIZES[0]; + const seed = + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}`; + const response = await fetch(`https://picsum.photos/seed/treecrdt-${seed}/${size.width}/${size.height}`, { + cache: "no-store", + }); + if (!response.ok) throw new Error(`random image request failed (${response.status})`); + const blob = await response.blob(); + const file = new File([blob], `random-${seed}-${size.width}x${size.height}.jpg`, { + type: blob.type || "image/jpeg", + }); + await submitImage(file); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setRandomBusy(false); + } + }, [randomImageSize, submitImage]); + + const parentContext = parentLabel ? `Under ${parentLabel}` : "Add under selected parent"; + const buttonClass = + variant === "header" + ? "relative flex h-9 items-center gap-2 rounded-lg border border-accent bg-accent px-3 text-xs font-semibold text-white shadow-lg shadow-accent/25 transition hover:-translate-y-0.5 hover:bg-accent/90 disabled:translate-y-0 disabled:opacity-50" + : "relative flex h-8 w-8 items-center justify-center rounded-lg border border-slate-700 bg-slate-800/70 text-slate-200 transition hover:border-accent hover:text-white disabled:opacity-50"; + + return ( + <> + + void submitImage(event.target.files?.[0] ?? null)} + /> + {isOpen && layout && typeof document !== "undefined" + ? createPortal( +
+
+ {parentContext} +
+ {mode === "menu" ? ( +
+ } + label="Text node" + detail="Create one child with a text payload." + disabled={!canWritePayload} + onClick={() => setMode("text")} + /> + } + label="Image node" + detail="Pick a file and create an image child." + disabled={!canWritePayload} + onClick={() => fileInputRef.current?.click()} + /> + } + label="Random image" + detail="Choose a sample size and create an image child." + disabled={!canWritePayload || randomBusy} + onClick={() => setMode("random")} + /> + } + label="Bulk nodes" + detail="Generate a flat list or k-ary tree for stress testing." + onClick={() => setMode("bulk")} + /> +
+ ) : null} + {mode === "text" ? ( +
{ + event.preventDefault(); + void runCreate(() => onAddText(parentId, textValue)); + }} + > + +
+ setMode("menu")}> + Back + + + Create text node + +
+
+ ) : null} + {mode === "random" ? ( +
{ + event.preventDefault(); + void fetchRandomImage(); + }} + > + +
+ Uses Picsum as a disposable JPEG source. Larger sizes are useful for manual cold-sync/image-load experiments. +
+
+ setMode("menu")}> + Back + + + {randomBusy ? "Fetching..." : "Create random image"} + +
+
+ ) : null} + {mode === "bulk" ? ( +
{ + event.preventDefault(); + const count = Math.max(1, Math.min(maxNodeCount, Math.floor(bulkCount))); + const fanout = Math.max(0, Math.floor(bulkFanout)); + void runCreate(() => onAddBulk(parentId, count, fanout, bulkValue)); + }} + > + +
+ + +
+
+ setMode("menu")}> + Back + + + Create bulk nodes + +
+
+ ) : null} + {selectedImage ? ( +
+ {selectedImage.name} · {formatContentBytes(selectedImage.size)} +
+ ) : null} + {error ?
{error}
: null} +
, + document.body, + ) + : null} + + ); +} + +function MenuAction({ + icon, + label, + detail, + disabled, + onClick, +}: { + icon: React.ReactNode; + label: string; + detail: string; + disabled?: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function MenuButton({ + type, + primary, + disabled, + onClick, + children, +}: { + type: "button" | "submit"; + primary?: boolean; + disabled?: boolean; + onClick?: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/examples/playground/src/playground/components/ComposerPanel.tsx b/examples/playground/src/playground/components/ComposerPanel.tsx deleted file mode 100644 index 4fa6bf01..00000000 --- a/examples/playground/src/playground/components/ComposerPanel.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import React from "react"; -import { MdExpandLess, MdExpandMore } from "react-icons/md"; - -import type { BulkAddProgress } from "../types"; - -import { ParentPicker } from "./ParentPicker"; - -export function ComposerPanel({ - composerOpen, - setComposerOpen, - nodeList, - parentChoice, - setParentChoice, - newNodeValue, - setNewNodeValue, - nodeCount, - setNodeCount, - maxNodeCount, - fanout, - setFanout, - onAddNodes, - ready, - busy, - bulkAddProgress, - canWritePayload, - canWriteStructure, -}: { - composerOpen: boolean; - setComposerOpen: React.Dispatch>; - nodeList: Array<{ id: string; label: string; depth: number }>; - parentChoice: string; - setParentChoice: (next: string) => void; - newNodeValue: string; - setNewNodeValue: React.Dispatch>; - nodeCount: number; - setNodeCount: React.Dispatch>; - maxNodeCount: number; - fanout: number; - setFanout: React.Dispatch>; - onAddNodes: (parentId: string, count: number, opts: { fanout: number }) => void | Promise; - ready: boolean; - busy: boolean; - bulkAddProgress: BulkAddProgress | null; - canWritePayload: boolean; - canWriteStructure: boolean; -}) { - const containerPadding = composerOpen ? "p-5" : "p-3"; - const headerMargin = composerOpen ? "mb-3" : "mb-0"; - const [progressNowMs, setProgressNowMs] = React.useState(() => Date.now()); - - React.useEffect(() => { - if (!bulkAddProgress) return; - setProgressNowMs(Date.now()); - const timer = window.setInterval(() => setProgressNowMs(Date.now()), 200); - return () => window.clearInterval(timer); - }, [bulkAddProgress]); - - const elapsedMs = bulkAddProgress ? Math.max(0, progressNowMs - bulkAddProgress.startedAtMs) : 0; - const progressRatio = bulkAddProgress ? Math.min(1, bulkAddProgress.completed / Math.max(1, bulkAddProgress.total)) : 0; - const etaMs = - bulkAddProgress && - bulkAddProgress.phase === "creating" && - bulkAddProgress.completed > 0 && - bulkAddProgress.completed < bulkAddProgress.total - ? Math.round((elapsedMs / bulkAddProgress.completed) * (bulkAddProgress.total - bulkAddProgress.completed)) - : null; - - const formatDuration = (ms: number): string => { - const totalSeconds = Math.max(0, Math.round(ms / 1000)); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - return minutes > 0 ? `${minutes}m ${String(seconds).padStart(2, "0")}s` : `${seconds}s`; - }; - - const submitLabel = bulkAddProgress - ? bulkAddProgress.phase === "creating" - ? `Loading ${Math.round(progressRatio * 100)}%` - : "Finishing..." - : `Add node${nodeCount > 1 ? "s" : ""}`; - - return ( -
-
-
Composer
- -
- {composerOpen ? ( - <> -
{ - e.preventDefault(); - void onAddNodes(parentChoice, nodeCount, { fanout }); - }} - > - - - - - - - {bulkAddProgress ? ( -
-
- - {bulkAddProgress.phase === "creating" - ? `Creating ${bulkAddProgress.completed} of ${bulkAddProgress.total} nodes` - : `Applying ${bulkAddProgress.total} generated nodes`} - - Elapsed {formatDuration(elapsedMs)} -
-
-
-
-
- - {bulkAddProgress.phase === "creating" - ? "Preparing inserts locally before the tree view refreshes." - : "Finalizing local tree state and sync updates."} - - - {etaMs !== null ? `ETA ~${formatDuration(etaMs)}` : bulkAddProgress.phase === "applying" ? "ETA settling..." : ""} - -
-
- ) : null} - - ) : null} -
- ); -} diff --git a/examples/playground/src/playground/components/ParentPicker.tsx b/examples/playground/src/playground/components/ParentPicker.tsx deleted file mode 100644 index 6961d3ea..00000000 --- a/examples/playground/src/playground/components/ParentPicker.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from "react"; - -export const ParentPicker = React.memo(function ParentPicker({ - nodeList, - value, - onChange, - disabled, -}: { - nodeList: Array<{ id: string; label: string; depth: number }>; - value: string; - onChange: (next: string) => void; - disabled: boolean; -}) { - return ( - - ); -}); diff --git a/examples/playground/src/playground/components/PeersPanel.tsx b/examples/playground/src/playground/components/PeersPanel.tsx index 8637237b..7c3b98c6 100644 --- a/examples/playground/src/playground/components/PeersPanel.tsx +++ b/examples/playground/src/playground/components/PeersPanel.tsx @@ -3,6 +3,24 @@ import { MdCheckCircle, MdCloudOff, MdCloudQueue, MdErrorOutline, MdSync } from import type { PeerInfo, RemoteSyncStatus, SyncTransportMode } from '../types'; +const REMOTE_SYNC_PRESETS = [ + { + label: 'Public bootstrap', + url: 'https://sync.emhub.net', + title: 'Use the shared HTTPS bootstrap endpoint', + }, + { + label: 'Local bootstrap', + url: 'http://localhost:8788', + title: 'Use pnpm discovery-server:local', + }, + { + label: 'Local WebSocket', + url: 'ws://localhost:8787', + title: 'Use pnpm sync-server:postgres:local directly', + }, +] as const; + function formatPeerId(id: string): string { if (id.startsWith('remote:')) return `remote(${id.slice('remote:'.length)})`; return id.length > 18 ? `${id.slice(0, 8)}…${id.slice(-6)}` : id; @@ -64,6 +82,10 @@ export function PeersPanel({ }) { const requiresRemoteUrl = syncTransportMode !== 'local'; const hasRemoteUrl = syncServerUrl.trim().length > 0; + const applyRemotePreset = (url: string) => { + setSyncServerUrl(url); + if (syncTransportMode === 'local') setSyncTransportMode('hybrid'); + }; return (
+
+ {REMOTE_SYNC_PRESETS.map((preset) => ( + + ))} +
{remoteSyncStatus.detail}
{!requiresRemoteUrl && !hasRemoteUrl && (
Optional in local-only mode.
diff --git a/examples/playground/src/playground/components/TreePanel.tsx b/examples/playground/src/playground/components/TreePanel.tsx index 1090d9d6..46a03e20 100644 --- a/examples/playground/src/playground/components/TreePanel.tsx +++ b/examples/playground/src/playground/components/TreePanel.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { formatContentBytes } from "@treecrdt/content"; import type { Virtualizer } from "@tanstack/react-virtual"; import { IoMdGitBranch } from "react-icons/io"; import { @@ -16,8 +17,9 @@ import { import { ROOT_ID } from "../constants"; import type { IssuedGrantRecord } from "../hooks/usePlaygroundAuth"; -import type { CollapseState, DisplayNode, NodeMeta, PeerInfo } from "../types"; +import type { BulkAddProgress, CollapseState, DisplayNode, NodeMeta, PayloadDisplay, PeerInfo } from "../types"; +import { AddNodeMenu } from "./AddNodeMenu"; import { PeersPanel } from "./PeersPanel"; import { SharingAuthPanel } from "./SharingAuthPanel"; import { TreeRow } from "./TreeRow"; @@ -37,6 +39,7 @@ export function TreePanel({ peerCount, authCanSyncAll, onSync, + bulkAddProgress, liveAllEnabled, setLiveAllEnabled, showPeersPanel, @@ -60,8 +63,12 @@ export function TreePanel({ toggleCollapse, openShareForNode, grantSubtreeToReplicaPubkey, + onAddTextNode, + onAddImageNode, + onAddBulkNodes, onSetValue, - onAddChild, + onSetImagePayload, + onClearPayload, onDelete, onMove, onMoveToRoot, @@ -79,6 +86,7 @@ export function TreePanel({ canWritePayload, canWriteStructure, canDelete, + maxNodeCount, liveChildrenParents, meta, childrenByParent, @@ -95,6 +103,7 @@ export function TreePanel({ peerCount: number; authCanSyncAll: boolean; onSync: () => void; + bulkAddProgress: BulkAddProgress | null; liveAllEnabled: boolean; setLiveAllEnabled: React.Dispatch>; showPeersPanel: boolean; @@ -123,8 +132,12 @@ export function TreePanel({ actions?: string[]; supersedesTokenIds?: string[]; }) => Promise; + onAddTextNode: (parentId: string, value: string) => void | Promise; + onAddImageNode: (parentId: string, file: File) => void | Promise; + onAddBulkNodes: (parentId: string, count: number, fanout: number, value: string) => void | Promise; onSetValue: (nodeId: string, value: string) => void | Promise; - onAddChild: (nodeId: string) => void; + onSetImagePayload: (nodeId: string, file: File) => void | Promise; + onClearPayload: (nodeId: string) => void | Promise; onDelete: (nodeId: string) => void; onMove: (nodeId: string, direction: "up" | "down") => void; onMoveToRoot: (nodeId: string) => void; @@ -142,10 +155,13 @@ export function TreePanel({ canWritePayload: boolean; canWriteStructure: boolean; canDelete: boolean; + maxNodeCount: number; liveChildrenParents: Set; meta: Record; childrenByParent: Record; }) { + const [previewImage, setPreviewImage] = React.useState | null>(null); + const [progressNowMs, setProgressNowMs] = React.useState(() => Date.now()); const measureTreeElement = React.useCallback( (element: Element | null) => { if (!element) return; @@ -160,6 +176,28 @@ export function TreePanel({ }, [treeVirtualizer] ); + React.useEffect(() => { + if (!bulkAddProgress) return; + setProgressNowMs(Date.now()); + const timer = window.setInterval(() => setProgressNowMs(Date.now()), 200); + return () => window.clearInterval(timer); + }, [bulkAddProgress]); + + const elapsedMs = bulkAddProgress ? Math.max(0, progressNowMs - bulkAddProgress.startedAtMs) : 0; + const progressRatio = bulkAddProgress ? Math.min(1, bulkAddProgress.completed / Math.max(1, bulkAddProgress.total)) : 0; + const etaMs = + bulkAddProgress && + bulkAddProgress.phase === "creating" && + bulkAddProgress.completed > 0 && + bulkAddProgress.completed < bulkAddProgress.total + ? Math.round((elapsedMs / bulkAddProgress.completed) * (bulkAddProgress.total - bulkAddProgress.completed)) + : null; + const formatDuration = (ms: number): string => { + const totalSeconds = Math.max(0, Math.round(ms / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return minutes > 0 ? `${minutes}m ${String(seconds).padStart(2, "0")}s` : `${seconds}s`; + }; return (
@@ -178,6 +216,19 @@ export function TreePanel({
+ +
+
+ {previewImage.name +
+
+ + ) : null} ); } diff --git a/examples/playground/src/playground/components/TreeRow.tsx b/examples/playground/src/playground/components/TreeRow.tsx index 7b1db30f..53658c85 100644 --- a/examples/playground/src/playground/components/TreeRow.tsx +++ b/examples/playground/src/playground/components/TreeRow.tsx @@ -1,13 +1,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { formatContentBytes, SUPPORTED_IMAGE_MIME_TYPES, validateImageContentFile } from "@treecrdt/content"; import { createPortal } from "react-dom"; import { - MdAdd, MdChevronRight, MdCheck, MdDeleteOutline, MdExpandMore, MdGroup, MdHome, + MdImage, MdKeyboardArrowDown, MdKeyboardArrowUp, MdLockOpen, @@ -27,7 +28,8 @@ import { } from "../capabilities"; import { ROOT_ID } from "../constants"; import type { IssuedGrantRecord } from "../hooks/usePlaygroundAuth"; -import type { CollapseState, DisplayNode, NodeMeta, PeerInfo } from "../types"; +import type { CollapseState, DisplayNode, NodeMeta, PayloadDisplay, PeerInfo } from "../types"; +import { AddNodeMenu } from "./AddNodeMenu"; type MembersMenuLayout = { top: number; @@ -44,7 +46,9 @@ export function TreeRow({ liveChildren, onToggle, onSetValue, - onAddChild, + onSetImagePayload, + onClearPayload, + onOpenImagePreview, onDelete, onMove, onMoveToRoot, @@ -52,6 +56,9 @@ export function TreeRow({ privateRoots, onTogglePrivateRoot, onShare, + onAddTextNode, + onAddImageNode, + onAddBulkNodes, peers, selfPeerId, busy, @@ -66,6 +73,7 @@ export function TreeRow({ canWritePayload, canWriteStructure, canDelete, + maxNodeCount, meta, childrenByParent, }: { @@ -75,7 +83,12 @@ export function TreeRow({ liveChildren: boolean; onToggle: (id: string) => void; onSetValue: (id: string, value: string) => void | Promise; - onAddChild: (id: string) => void; + onSetImagePayload: (id: string, file: File) => void | Promise; + onClearPayload: (id: string) => void | Promise; + onOpenImagePreview: (payload: Extract) => void; + onAddTextNode: (parentId: string, value: string) => void | Promise; + onAddImageNode: (parentId: string, file: File) => void | Promise; + onAddBulkNodes: (parentId: string, count: number, fanout: number, value: string) => void | Promise; onDelete: (id: string) => void; onMove: (id: string, direction: "up" | "down") => void; onMoveToRoot: (id: string) => void; @@ -102,6 +115,7 @@ export function TreeRow({ canWritePayload: boolean; canWriteStructure: boolean; canDelete: boolean; + maxNodeCount: number; meta: Record; childrenByParent: Record; }) { @@ -137,12 +151,16 @@ export function TreeRow({ const [showMembersMenu, setShowMembersMenu] = useState(false); const [manualRecipientKey, setManualRecipientKey] = useState(""); const [manualGrantActions, setManualGrantActions] = useState(DEFAULT_MEMBER_CAPABILITY_ACTIONS); + const [imageDropActive, setImageDropActive] = useState(false); + const [imageError, setImageError] = useState(null); const [memberGrantActions, setMemberGrantActions] = useState>({}); const [membersMenuLayout, setMembersMenuLayout] = useState(null); const membersButtonRef = useRef(null); const membersMenuRef = useRef(null); - const canEditValue = canWritePayload && !isRoot; - const canInsertChild = canWriteStructure; + const imagePayload = node.payload.kind === "image" ? node.payload : null; + const imageInputRef = useRef(null); + const canEditValue = canWritePayload && !isRoot && !imagePayload; + const canReplacePayload = canWritePayload && !isRoot; const canMoveStructure = canWriteStructure; const canMoveToDocRoot = canWriteStructure && scopeRootId === ROOT_ID; const showMembersButton = !isRoot && isPrivate; @@ -235,6 +253,16 @@ export function TreeRow({ ] ); + const replaceImageFromFile = useCallback( + async (file: File | null) => { + if (!file || !canReplacePayload) return; + validateImageContentFile(file); + await onSetImagePayload(node.id, file); + setImageError(null); + }, + [canReplacePayload, node.id, onSetImagePayload] + ); + const updateMembersMenuLayout = useCallback(() => { if (typeof window === "undefined") return; const button = membersButtonRef.current; @@ -357,6 +385,98 @@ export function TreeRow({
{isRoot ? (
{node.label}
+ ) : imagePayload ? ( +
+
+ +
+
+ {node.label} +
+
+ {imagePayload.mime} · {formatContentBytes(imagePayload.size)} · inline content · complete +
+ {imageError ?
{imageError}
: null} +
+ { + const file = e.target.files?.[0] ?? null; + void replaceImageFromFile(file) + .catch((err) => { + setImageError(err instanceof Error ? err.message : String(err)); + }) + .finally(() => { + if (imageInputRef.current) imageInputRef.current.value = ""; + }); + }} + /> + + +
+
+
+
) : isEditing ? (
- + {!isRoot && ( <>