Skip to content

Commit 27c1974

Browse files
author
lir-navcoo
committed
feat(L4): left view switcher + palette-driven drag-and-drop
The two UX gaps the demo exposed: 1. The left icon strip didn't actually drive the left panel — it was just decorative buttons. 2. There was no way to drag a component from anywhere into the canvas. This change wires both ends of the loop. * designer/Dragon: add `boost(meta, x, y)` so the palette can start a drag of a not-yet-instantiated component. `commit()` returns a discriminated union (move | boost) the host can `switch` on. New events: startBoost, dropBoost, cancelBoost. * designer: BuiltinSimulatorHost (new) — wires canvas pointer events to the Dragon. Computes a DropTarget from each `pointermove` (vertical-thirds algorithm: top → 'before', middle → 'inside', bottom → 'after') and commits on `pointerup`. Escape cancels. Window-level pointerup so a cursor that drifted outside the canvas still commits. * designer/Project: re-emit the Dragon's boost events on the Project emitter so plugins can subscribe in one place. * editor-skeleton: Skeleton now owns a `leftView` state ('outline' | 'components'). The default leftArea renders two view-switcher buttons (🌳 / 🧩); the left panel body swaps between OutlineView and the new ComponentPalette. The host can drive the state via `leftView` + `onLeftViewChange` (controlled mode). * editor-skeleton: ComponentPalette (new) — a vertical list of registered components, each row is a `pointerdown` source for `dragon.boost`. Pressed-row visual feedback via startBoost/dropBoost/cancelBoost subscriptions. * editor-skeleton: Skeleton mounts BuiltinSimulatorHost on the canvas element as soon as React attaches it (effect deps canvasEl + project). * designer: `DocumentModel.rename` now refuses to rename the document root (parent === null). The root's componentName is the render entry — renaming it would leave the simulator with an unresolvable name. Settings panel also hides the Rename button and shows an italic "root" hint when the root is the selected node. Both layers guard; the API guard is the safety net, the UI guard is the friendly face. * demo: extended leftArea with 🌳/🧩 view switcher buttons, wired leftView/onLeftViewChange to <Skeleton> in controlled mode. The user can now flip to the palette, drag a Button onto the canvas, and watch it insert + auto-select.
1 parent 11e98f7 commit 27c1974

15 files changed

Lines changed: 905 additions & 58 deletions

File tree

