Let variables, fields, ports and list items be dragged into order (#310)
Variables of a behavior, the fields of a struct, the ports of a start or end node and the items of a list could only be added and removed. Changing their order meant removing and adding them again. Now each of them is dragged by the mark in front of its row. - sortBlocks (ui/blockDrag.ts) makes the rows of one column sortable. A block is every row it covers, so a struct moves with its fields and a list item with its nested list. The block is dimmed while it is carried, an accent line shows where it lands, and a scrolled panel follows the pointer at its edge. Less than 4 px stays a click. - The arithmetic is pure and lives in ui/sortable.ts: blockGap, gapLineY, dropSlot and edgeScroll. dropIndex of the tile strip moved there too, so the reorder maths sits in one file. - MoveVariableCommand, MoveStructFieldCommand and MovePortCommand build on the existing MoveCommand. Undo moves back. - Lists need no command. InlineList replaces the list as a whole, as it does for every other edit of its items. - The grip is the kind mark, for a list item its number. Moving only happens among siblings: a field stays in its struct. - On a Struct asset the move runs through WithStructTypes. Typed variables, Pack/Expand ports and split outputs follow the new order. - Values and edges hang on names, so only the order changes. Nodes running a behavior read their ports from the start node, the new order reaches them without a copy. - Not sortable by design: the variables of a placed object, the fields of a typed struct (moved on the Struct asset) and the picked ports of Variables/Properties nodes. - Test hook __gm.inspectorMark(label) finds the mark of a row. Tests: - Gate on melektaus-atx: 235 files, 3572 tests, CHECK EXIT 0 (+9). - tests/core/reorder.test.ts: a variable, a field two levels deep, a Struct asset reaching a typed variable and Pack, and start ports reaching the calling node with edges and result kept. - e2e 22: ALL PASSED, 39 checks (+4, sum 1739). 22f-iii drags a variable past a struct of several rows, then a field inside it. - Counter-probes: dropSlot by top edge instead of middle and a field move that ignores its path fail 3 unit tests. With sortBlocks switched off, the three order checks of 22f-iii fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cf6043a066
commit
2c19c89878
18 files changed
+671
-83
No files matched your search
@@ -0,0 +1,152 @@
|
||||
import { type Container, type FederatedPointerEvent, Graphics, Point } from 'pixi.js';
|
||||
import { cancelFrameWork, onFrame } from './render';
|
||||
import { blockGap, type BlockSpan, dropSlot, edgeScroll, gapLineY } from './sortable';
|
||||
import { Theme } from './Theme';
|
||||
import type { Widget } from './Widget';
|
||||
import { ScrollContainer } from './widgets/ScrollContainer';
|
||||
|
||||
/** One thing to reorder: where it is taken hold of, and the rows it fills in its column. */
|
||||
export interface SortBlock {
|
||||
grip: Widget;
|
||||
/** Siblings in one column, top to bottom. */
|
||||
rows: readonly Widget[];
|
||||
}
|
||||
|
||||
/** How far the pointer goes before a press on a grip becomes a drag; less stays a click. */
|
||||
const DRAG_THRESHOLD = 4;
|
||||
const FRAME_KEY = 'block-drag';
|
||||
const DIMMED = 0.4;
|
||||
|
||||
/**
|
||||
* Lets the blocks of one column be reordered by dragging their grips (#310). Unlike a
|
||||
* `SortableList` the rows are ordinary widgets of any height – a struct is its head with its fields
|
||||
* below – so a block is told by the rows it covers. While dragging, the block is dimmed, a line
|
||||
* shows where it lands, and a scrolled column follows the pointer to its edge. `move` gets the
|
||||
* indices the model takes, the block already taken out.
|
||||
*/
|
||||
export function sortBlocks(blocks: readonly SortBlock[], move: (from: number, to: number) => void): void {
|
||||
if (blocks.length < 2) return;
|
||||
blocks.forEach((block, index) => {
|
||||
block.grip.eventMode = 'static';
|
||||
block.grip.cursor = 'grab';
|
||||
block.grip.on('pointerdown', (e: FederatedPointerEvent) => {
|
||||
if (e.button === 0) new BlockDrag(blocks, index, e.global.y, move);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class BlockDrag {
|
||||
private readonly column: Container | null;
|
||||
private readonly scroll: ScrollContainer | null;
|
||||
private readonly line = new Graphics();
|
||||
private readonly alphas: number[] = [];
|
||||
private readonly pointer = new Point();
|
||||
private active = false;
|
||||
private readonly unbind: () => void;
|
||||
|
||||
constructor(
|
||||
private readonly blocks: readonly SortBlock[],
|
||||
private readonly from: number,
|
||||
private readonly startY: number,
|
||||
private readonly move: (from: number, to: number) => void,
|
||||
) {
|
||||
const block = blocks[from]!;
|
||||
this.column = block.rows[0]?.parent ?? null;
|
||||
this.scroll = scrollOf(this.column);
|
||||
const grip = block.grip;
|
||||
const onMove = (e: FederatedPointerEvent) => this.onMove(e);
|
||||
const onUp = () => this.finish(true);
|
||||
const onGone = () => this.finish(false);
|
||||
grip.on('globalpointermove', onMove);
|
||||
grip.on('pointerup', onUp);
|
||||
grip.on('pointerupoutside', onUp);
|
||||
// A rebuild of the rows mid-drag takes the grip away – and with it the release.
|
||||
grip.once('destroyed', onGone);
|
||||
this.unbind = () => {
|
||||
grip.off('globalpointermove', onMove);
|
||||
grip.off('pointerup', onUp);
|
||||
grip.off('pointerupoutside', onUp);
|
||||
grip.off('destroyed', onGone);
|
||||
};
|
||||
}
|
||||
|
||||
private get rows(): readonly Widget[] {
|
||||
return this.blocks[this.from]!.rows;
|
||||
}
|
||||
|
||||
private onMove(e: FederatedPointerEvent): void {
|
||||
this.pointer.copyFrom(e.global);
|
||||
if (!this.active && Math.abs(e.global.y - this.startY) < DRAG_THRESHOLD) return;
|
||||
if (!this.column) return;
|
||||
if (!this.active) {
|
||||
this.active = true;
|
||||
for (const row of this.rows) {
|
||||
this.alphas.push(row.alpha);
|
||||
row.alpha = DIMMED;
|
||||
}
|
||||
this.column.addChild(this.line);
|
||||
}
|
||||
this.drawLine();
|
||||
this.followEdge();
|
||||
}
|
||||
|
||||
/** The blocks' extents in the column, read now: rows may have moved since the drag began. */
|
||||
private spans(): BlockSpan[] {
|
||||
return this.blocks.map(({ rows }) => ({
|
||||
top: Math.min(...rows.map((row) => row.y)),
|
||||
bottom: Math.max(...rows.map((row) => row.y + row.h)),
|
||||
}));
|
||||
}
|
||||
|
||||
private localY(): number {
|
||||
return this.column ? this.column.toLocal(this.pointer).y : 0;
|
||||
}
|
||||
|
||||
/** Only where a drop would move the block: a line where it already stands promises nothing. */
|
||||
private drawLine(): void {
|
||||
const column = this.column;
|
||||
this.line.clear();
|
||||
if (!column) return;
|
||||
const spans = this.spans();
|
||||
const y = this.localY();
|
||||
if (dropSlot(y, spans, this.from) === null) return;
|
||||
// From the grip's own indent, so a field's line shows it stays inside its struct.
|
||||
const left = column.toLocal(this.blocks[this.from]!.grip.getGlobalPosition()).x;
|
||||
const right = Math.max(...this.rows.map((row) => row.x + row.w));
|
||||
const at = Math.round(gapLineY(blockGap(y, spans), spans));
|
||||
this.line.rect(left, at - 1, Math.max(0, right - left), 2).fill(Theme.colors.accent);
|
||||
}
|
||||
|
||||
/** Scrolls a frame's worth while the pointer stays at the column's edge, and asks for the next. */
|
||||
private followEdge(): void {
|
||||
const scroll = this.scroll;
|
||||
if (!scroll || !this.active) return;
|
||||
onFrame(FRAME_KEY, () => {
|
||||
if (!this.active) return;
|
||||
const before = scroll.offset;
|
||||
scroll.scrollTo(before + edgeScroll(this.pointer.y, scroll.getGlobalPosition().y, scroll.h));
|
||||
if (scroll.offset === before) return;
|
||||
this.drawLine();
|
||||
this.followEdge();
|
||||
});
|
||||
}
|
||||
|
||||
private finish(drop: boolean): void {
|
||||
this.unbind();
|
||||
cancelFrameWork(FRAME_KEY);
|
||||
if (!this.active) return;
|
||||
this.active = false;
|
||||
const to = drop ? dropSlot(this.localY(), this.spans(), this.from) : null;
|
||||
this.rows.forEach((row, i) => {
|
||||
if (!row.destroyed) row.alpha = this.alphas[i] ?? 1;
|
||||
});
|
||||
if (!this.line.destroyed) this.line.destroy();
|
||||
if (to !== null) this.move(this.from, to);
|
||||
}
|
||||
}
|
||||
|
||||
function scrollOf(node: Container | null): ScrollContainer | null {
|
||||
for (let parent = node; parent; parent = parent.parent)
|
||||
if (parent instanceof ScrollContainer) return parent;
|
||||
return null;
|
||||
}
|
||||
+2
-1
@@ -65,7 +65,8 @@ export {
|
||||
type SortableValue,
|
||||
type ThumbClip,
|
||||
} from './widgets/SortableList';
|
||||
export { dropGap, dropMove, rowOrder } from './sortable';
|
||||
export { dropGap, dropIndex, dropMove, rowOrder } from './sortable';
|
||||
export { type SortBlock, sortBlocks } from './blockDrag';
|
||||
export { Popup, type PopupOptions } from './widgets/Popup';
|
||||
export {
|
||||
currentMenu,
|
||||
|
||||
+59
-1
@@ -1,10 +1,12 @@
|
||||
import type { DropSpot } from './widgets/ListView';
|
||||
|
||||
/**
|
||||
* How a sortable list maps between what is on screen and what its owner holds. A layer list shows the
|
||||
* drawing order upside down – the item drawn last is the topmost row – so every index has two
|
||||
* readings, and dropping a dragged row has a third: the place it lands in is counted with the row
|
||||
* still in the list, while the model takes it out first.
|
||||
*
|
||||
* Kept apart from the widget so the arithmetic can be measured without a canvas.
|
||||
* Kept apart from the widgets so the arithmetic can be measured without a canvas.
|
||||
*/
|
||||
|
||||
/** The ids as the list shows them, top first. `reversed` puts the model's last item on top. */
|
||||
@@ -36,3 +38,59 @@ export function dropMove(
|
||||
const last = count - 1;
|
||||
return { from: last - fromRow, to: last - toRow };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a dragged row lands, counted in the list it has *left* – which is what the move command
|
||||
* takes. A drop on the middle of a row takes that row's place: the list is flat, so there is no
|
||||
* "inside" for it to mean, and without this a drag downwards onto the next row would cancel itself
|
||||
* out. The gaps above and below a row are insert points, and one below the dragged row shifts up
|
||||
* by one once the row is out. `-1` is "nothing to do".
|
||||
*/
|
||||
export function dropIndex(from: number, spot: DropSpot, length: number): number {
|
||||
const last = length - 1;
|
||||
if (from < 0 || from > last) return -1;
|
||||
if (spot.at === 'end') return last;
|
||||
const target = Number(spot.targetId);
|
||||
if (!Number.isInteger(target) || target < 0 || target > last) return -1;
|
||||
if (spot.at === 'onto') return target;
|
||||
const insert = target + (spot.at === 'after' ? 1 : 0);
|
||||
return Math.min(last, insert > from ? insert - 1 : insert);
|
||||
}
|
||||
|
||||
/** The rows of one block, top to bottom – a struct with its fields, an item with its nested list. */
|
||||
export interface BlockSpan {
|
||||
top: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The gap a block dropped at `y` falls into, 0…count: past every block whose middle is above `y`.
|
||||
* Blocks differ in height, so the middle of each is what the pointer has to cross.
|
||||
*/
|
||||
export function blockGap(y: number, spans: readonly BlockSpan[]): number {
|
||||
return spans.filter((span) => (span.top + span.bottom) / 2 < y).length;
|
||||
}
|
||||
|
||||
/** Where the line for `gap` is drawn: halfway between the blocks around it, on the edge at either end. */
|
||||
export function gapLineY(gap: number, spans: readonly BlockSpan[]): number {
|
||||
const above = spans[gap - 1];
|
||||
const below = spans[gap];
|
||||
if (above && below) return (above.bottom + below.top) / 2;
|
||||
return above?.bottom ?? below?.top ?? 0;
|
||||
}
|
||||
|
||||
/** The place block `from` moves to when dropped at `y`, or null where it would stay. */
|
||||
export function dropSlot(y: number, spans: readonly BlockSpan[], from: number): number | null {
|
||||
return dropMove(spans.length, from, blockGap(y, spans), false)?.to ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* How far a scrolled column moves this frame while a drag holds the pointer near its top or bottom:
|
||||
* the deeper into the `edge` band, the faster, up to `step`. Positive scrolls down.
|
||||
*/
|
||||
export function edgeScroll(y: number, top: number, height: number, edge = 24, step = 12): number {
|
||||
const into = (depth: number) => Math.ceil((Math.min(edge, depth) / edge) * step);
|
||||
if (y < top + edge) return -into(top + edge - y);
|
||||
if (y > top + height - edge) return into(y - (top + height - edge));
|
||||
return 0;
|
||||
}
|
||||
Reference in new issue
Block a user