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.
553 lines
20 KiB
JavaScript
553 lines
20 KiB
JavaScript
// Small helper classes used by the editor, often with their own UI for the SVG overlay.
|
||
import { DIRECTIONS } from '../defs.js';
|
||
import { BitVector, mk, mk_svg } from '../util.js';
|
||
|
||
export class SVGConnection {
|
||
constructor(sx, sy, dx, dy) {
|
||
this.source = mk_svg('circle.-source', {r: 0.5});
|
||
this.line = mk_svg('line.-arrow', {});
|
||
this.dest = mk_svg('rect.-dest', {width: 1, height: 1});
|
||
this.element = mk_svg('g.overlay-connection', this.source, this.line, this.dest);
|
||
this.set_source(sx, sy);
|
||
this.set_dest(dx, dy);
|
||
}
|
||
|
||
set_source(sx, sy) {
|
||
this.sx = sx;
|
||
this.sy = sy;
|
||
this.source.setAttribute('cx', sx + 0.5);
|
||
this.source.setAttribute('cy', sy + 0.5);
|
||
this.line.setAttribute('x1', sx + 0.5);
|
||
this.line.setAttribute('y1', sy + 0.5);
|
||
}
|
||
|
||
set_dest(dx, dy) {
|
||
this.dx = dx;
|
||
this.dy = dy;
|
||
this.line.setAttribute('x2', dx + 0.5);
|
||
this.line.setAttribute('y2', dy + 0.5);
|
||
this.dest.setAttribute('x', dx);
|
||
this.dest.setAttribute('y', dy);
|
||
}
|
||
}
|
||
|
||
|
||
export class PendingRectangularSelection {
|
||
constructor(owner) {
|
||
this.owner = owner;
|
||
this.element = mk_svg('rect.overlay-pending-selection');
|
||
this.size_text = mk_svg('text.overlay-edit-tip');
|
||
this.owner.svg_group.append(this.element, this.size_text);
|
||
this.rect = null;
|
||
}
|
||
|
||
set_extrema(x0, y0, x1, y1) {
|
||
this.rect = new DOMRect(Math.min(x0, x1), Math.min(y0, y1), Math.abs(x0 - x1) + 1, Math.abs(y0 - y1) + 1);
|
||
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);
|
||
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.add_rect(this.rect);
|
||
this.element.remove();
|
||
this.size_text.remove();
|
||
}
|
||
|
||
discard() {
|
||
this.element.remove();
|
||
this.size_text.remove();
|
||
}
|
||
}
|
||
|
||
export class Selection {
|
||
constructor(editor) {
|
||
this.editor = editor;
|
||
|
||
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);
|
||
|
||
// 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.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.is_empty)
|
||
return true;
|
||
|
||
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 PendingRectangularSelection(this);
|
||
}
|
||
|
||
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._add_rect(rect),
|
||
() => {
|
||
this._set_from_set(old_cells);
|
||
},
|
||
false,
|
||
);
|
||
}
|
||
|
||
_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 = 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.is_empty)
|
||
return;
|
||
|
||
if (! this.floated_cells) {
|
||
console.error("Can't move a non-floating selection");
|
||
return;
|
||
}
|
||
|
||
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() {
|
||
// FIXME behavior when floating is undefined
|
||
if (this.is_empty)
|
||
return;
|
||
|
||
let old_cells = this.cells;
|
||
|
||
this.editor._do(
|
||
() => this._clear(),
|
||
() => {
|
||
this._set_from_set(old_cells);
|
||
},
|
||
false,
|
||
);
|
||
}
|
||
|
||
_clear() {
|
||
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() {
|
||
let stored_level = this.editor.stored_level;
|
||
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];
|
||
}
|
||
}
|
||
|
||
// Convert this selection into a floating selection, plucking all the selected cells from the
|
||
// level and replacing them with blank cells.
|
||
enfloat(copy = false) {
|
||
if (this.floated_cells)
|
||
console.error("Trying to float a selection that's already floating");
|
||
|
||
let floated_cells = new Map;
|
||
let stored_level = this.editor.stored_level;
|
||
for (let [x, y, n] of this.iter_coords()) {
|
||
let cell = stored_level.linear_cells[n];
|
||
if (copy) {
|
||
floated_cells.set(n, cell.map(tile => tile ? {...tile} : null));
|
||
}
|
||
else {
|
||
floated_cells.set(n, cell);
|
||
this.editor.replace_cell(cell, this.editor.make_blank_cell(x, y));
|
||
}
|
||
}
|
||
|
||
this.editor._do(
|
||
() => {
|
||
this.floated_cells = floated_cells;
|
||
this.floated_offset = [0, 0];
|
||
this._init_floated_canvas();
|
||
this.ring_element.classList.add('--floating');
|
||
},
|
||
() => 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;
|
||
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;
|
||
|
||
let n2 = stored_level.coords_to_scalar(x, y);
|
||
this.editor.replace_cell(stored_level.linear_cells[n2], cell);
|
||
}
|
||
}
|
||
|
||
// 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();
|
||
|
||
// 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._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,
|
||
);
|
||
}
|
||
|
||
_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;
|
||
}
|
||
|
||
// Redraw the selection canvas from scratch
|
||
redraw() {
|
||
if (! this.floated_canvas)
|
||
return;
|
||
|
||
// FIXME uhoh, how do i actually do this? we have no renderer of our own, we have a
|
||
// separate canvas, and all the renderer stuff expects to get ahold of a level. i guess
|
||
// refactor it to draw a block of cells?
|
||
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'),
|
||
});
|
||
}
|
||
|
||
// TODO allow floating/dragging, ctrl-dragging to copy, anchoring...
|
||
// TODO make more stuff respect this (more things should go through Editor for undo reasons anyway)
|
||
}
|
||
|