Compare commits

...
Author SHA1 Message Date
DumiFlex 720c296c7f refactor(injector): drag on the kit's shared useRowDrag 2026-11-12 04:04:42 +00:00
2 changed files with 55 additions and 282 deletions
+19 -91
View File
@@ -32,13 +32,6 @@ const props = defineProps<{
* events. When false (default), drag is a no-op so external
* consumers (tests, isolated mounts) aren't surprised by dnd. */
reorderable?: boolean;
/** Parent-coordinated drop indicator. `null` = no indicator on
* this row; `'before'` / `'after'` = paint the accent gap+bar at
* the corresponding edge. Lifted to InjectorWidget so only ONE
* row carries the indicator at a time — HTML5 dragleave races
* with the next dragenter, so per-row local state can leave a
* stale indicator on a row the pointer just left. */
dropIndicator?: "before" | "after" | null;
}>();
/** A general-template row: no slot, durable, resolved after all socket
@@ -52,7 +45,7 @@ const slotLabel = computed(() => props.displayLabel ?? props.row.slot_name);
const emit = defineEmits<{
(e: "update", patch: Partial<InjectorRow>): void;
(e: "disconnect"): void;
(e: "row-drag-start", payload: { fromIdx: number }): void;
(e: "row-drag-start", payload: { fromIdx: number; ev: DragEvent }): void;
(e: "row-drag-end"): void;
/** Right-click on the row root. Parent opens a shared ContextMenu
* with edit / disable / collapse / move-top / move-bottom /
@@ -106,35 +99,14 @@ function onBindingInput(ev: Event): void {
emit("update", { binding: next });
}
// Drag-to-reorder state. Only `isDragging` is local now — the visual
// drop indicator (`dropIndicator` prop) is driven by InjectorWidget
// based on `row-drag-over` events so a single source of truth picks
// the active target row + edge. Pre-lift this state caused stale
// indicators on rows the pointer just left (HTML5 dragleave races
// with the next row's dragenter).
const isDragging = ref(false);
// Drag-to-reorder: dragstart / dragend fire on the source row, so they
// stay here; the list-level target, gap, line, slide and pulse are the
// kit's useRowDrag in InjectorWidget (its classes fall through to this root).
function onDragStart(ev: DragEvent): void {
if (!props.reorderable || props.index === undefined) return;
isDragging.value = true;
if (ev.dataTransfer) {
ev.dataTransfer.effectAllowed = "move";
// Set a payload so the platform considers this a valid drag —
// some browsers/HiDPI hosts no-op dragstart when dataTransfer
// is empty. The actual payload is read back via the parent's
// tracked fromIdx; this is just a marker.
ev.dataTransfer.setData("text/wp-injector-row", String(props.index));
}
emit("row-drag-start", { fromIdx: props.index });
emit("row-drag-start", { fromIdx: props.index, ev });
}
// dragover + drop are handled at the LIST level by InjectorWidget so
// the drop-target stays stable when individual rows shift via
// margin-top/bottom (the gap-opening pattern oscillates per-row
// dragover otherwise). dragstart / dragend stay per-row because
// HTML5 fires them on the source element.
function onDragEnd(): void {
isDragging.value = false;
emit("row-drag-end");
}
</script>
@@ -149,9 +121,6 @@ function onDragEnd(): void {
:class="{
'wp-inj-row--disconnected': props.isConnected === false,
'wp-inj-row--disabled': !row.enabled,
'wp-inj-row--dragging': isDragging,
'wp-inj-row--drop-before': dropIndicator === 'before',
'wp-inj-row--drop-after': dropIndicator === 'after',
'wp-inj-row--has-template': hasTemplate,
'wp-inj-row--general': isGeneral,
'wp-conflict-info': conflictSeverity === 'info',
@@ -298,6 +267,7 @@ function onDragEnd(): void {
</div>
</template>
<style scoped src="@/kit/row-drag.css"></style>
<style scoped>
@import "../shared/row-primitives.css";
@@ -328,11 +298,14 @@ function onDragEnd(): void {
margin-bottom var(--wp-motion-fade) var(--wp-motion-curve-flip);
}
.wp-inj-row:last-child { margin-bottom: 0; }
/* Drop-after on the last row still needs the gap — the `:last-child`
* rule above would otherwise zero it out (more specific than the
* `--drop-after` class alone). Chain both selectors to win on
* specificity. */
.wp-inj-row.wp-inj-row--drop-after:last-child { margin-bottom: 14px; }
/* The kit's row-drag gap (row-drag.css loads before this block, so the
* row's own margins above would win without the extra class), also on
* the last row, whose `:last-child` rule zeroes its margin. */
.wp-inj-row.wp-rowdrag--before { margin-top: var(--wp-rowdrag-gap, 14px); }
.wp-inj-row.wp-rowdrag--after,
.wp-inj-row.wp-rowdrag--end,
.wp-inj-row.wp-rowdrag--after:last-child,
.wp-inj-row.wp-rowdrag--end:last-child { margin-bottom: var(--wp-rowdrag-gap, 14px); }
/* Header row — original flat layout lives here. All header controls
* stay on one line via flex-row, while the parent row can expand
@@ -342,56 +315,11 @@ function onDragEnd(): void {
align-items: center;
gap: var(--wp-row-gap, 6px);
}
/* Drag-to-reorder. Row root carries `draggable` when reorderable;
* the row gets a grab cursor on hover so the affordance is
* discoverable without a separate handle glyph. Interactive
* children (toggle, binding input, action buttons) carry
* draggable="false" so click + text-selection still work normally.
*
* Dragging row: dimmed via opacity so the user sees their grab.
* Drop target: 2px accent bar above (before-edge) or below
* (after-edge) — mirrors the cross-platform "drop here" affordance. */
/* Cursor: ModuleRow pattern — only `.wp-drag-handle` shows grab/grabbing
* (from shared/row-primitives.css). Row root keeps the default cursor
* so hovering over the binding input, buttons, or summary doesn't
* suggest the whole row is grab-target. Drag still works from anywhere
* on the row since `draggable=true` lives on root; the cursor is just
* an affordance hint, not a permission gate. */
.wp-inj-row--dragging { opacity: 0.4; }
/* Drop-zone gap + bar — mirrors ModuleRow's wp-gap-before/after
* pattern: target row gets extra margin so a 14px slot opens, then a
* 3px accent bar paints inside that slot via a ::before/::after
* pseudo-element. Bar carries a soft accent glow so the user sees
* where the row will land at a glance. Margins animate via the
* `transition: margin` on `.wp-inj-row` so the gap eases open as the
* pointer crosses the row's midline. */
.wp-inj-row {
position: relative;
/* Margin axis added on top of the existing background/border-color
* transitions so the slot opens smoothly instead of snap-jumping
* when `wp-inj-row--drop-before/after` toggles. */
}
.wp-inj-row--drop-before { margin-top: 14px; }
.wp-inj-row--drop-after { margin-bottom: 14px; }
.wp-inj-row--drop-before::before,
.wp-inj-row--drop-after::after {
content: "";
position: absolute;
left: 0;
right: 0;
height: 3px;
background: var(--wp-accent);
border-radius: min(2px, var(--wp-radius-cap, 999px));
box-shadow: 0 0 8px var(--wp-accent);
pointer-events: none;
animation: wp-inj-drop-bar-in var(--wp-motion-quick) ease-out;
}
.wp-inj-row--drop-before::before { top: -8px; }
.wp-inj-row--drop-after::after { bottom: -8px; }
@keyframes wp-inj-drop-bar-in {
from { opacity: 0; transform: scaleX(0.6); }
to { opacity: 1; transform: scaleX(1); }
}
/* Drag-to-reorder: the whole row is the drag source (interactive
* children carry draggable="false"); only `.wp-drag-handle` shows the
* grab cursor. The dim, gap, line and pulse are the kit's row-drag.css
* (the `wp-rowdrag--*` classes InjectorWidget binds on this root). */
.wp-inj-row { position: relative; }
/* Enter / leave / flash classes consumed by the kit's flip.ts (@/kit/flip)
* when InjectorWidget calls withEnterAnimation /
+36 -191
View File
@@ -11,7 +11,8 @@ import {
} from "../../widgets/_shared";
import { reorderInjectorRows } from "../../widgets/injector";
import { shortConflictLabel, scanInjectorConflicts } from "../../extension/conflicts";
import { applyFlip, captureRects, MOTION_FLIP_MS } from "@/kit/flip";
import { applyFlip, captureRects } from "@/kit/flip";
import { useRowDrag } from "@/kit/row-drag";
import InjectorRowComp from "./InjectorRow.vue";
import ContextMenu, { type ContextMenuItem } from "@/kit/ContextMenu.vue";
import InjectorBindingModal from "./InjectorBindingModal.vue";
@@ -404,26 +405,13 @@ watch(
{ immediate: true },
);
// ── Drag-to-reorder. Persists the new row order with sequential
// slot_name reassignment (reorderInjectorRows): the wire at each
// physical input_N socket then feeds whatever variable now sits at
// that index. dragSrcIdx is captured on dragstart and cleared on
// drop / dragend so a cancelled drag doesn't leak state.
//
// FLIP animation: captureRects BEFORE the row mutation snapshots
// each row's current Y position keyed by `data-uid`. After Vue
// re-renders the new order (nextTick), applyFlip computes the
// inverse-transform per row and tweens back to identity — the rows
// appear to slide to their new positions instead of jump-cutting.
// shared/flip.ts honors the wp-a11y-no-motion body class so the
// animation no-ops under reduce-motion settings.
const dragSrcIdx = ref<number | null>(null);
// Kind of the row being dragged, captured on dragstart. The list-level
// dragover handler reads this to confine the drop target to the
// dragged row's OWN kind-group (socket drags land only among socket
// rows; template drags only among template rows) so the two groups
// never interleave. Cleared on drop / dragend alongside dragSrcIdx.
const dragSrcKind = ref<"socket" | "general" | null>(null);
// ── Drag-to-reorder: the kit's useRowDrag (row-drag.ts + row-drag.css),
// which every reorderable list shares. Persists the new row order with
// sequential slot_name reassignment (reorderInjectorRows): the wire at
// each physical input_N socket then feeds whatever variable now sits at
// that index. A row lands only among rows of its OWN kind (socket rows
// among sockets, template rows among templates), so the two groups never
// interleave; the kit slides the rows into place and pulses the moved one.
const listEl = ref<HTMLElement | null>(null);
/** Half-open `[start, end)` index range of the kind-group a row at
@@ -443,170 +431,27 @@ function rowGroupBounds(idx: number): { start: number; end: number } {
: { start: 0, end: socketEnd };
}
// Parent-coordinated drop indicator. Tracks the row + edge the
// pointer is currently over so only ONE row shows the gap+bar
// indicator at a time. Per-row local state would race: HTML5 fires
// the previous row's dragleave AFTER the new row's dragenter, so a
// brief frame can have two rows showing indicators. Lifting here
// gives a single source: each row emits row-drag-over on its own
// dragover; we just overwrite the target.
const dragOverUid = ref<string | null>(null);
const dragOverEdge = ref<"before" | "after" | null>(null);
function dropIndicatorFor(uid: string): "before" | "after" | null {
return uid === dragOverUid.value ? dragOverEdge.value : null;
}
function clearDropIndicator(): void {
dragOverUid.value = null;
dragOverEdge.value = null;
}
/** UIDs of rows that just landed via drag-drop. Template binds the
* `.wp-drop-pulse` class while a UID is present; entries clear after
* the keyframe duration (420ms in shared/row-primitives.css). */
const recentDropUids = ref<Set<string>>(new Set());
const DROP_PULSE_MS = 420;
function onRowDragStart(fromIdx: number): void {
dragSrcIdx.value = fromIdx;
const row = value.value.rows[fromIdx];
dragSrcKind.value = row?.kind === "general" ? "general" : "socket";
}
// List-level dragover. Tracking at LIST level (not per-row) so the
// drop-target stays stable when individual rows shift by margin-top
// /bottom (the slot opens via `.wp-inj-row--drop-before/after`
// rules). Per-row dragover ran into oscillation: row 2 dragover
// opens its top gap → row 2 shifts down → pointer now sits in the
// gap above row 2 → row 1's dragover fires → row 1 opens its
// bottom gap → row 1 shifts down → flip-flop. List-level handler
// reads pointer Y vs each row's rect once, so shifts inside the
// list don't change the target computation. Mirrors
// ContextWidget.onListDragOver.
function onListDragOver(ev: DragEvent): void {
if (dragSrcIdx.value === null) return;
if (ev.dataTransfer) ev.dataTransfer.dropEffect = "move";
const list = listEl.value;
if (!list) return;
// Filter candidate rows to the dragged row's OWN kind-group. Socket
// rows never see template rows as drop targets and vice-versa, so
// the two groups can't interleave mid-drag. Each row carries
// data-kind="socket"|"general"; we discriminate on it. When
// dragSrcKind is unset (extremely defensive — would only happen if
// a drag started before kind was recorded), the empty filter falls
// through to the "no target" branch and the drop becomes a no-op.
const srcKind = dragSrcKind.value;
const allRows = Array.from(list.querySelectorAll<HTMLElement>(".wp-inj-row[data-uid]"));
const rows = srcKind
? allRows.filter((el) => el.dataset.kind === srcKind)
: [];
let targetUid: string | null = null;
let edge: "before" | "after" | null = null;
for (const el of rows) {
const r = el.getBoundingClientRect();
if (ev.clientY <= r.top + r.height / 2) {
targetUid = el.dataset.uid ?? null;
edge = "before";
break;
}
if (ev.clientY < r.bottom) {
targetUid = el.dataset.uid ?? null;
edge = "after";
break;
}
}
if (!targetUid && rows.length > 0) {
// Pointer past all rows in this group → drop at the END of the
// DRAGGED ROW'S GROUP. Falling back to the whole list's last row
// would let a socket drag drop "after" the final template row,
// re-interleaving the two groups; clamping to the group's last
// row keeps the invariant intact.
const lastEl = rows[rows.length - 1];
targetUid = lastEl.dataset.uid ?? null;
edge = "after";
}
if (targetUid && edge) {
if (dragOverUid.value !== targetUid) dragOverUid.value = targetUid;
if (dragOverEdge.value !== edge) dragOverEdge.value = edge;
}
}
// List-level drop. Reads the last-tracked target from state instead
// of recomputing from the drop event's clientY — by the time drop
// fires, the gap may have already opened (margin shifted the rows),
// so the pointer Y at drop time can pick a different target than
// the visual indicator showed during dragover. Trust the indicator.
function onListDrop(ev: DragEvent): void {
ev.preventDefault();
if (dragSrcIdx.value === null) {
clearDropIndicator();
return;
}
const targetUid = dragOverUid.value;
const edge = dragOverEdge.value;
clearDropIndicator();
if (!targetUid || !edge) return;
const overIdx = value.value.rows.findIndex((r) => r._uid === targetUid);
if (overIdx < 0) return;
onRowDrop(overIdx, edge);
}
function onRowDrop(overIdx: number, edge: "before" | "after"): void {
const from = dragSrcIdx.value;
dragSrcIdx.value = null;
dragSrcKind.value = null;
clearDropIndicator();
if (from === null || from === overIdx) return;
// Convert the (overIdx, edge) pair into a single splice-style
// target index. "before idx N" => insert at N; "after idx N" =>
// insert at N+1. reorderInjectorRows handles the post-removal
// shift internally.
const toIdx = edge === "before" ? overIdx : overIdx + 1;
const nextRows = reorderInjectorRows(value.value.rows, from, toIdx);
if (nextRows === value.value.rows) return;
// Capture the moved row's uid so we can pulse it AFTER the reorder.
// reorderInjectorRows reassigns slot_name in place (sequential
// input_N), but _uid is stable per row identity.
const movedUid = value.value.rows[from]?._uid;
// Snapshot row positions BEFORE the mutation. applyFlip below
// diffs against these to compute the slide distance per row.
const container = listEl.value;
const before = container
? captureRects(container, (el) => el.dataset.uid ?? null)
: null;
value.value = { ...value.value, rows: nextRows };
persist();
if (container && before) {
// Wait one render tick so Vue commits the new row order to the
// DOM before we re-measure rects in applyFlip.
void nextTick(() => {
applyFlip(container, before, (el) => el.dataset.uid ?? null);
});
}
// Drop-pulse on the dropped row. Delay the class apply by
// MOTION_FLIP_MS so the FLIP slide finishes first — without the
// delay the pulse ring would paint mid-swap on a row that's still
// animating into position (matches ContextWidget's pulseDrop
// pattern). Skip entirely under reduce-motion.
if (movedUid && !document.body.classList.contains("wp-a11y-no-motion")) {
setTimeout(() => {
recentDropUids.value = new Set([...recentDropUids.value, movedUid]);
setTimeout(() => {
const next = new Set(recentDropUids.value);
next.delete(movedUid);
recentDropUids.value = next;
}, DROP_PULSE_MS);
}, MOTION_FLIP_MS);
}
}
function onRowDragEnd(): void {
dragSrcIdx.value = null;
dragSrcKind.value = null;
clearDropIndicator();
const drag = useRowDrag({
list: listEl,
rowSelector: ".wp-inj-row[data-uid]",
keyOf: (el) => el.dataset.uid ?? null,
dataType: "text/wp-injector-row",
candidate: (el, from) => el.dataset.kind === kindOfUid(from),
onDrop: (from, t) => {
const rows = value.value.rows;
const fromIdx = rows.findIndex((r) => r._uid === from);
const overIdx = rows.findIndex((r) => r._uid === t.key);
if (fromIdx < 0 || overIdx < 0) return;
// "before N" inserts at N, "after N" (or below the last) at N + 1;
// reorderInjectorRows handles the post-removal shift.
const nextRows = reorderInjectorRows(rows, fromIdx, t.edge === "before" ? overIdx : overIdx + 1);
if (nextRows === rows) return;
value.value = { ...value.value, rows: nextRows };
persist();
},
});
function kindOfUid(uid: string): "socket" | "general" {
return value.value.rows.find((r) => r._uid === uid)?.kind === "general" ? "general" : "socket";
}
// ── Move-to-edge (top / bottom) ────────────────────────────────────
@@ -869,13 +714,14 @@ defineExpose({ addRow, addGeneralRow, removeRow });
<div
ref="listEl"
class="wp-inj-list"
@dragover.prevent="onListDragOver"
@drop.prevent="onListDrop"
@dragover="drag.over"
@drop="drag.drop"
>
<InjectorRowComp
v-for="(row, idx) in value.rows"
:key="row._uid"
:class="{ 'wp-drop-pulse': recentDropUids.has(row._uid) }"
:class="drag.classes(row._uid)"
:style="drag.lineStyle(row._uid)"
:row="row"
:index="idx"
:reorderable="row.kind === 'general' ? generalRowCount > 1 : socketRowCount > 1"
@@ -884,11 +730,10 @@ defineExpose({ addRow, addGeneralRow, removeRow });
:display-label="row.kind === 'general' ? undefined : (row.slot_label ?? slotLabels[row.slot_name])"
:conflict-severity="conflictByUid[row._uid]?.severity"
:conflict-label="conflictByUid[row._uid]?.label"
:drop-indicator="dropIndicatorFor(row._uid)"
@update="(patch) => updateRow(row._uid, patch)"
@disconnect="row.kind === 'general' ? removeRow(row._uid) : emit('disconnect-slot', row.slot_name)"
@row-drag-start="(p) => onRowDragStart(p.fromIdx)"
@row-drag-end="onRowDragEnd"
@row-drag-start="(p) => drag.start(p.ev, row._uid)"
@row-drag-end="drag.end"
@row-contextmenu="(p) => openRowContextMenu(p.ev, p.idx)"
/>
<div