add: drawings in questions
This commit is contained in:
parent
6be729de50
commit
16843a6ab8
28 changed files with 1181 additions and 32 deletions
|
@ -39,6 +39,7 @@ media_theme_pref();
|
|||
// init
|
||||
use("me", () => {});
|
||||
use("streams", () => {});
|
||||
use("carp", () => {});
|
||||
|
||||
// env
|
||||
self.DEBOUNCE = [];
|
||||
|
|
624
crates/app/src/public/js/carp.js
Normal file
624
crates/app/src/public/js/carp.js
Normal file
|
@ -0,0 +1,624 @@
|
|||
(() => {
|
||||
const self = reg_ns("carp");
|
||||
|
||||
const END_OF_HEADER = 0x1a;
|
||||
const COLOR = 0x1b;
|
||||
const SIZE = 0x2b;
|
||||
const LINE = 0x3b;
|
||||
const POINT = 0x4b;
|
||||
const EOF = 0x1f;
|
||||
|
||||
function enc(s, as = "guess") {
|
||||
if ((as === "guess" && typeof s === "number") || as === "u32") {
|
||||
// encode u32
|
||||
const view = new DataView(new ArrayBuffer(16));
|
||||
view.setUint32(0, s);
|
||||
return new Uint8Array(view.buffer).slice(0, 4);
|
||||
}
|
||||
|
||||
if (as === "u16") {
|
||||
// encode u16
|
||||
const view = new DataView(new ArrayBuffer(16));
|
||||
view.setUint16(0, s);
|
||||
return new Uint8Array(view.buffer).slice(0, 2);
|
||||
}
|
||||
|
||||
// encode string
|
||||
const encoder = new TextEncoder();
|
||||
return encoder.encode(s);
|
||||
}
|
||||
|
||||
function dec(as, from) {
|
||||
if (as === "u32") {
|
||||
// decode u32
|
||||
const view = new DataView(new Uint8Array(from).buffer);
|
||||
return view.getUint32(0);
|
||||
}
|
||||
|
||||
if (as === "u16") {
|
||||
// decode u16
|
||||
const view = new DataView(new Uint8Array(from).buffer);
|
||||
return view.getUint16(0);
|
||||
}
|
||||
|
||||
// decode string
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(from);
|
||||
}
|
||||
|
||||
function lpad(size, input) {
|
||||
if (input.length === size) {
|
||||
return input;
|
||||
}
|
||||
|
||||
for (let i = 0; i < size - (input.length - 1); i++) {
|
||||
input = [0, ...input];
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
self.enc = enc;
|
||||
self.dec = dec;
|
||||
self.lpad = lpad;
|
||||
|
||||
self.CARPS = {};
|
||||
self.define("new", function ({ $ }, bind_to, read_only = false) {
|
||||
const canvas = new CarpCanvas(bind_to, read_only);
|
||||
$.CARPS[bind_to.getAttribute("ui_ident")] = canvas;
|
||||
return canvas;
|
||||
});
|
||||
|
||||
class CarpCanvas {
|
||||
#element; // HTMLElement
|
||||
#ctx; // CanvasRenderingContext2D
|
||||
#pos = { x: 0, y: 0 }; // Vec2
|
||||
|
||||
STROKE_SIZE = 2;
|
||||
#stroke_size_old = 2;
|
||||
COLOR = "#000000";
|
||||
#color_old = "#000000";
|
||||
|
||||
COMMANDS = [];
|
||||
HISTORY = [];
|
||||
HISTORY_IDX = 0;
|
||||
#cmd_store = [];
|
||||
#undo_clear_future = false; // if we should clear to HISTORY_IDX on next draw
|
||||
|
||||
onedit;
|
||||
read_only;
|
||||
|
||||
/// Create a new [`CarpCanvas`]
|
||||
constructor(element, read_only) {
|
||||
this.#element = element;
|
||||
this.read_only = read_only;
|
||||
}
|
||||
|
||||
/// Push #line_store to LINES
|
||||
push_state() {
|
||||
this.COMMANDS = [...this.COMMANDS, ...this.#cmd_store];
|
||||
this.#cmd_store = [];
|
||||
|
||||
this.HISTORY.push(this.COMMANDS);
|
||||
this.HISTORY_IDX += 1;
|
||||
|
||||
if (this.#undo_clear_future) {
|
||||
this.HISTORY = this.HISTORY.slice(0, this.HISTORY_IDX);
|
||||
this.#undo_clear_future = false;
|
||||
}
|
||||
|
||||
if (this.onedit) {
|
||||
this.onedit(this.as_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Read current position in history and draw it.
|
||||
draw_from_history() {
|
||||
this.COMMANDS = this.HISTORY[this.HISTORY_IDX];
|
||||
const bytes = this.as_carp2();
|
||||
this.from_bytes(bytes); // draw
|
||||
}
|
||||
|
||||
/// Undo changes.
|
||||
undo() {
|
||||
if (this.HISTORY_IDX === 0) {
|
||||
// cannot undo
|
||||
return;
|
||||
}
|
||||
|
||||
this.HISTORY_IDX -= 1;
|
||||
this.draw_from_history();
|
||||
this.#undo_clear_future = false;
|
||||
}
|
||||
|
||||
/// Redo changes.
|
||||
redo() {
|
||||
if (this.HISTORY_IDX === this.HISTORY.length - 1) {
|
||||
// cannot redo
|
||||
return;
|
||||
}
|
||||
|
||||
this.HISTORY_IDX += 1;
|
||||
this.draw_from_history();
|
||||
}
|
||||
|
||||
/// Create canvas and init context
|
||||
async create_canvas() {
|
||||
const canvas = document.createElement("canvas");
|
||||
|
||||
canvas.width = "300";
|
||||
canvas.height = "200";
|
||||
|
||||
this.#ctx = canvas.getContext("2d");
|
||||
|
||||
if (!this.read_only) {
|
||||
// desktop
|
||||
canvas.addEventListener(
|
||||
"mousemove",
|
||||
(e) => {
|
||||
this.draw_event(e);
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
canvas.addEventListener(
|
||||
"mouseup",
|
||||
(e) => {
|
||||
this.push_state();
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
canvas.addEventListener(
|
||||
"mousedown",
|
||||
(e) => {
|
||||
this.#cmd_store.push({
|
||||
type: "Line",
|
||||
data: [],
|
||||
});
|
||||
|
||||
this.move_event(e);
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
canvas.addEventListener(
|
||||
"mouseenter",
|
||||
(e) => {
|
||||
this.move_event(e);
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
// mobile
|
||||
canvas.addEventListener(
|
||||
"touchmove",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
e.clientX = e.changedTouches[0].clientX;
|
||||
e.clientY = e.changedTouches[0].clientY;
|
||||
|
||||
this.draw_event(e, true);
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
canvas.addEventListener(
|
||||
"touchstart",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
e.clientX = e.changedTouches[0].clientX;
|
||||
e.clientY = e.changedTouches[0].clientY;
|
||||
|
||||
this.#cmd_store.push({
|
||||
type: "Line",
|
||||
data: [],
|
||||
});
|
||||
|
||||
this.move_event(e);
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
canvas.addEventListener(
|
||||
"touchend",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
e.clientX = e.changedTouches[0].clientX;
|
||||
e.clientY = e.changedTouches[0].clientY;
|
||||
|
||||
this.push_state();
|
||||
this.move_event(e);
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
// add controls
|
||||
const controls_tmpl = document
|
||||
.getElementById("carp_canvas")
|
||||
.content.cloneNode(true);
|
||||
this.#element.appendChild(controls_tmpl);
|
||||
|
||||
const canvas_loc = this.#element.querySelector(
|
||||
"[ui_ident=canvas_loc]",
|
||||
);
|
||||
canvas_loc.appendChild(canvas);
|
||||
|
||||
const color_picker = this.#element.querySelector(
|
||||
"[ui_ident=color_picker]",
|
||||
);
|
||||
color_picker.addEventListener("change", (e) => {
|
||||
this.set_old_color(this.COLOR);
|
||||
this.COLOR = e.target.value;
|
||||
});
|
||||
|
||||
const stroke_range = this.#element.querySelector(
|
||||
"[ui_ident=stroke_range]",
|
||||
);
|
||||
stroke_range.addEventListener("change", (e) => {
|
||||
this.set_old_stroke_size(this.STROKE_SIZE);
|
||||
this.STROKE_SIZE = e.target.value;
|
||||
});
|
||||
|
||||
const undo = this.#element.querySelector("[ui_ident=undo]");
|
||||
undo.addEventListener("click", () => {
|
||||
this.undo();
|
||||
});
|
||||
|
||||
const redo = this.#element.querySelector("[ui_ident=redo]");
|
||||
redo.addEventListener("click", () => {
|
||||
this.redo();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Resize the canvas
|
||||
resize(size) {
|
||||
this.#ctx.canvas.width = size.x;
|
||||
this.#ctx.canvas.height = size.y;
|
||||
}
|
||||
|
||||
/// Clear the canvas
|
||||
clear() {
|
||||
const canvas = this.#ctx.canvas;
|
||||
this.#ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
/// Set the old color
|
||||
set_old_color(value) {
|
||||
this.#color_old = value;
|
||||
}
|
||||
|
||||
/// Set the old stroke_size
|
||||
set_old_stroke_size(value) {
|
||||
this.#stroke_size_old = value;
|
||||
}
|
||||
|
||||
/// Update position (from event)
|
||||
move_event(e) {
|
||||
const rect = this.#ctx.canvas.getBoundingClientRect();
|
||||
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
this.move({ x, y });
|
||||
}
|
||||
|
||||
/// Update position
|
||||
move(pos) {
|
||||
this.#pos.x = pos.x;
|
||||
this.#pos.y = pos.y;
|
||||
}
|
||||
|
||||
/// Draw on the canvas (from event)
|
||||
draw_event(e, mobile = false) {
|
||||
if (e.buttons !== 1 && mobile === false) return;
|
||||
const rect = this.#ctx.canvas.getBoundingClientRect();
|
||||
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
this.draw({ x, y });
|
||||
}
|
||||
|
||||
/// Draw on the canvas
|
||||
draw(pos, skip_line_store = false) {
|
||||
this.#ctx.beginPath();
|
||||
|
||||
this.#ctx.lineWidth = this.STROKE_SIZE;
|
||||
this.#ctx.strokeStyle = this.COLOR;
|
||||
this.#ctx.lineCap = "round";
|
||||
|
||||
this.#ctx.moveTo(this.#pos.x, this.#pos.y);
|
||||
this.move(pos);
|
||||
this.#ctx.lineTo(this.#pos.x, this.#pos.y);
|
||||
|
||||
if (!skip_line_store) {
|
||||
// yes flooring the values will make the image SLIGHTLY different,
|
||||
// but it also saves THOUSANDS of characters
|
||||
const point = [
|
||||
Math.floor(this.#pos.x),
|
||||
Math.floor(this.#pos.y),
|
||||
];
|
||||
|
||||
if (this.#color_old !== this.COLOR) {
|
||||
this.#cmd_store.push({
|
||||
type: "Color",
|
||||
data: enc(this.COLOR.replace("#", "")),
|
||||
});
|
||||
}
|
||||
|
||||
if (this.#stroke_size_old !== this.STROKE_SIZE) {
|
||||
this.#cmd_store.push({
|
||||
type: "Size",
|
||||
data: lpad(2, enc(this.STROKE_SIZE, "u16")), // u16
|
||||
});
|
||||
}
|
||||
|
||||
this.#cmd_store.push({
|
||||
type: "Point",
|
||||
data: [
|
||||
// u32
|
||||
...lpad(4, enc(point[0])),
|
||||
...lpad(4, enc(point[1])),
|
||||
],
|
||||
});
|
||||
|
||||
if (this.#color_old !== this.COLOR) {
|
||||
// we've already seen it once, time to update it
|
||||
this.set_old_color(this.COLOR);
|
||||
}
|
||||
|
||||
if (this.#stroke_size_old !== this.STROKE_SIZE) {
|
||||
this.set_old_stroke_size(this.STROKE_SIZE);
|
||||
}
|
||||
}
|
||||
|
||||
this.#ctx.stroke();
|
||||
}
|
||||
|
||||
/// Create blob and get URL
|
||||
as_blob() {
|
||||
const blob = this.#ctx.canvas.toBlob();
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
/// Create Carp2 representation of the graph
|
||||
as_carp2() {
|
||||
// most stuff should have an lpad of 4 to make sure it's a u32 (4 bytes)
|
||||
const header = [
|
||||
...enc("CG"),
|
||||
...enc("02"),
|
||||
...lpad(4, enc(this.#ctx.canvas.width)),
|
||||
...lpad(4, enc(this.#ctx.canvas.height)),
|
||||
END_OF_HEADER,
|
||||
];
|
||||
|
||||
// build commands
|
||||
const commands = [];
|
||||
commands.push(COLOR);
|
||||
commands.push(...enc("000000"));
|
||||
commands.push(SIZE);
|
||||
commands.push(...lpad(4, enc(2)).slice(2));
|
||||
|
||||
for (const command of this.COMMANDS) {
|
||||
// this is `impl Into<Vec<u8>> for Command`
|
||||
switch (command.type) {
|
||||
case "Point":
|
||||
commands.push(POINT);
|
||||
break;
|
||||
|
||||
case "Line":
|
||||
commands.push(LINE);
|
||||
break;
|
||||
|
||||
case "Color":
|
||||
commands.push(COLOR);
|
||||
break;
|
||||
|
||||
case "Size":
|
||||
commands.push(SIZE);
|
||||
break;
|
||||
}
|
||||
|
||||
commands.push(...command.data);
|
||||
}
|
||||
|
||||
// this is so fucking stupid the fact that arraybuffers send as a fucking
|
||||
// concatenated string of the NUMBERS of the bytes is so stupid this is
|
||||
// actually crazy what the fuck is this shit
|
||||
//
|
||||
// didn't expect i'd have to do this shit myself considering it's done
|
||||
// for you with File prototypes from a file input
|
||||
const bin = [...header, ...commands, EOF];
|
||||
let bin_str = "";
|
||||
|
||||
for (const byte of bin) {
|
||||
bin_str += String.fromCharCode(byte);
|
||||
}
|
||||
|
||||
// return
|
||||
return bin;
|
||||
}
|
||||
|
||||
/// Export lines as string
|
||||
as_string() {
|
||||
return JSON.stringify(this.COMMANDS);
|
||||
}
|
||||
|
||||
/// From an array of bytes
|
||||
from_bytes(input) {
|
||||
this.clear();
|
||||
|
||||
let idx = -1;
|
||||
function next() {
|
||||
idx += 1;
|
||||
return [idx, input[idx]];
|
||||
}
|
||||
|
||||
function select_bytes(count) {
|
||||
// select_bytes! macro
|
||||
const data = [];
|
||||
let seen_bytes = 0;
|
||||
|
||||
let [_, byte] = next();
|
||||
while (byte !== undefined) {
|
||||
seen_bytes += 1;
|
||||
data.push(byte);
|
||||
|
||||
if (seen_bytes === count) {
|
||||
break;
|
||||
}
|
||||
|
||||
[_, byte] = next();
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// everything past this is just a reverse implementation of carp2.rs in js
|
||||
const commands = [];
|
||||
const dimensions = { x: 0, y: 0 };
|
||||
let in_header = true;
|
||||
let seen_point = false;
|
||||
let byte_buffer = [];
|
||||
|
||||
let [i, byte] = next();
|
||||
while (byte !== undefined) {
|
||||
switch (byte) {
|
||||
case END_OF_HEADER:
|
||||
in_header = false;
|
||||
break;
|
||||
|
||||
case COLOR:
|
||||
{
|
||||
const data = select_bytes(6);
|
||||
commands.push({
|
||||
type: "Color",
|
||||
data,
|
||||
});
|
||||
this.COLOR = `#${dec("string", new Uint8Array(data))}`;
|
||||
}
|
||||
break;
|
||||
|
||||
case SIZE:
|
||||
{
|
||||
const data = select_bytes(2);
|
||||
commands.push({
|
||||
type: "Size",
|
||||
data,
|
||||
});
|
||||
this.STROKE_SIZE = dec("u16", data);
|
||||
}
|
||||
break;
|
||||
|
||||
case POINT:
|
||||
{
|
||||
const data = select_bytes(8);
|
||||
commands.push({
|
||||
type: "Point",
|
||||
data,
|
||||
});
|
||||
|
||||
const point = {
|
||||
x: dec("u32", data.slice(0, 4)),
|
||||
y: dec("u32", data.slice(4, 8)),
|
||||
};
|
||||
|
||||
if (!seen_point) {
|
||||
// this is the FIRST POINT that has been seen...
|
||||
// we need to start drawing from here to avoid a line
|
||||
// from 0,0 to the point
|
||||
this.move(point);
|
||||
seen_point = true;
|
||||
}
|
||||
|
||||
this.draw(point, true);
|
||||
}
|
||||
break;
|
||||
|
||||
case LINE:
|
||||
// each line starts at a new place (probably)
|
||||
seen_point = false;
|
||||
break;
|
||||
|
||||
case EOF:
|
||||
break;
|
||||
|
||||
default:
|
||||
if (in_header) {
|
||||
if (0 <= i < 2) {
|
||||
// tag
|
||||
} else if (2 <= i < 4) {
|
||||
//version
|
||||
} else if (4 <= i < 8) {
|
||||
// width
|
||||
byte_buffer.push(byte);
|
||||
|
||||
if (i === 7) {
|
||||
dimensions.x = dec("u32", byte_buffer);
|
||||
byte_buffer = [];
|
||||
}
|
||||
} else if (8 <= i < 12) {
|
||||
// height
|
||||
byte_buffer.push(byte);
|
||||
|
||||
if (i === 7) {
|
||||
dimensions.y = dec("u32", byte_buffer);
|
||||
byte_buffer = [];
|
||||
this.resize(dimensions); // update canvas
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// misc byte
|
||||
console.log(`extraneous byte at ${i}`);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// ...
|
||||
[i, byte] = next();
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
/// Download image as `.carpgraph`
|
||||
download() {
|
||||
const blob = new Blob([new Uint8Array(this.as_carp2())], {
|
||||
type: "image/carpgraph",
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.setAttribute("download", "image.carpgraph");
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/// Download image as `.carpgraph1`
|
||||
download_json() {
|
||||
const string = this.as_string();
|
||||
const blob = new Blob([string], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.setAttribute("download", "image.carpgraph_json");
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
})();
|
|
@ -24,7 +24,7 @@ globalThis.ns = (ns) => {
|
|||
|
||||
if (!res) {
|
||||
return console.error(
|
||||
"namespace does not exist, please use one of the following:",
|
||||
`namespace "${ns}" does not exist, please use one of the following:`,
|
||||
Object.keys(globalThis._app_base.ns_store),
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue