ID9 r218 — VSIL Editor Refinement: Marquee Drag-Select, [ / ] Layer Reorder, Multi-Select Block Move

**Branch**: `feat/id9-r218-refinement` @ `51b2cd3`

**Base**: `main` @ `3fbedab` (post r217 merge)

**Worktree**: `/workspace/vibecoder-standalone-r218` (Vite port 5305)

**Tests**: 33/33 r218 unit + 19/19 r218 Playwright + 279/279 prior unit + 158/158 prior Playwright = **489/489 ✅**

**Build**: `npm run build` clean, 0 postMessage, 0 iframes (1 dynamic-import ref to `replace-iframe-vsil.js`, expected)

---

1. What is r218?

r218 is the **VSIL Editor refinement pass** that adds three integrated quality-of-life features to the multi-select editor built in r213–r217:

1. **Marquee drag-select** — click-and-drag on the canvas to rubber-band-select all layers whose bounds intersect the rectangle. Replaces the previous "click a single layer to select it" workflow for batch operations.

2. **`[` / `]` keyboard shortcuts** — move the currently-selected layer (or selection block) up or down in the layers panel. Bound to the editor's `useShortcuts` hook alongside the existing shortcuts.

3. **Multi-select block move (preserves internal order)** — when multiple layers are selected and the operator hits `[` or `]`, the entire block moves as a contiguous unit, preserving the internal order of the selection.

These three features work together: drag-select builds the multi-selection, then `[` / `]` repositions the whole block in the stack.

---

2. Architecture

2.1 Single-slot reorder listener (module-level)

The layers panel's local React state must stay in sync with the canonical layers list maintained by the selection hook. The selection hook is the single source of truth (singleton `currentLayers`), but the panel's local state is what the user sees.

**The pattern**: a module-level `reorderListener: fn | null` slot. `setReorderListener(fn)` replaces whatever was there. `getReorderListener()` returns the current. When `reorderSelected` mutates the layers, it invokes the listener.

```ts

// selection.ts

let reorderListener: ((next: LayerSnapshot[]) => void) | null = null;

export function setReorderListener(fn: ((next: LayerSnapshot[]) => void) | null) {

reorderListener = fn;

}

export function getReorderListener() {

return reorderListener;

}

```

The LayersPanel composes with whatever was there before (so test listeners can still fire), and restores the previous listener on unmount:

```ts

// LayersPanel.tsx mount effect

useEffect(() => {

selection.pushSnapshot(layers.map(...));

selection.setCurrentLayers(layers);

const prevListener = getReorderListener();

setReorderListener((next) => {

if (prevListener) prevListener(next);

setLayers((prev) => { /* remap local state */ });

});

return () => { setReorderListener(prevListener); };

}, []);

```

This is the r218 single-slot model: only one production listener is active at a time, but composing with the previous listener allows the test runner (which installs its own listener per test) to keep working.

2.2 `reorderSelected` block-move algorithm

The naive approach (swap pairs) destroys internal multi-select order. r218 uses a block-move algorithm that preserves the order of the selection while repositioning the whole block.

```ts

const reorderSelected = useCallback((direction, mutate) => {

const selectedSet = new Set(singleton.currentSelected);

if (selectedSet.size === 0) return;

if (!singleton.currentLayers || singleton.currentLayers.length === 0) return;

const current = [...singleton.currentLayers];

const selected: typeof current = [];

const unselected: typeof current = [];

for (const l of current) {

if (selectedSet.has(l.id)) selected.push(l);

else unselected.push(l);

}

let next: typeof current;

if (direction === 'up') {

const firstSelectedIdx = current.findIndex((l) => selectedSet.has(l.id));

if (firstSelectedIdx === 0) return; // already at top

const insertAt = firstSelectedIdx - 1;

const filtered = current.filter((l) => !selectedSet.has(l.id));

next = [...filtered.slice(0, insertAt), ...selected, ...filtered.slice(insertAt)];

} else {

let lastSelectedIdx = -1;

for (let i = 0; i < current.length; i++) {

if (selectedSet.has(current[i].id)) lastSelectedIdx = i;

}

if (lastSelectedIdx === current.length - 1) return; // already at bottom

const insertAt = lastSelectedIdx + 1;

const filtered = current.filter((l) => !selectedSet.has(l.id));

next = [...filtered.slice(0, insertAt), ...selected, ...filtered.slice(insertAt)];

}

if (next.length === current.length && next.every((l, i) => l.id === current[i].id)) return;

mutate(next);

if (reorderListener) reorderListener(next);

singleton.currentLayers = next.map((l) => ({ ...l }));

pushSnap(buildSnap({ layers: next.map((l) => ({ ...l })) }));

}, [ctx]);

```

