Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 43 additions & 9 deletions packages/editor-ui/src/edit/EditorApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,14 @@ export default function EditorApp() {
event.preventDefault();
void handleSave();
}
if (!isTypingTarget && (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "d") {
event.preventDefault();
handleDuplicateSelected();
}
if (!isTypingTarget && (event.key === "Backspace" || event.key === "Delete")) {
event.preventDefault();
handleDeleteSelected();
}
if (!isTypingTarget && event.key === "]") {
event.preventDefault();
setInspectorVisible((prev) => !prev);
Expand All @@ -764,9 +772,9 @@ export default function EditorApp() {
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
}, [handleDeleteSelected, handleDuplicateSelected, handleSave]);

const handleTransform = useCallback(
const runTransform = useCallback(
async (transform: EditorTransform | TransformRequest, successMessage?: string) => {
if (runtimeState.mode === "playback") {
runtimeActions.setMode("edit");
Expand All @@ -787,11 +795,21 @@ export default function EditorApp() {
[docService, runtimeActions, runtimeState.mode],
);

const handleTransform = useCallback(
(transform: TransformRequest, successMessage?: string) => runTransform(transform, successMessage),
[runTransform],
);

const handleEditorTransform = useCallback(
(transform: EditorTransform, successMessage?: string) => runTransform(transform, successMessage),
[runTransform],
);

const applyTextContent = useCallback(
(id: string, payload: { text?: string; richText?: JSONContent }) => {
void handleTransform({ type: "setTextNodeContent", id, ...payload });
void handleEditorTransform({ type: "setTextNodeContent", id, ...payload });
},
[handleTransform],
[handleEditorTransform],
);

const [debouncedRichTextUpdate, flushRichTextUpdate] = useDebouncedCallback((id: string, richText: JSONContent) => {
Expand All @@ -813,9 +831,9 @@ export default function EditorApp() {
(slotId: string, spec: SlotGeneratorSpec) => {
const expr = buildGeneratorExpr(spec);
const generator = expr ? wrapExpressionValue(expr) : (spec as unknown as Record<string, unknown>);
void handleTransform({ type: "setSlotGenerator", id: slotId, generator });
void handleEditorTransform({ type: "setSlotGenerator", id: slotId, generator });
},
[handleTransform],
[handleEditorTransform],
);

const handlePreviewTransition = useCallback(
Expand Down Expand Up @@ -917,6 +935,16 @@ export default function EditorApp() {
void handleTransform(buildAddSlotTransform(), "Slot inserted");
}, [handleTransform]);

const handleDuplicateSelected = useCallback(() => {
if (!selectedId) return;
void handleTransform({ op: "duplicateNode", args: { id: selectedId } }, "Node duplicated");
}, [handleTransform, selectedId]);

const handleDeleteSelected = useCallback(() => {
if (!selectedId) return;
void handleTransform({ op: "deleteNode", args: { id: selectedId } }, "Node deleted");
}, [handleTransform, selectedId]);

const scrollPageIntoView = useCallback((pageId: string) => {
const frameDoc = previewFrameRef.current?.contentDocument;
if (!frameDoc) return;
Expand Down Expand Up @@ -1191,6 +1219,8 @@ export default function EditorApp() {
runtimeState,
runtimeActions,
selectionId: selectedId,
handleDuplicate: handleDuplicateSelected,
handleDelete: handleDeleteSelected,
handleSave,
handleExportPdf,
handleResetLayout,
Expand Down Expand Up @@ -1222,6 +1252,8 @@ export default function EditorApp() {
runtimeActions,
runtimeState,
selectedId,
handleDuplicateSelected,
handleDeleteSelected,
handleSave,
handleExportPdf,
handleResetLayout,
Expand Down Expand Up @@ -1641,7 +1673,7 @@ export default function EditorApp() {
} as DocumentNode;
delete (cleaned as any).refresh;
delete (cleaned as any).transition;
void handleTransform({ type: "replaceNode", id: selectedNode.id, node: cleaned });
void handleEditorTransform({ type: "replaceNode", id: selectedNode.id, node: cleaned });
}}
>
Fix
Expand Down Expand Up @@ -1690,7 +1722,9 @@ export default function EditorApp() {
reducedMotion={runtimeState.reducedMotion}
onLiteralChange={(textId, text) => applyTextContent(textId, { text })}
onGeneratorChange={(spec) => applySlotGenerator(selectedNode.id, spec)}
onPropsChange={(payload) => handleTransform({ type: "setSlotProps", id: selectedNode.id, ...payload })}
onPropsChange={(payload) =>
handleEditorTransform({ type: "setSlotProps", id: selectedNode.id, ...payload })
}
onPreviewTransition={handlePreviewTransition}
lockReset={docState.dirty || docState.isApplying}
onDirty={() => docService.markDirtyDraft()}
Expand All @@ -1706,7 +1740,7 @@ export default function EditorApp() {
onDirty={() => docService.markDirtyDraft()}
onCommit={(value) => {
if (captionTarget.kind === "prop") {
handleTransform({
handleEditorTransform({
type: "setNodeProps",
id: captionTarget.id,
props: { caption: { kind: "LiteralValue", value } },
Expand Down
6 changes: 4 additions & 2 deletions packages/editor-ui/src/edit/commands/editorCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ export type EditorCommandContext = {
runtimeState: EditorRuntimeState;
runtimeActions: EditorRuntimeActions;
selectionId: string | null;
handleDuplicate: () => void;
handleDelete: () => void;
handleSave: () => void;
handleExportPdf: () => void;
handleResetLayout: () => void;
Expand Down Expand Up @@ -271,14 +273,14 @@ export function buildEditorCommands(ctx: EditorCommandContext): Record<EditorCom
label: "Duplicate",
shortcut: "⌘D",
enabled: Boolean(ctx.selectionId),
run: () => {},
run: ctx.handleDuplicate,
}),
"edit.delete": make({
id: "edit.delete",
label: "Delete",
shortcut: "⌫",
enabled: Boolean(ctx.selectionId),
run: () => {},
run: ctx.handleDelete,
}),
"edit.find": make({
id: "edit.find",
Expand Down
141 changes: 141 additions & 0 deletions packages/viewer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -989,6 +989,30 @@ export async function startViewerServer(options: ViewerServerOptions): Promise<V
}
nextSource = formatFluxSource(result.source);
selectedId = result.selectedId;
} else if (op === "deleteNode") {
const id = typeof args.id === "string" ? args.id : "";
const result = applyDeleteNodeTransform(source, parsed.doc, id, docPath);
if (!result.ok) {
sendTransform(
{ ok: false, diagnostics: buildDiagnosticsBundle([result.diagnostic]) },
{ applied: false },
);
return;
}
nextSource = formatFluxSource(result.source);
selectedId = result.selectedId ?? undefined;
} else if (op === "duplicateNode") {
const id = typeof args.id === "string" ? args.id : "";
const result = applyDuplicateNodeTransform(source, parsed.doc, id, docPath);
if (!result.ok) {
sendTransform(
{ ok: false, diagnostics: buildDiagnosticsBundle([result.diagnostic]) },
{ applied: false },
);
return;
}
nextSource = formatFluxSource(result.source);
selectedId = result.selectedId;
} else if (op === "moveNode") {
const nodeId = typeof args.nodeId === "string" ? args.nodeId : "";
const fromContainerId = typeof args.fromContainerId === "string" ? args.fromContainerId : "";
Expand Down Expand Up @@ -2756,6 +2780,20 @@ function findNodeById(nodes: DocumentNode[], id: string): DocumentNode | null {
return null;
}

function findNodeWithParent(
nodes: DocumentNode[],
id: string,
parent: DocumentNode | null = null,
): { node: DocumentNode; parent: DocumentNode | null; index: number } | null {
for (let index = 0; index < (nodes ?? []).length; index += 1) {
const node = nodes[index];
if (node.id === id) return { node, parent, index };
const child = findNodeWithParent(node.children ?? [], id, node);
if (child) return child;
}
return null;
}

type CanonicalDynamicValue = Extract<NodePropValue, { kind: "DynamicValue" }>;

function normalizeEditTransformPayload(
Expand Down Expand Up @@ -3019,6 +3057,11 @@ function collectIdsFromDoc(doc: FluxDocument): Set<string> {
return ids;
}

function nodeIdPrefix(kind: string): string {
if (kind === "inline_slot") return "inlineSlot";
return kind;
}

function nextId(prefix: string, ids: Set<string>): string {
let n = 1;
let candidate = `${prefix}${n}`;
Expand All @@ -3030,6 +3073,17 @@ function nextId(prefix: string, ids: Set<string>): string {
return candidate;
}

function cloneNodeWithNewIds(node: DocumentNode, ids: Set<string>): DocumentNode {
const { loc, ...rest } = node as DocumentNode & { loc?: unknown };
const id = nextId(nodeIdPrefix(node.kind), ids);
const children = (node.children ?? []).map((child) => cloneNodeWithNewIds(child, ids));
return {
...(rest as DocumentNode),
id,
children,
};
}

function findBlockRange(source: string, blockName: string): { start: number; end: number; indent: string } | null {
const regex = new RegExp(`(^|\\n)([\\t ]*)${blockName}\\s*\\{`, "m");
const match = regex.exec(source);
Expand Down Expand Up @@ -3147,6 +3201,93 @@ function applyInsertPageTransform(
return { ok: true, source: nextSource, selectedId: pageId };
}

function applyReplaceBodyNodesTransform(
source: string,
nextNodes: DocumentNode[],
docPath: string,
): { ok: true; source: string } | { ok: false; diagnostic: EditDiagnostic } {
const block = findBlockRange(source, "body");
if (!block) {
return { ok: false, diagnostic: buildDiagnosticFromMessage("No body block found", source, docPath, "fail") };
}
const childIndent = block.indent + INSERT_INDENT;
const printed = nextNodes.map((node) => printDocumentNode(node, childIndent)).join("\n");
const prefix = source.slice(0, block.start);
const suffix = source.slice(block.end);
const content = printed ? `\n${printed}\n${block.indent}` : `\n${block.indent}`;
return { ok: true, source: prefix + content + suffix };
}

function applyDeleteNodeTransform(
source: string,
doc: FluxDocument,
id: string,
docPath: string,
): { ok: true; source: string; selectedId: string | null } | { ok: false; diagnostic: EditDiagnostic } {
if (!id) {
return { ok: false, diagnostic: buildDiagnosticFromMessage("Missing node id", source, docPath, "fail") };
}
const found = findNodeWithParent(doc.body?.nodes ?? [], id);
if (!found) {
return { ok: false, diagnostic: buildDiagnosticFromMessage("Node not found", source, docPath, "fail") };
}
if (found.node.kind === "document") {
return {
ok: false,
diagnostic: buildDiagnosticFromMessage("Cannot delete document root", source, docPath, "fail"),
};
}

if (!found.parent) {
const nodes = [...(doc.body?.nodes ?? [])];
nodes.splice(found.index, 1);
const nextSelection = nodes[found.index] ?? nodes[found.index - 1] ?? null;
const result = applyReplaceBodyNodesTransform(source, nodes, docPath);
if (!result.ok) return result;
return { ok: true, source: result.source, selectedId: nextSelection ? nextSelection.id : null };
}

const children = [...(found.parent.children ?? [])];
children.splice(found.index, 1);
const nextParent: DocumentNode = { ...found.parent, children };
const nextSelection = children[found.index] ?? children[found.index - 1] ?? found.parent;
const result = applyReplaceNodeTransform(source, doc, found.parent.id, nextParent, docPath);
if (!result.ok) return result;
return { ok: true, source: result.source, selectedId: nextSelection ? nextSelection.id : null };
}

function applyDuplicateNodeTransform(
source: string,
doc: FluxDocument,
id: string,
docPath: string,
): { ok: true; source: string; selectedId: string } | { ok: false; diagnostic: EditDiagnostic } {
if (!id) {
return { ok: false, diagnostic: buildDiagnosticFromMessage("Missing node id", source, docPath, "fail") };
}
const found = findNodeWithParent(doc.body?.nodes ?? [], id);
if (!found) {
return { ok: false, diagnostic: buildDiagnosticFromMessage("Node not found", source, docPath, "fail") };
}
const ids = collectIdsFromDoc(doc);
const clone = cloneNodeWithNewIds(found.node, ids);

if (!found.parent) {
const nodes = [...(doc.body?.nodes ?? [])];
nodes.splice(found.index + 1, 0, clone);
const result = applyReplaceBodyNodesTransform(source, nodes, docPath);
if (!result.ok) return result;
return { ok: true, source: result.source, selectedId: clone.id };
}

const children = [...(found.parent.children ?? [])];
children.splice(found.index + 1, 0, clone);
const nextParent: DocumentNode = { ...found.parent, children };
const result = applyReplaceNodeTransform(source, doc, found.parent.id, nextParent, docPath);
if (!result.ok) return result;
return { ok: true, source: result.source, selectedId: clone.id };
}

type ContainerRef = { kind: "page" | "section"; id: string };
type ParseSource = (source: string) => { doc: FluxDocument | null; errors: string[]; diagnostics: EditDiagnostics };

Expand Down
Loading