Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
720c296c7f |
@@ -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 /
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user