The algorithm:

1. Partition `current` into `selected` (preserving order) and `unselected`.

2. Find the first/last selected index in the original `current`.

3. Compute the insertAt position: one before the first selected (up) or one after the last selected (down).

4. Build the new list: `unselected[0..insertAt-1] + selected + unselected[insertAt..]`.

The "block" moves as a contiguous unit. Internal order is preserved. If the block is at the boundary (top or bottom), it's a no-op.

2.3 VSILEditor wiring

The previous r214 code had:

```ts

const layersRef = useRef<Layer[]>([]); // <-- NEVER WRITTEN. Read in onLayerUp/onLayerDown.

```

The r217 fix replaced this with `selection.layers` (sourced from the singleton). r218 takes the next step and uses the same `selection.layers` for `onLayerUp` / `onLayerDown`:

```ts

const onLayerUp = useCallback(() => {

selection.reorderSelected('up', () => {});

}, [selection]);

const onLayerDown = useCallback(() => {

selection.reorderSelected('down', () => {});

}, [selection]);

```

The `mutate` argument is a stub (the selection hook mutates the singleton directly; the listener is what reaches React state).

2.4 Canvas marquee

The marquee is drawn in viewport-relative CSS pixels and the layer box bounds are in scene coords (0..1920, 0..1080). The commit logic maps viewport → scene using the viewport's bounding rect.

```ts

// onMouseDown: clamp startX/Y to viewport

const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left));

const y = Math.max(0, Math.min(rect.height, e.clientY - rect.top));

marqueeRef.current = { startX: x, startY: y, curX: x, curY: y, shift: e.shiftKey };

didDragRef.current = false;

setMarquee({ startX: x, startY: y, curX: x, curY: y });

// onMouseMove: track current X/Y

marqueeRef.current.curX = x;

marqueeRef.current.curY = y;

setMarquee({ ... });

// onMouseUp (or document-level mouseup): commit

const scaleX = 1920 / rect.width;

const scaleY = 1080 / rect.height;

const x1 = Math.min(m.startX, m.curX) * scaleX;

const y1 = Math.min(m.startY, m.curY) * scaleY;

const x2 = Math.max(m.startX, m.curX) * scaleX;

const y2 = Math.max(m.startY, m.curY) * scaleY;

const hits = [];

for (const l of layers) {

if (l.bounds.x < x2 && l.bounds.x + l.bounds.w > x1 &&

l.bounds.y < y2 && l.bounds.y + l.bounds.h > y1) {

hits.push(l.id);

}

}

if (hits.length > 0) selection.setSelectedByRect(hits, { toggle: m.shift });

```

**Critical fix (the one that took 4 hours to find)**: the canvas's `onMouseUp` doesn't always fire because the mouseup can land on a child element (a layer box). r218 adds a **document-level mouseup listener** that fires the commit when a marquee is active, regardless of where mouseup lands:

```ts

useEffect(() => {

if (typeof window === 'undefined') return;

const onDocMouseUp = () => {

if (marqueeRef.current) onMouseUp();

};

document.addEventListener('mouseup', onDocMouseUp);

return () => document.removeEventListener('mouseup', onDocMouseUp);

}, [onMouseUp]);

```

