Place assets by dragging them from the panel into the view
The context menu entry is gone; dragging replaces it entirely. The drop target is the view under the pointer, not the active tab, so the same gesture works for scenes and for container templates. While dragging, an outline shows the footprint the object will have plus a crosshair on its origin, turning red when the drop would be refused. Preview and command share one function (placementRejection), so the outline cannot promise something the drop then rejects. A left-press used to select the list row immediately. Since the container tab follows the selection, it switched to the very container being dragged and every drop would have nested it inside itself. The list now selects on release without a drag: picking something up is not the same as choosing it.
This commit is contained in:
1 parent
14562cf157
commit
75d377af39
18 files changed
+545
-77
No files matched your search
@@ -0,0 +1,87 @@
|
||||
import { Container, Graphics, Text } from 'pixi.js';
|
||||
import { Theme, textStyle } from './Theme';
|
||||
import { ui } from './UIContext';
|
||||
|
||||
/** What is being dragged. `kind` lets a drop target ignore payloads it does not take. */
|
||||
export interface DragPayload {
|
||||
kind: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
/** Offset of the ghost from the pointer, so the cursor keeps a clear view of the drop point. */
|
||||
const GHOST_OFFSET = 14;
|
||||
|
||||
interface Session {
|
||||
payload: DragPayload;
|
||||
ghost: Container;
|
||||
move: () => void;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
let session: Session | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
/**
|
||||
* A drag that left the widget it started in, e.g. an asset on its way to the viewport. The
|
||||
* source starts it and forgets it; whoever is under the pointer reads `activeDrag()` and, on
|
||||
* `pointerup`, acts. The session ends itself on release: it listens on the stage, which sees
|
||||
* every pointer event last, so a drop target still has the payload when its own handler runs.
|
||||
*/
|
||||
export function startDrag(payload: DragPayload, label: string): void {
|
||||
endDrag();
|
||||
const { app, overlay } = ui();
|
||||
const ghost = buildGhost(label);
|
||||
overlay.addChild(ghost);
|
||||
|
||||
const move = (): void => {
|
||||
const p = app.renderer.events.pointer.global;
|
||||
ghost.position.set(p.x + GHOST_OFFSET, p.y + GHOST_OFFSET);
|
||||
};
|
||||
const stop = (): void => endDrag();
|
||||
session = { payload, ghost, move, stop };
|
||||
move();
|
||||
|
||||
app.stage.on('globalpointermove', move);
|
||||
app.stage.on('pointerup', stop);
|
||||
app.stage.on('pointerupoutside', stop);
|
||||
notify();
|
||||
}
|
||||
|
||||
export function activeDrag(): DragPayload | null {
|
||||
return session?.payload ?? null;
|
||||
}
|
||||
|
||||
export function endDrag(): void {
|
||||
if (!session) return;
|
||||
const { ghost, move, stop } = session;
|
||||
session = null;
|
||||
const { app } = ui();
|
||||
app.stage.off('globalpointermove', move);
|
||||
app.stage.off('pointerup', stop);
|
||||
app.stage.off('pointerupoutside', stop);
|
||||
ghost.destroy({ children: true });
|
||||
notify();
|
||||
}
|
||||
|
||||
/** Runs whenever a drag starts or ends – a hover preview uses it to clear itself. */
|
||||
export function onDragChange(fn: () => void): void {
|
||||
listeners.add(fn);
|
||||
}
|
||||
|
||||
function notify(): void {
|
||||
for (const fn of listeners) fn();
|
||||
}
|
||||
|
||||
function buildGhost(label: string): Container {
|
||||
const ghost = new Container();
|
||||
ghost.eventMode = 'none';
|
||||
ghost.alpha = 0.85;
|
||||
const text = new Text({ text: label, style: textStyle('body') });
|
||||
const box = new Graphics()
|
||||
.roundRect(0, 0, text.width + 16, Theme.rowHeight, 4)
|
||||
.fill(Theme.colors.panel)
|
||||
.stroke({ width: 1, color: Theme.colors.accent });
|
||||
text.position.set(8, (Theme.rowHeight - text.height) / 2);
|
||||
ghost.addChild(box, text);
|
||||
return ghost;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export { Theme, textStyle, type TextStyleToken } from './Theme';
|
||||
export { initUI, ui, type UIContext } from './UIContext';
|
||||
export { drawControlBox, drawGrid, type ControlBoxOptions, type GridOptions } from './draw';
|
||||
export { clamp, centreY, formatNumber, NUMBER_DECIMALS, snapToStep, zoomFactor } from './math';
|
||||
export { activeDrag, type DragPayload, endDrag, onDragChange, startDrag } from './drag';
|
||||
export { Label, truncate } from './widgets/Label';
|
||||
export { Button, type ButtonOptions } from './widgets/Button';
|
||||
export { Dropdown } from './widgets/Dropdown';
|
||||
|
||||
@@ -63,6 +63,11 @@ export class ListView extends Widget {
|
||||
onContextMenu?: (id: string, globalX: number, globalY: number) => Popup | void;
|
||||
/** Called when a dragged row is released somewhere that could take it. */
|
||||
onDrop?: (draggedId: string, spot: DropSpot) => void;
|
||||
/**
|
||||
* Called once a press has travelled far enough to be a drag, for rows that are dragged out of
|
||||
* the list rather than within it. The list then drops the gesture: the owner owns it from here.
|
||||
*/
|
||||
onDragOut?: (id: string) => 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.
|
||||
@@ -136,6 +141,14 @@ export class ListView extends Widget {
|
||||
return this.rows.find((r) => r.item.id === id)?.labelColor ?? null;
|
||||
}
|
||||
|
||||
/** Global centre of a row; null when no such row is shown (test hook). */
|
||||
rowGlobal(id: string): { x: number; y: number } | null {
|
||||
const row = this.rows.find((r) => r.item.id === id);
|
||||
if (!row) return null;
|
||||
const p = row.getGlobalPosition();
|
||||
return { x: p.x + this.w / 2, y: p.y + this.rowHeight / 2 };
|
||||
}
|
||||
|
||||
/** Global centre of a row's chevron; null when the row has none. */
|
||||
chevronGlobal(id: string): { x: number; y: number } | null {
|
||||
return this.rows.find((r) => r.item.id === id)?.chevronGlobal() ?? null;
|
||||
@@ -205,10 +218,13 @@ export class ListView extends Widget {
|
||||
} else if (modifiers.ctrl || modifiers.shift) {
|
||||
this.onSelect?.(item.id, modifiers);
|
||||
} else {
|
||||
// Clicking a highlighted row keeps the multi-selection so it can be dragged as a whole.
|
||||
if (!this.highlighted.has(item.id) || item.id === this.selectedId) this.select(item.id);
|
||||
// A row that leaves the list is selected on release instead (see `onDragEnd`): picking
|
||||
// something up is not choosing it, and selecting an asset can move the very view it is
|
||||
// being dragged into.
|
||||
if (!this.onDragOut && (!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) {
|
||||
else if (this.onDrop || this.onDragOut) {
|
||||
this.drag = { id: item.id, startX: e.global.x, startY: e.global.y, active: false, spot: null };
|
||||
}
|
||||
}
|
||||
@@ -221,6 +237,12 @@ export class ListView extends Widget {
|
||||
if (Math.hypot(e.global.x - drag.startX, e.global.y - drag.startY) < DRAG_THRESHOLD) return;
|
||||
drag.active = true;
|
||||
this.cursor = 'grabbing';
|
||||
if (this.onDragOut) {
|
||||
this.drag = null;
|
||||
this.cursor = 'default';
|
||||
this.onDragOut(drag.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const local = this.toLocal(e.global);
|
||||
drag.spot = this.spotAt(local.x, local.y, drag.id);
|
||||
@@ -249,7 +271,11 @@ export class ListView extends Widget {
|
||||
const drag = this.drag;
|
||||
this.drag = null;
|
||||
this.cursor = 'default';
|
||||
if (!drag?.active) return;
|
||||
if (!drag?.active) {
|
||||
// The press never travelled, so it was a click after all – now it may select.
|
||||
if (drag && this.onDragOut) this.select(drag.id);
|
||||
return;
|
||||
}
|
||||
this.invalidate();
|
||||
if (drag.spot) this.onDrop?.(drag.id, drag.spot);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user