jspaint/src/OnCanvasTextBox.js

287 lines
9.3 KiB
JavaScript

class OnCanvasTextBox extends OnCanvasObject {
constructor(x, y, width, height, starting_text) {
super(x, y, width, height, true);
this.$el.addClass("textbox");
this.$editor = $(E("textarea")).addClass("textbox-editor");
var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("version", 1.1);
var foreignObject = document.createElementNS("http://www.w3.org/2000/svg", "foreignObject");
foreignObject.setAttribute("x", 0);
foreignObject.setAttribute("y", 0);
svg.append(foreignObject);
// inline styles so that they'll be serialized for the SVG
this.$editor.css({
position: "absolute",
left: "0",
top: "0",
right: "0",
bottom: "0",
padding: "0",
margin: "0",
border: "0",
resize: "none",
overflow: "hidden",
minWidth: "3em",
});
var edit_textarea = this.$editor[0];
var render_textarea = edit_textarea.cloneNode(false);
foreignObject.append(render_textarea);
edit_textarea.value = starting_text || "";
this.canvas = make_canvas(width, height);
this.canvas.style.pointerEvents = "none";
this.$el.append(this.canvas);
const update_size = () => {
this.position();
this.$el.triggerHandler("update"); // update handles
this.$editor.add(render_textarea).css({
width: this.width,
height: this.height,
});
};
const auto_size = () => {
// Auto-expand, and apply minimum size.
edit_textarea.style.height = "";
edit_textarea.style.minHeight = "0px";
edit_textarea.style.bottom = ""; // needed for when magnified
edit_textarea.setAttribute("rows", 1);
this.height = Math.max(edit_textarea.scrollHeight, this.height);
edit_textarea.removeAttribute("rows");
this.width = edit_textarea.scrollWidth;
edit_textarea.style.bottom = "0"; // doesn't seem to be needed?
// always needs to update at least this.$editor, since style.height is reset above
update_size();
};
const update = () => {
requestAnimationFrame(() => {
edit_textarea.scrollTop = 0; // prevent scrolling edit textarea to keep in sync
});
const font = text_tool_font;
const get_solid_color = (swatch) => `rgba(${get_rgba_from_color(swatch).join(", ")}`;
font.color = get_solid_color(selected_colors.foreground);
font.background = tool_transparent_mode ? "transparent" : get_solid_color(selected_colors.background);
this.$editor.add(this.canvas).css({
transform: `scale(${magnification})`,
transformOrigin: "left top",
});
this.$editor.add(render_textarea).css({
width: this.width,
height: this.height,
fontFamily: font.family,
fontSize: `${font.size}pt`,
fontWeight: font.bold ? "bold" : "normal",
fontStyle: font.italic ? "italic" : "normal",
textDecoration: font.underline ? "underline" : "none",
writingMode: font.vertical ? "vertical-lr" : "",
MsWritingMode: font.vertical ? "vertical-lr" : "",
WebkitWritingMode: font.vertical ? "vertical-lr" : "",
lineHeight: `${font.size * font.line_scale}px`,
color: font.color,
background: font.background,
});
// Must be after font is updated, since the minimum size depends on the font size.
auto_size();
while (render_textarea.firstChild) {
render_textarea.removeChild(render_textarea.firstChild);
}
render_textarea.appendChild(document.createTextNode(edit_textarea.value));
svg.setAttribute("width", this.width);
svg.setAttribute("height", this.height);
foreignObject.setAttribute("width", this.width);
foreignObject.setAttribute("height", this.height);
var svg_source = new XMLSerializer().serializeToString(svg);
var data_url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg_source)}`;
var img = new Image();
img.onload = () => {
this.canvas.width = this.width;
this.canvas.height = this.height;
this.canvas.ctx.drawImage(img, 0, 0);
update_helper_layer(); // @TODO: under-grid specific helper layer?
};
img.onerror = (event) => {
window.console && console.log("Failed to load image", event);
};
img.src = data_url;
};
$G.on("option-changed", this._on_option_changed = update);
this.$editor.on("input", this._on_input = update);
this.$editor.on("scroll", this._on_scroll = () => {
requestAnimationFrame(() => {
edit_textarea.scrollTop = 0; // prevent scrolling edit textarea to keep in sync
});
});
this.$el.css({
cursor: make_css_cursor("move", [8, 8], "move"),
touchAction: "none",
});
this.position();
this.$el.append(this.$editor);
this.$editor[0].focus();
this.handles = new Handles({
$handles_container: this.$el,
$object_container: $canvas_area,
outset: 2,
thick: true,
get_rect: () => ({ x: this.x, y: this.y, width: this.width, height: this.height }),
set_rect: ({ x, y, width, height }) => {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.position();
update();
// clear canvas to avoid an occasional flash of the old canvas (with old size) in the new position
// (trade it off for a flash of the background behind the textbox)
this.canvas.width = width;
this.canvas.height = height;
},
constrain_rect: ({ x, y, width, height }, x_axis, y_axis) => {
// remember dimensions
const old_x = this.x;
const old_y = this.y;
const old_width = this.width;
const old_height = this.height;
// apply prospective new dimensions
this.x = x;
this.y = y;
this.width = width;
this.height = height;
update_size();
// apply constraints
auto_size();
// prevent free movement via resize
if (x_axis === -1) {
x = Math.min(this.x, old_x + old_width - this.width);
}
if (y_axis === -1) {
y = Math.min(this.y, old_y + old_height - this.height);
}
// remember constrained dimensions
width = this.width;
height = this.height;
// reset
this.x = old_x;
this.y = old_y;
this.width = old_width;
this.height = old_height;
update_size();
return { x, y, width, height };
},
get_ghost_offset_left: () => parseFloat($canvas_area.css("padding-left")) + 1,
get_ghost_offset_top: () => parseFloat($canvas_area.css("padding-top")) + 1,
});
let mox, moy; // mouse offset
const pointermove = e => {
const m = to_canvas_coords(e);
this.x = Math.max(Math.min(m.x - mox, main_canvas.width), -this.width);
this.y = Math.max(Math.min(m.y - moy, main_canvas.height), -this.height);
this.position();
if (e.shiftKey) {
// @TODO: maybe re-enable but handle undoables well
// this.draw();
}
};
this.$el.on("pointerdown", e => {
if (e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target.classList.contains("handle") ||
e.target.classList.contains("grab-region")) {
return;
}
e.preventDefault();
const rect = this.$el[0].getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
mox = ~~(cx / rect.width * this.canvas.width);
moy = ~~(cy / rect.height * this.canvas.height);
this.dragging = true;
update_helper_layer(); // for thumbnail, which draws textbox outline if it's not being dragged
$G.on("pointermove", pointermove);
$G.one("pointerup", () => {
$G.off("pointermove", pointermove);
this.dragging = false;
update_helper_layer(); // for thumbnail, which draws textbox outline if it's not being dragged
});
});
$status_position.text("");
$status_size.text("");
$canvas_area.trigger("resize"); // to update handles, get them to hide?
if (OnCanvasTextBox.$fontbox && OnCanvasTextBox.$fontbox.closed) {
OnCanvasTextBox.$fontbox = null;
}
const $fb = OnCanvasTextBox.$fontbox = OnCanvasTextBox.$fontbox || new $FontBox();
const displace_font_box = () => {
// move the font box out of the way if it's overlapping the OnCanvasTextBox
const fb_rect = $fb[0].getBoundingClientRect();
const tb_rect = this.$el[0].getBoundingClientRect();
if (
// the fontbox overlaps textbox
fb_rect.left <= tb_rect.right &&
tb_rect.left <= fb_rect.right &&
fb_rect.top <= tb_rect.bottom &&
tb_rect.top <= fb_rect.bottom
) {
// move the font box out of the way
$fb.css({
top: this.$el.position().top - $fb.height()
});
}
$fb.applyBounds();
};
// must be after textbox is in the DOM
update();
displace_font_box();
// In case a software keyboard opens, like Optikey for eye gaze / head tracking users,
// or perhaps a handwriting input for pen tablet users, or *partially* for mobile browsers.
// Mobile browsers generally scroll the view for a textbox well enough, but
// don't include the custom behavior of moving the font box out of the way.
$(window).on("resize", this._on_window_resize = () => {
this.$editor[0].scrollIntoView({ block: 'nearest', inline: 'nearest' });
displace_font_box();
});
}
position() {
super.position(true);
update_helper_layer(); // @TODO: under-grid specific helper layer?
}
destroy() {
super.destroy();
if (OnCanvasTextBox.$fontbox && !OnCanvasTextBox.$fontbox.closed) {
OnCanvasTextBox.$fontbox.close();
}
OnCanvasTextBox.$fontbox = null;
$G.off("option-changed", this._on_option_changed);
this.$editor.off("input", this._on_input);
this.$editor.off("scroll", this._on_scroll);
$(window).off("resize", this._on_window_resize);
update_helper_layer(); // @TODO: under-grid specific helper layer?
}
}