2.5 `didDragRef` — marquee vs click disambiguation

When the user clicks on a layer box, two things happen:

1. `mousedown` → canvas's onMouseDown starts a marquee at the click position.

2. `mouseup` → the layer box's `onClick` fires.

If the user dragged (mouse moved > 2px), the marquee wins. If the user clicked (no drag), the layer box's `onClick` wins (single-select).

```ts

// onClickLayer

if (didDragRef.current) {

didDragRef.current = false; // reset for next click

return; // marquee already committed; don't single-select

}

selection.setActiveRegionId(layerId as any);

```

---

3. Edge cases handled

| Case | Behavior |

|------|----------|

| Marquee with no movement (click without drag) | didDragRef stays false; onClickLayer fires (single-select wins) |

| Marquee starts outside viewport (canvas padding) | Clamped to [0, viewport.size] on onMouseDown |

| Marquee ends on a layer box (mouseup intercepted) | Document-level mouseup listener catches it; commit fires |

| Multi-select with `[` at top boundary | No-op (firstSelectedIdx === 0) |

| Multi-select with `]` at bottom boundary | No-op (lastSelectedIdx === length-1) |

| Selection of 1 layer via `[` or `]` | Works the same as multi-select (block-of-1) |

| Drag-select with Shift held | Toggles intersecting layers in/out of selection |

| Drag-select with plain click | Replaces selection with intersecting layers |

| Esc during drag-select | No effect (the marquee is committed on mouseup, not on Esc) |

| `reorderSelected` on empty selection | Early return |

| Marquee commits to 0 hits | No-op (selection unchanged) |

---

4. Test coverage

4.1 r218 unit tests (33/33)

`src/host/editor/__tests__/editor-r218.test.tsx` — 25 sections:

1. selection hook exports `reorderSelected` and `setSelectedByRect`

2. selection hook exports `setReorderListener` and `getReorderListener`

3. `reorderSelected('up')` on single layer moves it up

4. `reorderSelected('down')` on single layer moves it down

5. `reorderSelected('up')` at top is a no-op

6. `reorderSelected('down')` at bottom is a no-op

7. `reorderSelected('up')` on multi-select moves block as a unit

8. `reorderSelected('down')` on multi-select moves block as a unit

9. multi-select block move preserves INTERNAL order

10. `reorderSelected` on empty selection is a no-op

11. `reorderSelected` invokes the registered listener

12. `reorderSelected` pushes a history snapshot (6 surfaces)

13. `setSelectedByRect` with no `toggle` replaces selection

14. `setSelectedByRect` with `toggle: true` toggles each hit

15. `setReorderListener` replaces prior listener (single-slot)

16. `setReorderListener(null)` clears the slot

17. LayersPanel mounts and registers a reorder listener

18. LayersPanel unmounts and restores the previous listener

19. LayersPanel local state stays in sync after reorderSelected

20. Canvas marquee state renders when marquee is non-null

21. Canvas marquee state is null after commit

22. Source-level: 0 iframes, 0 postMessage in r218 source

23. r217 selectAll after reorderSelected still selects all

24. r217 Esc after reorderSelected still clears selection

25. reorderSelected updates LayersPanel local state via listener

4.2 r218 Playwright smoke (19/19)

`scripts/smoke/r218-smoke.spec.cjs`:

1. Editor mounted with 4 default layers

2. `]` moves selected layer (fx) down

3. `]` again moves fx to next position

4. `]` at bottom is a no-op

5. `[` moves selected layer (bg) up

6. `[` moves layer-fx to top

7. `]` moves layer-fx to index 1

8. `]` again moves layer-fx to index 2

9. Multi-select reorder (shift+click 2 layers, then `[`)

10. Undo restores original order

11. Multi-transform apply (`r216`)

12. Canvas drag-select draws a marquee

