diff --git a/js/editor/helpers.js b/js/editor/helpers.js index d6234e1..3009e28 100644 --- a/js/editor/helpers.js +++ b/js/editor/helpers.js @@ -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'), diff --git a/js/editor/main.js b/js/editor/main.js index c4edc0d..35444c6 100644 --- a/js/editor/main.js +++ b/js/editor/main.js @@ -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)); } diff --git a/js/editor/mouseops.js b/js/editor/mouseops.js index 28bdea1..27d93f8 100644 --- a/js/editor/mouseops.js +++ b/js/editor/mouseops.js @@ -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); } diff --git a/js/util.js b/js/util.js index 66f5fb0..f789975 100644 --- a/js/util.js +++ b/js/util.js @@ -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 diff --git a/style.css b/style.css index 74a7195..9546f5a 100644 --- a/style.css +++ b/style.css @@ -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 {