Add points by double-click and delete the picked one (#288)
Four editors edit points, and all of them put a point down on a single press on the line and took one away on a double-click. A stray click added a point, and there was no way to see which point a key would hit. All four now work the same way: the Curve tab, the Vector art tab, a polygon collider in the scene view and a frame's collision outline in the Texture tab. - A double-click on a line, edge or segment puts a point there, in the Curve tab on free space too. The point is picked and stays in place. It can be dragged with the next press, as answered in the issue. - A click on a point picks it, and it is drawn filled. A double-click on a point takes nothing away any more. - Delete or Backspace takes the picked point away. Without a picked point the key removes the shape, the object or the outline as before. - A right-click on a point picks it and opens a menu with Delete point. In the Curve and Vector art tab the four point types stand below it. Alt-click stays. - A polygon keeps three corners and a curve keeps its ends. The entry is greyed out then. - The pick ends with a click beside the points, with another object, shape or frame, and after the point is gone. - editor/pointGestures.ts holds the rule as a pure function, and editor/pointMenu.ts builds the menu for all four. Decided here: - A single click on a line no longer adds anything. In Vector art it drags the shape, on a polygon it drops the pick, and in the Texture tab it makes that outline the active one and drops the pick. - In the Texture tab a click inside the frame still appends a corner, and that corner is picked. Delete takes the last corner first. To remove the whole outline, click one of its edges before. - The point the Pen put down last is picked, so Delete takes it and not the shape. - Delete hits the picked point also when the tab has lost the keyboard focus, after a click in the shape list or after the menu. - A vector shape that loses its last point is removed whole. Before, an empty shape stayed in the list. Findings: - Fixed: the new hint line in the Texture tab was cut off at the edge. The shorter text was not looked at in a picture again. - Not fixed: in the scene view the collider's name label covers a corner that sits near the centre. That was so before. Tests: - Gate: 200 files, 2967 tests (2944 + 23), exit 0 - e2e, full run: 1629 checks passed (1625 + 4), exit 0 - Smokes: 24 / 19 / 21, exit 0 - After the full run an unused parameter and a comment were tidied. For that final state the gate and e2e 42,47,53 (105 checks) ran again, the full run did not. - 23 unit tests in tests/editor/point-gestures.test.ts, for the rule and for each of the four editors - Four e2e checks with a real mouse, two in module 42 and one each in 47 and 53. The existing checks for adding and removing a point use the new gestures. - 20 unit counter-probes all fail tests, run on stein2 - Two browser counter-probes fail, the build went through both times. Without the Delete wiring the key removes the collider. Without the right-click hook the menu is missing, and that run stops in module 42, so 47 and 53 were not reached with it. - Three screenshots looked at, the picked point is filled. None of the Vector art tab. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1aa824ecb4
commit
0f85476b93
19 files changed
+1180
-168
No files matched your search
@@ -44,6 +44,8 @@ export abstract class SheetCanvas<A> extends Widget {
|
||||
/** The pointer moved while no pan runs. */
|
||||
protected abstract move(local: Vec2): void;
|
||||
protected abstract release(): void;
|
||||
/** A right-click; a tab with a menu for what lies under the pointer opens it here. */
|
||||
protected context(_target: A, _e: FederatedPointerEvent, _local: Vec2): void {}
|
||||
|
||||
override measure(): Size {
|
||||
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
|
||||
@@ -104,6 +106,7 @@ export abstract class SheetCanvas<A> extends Widget {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (e.button === 2) this.context(target, e, { x: local.x, y: local.y });
|
||||
if (e.button !== 0) return;
|
||||
ui().focus.focus(this);
|
||||
this.press(target, e, { x: local.x, y: local.y });
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
gripAt,
|
||||
insideOutline,
|
||||
MIN_CORNERS,
|
||||
type PolygonGrip,
|
||||
withCornerAfter,
|
||||
withCornerMoved,
|
||||
withoutCorner,
|
||||
@@ -38,6 +37,8 @@ import {
|
||||
import { COLLIDER_FILL_ALPHA, dashedLine } from '../../viewport/symbols';
|
||||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||||
import { FramePreview } from '../framePreview';
|
||||
import { deleteTakes, pickedIn, pointPress } from '../pointGestures';
|
||||
import { openPointMenu } from '../pointMenu';
|
||||
import type { EditorState } from '../state';
|
||||
import { drawChecker } from './checker';
|
||||
import { SheetCanvas } from './SheetCanvas';
|
||||
@@ -145,6 +146,8 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||
private collision = false;
|
||||
/** The outline a click adds corners to; for another frame, or one gone, the frame's last one. */
|
||||
private active = { frameId: '', index: -1 };
|
||||
/** The corner last pressed or put down (#288): Delete takes it rather than its whole outline. */
|
||||
private picked: { frameId: string; outline: number; index: number } | null = null;
|
||||
private readonly stopPictures: () => void;
|
||||
private readonly perFrame: FramePreview;
|
||||
|
||||
@@ -156,9 +159,13 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||
this.content.addChild(this.image, this.plates, this.marks, this.labelLayer);
|
||||
|
||||
state.on('project', () => this.redraw());
|
||||
state.on('selection', () => this.redraw());
|
||||
state.on('selection', () => {
|
||||
if (this.picked && this.picked.frameId !== this.selectedFrameId()) this.picked = null;
|
||||
this.redraw();
|
||||
});
|
||||
state.on('load', () => {
|
||||
this.needsFit = true;
|
||||
this.picked = null;
|
||||
this.redraw();
|
||||
});
|
||||
// A picture arrives asynchronously; until then there is nothing to fit the view to.
|
||||
@@ -176,15 +183,45 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||
if (this.assetId === assetId) return;
|
||||
this.assetId = assetId;
|
||||
this.needsFit = true;
|
||||
this.picked = null;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
setCollisionMode(on: boolean): void {
|
||||
if (this.collision === on) return;
|
||||
this.collision = on;
|
||||
this.picked = null;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** The picked corner of the selected frame's outlines, null for none (test hook, and what is drawn filled). */
|
||||
get pickedCorner(): { outline: number; index: number } | null {
|
||||
const frame = this.frame(this.selectedFrameId());
|
||||
const picked = this.picked;
|
||||
if (!this.collision || !frame || picked?.frameId !== frame.id) return null;
|
||||
const corners = frameCollision(frame)[picked.outline]?.length ?? 0;
|
||||
return pickedIn(corners, picked.index) < 0 ? null : { outline: picked.outline, index: picked.index };
|
||||
}
|
||||
|
||||
/** False while no corner is picked, so Delete goes on to the active outline, or to the frame. */
|
||||
removePickedPoint(): boolean {
|
||||
if (deleteTakes(this.pickedCorner !== null) !== 'point') return false;
|
||||
this.removePicked();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Takes the picked corner out of its outline; an outline left with no corner goes with it. */
|
||||
private removePicked(): void {
|
||||
const frame = this.frame(this.selectedFrameId());
|
||||
const picked = this.pickedCorner;
|
||||
if (!frame || !picked) return;
|
||||
const outlines = frameCollision(frame);
|
||||
const next = outlines.map((o, i) => (i === picked.outline ? withoutCorner(o, picked.index, 0)! : o));
|
||||
this.active = { frameId: frame.id, index: next[picked.outline]!.length > 0 ? picked.outline : -1 };
|
||||
this.picked = null;
|
||||
this.state.execute(new SetFrameCollisionCommand(this.assetId, frame.id, next));
|
||||
}
|
||||
|
||||
get collisionMode(): boolean {
|
||||
return this.collision;
|
||||
}
|
||||
@@ -235,12 +272,16 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||
return frameId === frame.id && index < outlines.length ? index : outlines.length - 1;
|
||||
}
|
||||
|
||||
/** Delete takes the active outline away whole, in collision mode – not the frame it belongs to. */
|
||||
/**
|
||||
* Delete in collision mode takes the picked corner away (#288), and the active outline whole
|
||||
* while none is picked – never the frame they belong to.
|
||||
*/
|
||||
override onKeyDown(event: KeyboardEvent): boolean {
|
||||
if (!this.collision || (event.key !== 'Delete' && event.key !== 'Backspace'))
|
||||
return super.onKeyDown(event);
|
||||
const frame = this.frame(this.selectedFrameId());
|
||||
if (!frame) return super.onKeyDown(event);
|
||||
if (this.removePickedPoint()) return true;
|
||||
const outlines = frameCollision(frame);
|
||||
const index = this.activeOutline(frame, outlines);
|
||||
// Nothing active is nothing to delete – and still not the frame, which Delete would take otherwise.
|
||||
@@ -320,13 +361,42 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
/** The corner or edge of the selected frame's outlines under an image pixel, the active outline first. */
|
||||
private outlineGripAt(frame: TextureFrame, point: Vec2, tolerance: number) {
|
||||
const at = { x: point.x - frame.x, y: point.y - frame.y };
|
||||
const outlines = frameCollision(frame);
|
||||
const active = this.activeOutline(frame, outlines);
|
||||
// The active outline first, so its corners stay reachable where two outlines meet.
|
||||
const order = outlines.map((_, i) => i).sort((a, b) => Number(b === active) - Number(a === active));
|
||||
for (const target of order) {
|
||||
const grip = gripAt(outlines[target]!, at, tolerance);
|
||||
if (grip) return { at, outlines, active, target, grip };
|
||||
}
|
||||
return { at, outlines, active, target: active, grip: null };
|
||||
}
|
||||
|
||||
/** A right-click on a corner in collision mode picks it and opens its menu (#288). */
|
||||
protected override context(_asset: TextureAsset, e: FederatedPointerEvent, local: Vec2): void {
|
||||
const selected = this.collision ? this.frame(this.selectedFrameId()) : undefined;
|
||||
if (!selected) return;
|
||||
const point = localToImage(this.view, local.x, local.y);
|
||||
const { target, grip } = this.outlineGripAt(selected, point, HANDLE_SIZE / this.view.zoom);
|
||||
if (grip?.kind !== 'corner') return;
|
||||
e.stopPropagation();
|
||||
this.active = { frameId: selected.id, index: target };
|
||||
this.picked = { frameId: selected.id, outline: target, index: grip.index };
|
||||
this.redraw();
|
||||
openPointMenu({ removable: true, remove: () => this.removePicked() }, e.global.x, e.global.y, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* A press in collision mode. On the selected frame, on any of its outlines: a double-click on a
|
||||
* corner takes it away, a press on a corner drags it, on an edge puts a corner there – and makes
|
||||
* that outline the active one. Anywhere else inside the frame adds a corner after the active
|
||||
* outline's last, which is how an outline is drawn from nothing; with Ctrl (Cmd) held it starts a
|
||||
* new outline, and a press inside another finished outline only makes it the active one. Another
|
||||
* frame is only selected, and no frame is moved or spanned while the mode is on.
|
||||
* A press in collision mode. On the selected frame, on any of its outlines: a press on a corner
|
||||
* picks it and drags it; on an edge a click only makes that outline the active one, and a
|
||||
* double-click puts a corner there, picked and in place until it is pressed again (#288).
|
||||
* Anywhere else inside the frame adds a corner after the active outline's last, which is how an
|
||||
* outline is drawn from nothing; with Ctrl (Cmd) held it starts a new outline, and a press inside
|
||||
* another finished outline only makes it the active one. Another frame is only selected, and no
|
||||
* frame is moved or spanned while the mode is on.
|
||||
*/
|
||||
private pressOutline(
|
||||
asset: TextureAsset,
|
||||
@@ -337,24 +407,21 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||
): void {
|
||||
const selected = this.frame(this.selectedFrameId());
|
||||
if (selected) {
|
||||
const at = { x: point.x - selected.x, y: point.y - selected.y };
|
||||
const outlines = frameCollision(selected);
|
||||
const active = this.activeOutline(selected, outlines);
|
||||
// The active outline first, so its corners stay reachable where two outlines meet.
|
||||
const order = outlines.map((_, i) => i).sort((a, b) => Number(b === active) - Number(a === active));
|
||||
let target = active;
|
||||
let grip: PolygonGrip | null = null;
|
||||
for (const i of order) {
|
||||
grip = gripAt(outlines[i]!, at, tolerance);
|
||||
if (!grip) continue;
|
||||
target = i;
|
||||
break;
|
||||
}
|
||||
const under = this.outlineGripAt(selected, point, tolerance);
|
||||
const { at, outlines, active, grip } = under;
|
||||
let target = under.target;
|
||||
const activate = (index: number) => (this.active = { frameId: selected.id, index });
|
||||
if (grip?.kind === 'corner' && pressCount(e, `outline ${selected.id} ${target} ${grip.index}`) === 2) {
|
||||
const next = outlines.map((o, i) => (i === target ? withoutCorner(o, grip.index, 0)! : o));
|
||||
activate(next[target]!.length > 0 ? target : -1);
|
||||
this.state.execute(new SetFrameCollisionCommand(asset.id, selected.id, next));
|
||||
const pick = (outline: number, index: number) =>
|
||||
(this.picked = { frameId: selected.id, outline, index });
|
||||
if (grip?.kind === 'edge') {
|
||||
activate(target);
|
||||
this.picked = null;
|
||||
if (pointPress('line', pressCount(e, `outline add ${selected.id}`)) === 'add') {
|
||||
const next = [...outlines];
|
||||
next[target] = withCornerAfter(outlines[target]!, grip.index, inFrame(selected, grip.at));
|
||||
pick(target, grip.index + 1);
|
||||
this.state.execute(new SetFrameCollisionCommand(asset.id, selected.id, next));
|
||||
} else this.redraw();
|
||||
return;
|
||||
}
|
||||
// A corner often belongs on the frame's edge: a press just beside it still counts as in it.
|
||||
@@ -368,31 +435,28 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||
const other = outlines.findIndex((o, i) => i !== active && insideOutline(o, at));
|
||||
if (other >= 0) {
|
||||
activate(other);
|
||||
this.picked = null;
|
||||
this.redraw();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (grip || inside) {
|
||||
const corner = inFrame(selected, grip?.kind === 'edge' ? grip.at : at);
|
||||
if (!grip && fresh) target = outlines.length;
|
||||
const points = outlines[target] ?? [];
|
||||
const changed =
|
||||
grip?.kind === 'corner'
|
||||
? points
|
||||
: grip?.kind === 'edge'
|
||||
? withCornerAfter(points, grip.index, corner)
|
||||
: [...points, corner];
|
||||
const changed = grip ? points : [...points, inFrame(selected, at)];
|
||||
const next = [...outlines];
|
||||
next[target] = changed;
|
||||
activate(target);
|
||||
const index = grip ? grip.index : changed.length - 1;
|
||||
pick(target, index);
|
||||
this.corner = {
|
||||
frameId: selected.id,
|
||||
before: outlines,
|
||||
outlines: next,
|
||||
outline: target,
|
||||
index: grip?.kind === 'corner' ? grip.index : grip ? grip.index + 1 : changed.length - 1,
|
||||
index,
|
||||
pointer: { x: local.x, y: local.y },
|
||||
added: grip?.kind !== 'corner',
|
||||
added: !grip,
|
||||
moved: false,
|
||||
};
|
||||
if (this.corner.added) this.previewOutline();
|
||||
@@ -400,6 +464,7 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.picked = null;
|
||||
for (let i = asset.frames.length - 1; i >= 0; i--) {
|
||||
const frame = asset.frames[i]!;
|
||||
if (handleAt(frame, point, tolerance) === null) continue;
|
||||
@@ -766,10 +831,19 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||
g.moveTo(screen[0]!.x, screen[0]!.y).lineTo(screen[1]!.x, screen[1]!.y).stroke({ width: 2, color });
|
||||
if (!edited) continue;
|
||||
const half = HANDLE_SIZE / 2;
|
||||
for (const c of screen)
|
||||
const picked = this.pickedCorner;
|
||||
for (const [corner, c] of screen.entries()) {
|
||||
const isPicked = picked?.outline === index && picked.index === corner;
|
||||
g.rect(c.x - half, c.y - half, HANDLE_SIZE, HANDLE_SIZE)
|
||||
.fill(index === active ? Theme.colors.accentText : Theme.colors.viewport)
|
||||
.stroke({ width: 1, color });
|
||||
.fill(
|
||||
isPicked
|
||||
? Theme.colors.accent
|
||||
: index === active
|
||||
? Theme.colors.accentText
|
||||
: Theme.colors.viewport,
|
||||
)
|
||||
.stroke({ width: 1, color: isPicked ? Theme.colors.accentText : color });
|
||||
}
|
||||
}
|
||||
return tinted;
|
||||
}
|
||||
@@ -832,7 +906,7 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||
? 'No texture selected – pick one in the Assets panel or import an image.'
|
||||
: this.collision && asset.file && hasPicture
|
||||
? this.selectedFrameId()
|
||||
? 'Collision: click adds a corner, Ctrl+click starts a new outline, Delete removes one.'
|
||||
? 'Collision: click adds a corner, double-click an edge too, Ctrl+click a new outline, Delete removes.'
|
||||
: 'Collision: select a frame to draw the outlines its tiles block with.'
|
||||
: !asset.file
|
||||
? 'This texture has no picture yet.'
|
||||
|
||||
Reference in new issue
Block a user