Improve the editor's selection tool (slightly WIP)

It now supports arbitrary regions!  The tool itself still makes
rectangles, but you can shift-drag to add to the selection.

It also distinguishes visually between a floating selection and not, is
more easily visible against certain tile backgrounds and at small zoom
levels, and, I don't know, probably some other stuff.
This commit is contained in:
Eevee (Evelyn Woods) 2024-04-16 23:55:35 -06:00
parent 48482b2a65
commit 7e0c1b0337
5 changed files with 495 additions and 131 deletions

View File

@ -1,5 +1,6 @@
// Small helper classes used by the editor, often with their own UI for the SVG overlay.
import { mk, mk_svg } from '../util.js';
import { DIRECTIONS } from '../defs.js';
import { BitVector, mk, mk_svg } from '../util.js';
export class SVGConnection {
constructor(sx, sy, dx, dy) {
@ -31,12 +32,12 @@ export class SVGConnection {
}
// TODO probably need to combine this with Selection somehow since it IS one, just not committed yet
export class PendingSelection {
export class PendingRectangularSelection {
constructor(owner) {
this.owner = owner;
this.element = mk_svg('rect.overlay-pending-selection');
this.owner.svg_group.append(this.element);
this.size_text = mk_svg('text.overlay-edit-tip');
this.owner.svg_group.append(this.element, this.size_text);
this.rect = null;
}
@ -47,15 +48,20 @@ export class PendingSelection {
this.element.setAttribute('y', this.rect.y);
this.element.setAttribute('width', this.rect.width);
this.element.setAttribute('height', this.rect.height);
this.size_text.textContent = `${this.rect.width} × ${this.rect.height}`;
this.size_text.setAttribute('x', this.rect.x + this.rect.width / 2);
this.size_text.setAttribute('y', this.rect.y + this.rect.height / 2);
}
commit() {
this.owner.set_from_rect(this.rect);
this.owner.add_rect(this.rect);
this.element.remove();
this.size_text.remove();
}
discard() {
this.element.remove();
this.size_text.remove();
}
}
@ -65,113 +71,315 @@ export class Selection {
this.svg_group = mk_svg('g');
this.editor.svg_overlay.append(this.svg_group);
// Used for the floating preview and selection rings, which should all move together
this.selection_group = mk_svg('g');
this.svg_group.append(this.selection_group);
this.rect = null;
this.element = mk_svg('rect.overlay-selection.overlay-transient');
this.svg_group.append(this.element);
// Note that this is a set of the ORIGINAL coordinates of the selected cells. Moving a
// floated selection doesn't change this; instead it updates floated_offset
this.cells = new Set;
this.bbox = null;
// I want a black-and-white outline ring so it shows against any background, but the only
// way to do that in SVG is apparently to just duplicate the path
this.ring_bg_element = mk_svg('path.overlay-selection-background.overlay-transient');
this.ring_element = mk_svg('path.overlay-selection.overlay-transient');
this.selection_group.append(this.ring_bg_element, this.ring_element);
this.floated_cells = null;
this.floated_element = null;
this.floated_canvas = null;
this.floated_offset = null;
}
get is_empty() {
return this.rect === null;
return this.cells.size === 0;
}
get is_floating() {
return !! this.floated_cells;
}
get has_moved() {
return !! (this.floated_offset && (this.floated_offset[0] || this.floated_offset[0]));
}
contains(x, y) {
// Empty selection means everything is selected?
if (this.rect === null)
if (this.is_empty)
return true;
return this.rect.left <= x && x < this.rect.right && this.rect.top <= y && y < this.rect.bottom;
if (this.floated_offset) {
x -= this.floated_offset[0];
y -= this.floated_offset[1];
}
return this.cells.has(this.editor.stored_level.coords_to_scalar(x, y));
}
create_pending() {
return new PendingSelection(this);
return new PendingRectangularSelection(this);
}
set_from_rect(rect) {
let old_rect = this.rect;
add_rect(rect) {
let old_cells = this.cells;
// TODO would be nice to only store the difference between the old/new sets of cells?
this.cells = new Set(this.cells);
this.editor._do(
() => this._set_from_rect(rect),
() => this._add_rect(rect),
() => {
if (old_rect) {
this._set_from_rect(old_rect);
}
else {
this._clear();
}
this._set_from_set(old_cells);
},
false,
);
}
_set_from_rect(rect) {
this.rect = rect;
this.element.classList.add('--visible');
this.element.setAttribute('x', this.rect.x);
this.element.setAttribute('y', this.rect.y);
this.element.setAttribute('width', this.rect.width);
this.element.setAttribute('height', this.rect.height);
_add_rect(rect) {
let stored_level = this.editor.stored_level;
for (let y = rect.top; y < rect.bottom; y++) {
for (let x = rect.left; x < rect.right; x++) {
this.cells.add(stored_level.coords_to_scalar(x, y));
}
}
if (! this.bbox) {
this.bbox = rect;
}
else {
// Just recreate it from scratch to avoid mixing old and new properties
this.bbox = new DOMRect(
Math.min(this.bbox.x, rect.x),
Math.min(this.bbox.y, rect.y),
Math.max(this.bbox.right, rect.right) - this.bbox.x,
Math.max(this.bbox.bottom, rect.bottom) - this.bbox.y);
}
// XXX wait what the hell is this doing here? why would we set_from_rect while floating, vs
// stamping it first?
if (this.floated_element) {
console.log("what the hell is this doing here");
let tileset = this.editor.renderer.tileset;
this.floated_canvas.width = rect.width * tileset.size_x;
this.floated_canvas.height = rect.height * tileset.size_y;
this.floated_canvas.width = this.bbox.width * tileset.size_x;
this.floated_canvas.height = this.bbox.height * tileset.size_y;
let foreign_obj = this.floated_element.querySelector('foreignObject');
foreign_obj.setAttribute('width', this.floated_canvas.width);
foreign_obj.setAttribute('height', this.floated_canvas.height);
}
this._update_outline();
}
_set_from_set(cells) {
this.cells = cells;
// Recompute bbox
if (cells.size === 0) {
this.bbox = null;
}
else {
let min_x = null;
let min_y = null;
let max_x = null;
let max_y = null;
for (let n of cells) {
let [x, y] = this.editor.stored_level.scalar_to_coords(n);
if (min_x === null) {
min_x = x;
min_y = y;
max_x = x;
max_y = y;
}
else {
min_x = Math.min(min_x, x);
max_x = Math.max(max_x, x);
min_y = Math.min(min_y, y);
max_y = Math.max(max_y, y);
}
}
this.bbox = new DOMRect(min_x, min_y, max_x - min_x + 1, max_y - min_y + 1);
}
// XXX ??? if (this.floated_element) {
this._update_outline();
}
// Faster internal version of contains() that ignores the floating offset
_contains(x, y) {
let stored_level = this.editor.stored_level;
return stored_level.is_point_within_bounds(x, y) &&
this.cells.has(stored_level.coords_to_scalar(x, y));
}
_update_outline() {
if (this.is_empty) {
this.ring_bg_element.classList.remove('--visible');
this.ring_element.classList.remove('--visible');
return;
}
// Convert the borders between cells to an SVG path.
// I don't know an especially clever way to do this so I guess I'll just make it up. The
// basic idea is to start with the top-left highlighted cell, start tracing from its top
// left corner towards the right (which must be a border, because this is the top left
// selected cell, so nothing above it is selected), then just keep going until we get back
// to where we started. Then we... repeat.
// But how do we repeat? My tiny insight is that every island (including holes) must cross
// the top of at least one cell; the only alternatives are for it to be zero width or only
// exist in the bottom row, and either way that makes it zero area, which isn't allowed. So
// we only have to track and check the top edges of cells, and run through every cell in the
// grid in order, stopping to draw a new outline when we find a cell whose top edge we
// haven't yet examined (and whose top edge is in fact a border). We unfortunately need to
// examine cells outside the selection, too, so that we can identify holes. But we can
// restrict all of this to within the bbox, so that's nice.
// Also, note that we concern ourselves with /grid points/ here, which are intersections of
// grid lines, whereas the grid cells are the spaces between grid lines.
// TODO might be more efficient to store a list of horizontal spans instead of just cells,
// but of course this would be more complicated
let seen_tops = new BitVector(this.bbox.width * this.bbox.height);
// In clockwise order for ease of rotation, starting with right
let directions = [
[1, 0],
[0, 1],
[-1, 0],
[0, -1],
];
let segments = [];
for (let y = this.bbox.top; y < this.bbox.bottom; y++) {
for (let x = this.bbox.left; x < this.bbox.right; x++) {
if (seen_tops.get((x - this.bbox.left) + this.bbox.width * (y - this.bbox.top)))
// Already traced
continue;
if (this._contains(x, y) === this._contains(x, y - 1))
// Not a top border
continue;
// Start a new segment!
let gx = x;
let gy = y;
let dx = 1;
let dy = 0;
let d = 0;
let segment = [];
segments.push(segment);
segment.push([gx, gy]);
while (segment.length < 100) {
// At this point we know that d is a valid direction and we've just traced it
if (dx === 1) {
seen_tops.set((gx - this.bbox.left) + this.bbox.width * (gy - this.bbox.top));
}
else if (dx === -1) {
seen_tops.set((gx - 1 - this.bbox.left) + this.bbox.width * (gy - this.bbox.top));
}
gx += dx;
gy += dy;
if (gx === x && gy === y)
break;
// Now we're at a new point, so search for the next direction, starting from the left
// Again, this is clockwise order (tr, br, bl, tl), arranged so that direction D goes
// between cells D and D + 1
let neighbors = [
this._contains(gx, gy - 1),
this._contains(gx, gy),
this._contains(gx - 1, gy),
this._contains(gx - 1, gy - 1),
];
let new_d = (d + 1) % 4;
for (let i = 3; i <= 4; i++) {
let sd = (d + i) % 4;
if (neighbors[sd] !== neighbors[(sd + 1) % 4]) {
new_d = sd;
break;
}
}
if (new_d !== d) {
// We're turning, so this is a new point
segment.push([gx, gy]);
d = new_d;
[dx, dy] = directions[d];
}
}
}
}
// TODO do it again for the next region... but how do i tell where the next region is?
let pathdata = [];
for (let subpath of segments) {
let first = true;
for (let [x, y] of subpath) {
if (first) {
first = false;
pathdata.push(`M${x},${y}`);
}
else {
pathdata.push(`L${x},${y}`);
}
}
pathdata.push('z');
}
this.ring_bg_element.classList.add('--visible');
this.ring_bg_element.setAttribute('d', pathdata.join(' '));
this.ring_element.classList.add('--visible');
this.ring_element.setAttribute('d', pathdata.join(' '));
}
move_by(dx, dy) {
if (! this.rect)
if (this.is_empty)
return;
this.rect.x += dx;
this.rect.y += dy;
this.element.setAttribute('x', this.rect.x);
this.element.setAttribute('y', this.rect.y);
if (! this.floated_element)
if (! this.floated_cells) {
console.error("Can't move a non-floating selection");
return;
}
let bbox = this.rect;
this.floated_element.setAttribute('transform', `translate(${bbox.x} ${bbox.y})`);
this.floated_offset[0] += dx;
this.floated_offset[1] += dy;
this._update_floating_transform();
}
_update_floating_transform() {
let transform = `translate(${this.floated_offset[0]} ${this.floated_offset[1]})`;
this.selection_group.setAttribute('transform', transform);
}
clear() {
let rect = this.rect;
if (! rect)
// FIXME behavior when floating is undefined
if (this.is_empty)
return;
let old_cells = this.cells;
this.editor._do(
() => this._clear(),
() => this._set_from_rect(rect),
() => {
this._set_from_set(old_cells);
},
false,
);
}
_clear() {
this.rect = null;
this.element.classList.remove('--visible');
this.cells = new Set;
this.bbox = null;
this.ring_bg_element.classList.remove('--visible');
this.ring_element.classList.remove('--visible');
}
// TODO only used internally in one place?
*iter_coords() {
if (! this.rect)
return;
let stored_level = this.editor.stored_level;
for (let y = this.rect.top; y < this.rect.bottom; y++) {
for (let x = this.rect.left; x < this.rect.right; x++) {
let n = stored_level.coords_to_scalar(x, y);
yield [x, y, n];
for (let n of this.cells) {
let [x, y] = stored_level.scalar_to_coords(n);
if (this.floated_offset) {
x += this.floated_offset[0];
y += this.floated_offset[1];
}
yield [x, y, n];
}
}
@ -181,90 +389,143 @@ export class Selection {
if (this.floated_cells)
console.error("Trying to float a selection that's already floating");
let floated_cells = [];
let tileset = this.editor.renderer.tileset;
let floated_cells = new Map;
let stored_level = this.editor.stored_level;
let bbox = this.rect;
let canvas = mk('canvas', {width: bbox.width * tileset.size_x, height: bbox.height * tileset.size_y});
let ctx = canvas.getContext('2d');
ctx.drawImage(
this.editor.renderer.canvas,
bbox.x * tileset.size_x, bbox.y * tileset.size_y, bbox.width * tileset.size_x, bbox.height * tileset.size_y,
0, 0, bbox.width * tileset.size_x, bbox.height * tileset.size_y);
for (let [x, y, n] of this.iter_coords()) {
let cell = stored_level.linear_cells[n];
if (copy) {
floated_cells.push(cell.map(tile => tile ? {...tile} : null));
floated_cells.set(n, cell.map(tile => tile ? {...tile} : null));
}
else {
floated_cells.push(cell);
floated_cells.set(n, cell);
this.editor.replace_cell(cell, this.editor.make_blank_cell(x, y));
}
}
let floated_element = mk_svg('g', mk_svg('foreignObject', {
x: 0, y: 0,
width: canvas.width, height: canvas.height,
transform: `scale(${1/tileset.size_x} ${1/tileset.size_y})`,
}, canvas));
floated_element.setAttribute('transform', `translate(${bbox.x} ${bbox.y})`);
// FIXME far more memory efficient to recreate the canvas in the redo, rather than hold onto
// it forever
this.editor._do(
() => {
this.floated_canvas = canvas;
this.floated_element = floated_element;
this.floated_cells = floated_cells;
this.svg_group.append(floated_element);
this.floated_offset = [0, 0];
this._init_floated_canvas();
this.ring_element.classList.add('--floating');
},
() => this._defloat(),
() => this._delete_floating(),
);
}
// Create floated_canvas and floated_element, based on floated_cells
_init_floated_canvas() {
let tileset = this.editor.renderer.tileset;
this.floated_canvas = mk('canvas', {
width: this.bbox.width * tileset.size_x,
height: this.bbox.height * tileset.size_y,
});
let ctx = this.floated_canvas.getContext('2d');
for (let n of this.cells) {
let [x, y] = this.editor.stored_level.scalar_to_coords(n);
this.editor.renderer.draw_static_generic({
// Incredibly stupid hack for just drawing one cell
x0: 0, x1: 0,
y0: 0, y1: 0,
width: 1,
cells: [this.floated_cells.get(n)],
ctx: ctx,
destx: x - this.bbox.left,
desty: y - this.bbox.top,
});
}
this.floated_element = mk_svg('g', mk_svg('foreignObject', {
x: 0,
y: 0,
width: this.floated_canvas.width,
height: this.floated_canvas.height,
transform: `scale(${1/tileset.size_x} ${1/tileset.size_y})`,
}, this.floated_canvas));
this.floated_element.setAttribute('transform', `translate(${this.bbox.x} ${this.bbox.y})`);
// This goes first, so the selection ring still appears on top
this.selection_group.prepend(this.floated_element);
}
stamp_float(copy = false) {
if (! this.floated_element)
return;
let stored_level = this.editor.stored_level;
let i = 0;
for (let [x, y, n] of this.iter_coords()) {
let cell = this.floated_cells[i];
for (let n of this.cells) {
let [x, y] = stored_level.scalar_to_coords(n);
x += this.floated_offset[0];
y += this.floated_offset[1];
// If the selection is moved so that part of it is outside the level, skip that bit
if (! stored_level.is_point_within_bounds(x, y))
continue;
let cell = this.floated_cells.get(n);
if (copy) {
cell = cell.map(tile => tile ? {...tile} : null);
}
cell.x = x;
cell.y = y;
this.editor.replace_cell(stored_level.linear_cells[n], cell);
i += 1;
let n2 = stored_level.coords_to_scalar(x, y);
this.editor.replace_cell(stored_level.linear_cells[n2], cell);
}
}
defloat() {
// Converts a floating selection back to a regular selection, including stamping it in place
commit_floating() {
// This is OK; we're idempotent
if (! this.floated_element)
return;
this.stamp_float();
let element = this.floated_element;
let canvas = this.floated_canvas;
let cells = this.floated_cells;
// Actually apply the offset, so we can be a regular selection again
let old_cells = this.cells;
let old_bbox = DOMRect.fromRect(this.bbox);
let new_cells = new Set;
let stored_level = this.editor.stored_level;
for (let n of old_cells) {
let [x, y] = stored_level.scalar_to_coords(n);
x += this.floated_offset[0];
y += this.floated_offset[1];
if (stored_level.is_point_within_bounds(x, y)) {
new_cells.add(stored_level.coords_to_scalar(x, y));
}
}
let old_floated_cells = this.floated_cells;
let old_floated_offset = this.floated_offset;
this.editor._do(
() => this._defloat(),
() => {
this.floated_cells = cells;
this.floated_canvas = canvas;
this.floated_element = element;
this.svg_group.append(element);
this._delete_floating();
this._set_from_set(new_cells);
},
() => {
// Don't use _set_from_set here; it's not designed for an offset float
this.cells = old_cells;
this.bbox = old_bbox;
this._update_outline();
this.floated_cells = old_floated_cells;
this.floated_offset = old_floated_offset;
this._init_floated_canvas();
this._update_floating_transform();
this.ring_element.classList.add('--floating');
},
false,
);
}
_defloat() {
_delete_floating() {
this.selection_group.removeAttribute('transform');
this.ring_element.classList.remove('--floating');
this.floated_element.remove();
this.floated_cells = null;
this.floated_offset = null;
this.floated_element = null;
this.floated_canvas = null;
this.floated_cells = null;
}
// Redraw the selection canvas from scratch
@ -278,6 +539,7 @@ export class Selection {
this.editor.renderer.draw_static_generic({
x0: 0, y0: 0,
x1: this.rect.width, y1: this.rect.height,
// FIXME this is now a set, not a flat list
cells: this.floated_cells,
width: this.rect.width,
ctx: this.floated_canvas.getContext('2d'),

View File

@ -16,6 +16,18 @@ import { SVGConnection, Selection } from './helpers.js';
import * as mouseops from './mouseops.js';
import { TILES_WITH_PROPS } from './tile-overlays.js';
// FIXME some idle thoughts
// for adjust tool:
// - preview gray button (or click to actually do it)
// - preview wire reach
// - preview destination teleporter (or maybe order)
// - preview monster pathing
// - preview ice/ff routing (what about e.g. doublemaze)
// generally:
// - show wires that are initially powered
// - show traps that are initially closed
// - show implicit red/brown connections
// - selection and eyedropper should preserve red/brown button connections (somehow)
// Edited levels are stored as follows.
// StoredPack and StoredLevel both have an editor_metadata containing:
@ -76,12 +88,12 @@ export class Editor extends PrimaryView {
mk_svg('marker', {id: 'overlay-arrowhead', markerWidth: 4, markerHeight: 4, refX: 3, refY: 2, orient: 'auto'},
mk_svg('polygon', {points: '0 0, 4 2, 0 4'}),
),
),
mk_svg('filter', {id: 'overlay-filter-outline'},
mk_svg('feMorphology', {'in': 'SourceAlpha', result: 'dilated', operator: 'dilate', radius: 0.03125}),
mk_svg('feFlood', {'flood-color': '#0009', result: 'fill'}),
mk_svg('feComposite', {'in': 'fill', in2: 'dilated', operator: 'in'}),
mk_svg('feComposite', {'in': 'SourceGraphic'}),
mk_svg('filter', {id: 'overlay-filter-outline'},
mk_svg('feMorphology', {'in': 'SourceAlpha', result: 'dilated', operator: 'dilate', radius: 0.03125}),
mk_svg('feFlood', {'flood-color': '#0009', result: 'fill'}),
mk_svg('feComposite', {'in': 'fill', in2: 'dilated', operator: 'in'}),
mk_svg('feComposite', {'in': 'SourceGraphic'}),
),
),
this.connections_g,
);
@ -143,9 +155,9 @@ export class Editor extends PrimaryView {
this.cancel_mouse_drag();
}
let new_rect = new DOMRect(0, 0, this.stored_level.size_x, this.stored_level.size_y);
let old_rect = this.selection.rect;
if (! (old_rect && old_rect.x === new_rect.x && old_rect.y === new_rect.y && old_rect.width === new_rect.width && old_rect.height === new_rect.height)) {
this.selection.set_from_rect(new_rect);
if (this.selection.cells.size !== this.stored_level.size_x * this.stored_level.size_y) {
this.selection.clear();
this.selection.add_rect(new_rect);
this.commit_undo();
}
}
@ -156,7 +168,7 @@ export class Editor extends PrimaryView {
this.cancel_mouse_drag();
}
if (! this.selection.is_empty) {
this.selection.defloat();
this.selection.commit_floating();
this.selection.clear();
this.commit_undo();
}
@ -285,6 +297,13 @@ export class Editor extends PrimaryView {
});
window.addEventListener('blur', () => {
this.cancel_mouse_drag();
// Assume all modifiers are released
for (let mouse_op of this.mouse_ops) {
if (mouse_op) {
mouse_op.clear_modifiers();
}
}
});
window.addEventListener('mouseleave', () => {
this.mouse_coords = null;
@ -1088,6 +1107,7 @@ export class Editor extends PrimaryView {
this.zoom = zoom;
this.renderer.canvas.style.setProperty('--scale', this.zoom);
this.svg_overlay.style.setProperty('--scale', this.zoom);
this.actual_viewport_el.classList.toggle('--crispy', this.zoom >= 1);
this.statusbar_zoom.textContent = `${this.zoom * 100}%`;
@ -1556,6 +1576,7 @@ export class Editor extends PrimaryView {
old_w = w;
if (swap_dimensions) {
[w, h] = [h, w];
// FIXME this will need more interesting rearranging
this.selection._set_from_rect(new DOMRect(
this.selection.rect.x, this.selection.rect.y, w, h));
}

View File

@ -8,6 +8,15 @@ import { mk, mk_svg, walk_grid } from '../util.js';
import { SVGConnection } from './helpers.js';
import { TILES_WITH_PROPS } from './tile-overlays.js';
// TODO some minor grievances
// - the track overlay doesn't explain "direction" (may not be necessary anyway), allows picking a
// bad initial switch direction
// - track tool should add a switch to a track on right-click, if possible (and also probably delete
// it on ctrl-right-click?)
// - no preview tile with force floor or track tool
// - no ice drawing tool
// - cursor box shows with selection tool which seems inappropriate
// - controls do not exactly stand out and are just plain text
const MOUSE_BUTTON_MASKS = [1, 4, 2]; // MouseEvent.button/buttons are ordered differently
export class MouseOperation {
constructor(editor, physical_button) {
@ -127,6 +136,13 @@ export class MouseOperation {
_update_modifiers(ev) {
this.ctrl = ev.ctrlKey;
this.shift = ev.shiftKey;
this.alt = ev.altKey;
}
clear_modifiers() {
this.ctrl = false;
this.shift = false;
this.alt = false;
}
do_commit() {
@ -508,16 +524,21 @@ export class FillOperation extends MouseOperation {
// TODO also, delete
// TODO also, non-rectangular selections
// TODO also, better marching ants, hard to see on gravel
export class SelectOperation extends MouseOperation {
handle_press() {
if (! this.editor.selection.is_empty && this.editor.selection.contains(this.click_cell_x, this.click_cell_y)) {
if (this.shift) {
// Extend selection
this.mode = 'extend';
this.pending_selection = this.editor.selection.create_pending();
this.update_pending_selection();
}
else if (! this.editor.selection.is_empty &&
this.editor.selection.contains(this.click_cell_x, this.click_cell_y))
{
// Move existing selection
this.mode = 'float';
if (this.ctrl) {
this.make_copy = true;
}
this.make_copy = this.ctrl;
}
else {
// Create new selection
@ -569,17 +590,31 @@ export class SelectOperation extends MouseOperation {
);
}
}
else {
// If there's an existing floating selection (which isn't what we're operating on),
// commit it before doing anything else
this.editor.selection.defloat();
if (! this.has_moved) {
// Plain click clears selection
this.pending_selection.discard();
this.editor.selection.clear();
else { // create/extend
if (this.has_moved) {
// Drag either creates or extends the selection
// If there's an existing floating selection (which isn't what we're operating on),
// commit it before doing anything else
this.editor.selection.commit_floating();
if (this.mode === 'create') {
this.editor.selection.clear();
}
this.pending_selection.commit();
}
else {
this.pending_selection.commit();
// Plain click clears selection. But first, if there's a floating selection and
// it's moved, commit that movement as a separate undo entry
if (this.editor.selection.is_floating) {
let float_moved = this.editor.selection.has_moved;
if (float_moved) {
this.editor.commit_undo();
}
this.editor.selection.commit_floating();
}
this.pending_selection.discard();
this.editor.selection.clear();
}
}
this.editor.commit_undo();
@ -592,6 +627,13 @@ export class SelectOperation extends MouseOperation {
this.pending_selection.discard();
}
}
do_destroy() {
// Don't let a floating selection persist when switching tools
this.editor.selection.commit_floating();
this.editor.commit_undo();
super.do_destroy();
}
}
export class ForceFloorOperation extends MouseOperation {
@ -1166,10 +1208,7 @@ export class CameraOperation extends MouseOperation {
this.editor.viewport_el.style.cursor = cursor;
// Create a text element to show the size while editing
this.size_text = mk_svg('text.overlay-edit-tip', {
// Center it within the rectangle probably (x and y are set in _update_size_text)
'text-anchor': 'middle', 'dominant-baseline': 'middle',
});
this.size_text = mk_svg('text.overlay-edit-tip');
this._update_size_text();
this.editor.svg_overlay.append(this.size_text);
}

View File

@ -337,6 +337,33 @@ export function* walk_grid(x0, y0, x1, y1, min_a, min_b, max_a, max_b) {
}
}
// Baby's first bit vector
export class BitVector {
constructor(size) {
this.array = new Uint32Array(Math.ceil(size / 32));
}
get(bit) {
let i = Math.floor(bit / 32);
let b = bit % 32;
return (this.array[i] & (1 << b)) !== 0;
}
set(bit) {
let i = Math.floor(bit / 32);
let b = bit % 32;
this.array[i] |= (1 << b);
}
clear(bit) {
let i = Math.floor(bit / 32);
let b = bit % 32;
this.array[i] &= ~(1 << b);
}
}
// Root class to indirect over where we might get files from
// - a pool of uploaded in-memory files
// - a single uploaded zip file

View File

@ -2215,8 +2215,11 @@ svg.level-editor-overlay {
/* allow clicks to go through us! */
pointer-events: none;
/* not used to shrink us (absolute positioning does that), just to make the stroke width a
* consistent size at any zoom level */
--scale: 1;
/* default svg properties */
stroke-width: 0.0625;
stroke-width: calc(0.0625px / var(--scale));
fill: none;
}
svg.level-editor-overlay .overlay-transient {
@ -2226,24 +2229,33 @@ svg.level-editor-overlay .overlay-transient.--visible {
display: initial;
}
svg.level-editor-overlay rect.overlay-cursor {
x-stroke: hsla(220, 100%, 60%, 0.5);
fill: hsla(220, 100%, 75%, 0.25);
stroke: hsla(var(--main-hue), 100%, 90%, 0.75);
fill: hsla(var(--main-hue), 100%, 75%, 0.25);
}
svg.level-editor-overlay rect.overlay-pending-selection {
stroke: hsla(220, 100%, 60%, 0.5);
fill: hsla(220, 100%, 75%, 0.25);
stroke: hsla(var(--selected-hue), 100%, 60%, 0.5);
fill: hsla(var(--selected-hue), 100%, 75%, 0.25);
}
svg.level-editor-overlay rect.overlay-selection {
stroke: #000c;
fill: hsla(220, 0%, 75%, 0.25);
stroke-dasharray: 0.125, 0.125;
animation: marching-ants 1s linear infinite;
svg.level-editor-overlay path.overlay-selection-background {
stroke: hsla(var(--selected-hue), 10%, 90%, 0.9);
fill: none;
pointer-events: none;
}
svg.level-editor-overlay path.overlay-selection {
stroke: hsla(var(--selected-hue), 10%, 10%, 0.75);
fill: hsla(var(--selected-hue), 50%, 75%, 0.375);
stroke-width: calc(0.125px / var(--scale));
stroke-dasharray: calc(0.125px / var(--scale)), calc(0.125px / var(--scale));
animation: marching-ants 0.5s linear infinite;
pointer-events: auto;
cursor: move;
}
svg.level-editor-overlay path.overlay-selection.--floating {
stroke: hsla(var(--selected-hue), 80%, 50%, 0.75);
}
@keyframes marching-ants {
0% {
stroke-dashoffset: 0.25;
stroke-dashoffset: calc(0.25px / var(--scale));
}
100% {
stroke-dashoffset: 0;
@ -2269,8 +2281,11 @@ svg.level-editor-overlay text {
font-size: 1px;
}
svg.level-editor-overlay text.overlay-edit-tip {
/* Used for showing e.g. the size of a pending selection. Centered around its anchor */
stroke: none;
fill: black;
fill: hsl(var(--selected-hue), 80%, 30%);
text-anchor: middle;
dominant-baseline: middle;
}
.editor-big-tooltip {
@ -2293,7 +2308,7 @@ svg.level-editor-overlay text.overlay-edit-tip {
text-transform: none;
text-align: left;
color: #d8d8d8;
background: hsl(220, 10%, 20%);
background: hsl(var(--main-hue), 10%, 20%);
box-shadow: 0 1px 2px 1px #0004;
}
.editor-big-tooltip h3 {