examples/demo/src/main.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,13 @@ function App({ engine }: { engine: ISapuEngine }) {
140140
// `Input` setter for that prop.
141141
const [customOn, setCustomOn] = useState(false);
142142

143+
// L4 left view: which built-in view the left panel is showing.
144+
// The demo drives the Skeleton in CONTROLLED mode (passes
145+
// `leftView` + `onLeftViewChange`) so the icon strip in the
146+
// leftArea slot can flip the state. Without this, the user has
147+
// no way to switch between Outline and Component palette.
148+
const [leftView, setLeftView] = useState<'outline' | 'components'>('outline');
149+
143150
// Push schema into the project AFTER render, never during it.
144151
useEffect(() => {
145152
project.load(schema);
@@ -394,12 +401,39 @@ function App({ engine }: { engine: ISapuEngine }) {
394401

395402
// The Skeleton's `leftArea` slot — a thin icon strip to the LEFT
396403
// of the outline panel. Ali's `leftArea` is the icon column.
397-
// For the demo we just put a couple of icon buttons; their
398-
// semantics are TBD but the slot itself is the proof.
404+
// The demo uses CONTROLLED mode (`leftView` + `onLeftViewChange`
405+
// wired to <Skeleton>) so the user can flip between the Outline
406+
// tree and the Component palette (drag-and-drop source). The
407+
// ⧉ and ↻ buttons are demo-only (open second doc / reset).
399408
const leftArea = () =>
400409
React.createElement(
401410
'div',
402411
{ className: 'flex flex-col items-center gap-1' },
412+
React.createElement(
413+
'button',
414+
{
415+
className:
416+
'w-7 h-7 flex items-center justify-center border border-slate-200 ' +
417+
'rounded hover:bg-slate-100 text-sm ' +
418+
(leftView === 'outline' ? 'bg-blue-100 text-blue-700 ring-1 ring-blue-300' : ''),
419+
onClick: () => setLeftView('outline'),
420+
title: 'Outline view',
421+
},
422+
'🌳',
423+
),
424+
React.createElement(
425+
'button',
426+
{
427+
className:
428+
'w-7 h-7 flex items-center justify-center border border-slate-200 ' +
429+
'rounded hover:bg-slate-100 text-sm ' +
430+
(leftView === 'components' ? 'bg-blue-100 text-blue-700 ring-1 ring-blue-300' : ''),
431+
onClick: () => setLeftView('components'),
432+
title: 'Component palette (drag to canvas)',
433+
},
434+
'🧩',
435+
),
436+
React.createElement('div', { className: 'w-5 h-px bg-slate-200 my-0.5' }),
403437
React.createElement(
404438
'button',
405439
{
@@ -450,6 +484,8 @@ function App({ engine }: { engine: ISapuEngine }) {
450484
setterConfig,
451485
topArea,
452486
leftArea,
487+
leftView,
488+
onLeftViewChange: setLeftView,
453489
}),
454490
),
455491
),

packages/designer/src/document.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,14 @@ export class DocumentModel implements IDocumentModel {
119119
}
120120

121121
rename(node: Node, newName: string): void {
122+
// The root componentName is the render entry (e.g. "Page" maps
123+
// to the host's <Page> component). Renaming it would change
124+
// the document's *root type* — the simulator would no longer
125+
// have a matching component in the registry and would fall
126+
// back to a placeholder. Refuse the mutation here so the
127+
// safety net is in place even if a host forgets to hide the
128+
// Rename UI for the root selection.
129+
if (node.parent === null) return;
122130
const old = node.schema.componentName;
123131
if (old === newName) return;
124132
node.schema.componentName = newName;

packages/designer/src/dragon.ts

Lines changed: 81 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,27 @@
22
* @monbolc/lowcode-designer — Dragon
33
*
44
* Drag state machine. Tracks:
5-
* - which node is being dragged
5+
* - which existing node is being dragged (move), OR
6+
* - which component meta is being dragged from a palette (boost)
67
* - current pointer position
78
* - current drop target (parent node + insertion index)
89
*
910
* The host wires pointer events on the canvas and calls
10-
* `dragon.move(x, y)` and `dragon.drop()` as appropriate.
11+
* `dragon.move(x, y)` and `dragon.commit()` as appropriate.
1112
*
1213
* For the L3 milestone this is a self-contained state machine.
1314
* Visual feedback (ghost, insertion indicator) is rendered by the
1415
* Skeleton layer (L4+).
1516
*/
1617

1718
import { Emitter } from '@monbolc/lowcode-utils';
19+
import type { JSONValue } from '@monbolc/lowcode-types';
1820

1921
export interface DragonState {
2022
/** id of the node being dragged, or null. */
2123
draggingNodeId: string | null;
24+
/** Boost payload (the component about to be instantiated on drop), or null. */
25+
boost: BoostMeta | null;
2226
/** Current pointer x in viewport coords. */
2327
x: number;
2428
/** Current pointer y in viewport coords. */
@@ -36,62 +40,114 @@ export interface DropTarget {
3640
placement: 'before' | 'after' | 'inside';
3741
}
3842

43+
/**
44+
* Payload for a "boost" — a drag that hasn't started from an existing
45+
* node. The component is created from `componentName` (+ optional
46+
* `initialProps`) on a successful drop.
47+
*/
48+
export interface BoostMeta {
49+
componentName: string;
50+
initialProps?: Record<string, JSONValue>;
51+
}
52+
3953
export interface DragonEvents extends Record<string, unknown> {
40-
/** A drag just started. */
54+
/** A drag of an existing node just started. */
4155
start: { nodeId: string };
56+
/** A boost (palette → canvas) drag just started. */
57+
startBoost: { meta: BoostMeta };
4258
/** Pointer moved during a drag. */
4359
move: { x: number; y: number; dropTarget: DropTarget | null };
4460
/** A drag ended without a successful drop. */
4561
cancel: { nodeId: string };
46-
/** A drag ended with a successful drop. */
62+
/** A boost drag ended without a successful drop. */
63+
cancelBoost: { meta: BoostMeta };
64+
/** A drag of an existing node ended with a successful drop. */
4765
drop: { nodeId: string; target: DropTarget };
66+
/** A boost drag ended with a successful drop. */
67+
dropBoost: { meta: BoostMeta; target: DropTarget };
4868
}
4969

5070
export class Dragon {
5171
readonly events = new Emitter<DragonEvents>();
52-
private _state: DragonState = { draggingNodeId: null, x: 0, y: 0, dropTarget: null };
72+
private _state: DragonState = { draggingNodeId: null, boost: null, x: 0, y: 0, dropTarget: null };
5373

5474
get state(): DragonState {
5575
return this._state;
5676
}
5777

5878
get isDragging(): boolean {
59-
return this._state.draggingNodeId !== null;
79+
return this._state.draggingNodeId !== null || this._state.boost !== null;
80+
}
81+
82+
/** True iff a boost (palette → canvas) drag is in progress. */
83+
get isBoosting(): boolean {
84+
return this._state.boost !== null;
6085
}
6186

6287
start(nodeId: string, x: number, y: number): void {
63-
if (this._state.draggingNodeId) return;
64-
this._state = { draggingNodeId: nodeId, x, y, dropTarget: null };
88+
if (this._state.draggingNodeId || this._state.boost) return;
89+
this._state = { draggingNodeId: nodeId, boost: null, x, y, dropTarget: null };
6590
this.events.emit('start', { nodeId });
6691
}
6792

93+
/**
94+
* Start a "boost" drag — the user is dragging a not-yet-instantiated
95+
* component out of a palette. On a successful `commit()`, the host
96+
* should create a new schema node from `meta.componentName` at the
97+
* drop target.
98+
*/
99+
boost(meta: BoostMeta, x: number, y: number): void {
100+
if (this._state.draggingNodeId || this._state.boost) return;
101+
this._state = { draggingNodeId: null, boost: meta, x, y, dropTarget: null };
102+
this.events.emit('startBoost', { meta });
103+
}
104+
68105
move(x: number, y: number, dropTarget: DropTarget | null = null): void {
69-
if (!this._state.draggingNodeId) return;
106+
if (!this._state.draggingNodeId && !this._state.boost) return;
70107
this._state = { ...this._state, x, y, dropTarget };
71108
this.events.emit('move', { x, y, dropTarget });
72109
}
73110

74-
/** Mark the drag as successful; emits a `drop` event with the
75-
* current drop target. The host should commit the actual mutation
76-
* (e.g. via Project.document.move) on receipt. */
77-
commit(): { nodeId: string; target: DropTarget } | null {
78-
const id = this._state.draggingNodeId;
79-
const target = this._state.dropTarget;
80-
if (!id) return null;
81-
this._state = { draggingNodeId: null, x: 0, y: 0, dropTarget: null };
82-
if (target) {
83-
this.events.emit('drop', { nodeId: id, target });
111+
/**
112+
* Mark the drag as successful; emits a `drop` (existing-node move)
113+
* or `dropBoost` (palette → canvas) event with the current target.
114+
* The host should commit the actual mutation on receipt.
115+
*
116+
* Returns a discriminated union so the caller can dispatch on
117+
* `'nodeId' in result` vs `'meta' in result`.
118+
*/
119+
commit():
120+
| { kind: 'move'; nodeId: string; target: DropTarget }
121+
| { kind: 'boost'; meta: BoostMeta; target: DropTarget }
122+
| null {
123+
const { draggingNodeId, boost, dropTarget } = this._state;
124+
if (!draggingNodeId && !boost) return null;
125+
this._state = { draggingNodeId: null, boost: null, x: 0, y: 0, dropTarget: null };
126+
if (dropTarget) {
127+
if (boost) {
128+
this.events.emit('dropBoost', { meta: boost, target: dropTarget });
129+
return { kind: 'boost', meta: boost, target: dropTarget };
130+
}
131+
this.events.emit('drop', { nodeId: draggingNodeId!, target: dropTarget });
132+
return { kind: 'move', nodeId: draggingNodeId!, target: dropTarget };
133+
}
134+
if (boost) {
135+
this.events.emit('cancelBoost', { meta: boost });
84136
} else {
85-
this.events.emit('cancel', { nodeId: id });
137+
this.events.emit('cancel', { nodeId: draggingNodeId! });
86138
}
87-
return target ? { nodeId: id, target } : null;
139+
return null;
88140
}
89141

90142
/** Cancel an in-progress drag (e.g. on Escape). */
91143
cancel(): void {
92-
const id = this._state.draggingNodeId;
93-
if (!id) return;
94-
this._state = { draggingNodeId: null, x: 0, y: 0, dropTarget: null };
95-
this.events.emit('cancel', { nodeId: id });
144+
const { draggingNodeId, boost } = this._state;
145+
if (!draggingNodeId && !boost) return;
146+
this._state = { draggingNodeId: null, boost: null, x: 0, y: 0, dropTarget: null };
147+
if (boost) {
148+
this.events.emit('cancelBoost', { meta: boost });
149+
} else {
150+
this.events.emit('cancel', { nodeId: draggingNodeId! });
151+
}
96152
}
97153
}

packages/designer/src/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,14 @@ export { Project } from './project';
2020
export type { ProjectEvents } from './project';
2121

2222
export { Dragon } from './dragon';
23-
export type { DragonEvents, DragonState, DropTarget } from './dragon';
23+
export type { DragonEvents, DragonState, DropTarget, BoostMeta } from './dragon';
2424

2525
export { Simulator } from './simulator';
2626
export type { SimulatorOptions } from './simulator';
2727

28+
export { BuiltinSimulatorHost } from './simulator-host';
29+
export type { SimulatorHostOptions } from './simulator-host';
30+
2831
export {
2932
InsertCommand,
3033
RemoveCommand,

packages/designer/src/project.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,11 @@ export class Project {
5151
this.document.events.on('nodeMoved', (e) => this.events.emit('nodeMoved', e));
5252
this.document.events.on('nodeRenamed', (e) => this.events.emit('nodeRenamed', e));
5353
this.dragon.events.on('start', (e) => this.events.emit('start', e));
54+
this.dragon.events.on('startBoost', (e) => this.events.emit('startBoost', e));
5455
this.dragon.events.on('move', (e) => this.events.emit('move', e));
5556
this.dragon.events.on('end', (e) => this.events.emit('end', e));
57+
this.dragon.events.on('dropBoost', (e) => this.events.emit('dropBoost', e));
58+
this.dragon.events.on('cancelBoost', (e) => this.events.emit('cancelBoost', e));
5659
}
5760

5861
/** Replace the document with a new root schema. */

0 commit comments

Comments
 (0)