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>
97 lines
4.2 KiB
TypeScript
97 lines
4.2 KiB
TypeScript
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 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. */
|
||
export function rowOrder<T>(ids: readonly T[], reversed: boolean): T[] {
|
||
return reversed ? [...ids].reverse() : [...ids];
|
||
}
|
||
|
||
/** The gap a row dropped at `y` falls into, 0…count – the boundary above row 0 being 0. */
|
||
export function dropGap(count: number, y: number, rowHeight: number): number {
|
||
if (rowHeight <= 0) return 0;
|
||
return Math.max(0, Math.min(count, Math.round(y / rowHeight)));
|
||
}
|
||
|
||
/**
|
||
* The move a drop makes, in model indices, or null when the row would land where it already is.
|
||
* `fromRow` and `gap` are in display positions; a gap below the dragged row counts that row itself,
|
||
* which the model does not once it has taken it out.
|
||
*/
|
||
export function dropMove(
|
||
count: number,
|
||
fromRow: number,
|
||
gap: number,
|
||
reversed: boolean,
|
||
): { from: number; to: number } | null {
|
||
if (fromRow < 0 || fromRow >= count) return null;
|
||
const toRow = gap > fromRow ? gap - 1 : gap;
|
||
if (toRow === fromRow || toRow < 0 || toRow >= count) return null;
|
||
if (!reversed) return { from: fromRow, to: toRow };
|
||
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;
|
||
}
|