Add pixel-size fit and drag-to-reorder in the hierarchy
The aspect button now carries two offers, so it becomes a menu: fit one side to the texture ratio, or set both sides to the exact pixel size as a single undo step. Dropping a row on the outer quarter of another row inserts it into that gap instead of parenting into it, reordering among the siblings there. Order is the stored order, so ReorderObjectCommand is a splice; equal z falls back to tree order, which makes this the way to draw one object in front of another. Rejected on container content like the other structural edits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f40b8a0523
commit
4568c6581b
17 files changed
+416
-85
No files matched your search
+44
-15
@@ -28,13 +28,19 @@ export interface PickModifiers {
|
||||
shift: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a drop would land. `onto` is the row itself (the tree meaning: inside it), `before` and
|
||||
* `after` are the gaps around it, `end` is the empty space below the last row.
|
||||
*/
|
||||
export type DropSpot = { at: 'onto' | 'before' | 'after'; targetId: string } | { at: 'end' };
|
||||
|
||||
interface DragState {
|
||||
id: string;
|
||||
startX: number;
|
||||
startY: number;
|
||||
/** Set once the pointer travelled past `DRAG_THRESHOLD`. */
|
||||
active: boolean;
|
||||
targetId: string | null;
|
||||
spot: DropSpot | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,8 +59,8 @@ export class ListView extends Widget {
|
||||
onActivate?: (id: string) => void;
|
||||
/** Returning the opened menu keeps its row marked for as long as it is up. */
|
||||
onContextMenu?: (id: string, globalX: number, globalY: number) => Popup | void;
|
||||
/** Called when a dragged row is released over another row (`targetId`) or over empty space (null). */
|
||||
onDrop?: (draggedId: string, targetId: string | null) => void;
|
||||
/** Called when a dragged row is released somewhere that could take it. */
|
||||
onDrop?: (draggedId: string, spot: DropSpot) => void;
|
||||
/**
|
||||
* Called when a row's chevron is clicked or ←/→ is pressed on it. Setting this makes the list
|
||||
* a tree: every row reserves the chevron slot, so labels stay aligned across a level.
|
||||
@@ -196,7 +202,7 @@ export class ListView extends Widget {
|
||||
if (!this.highlighted.has(item.id) || item.id === this.selectedId) this.select(item.id);
|
||||
if (e.detail === 2) this.onActivate?.(item.id);
|
||||
else if (this.onDrop) {
|
||||
this.drag = { id: item.id, startX: e.global.x, startY: e.global.y, active: false, targetId: null };
|
||||
this.drag = { id: item.id, startX: e.global.x, startY: e.global.y, active: false, spot: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,34 +216,57 @@ export class ListView extends Widget {
|
||||
this.cursor = 'grabbing';
|
||||
}
|
||||
const local = this.toLocal(e.global);
|
||||
const index = Math.floor(local.y / this.rowHeight);
|
||||
const target = local.x >= 0 && local.x <= this.w && index >= 0 ? this.items[index] : undefined;
|
||||
drag.targetId = target && target.id !== drag.id ? target.id : null;
|
||||
drag.spot = this.spotAt(local.x, local.y, drag.id);
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a drop at this point would land. The outer quarter of a row is its gap, so the same
|
||||
* drag can put a row inside another (middle) or next to it (edge); a point on the dragged row
|
||||
* itself, or outside the list, takes nothing.
|
||||
*/
|
||||
private spotAt(x: number, y: number, draggedId: string): DropSpot | null {
|
||||
if (x < 0 || x > this.w || y < 0) return null;
|
||||
const index = Math.floor(y / this.rowHeight);
|
||||
const item = this.items[index];
|
||||
if (!item) return { at: 'end' };
|
||||
if (item.id === draggedId) return null;
|
||||
const offset = y - index * this.rowHeight;
|
||||
const edge = this.rowHeight / 4;
|
||||
if (offset < edge) return { at: 'before', targetId: item.id };
|
||||
if (offset > this.rowHeight - edge) return { at: 'after', targetId: item.id };
|
||||
return { at: 'onto', targetId: item.id };
|
||||
}
|
||||
|
||||
private onDragEnd(): void {
|
||||
const drag = this.drag;
|
||||
this.drag = null;
|
||||
this.cursor = 'default';
|
||||
if (!drag?.active) return;
|
||||
this.invalidate();
|
||||
this.onDrop?.(drag.id, drag.targetId);
|
||||
if (drag.spot) this.onDrop?.(drag.id, drag.spot);
|
||||
}
|
||||
|
||||
/** Highlights the row a drag would drop onto, or underlines the list for "drop at top level". */
|
||||
/** Outlines the row a drag would drop into, or draws a line in the gap it would drop between. */
|
||||
private drawDropMarker(): void {
|
||||
this.dropMarker.clear();
|
||||
const drag = this.drag;
|
||||
if (!drag?.active) return;
|
||||
const index = this.items.findIndex((i) => i.id === drag.targetId);
|
||||
if (index >= 0) {
|
||||
const spot = this.drag?.active ? this.drag.spot : null;
|
||||
if (!spot) return;
|
||||
const line = (y: number): void => {
|
||||
this.dropMarker.rect(1, Math.max(0, y - 1), this.w - 2, 2).fill(Theme.colors.accent);
|
||||
};
|
||||
if (spot.at === 'end') {
|
||||
line(Math.min(this.h, this.items.length * this.rowHeight) - 1);
|
||||
return;
|
||||
}
|
||||
const index = this.items.findIndex((i) => i.id === spot.targetId);
|
||||
if (index < 0) return;
|
||||
if (spot.at === 'onto') {
|
||||
this.dropMarker
|
||||
.rect(1, index * this.rowHeight + 1, this.w - 2, this.rowHeight - 2)
|
||||
.stroke({ width: 2, color: Theme.colors.accent });
|
||||
} else {
|
||||
const y = Math.min(this.h, this.items.length * this.rowHeight) - 1;
|
||||
this.dropMarker.rect(1, Math.max(0, y - 1), this.w - 2, 2).fill(Theme.colors.accent);
|
||||
line((index + (spot.at === 'after' ? 1 : 0)) * this.rowHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user