13. Marquee select selects intersecting layers

14. Esc clears the drag-select

15. r217 Cmd+A still works

16. 0 iframes in DOM

(Plus 3 supporting sections for invariant verification.)

4.3 Prior tests (no regressions)

| Suite | Tests | Status |

|-------|-------|--------|

| r207 mounts | 24 | ✅ |

| r208 integration | 27 | ✅ |

| r210 minimal-header | 27 | ✅ |

| r212 side-chrome | 27 | ✅ |

| r213 editor (base) | 36 | ✅ |

| r214 editor polish | 24 | ✅ |

| r214.1 history-applied | 18 | ✅ |

| r215 multi-select + drag-reorder | 25 | ✅ |

| r216 multi-transform + context menu | 41 | ✅ |

| r217 multi-select keyboard shortcuts | 30 | ✅ |

| **r218 marquee + reorder** | **33** | **✅** |

| **Subtotal unit** | **312** | **✅** |

| Prior Playwright | 158 | ✅ |

| **r218 Playwright smoke** | **19** | **✅** |

| **TOTAL** | **489** | **✅** |

---

5. Files modified

| File | Lines changed | Purpose |

|------|---------------|---------|

| `src/host/editor/selection.ts` | +141 | Added `reorderSelected`, `setSelectedByRect`, `setReorderListener`, `getReorderListener` |

| `src/host/editor/VSILEditor.tsx` | +20 / -12 | Wired `[` / `]` shortcuts to `reorderSelected`; removed dead `layersRef` |

| `src/host/editor/LayersPanel.tsx` | +21 | Registers reorder listener on mount, composes with prevListener |

| `src/host/editor/Canvas.tsx` | +167 / -30 | Marquee state, drag-ref-based click suppression, viewport-relative coords, document mouseup |

| `src/host/editor/__tests__/editor-r218.test.tsx` | +499 (new) | 33 unit tests across 25 sections |

| `scripts/smoke/r218-smoke.spec.cjs` | +322 (new) | 19 Playwright smoke tests |

| `scripts/smoke/screenshots-r218/*.png` | 4 (new) | Visual evidence |

---

6. Invariants preserved

---

7. Known limitations / follow-ups

1. **Marquee Esc during drag doesn't cancel** — Esc is bound to clearSelection, but during an in-progress drag the marquee is not yet committed, so Esc has no effect. A future iteration could add Esc-during-drag-cancel. Not in scope for r218.

2. **Marquee doesn't union with current selection unless Shift held** — by design: plain drag replaces. Shift+drag toggles. This matches the convention of Figma, Photoshop, and other tools.

3. **`reorderSelected` with non-contiguous multi-select** — the algorithm assumes the selected layers form a contiguous block. If they don't, the result still moves them as a contiguous block (re-grouped). This matches the user's mental model ("move my selection up").

4. **Document-level mouseup listener** — could leak if Canvas unmounts mid-drag. The cleanup function in useEffect handles normal unmount, but a force-unmount during a drag leaves the listener in place until next render. Acceptable for now; r219 could add a guard.

---

8. Operator action

1. **Review the r218 branch** on `feat/id9-r218-refinement` @ `51b2cd3` (already pushed to origin).

2. **Run locally** to verify: `cd /workspace/vibecoder-standalone-r218 && npm install && npm run dev` (port 5305) — open `http://localhost:5305`, navigate to a scene, try the marquee + `[` / `]` keys.

3. **Approve and merge to main** when ready. r218 follows the r217 merge pattern: `git checkout main && git merge --no-ff feat/id9-r218-refinement -m "merge: r218 → main (...)" && git tag -a r218-shipped -m "..." && git push origin main r218-shipped`.

4. **Production deploy is still BLOCKED** — needs SSH access to `operator@185.249.73.178` to run `deploy.sh`. See bulletin b1866.

---

*Generated 2026-08-30 by VibeCoder-standalone Mavis (thread 9).*