feat(builder): add dynamic blocks email and dnd packages
This commit is contained in:
1 parent
06433e4ac4
commit
0bd2a6860d
37 files changed
+3060
-1
No files matched your search
@@ -0,0 +1,4 @@
|
||||
# @wrnexus/block-editor
|
||||
|
||||
Hydrated editor state and DOM runtime for portable block documents. All changes
|
||||
flow through validated `@wrnexus/blocks` commands; DnD produces move intents.
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@wrnexus/block-editor",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Hydrated accessible editor runtime for WRNexus block documents.",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./runtime": "./src/runtime.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/blocks": "workspace:*",
|
||||
"@wrnexus/dnd": "workspace:*",
|
||||
"@wrnexus/reactive": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.4.0",
|
||||
"happy-dom": "^20.11.2",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./state.ts";
|
||||
export * from "./runtime.ts";
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createDndManager, type DndAnnouncement, type DndManager } from "@wrnexus/dnd";
|
||||
|
||||
export interface MountedEditor {
|
||||
destroy(): void;
|
||||
refresh(): void;
|
||||
}
|
||||
export interface MountEditorOptions {
|
||||
direction?: "ltr" | "rtl";
|
||||
announce?(announcement: DndAnnouncement): string;
|
||||
onMove(intent: {
|
||||
nodeIds: string[];
|
||||
to: { parentId: string; slot: string; index: number };
|
||||
}): boolean | void | Promise<boolean | void>;
|
||||
}
|
||||
|
||||
export function mountBlockEditor(root: HTMLElement, options: MountEditorOptions): MountedEditor {
|
||||
let manager: DndManager | undefined;
|
||||
let cleanups: Array<() => void> = [];
|
||||
const live = document.createElement("div");
|
||||
live.setAttribute("role", "status");
|
||||
live.setAttribute("aria-live", "polite");
|
||||
live.setAttribute("aria-atomic", "true");
|
||||
live.style.cssText =
|
||||
"position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0";
|
||||
root.append(live);
|
||||
const refresh = () => {
|
||||
cleanups.forEach((cleanup) => cleanup());
|
||||
cleanups = [];
|
||||
manager?.destroy();
|
||||
manager = createDndManager({
|
||||
direction: options.direction ?? (getComputedStyle(root).direction === "rtl" ? "rtl" : "ltr"),
|
||||
reducedMotion: matchMedia("(prefers-reduced-motion: reduce)").matches,
|
||||
announce(announcement) {
|
||||
live.textContent = options.announce?.(announcement) ?? announcement.type;
|
||||
},
|
||||
async onMoveIntent(intent) {
|
||||
const [parentId, slot] = String(intent.to.group).split(":", 2);
|
||||
if (!parentId || !slot) return false;
|
||||
return options.onMove({
|
||||
nodeIds: intent.ids.map(String),
|
||||
to: { parentId, slot, index: intent.to.index },
|
||||
});
|
||||
},
|
||||
});
|
||||
for (const element of root.querySelectorAll<HTMLElement>(
|
||||
"[data-block-id][data-block-parent][data-block-slot]",
|
||||
)) {
|
||||
const id = element.dataset.blockId!;
|
||||
const parent = element.dataset.blockParent!;
|
||||
const slot = element.dataset.blockSlot!;
|
||||
const siblings = [
|
||||
...element.parentElement!.querySelectorAll<HTMLElement>(":scope > [data-block-id]"),
|
||||
];
|
||||
const handle = element.querySelector<HTMLElement>("[data-block-handle]") ?? undefined;
|
||||
cleanups.push(
|
||||
manager.registerSortable({
|
||||
id,
|
||||
group: `${parent}:${slot}`,
|
||||
index: siblings.indexOf(element),
|
||||
element,
|
||||
handle,
|
||||
type: element.dataset.blockType,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
refresh();
|
||||
return {
|
||||
refresh,
|
||||
destroy() {
|
||||
cleanups.forEach((cleanup) => cleanup());
|
||||
manager?.destroy();
|
||||
live.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { signal, type Signal } from "@wrnexus/reactive";
|
||||
import {
|
||||
applyCommand,
|
||||
type BlockCommand,
|
||||
type BlockDocument,
|
||||
type CommandResult,
|
||||
type DocumentKindDefinition,
|
||||
type ReadonlyBlockRegistry,
|
||||
type Result,
|
||||
type SelectionState,
|
||||
} from "@wrnexus/blocks";
|
||||
|
||||
export interface EditorSnapshot {
|
||||
document: BlockDocument;
|
||||
revision: number;
|
||||
selection: SelectionState;
|
||||
dirty: boolean;
|
||||
status: "idle" | "saving" | "saved" | "conflict" | "error";
|
||||
}
|
||||
|
||||
interface HistoryEntry {
|
||||
forward: BlockCommand;
|
||||
inverse: BlockCommand;
|
||||
}
|
||||
|
||||
export interface BlockEditorState extends Signal<EditorSnapshot> {
|
||||
execute(command: BlockCommand): Result<CommandResult>;
|
||||
undo(): boolean;
|
||||
redo(): boolean;
|
||||
select(ids: string[], primary?: string | null): void;
|
||||
markSaving(): void;
|
||||
markSaved(revision: number): void;
|
||||
markConflict(): void;
|
||||
replaceFromServer(document: BlockDocument, revision: number): void;
|
||||
canUndo(): boolean;
|
||||
canRedo(): boolean;
|
||||
}
|
||||
|
||||
export function createBlockEditorState(options: {
|
||||
document: BlockDocument;
|
||||
revision: number;
|
||||
kind: DocumentKindDefinition;
|
||||
registry: ReadonlyBlockRegistry;
|
||||
historyLimit?: number;
|
||||
}): BlockEditorState {
|
||||
const current = signal<EditorSnapshot>({
|
||||
document: options.document,
|
||||
revision: options.revision,
|
||||
selection: { primary: null, ordered: [] },
|
||||
dirty: false,
|
||||
status: "idle",
|
||||
});
|
||||
const past: HistoryEntry[] = [];
|
||||
const future: HistoryEntry[] = [];
|
||||
const limit = Math.max(1, options.historyLimit ?? 100);
|
||||
const apply = (command: BlockCommand, remember: boolean): Result<CommandResult> => {
|
||||
const result = applyCommand(current.get().document, command, options.kind, options.registry);
|
||||
if (!result.ok) return result;
|
||||
if (remember) {
|
||||
past.push({ forward: command, inverse: result.value.inverse });
|
||||
if (past.length > limit) past.shift();
|
||||
future.length = 0;
|
||||
}
|
||||
current.set({
|
||||
...current.get(),
|
||||
document: result.value.document,
|
||||
selection: result.value.selection,
|
||||
dirty: true,
|
||||
status: "idle",
|
||||
});
|
||||
return result;
|
||||
};
|
||||
return Object.assign(current, {
|
||||
execute: (command: BlockCommand) => apply(command, true),
|
||||
undo() {
|
||||
const entry = past.pop();
|
||||
if (!entry) return false;
|
||||
const result = apply(entry.inverse, false);
|
||||
if (!result.ok) {
|
||||
past.push(entry);
|
||||
return false;
|
||||
}
|
||||
future.push(entry);
|
||||
return true;
|
||||
},
|
||||
redo() {
|
||||
const entry = future.pop();
|
||||
if (!entry) return false;
|
||||
const result = apply(entry.forward, false);
|
||||
if (!result.ok) {
|
||||
future.push(entry);
|
||||
return false;
|
||||
}
|
||||
past.push(entry);
|
||||
return true;
|
||||
},
|
||||
select(ids: string[], primary: string | null = ids[0] ?? null) {
|
||||
current.set({ ...current.get(), selection: { primary, ordered: [...new Set(ids)] } });
|
||||
},
|
||||
markSaving() {
|
||||
current.set({ ...current.get(), status: "saving" });
|
||||
},
|
||||
markSaved(revision: number) {
|
||||
current.set({ ...current.get(), revision, dirty: false, status: "saved" });
|
||||
},
|
||||
markConflict() {
|
||||
current.set({ ...current.get(), status: "conflict" });
|
||||
},
|
||||
replaceFromServer(document: BlockDocument, revision: number) {
|
||||
past.length = 0;
|
||||
future.length = 0;
|
||||
current.set({
|
||||
document,
|
||||
revision,
|
||||
selection: { primary: null, ordered: [] },
|
||||
dirty: false,
|
||||
status: "idle",
|
||||
});
|
||||
},
|
||||
canUndo: () => past.length > 0,
|
||||
canRedo: () => future.length > 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
createRegistry,
|
||||
defineBlock,
|
||||
defineDocumentKind,
|
||||
json,
|
||||
object,
|
||||
record,
|
||||
string,
|
||||
type BlockDocument,
|
||||
} from "@wrnexus/blocks";
|
||||
import { createBlockEditorState } from "../src/index.ts";
|
||||
|
||||
const styles = record(json) as never;
|
||||
const definitions = [
|
||||
defineBlock({
|
||||
manifest: {
|
||||
type: "layout.root",
|
||||
version: 1,
|
||||
name: { key: "root", fallback: "Root" },
|
||||
icon: "root",
|
||||
category: "layout",
|
||||
props: object({}) as never,
|
||||
styles,
|
||||
slots: { content: { accepts: { capabilities: ["content"] } } },
|
||||
},
|
||||
create: ({ id }) => ({
|
||||
id: id(),
|
||||
type: "layout.root",
|
||||
version: 1,
|
||||
props: {},
|
||||
styles: {},
|
||||
slots: { content: [] },
|
||||
}),
|
||||
}),
|
||||
defineBlock({
|
||||
manifest: {
|
||||
type: "content.text",
|
||||
version: 1,
|
||||
name: { key: "text", fallback: "Text" },
|
||||
icon: "text",
|
||||
category: "content",
|
||||
capabilities: ["content"],
|
||||
props: object({ text: string() }) as never,
|
||||
styles,
|
||||
slots: {},
|
||||
},
|
||||
create: ({ id }) => ({
|
||||
id: id(),
|
||||
type: "content.text",
|
||||
version: 1,
|
||||
props: { text: "" },
|
||||
styles: {},
|
||||
slots: {},
|
||||
}),
|
||||
}),
|
||||
];
|
||||
const kind = defineDocumentKind({
|
||||
kind: "test",
|
||||
version: 1,
|
||||
metadata: object({ title: string() }) as never,
|
||||
rootTypes: ["layout.root"],
|
||||
blocks: definitions,
|
||||
variables: {},
|
||||
breakpoints: {},
|
||||
capabilities: [],
|
||||
targets: [],
|
||||
limits: {
|
||||
maxBytes: 10000,
|
||||
maxNodes: 20,
|
||||
maxDepth: 5,
|
||||
maxChildrenPerSlot: 10,
|
||||
maxStringLength: 100,
|
||||
maxOutputBytes: 1000,
|
||||
},
|
||||
urlSchemes: ["https"],
|
||||
});
|
||||
const registry = createRegistry(definitions).freeze();
|
||||
const document: BlockDocument = {
|
||||
schemaVersion: 1,
|
||||
kind: "test",
|
||||
meta: { title: "x" },
|
||||
theme: { tokens: {} },
|
||||
root: definitions[0]!.create({ id: () => "root" }),
|
||||
};
|
||||
|
||||
test("editor state executes undo and redo", () => {
|
||||
const state = createBlockEditorState({ document, revision: 1, kind, registry });
|
||||
const node = definitions[1]!.create({ id: () => "text" });
|
||||
expect(
|
||||
state.execute({
|
||||
type: "insert",
|
||||
location: { parentId: "root", slot: "content", index: 0 },
|
||||
nodes: [node],
|
||||
}).ok,
|
||||
).toBe(true);
|
||||
expect(state.get().dirty).toBe(true);
|
||||
expect(state.undo()).toBe(true);
|
||||
expect(state.get().document.root.slots.content).toEqual([]);
|
||||
expect(state.redo()).toBe(true);
|
||||
expect(state.get().document.root.slots.content[0]?.id).toBe("text");
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
Reference in new issue
Block a user