add: drawings in questions

This commit is contained in:
trisua 2025-06-20 17:40:55 -04:00
parent 6be729de50
commit 16843a6ab8
28 changed files with 1181 additions and 32 deletions

View file

@ -39,6 +39,7 @@ pub const LOADER_JS: &str = include_str!("./public/js/loader.js");
pub const ATTO_JS: &str = include_str!("./public/js/atto.js"); pub const ATTO_JS: &str = include_str!("./public/js/atto.js");
pub const ME_JS: &str = include_str!("./public/js/me.js"); pub const ME_JS: &str = include_str!("./public/js/me.js");
pub const STREAMS_JS: &str = include_str!("./public/js/streams.js"); pub const STREAMS_JS: &str = include_str!("./public/js/streams.js");
pub const CARP_JS: &str = include_str!("./public/js/carp.js");
// html // html
pub const BODY: &str = include_str!("./public/html/body.lisp"); pub const BODY: &str = include_str!("./public/html/body.lisp");

View file

@ -135,6 +135,8 @@ version = "1.0.0"
"communities:label.file" = "File" "communities:label.file" = "File"
"communities:label.drafts" = "Drafts" "communities:label.drafts" = "Drafts"
"communities:label.load" = "Load" "communities:label.load" = "Load"
"communities:action.draw" = "Draw"
"communities:action.remove_drawing" = "Remove drawing"
"notifs:action.mark_as_read" = "Mark as read" "notifs:action.mark_as_read" = "Mark as read"
"notifs:action.mark_as_unread" = "Mark as unread" "notifs:action.mark_as_unread" = "Mark as unread"

View file

@ -389,3 +389,11 @@ blockquote {
transform: rotateZ(360deg); transform: rotateZ(360deg);
} }
} }
canvas {
border-radius: var(--radius);
border: solid 5px var(--color-primary);
background: white;
box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size)
var(--color-shadow);
}

View file

@ -18,6 +18,42 @@
(div ("class" "skel") ("style" "width: 25%; height: 25px;")) (div ("class" "skel") ("style" "width: 25%; height: 25px;"))
(div ("class" "skel") ("style" "width: 100%; height: 150px")))))) (div ("class" "skel") ("style" "width: 100%; height: 150px"))))))
(template
("id" "carp_canvas")
(div
("class" "flex flex-col gap-2")
(div ("ui_ident" "canvas_loc"))
(div
("class" "flex justify-between gap-2")
(div
("class" "flex gap-2")
(input
("type" "color")
("style" "width: 5rem")
("ui_ident" "color_picker"))
(input
("type" "range")
("min" "1")
("max" "25")
("step" "1")
("value" "2")
("ui_ident" "stroke_range")))
(div
("class" "flex gap-2")
(button
("title" "Undo")
("ui_ident" "undo")
("type" "button")
(icon (text "undo")))
(button
("title" "Redo")
("ui_ident" "redo")
("type" "button")
(icon (text "redo")))))))
; random js ; random js
(text "<script data-turbo-permanent=\"true\" id=\"init-script\"> (text "<script data-turbo-permanent=\"true\" id=\"init-script\">
document.documentElement.addEventListener(\"turbo:load\", () => { document.documentElement.addEventListener(\"turbo:load\", () => {

View file

@ -405,7 +405,7 @@
(text "{%- endif %}")))) (text "{%- endif %}"))))
(text "{% if community and show_community and community.id != config.town_square or question %}")) (text "{% if community and show_community and community.id != config.town_square or question %}"))
(text "{%- endif %} {%- endmacro %} {% macro post_media(upload_ids) -%} {% if upload_ids|length > 0%}") (text "{%- endif %} {%- endmacro %} {% macro post_media(upload_ids) -%} {% if upload_ids|length > 0 -%}")
(div (div
("class" "media_gallery gap-2") ("class" "media_gallery gap-2")
(text "{% for upload in upload_ids %}") (text "{% for upload in upload_ids %}")
@ -677,6 +677,8 @@
("class" "no_p_margin") ("class" "no_p_margin")
("style" "font-weight: 500") ("style" "font-weight: 500")
(text "{{ question.content|markdown|safe }}")) (text "{{ question.content|markdown|safe }}"))
; question drawings
(text "{{ self::post_media(upload_ids=question.drawings) }}")
; anonymous user ip thing ; anonymous user ip thing
; this is only shown if the post author is anonymous AND we are a helper ; this is only shown if the post author is anonymous AND we are a helper
(text "{% if is_helper and owner.id == 0 %}") (text "{% if is_helper and owner.id == 0 %}")
@ -693,7 +695,7 @@
(div (div
("class" "flex gap-2 items-center justify-between")))) ("class" "flex gap-2 items-center justify-between"))))
(text "{%- endmacro %} {% macro create_question_form(receiver=\"0\", community=\"\", header=\"\", is_global=false) -%}") (text "{%- endmacro %} {% macro create_question_form(receiver=\"0\", community=\"\", header=\"\", is_global=false, drawing_enabled=false) -%}")
(div (div
("class" "card-nest") ("class" "card-nest")
(div (div
@ -707,6 +709,12 @@
("onsubmit" "create_question_from_form(event)") ("onsubmit" "create_question_from_form(event)")
(div (div
("class" "flex flex-col gap-1") ("class" "flex flex-col gap-1")
; carp canvas
(text "{% if drawing_enabled -%}")
(div ("ui_ident" "carp_canvas_field"))
(text "{%- endif %}")
; form
(label (label
("for" "content") ("for" "content")
(text "{{ text \"communities:label.content\" }}")) (text "{{ text \"communities:label.content\" }}"))
@ -718,25 +726,83 @@
("required" "") ("required" "")
("minlength" "2") ("minlength" "2")
("maxlength" "4096"))) ("maxlength" "4096")))
(button (div
("class" "primary") ("class" "flex gap-2")
(text "{{ text \"communities:action.create\" }}")))) (button
("class" "primary")
(text "{{ text \"communities:action.create\" }}"))
(text "{% if drawing_enabled -%}")
(button
("class" "lowered")
("ui_ident" "add_drawing")
("onclick" "attach_drawing()")
("type" "button")
(text "{{ text \"communities:action.draw\" }}"))
(button
("class" "lowered red hidden")
("ui_ident" "remove_drawing")
("onclick" "remove_drawing()")
("type" "button")
(text "{{ text \"communities:action.remove_drawing\" }}"))
(script
(text "globalThis.attach_drawing = () => {
globalThis.gerald = trigger(\"carp::new\", [document.querySelector(\"[ui_ident=carp_canvas_field]\")]);
globalThis.gerald.create_canvas();
document.querySelector(\"[ui_ident=add_drawing]\").classList.add(\"hidden\");
document.querySelector(\"[ui_ident=remove_drawing]\").classList.remove(\"hidden\");
}
globalThis.remove_drawing = async () => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
document.querySelector(\"[ui_ident=carp_canvas_field]\").innerHTML = \"\";
globalThis.gerald = null;
document.querySelector(\"[ui_ident=add_drawing]\").classList.remove(\"hidden\");
document.querySelector(\"[ui_ident=remove_drawing]\").classList.add(\"hidden\");
}"))
(text "{%- endif %}"))))
(script (script
(text "async function create_question_from_form(e) { (text "globalThis.gerald = null;
async function create_question_from_form(e) {
e.preventDefault(); e.preventDefault();
await trigger(\"atto::debounce\", [\"questions::create\"]); await trigger(\"atto::debounce\", [\"questions::create\"]);
fetch(\"/api/v1/questions\", {
method: \"POST\", // create body
headers: { const body = new FormData();
\"Content-Type\": \"application/json\",
}, if (globalThis.gerald) {
body: JSON.stringify({ body.append(\"drawing.carpgraph\", new Blob([new Uint8Array(globalThis.gerald.as_carp2())], {
type: \"application/octet-stream\"
}));
}
body.append(
\"body\",
JSON.stringify({
content: e.target.content.value, content: e.target.content.value,
receiver: \"{{ receiver }}\", receiver: \"{{ receiver }}\",
community: \"{{ community }}\", community: \"{{ community }}\",
is_global: \"{{ is_global }}\" == \"true\", is_global: \"{{ is_global }}\" == \"true\",
}), }),
);
// ...
fetch(\"/api/v1/questions\", {
method: \"POST\",
body,
}) })
.then((res) => res.json()) .then((res) => res.json())
.then((res) => { .then((res) => {
@ -747,6 +813,10 @@
if (res.ok) { if (res.ok) {
e.target.reset(); e.target.reset();
if (globalThis.gerald) {
globalThis.gerald.clear();
}
} }
}); });
}")) }"))

View file

@ -276,7 +276,7 @@
; init codemirror ; init codemirror
(script ("id" "editor_content") ("type" "text/markdown") (text "{{ note.content|remove_script_tags|safe }}")) (script ("id" "editor_content") ("type" "text/markdown") (text "{{ note.content|remove_script_tags|safe }}"))
(script (script
(text "setTimeout(() => { (text "setTimeout(async () => {
if (!document.getElementById(\"preview_tab\").shadowRoot) { if (!document.getElementById(\"preview_tab\").shadowRoot) {
document.getElementById(\"preview_tab\").attachShadow({ mode: \"open\" }); document.getElementById(\"preview_tab\").attachShadow({ mode: \"open\" });
} }
@ -335,6 +335,7 @@
const preview_token = window.crypto.randomUUID(); const preview_token = window.crypto.randomUUID();
document.getElementById(\"preview_tab\").shadowRoot.innerHTML = `${res}<style> document.getElementById(\"preview_tab\").shadowRoot.innerHTML = `${res}<style>
@import url(\"/css/style.css\");
@import url(\"/api/v1/journals/{{ journal.id }}/journal.css?v=preview-${preview_token}\"); @import url(\"/api/v1/journals/{{ journal.id }}/journal.css?v=preview-${preview_token}\");
</style>`; </style>`;
trigger(\"atto::hooks::tabs:switch\", [\"preview\"]); trigger(\"atto::hooks::tabs:switch\", [\"preview\"]);

View file

@ -1,7 +1,7 @@
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}") (text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
(div (div
("style" "display: contents") ("style" "display: contents")
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header) }}")) (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}"))
(text "{%- endif %} {{ macros::profile_nav(selected=\"media\") }}") (text "{%- endif %} {{ macros::profile_nav(selected=\"media\") }}")
(div (div

View file

@ -1,7 +1,7 @@
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}") (text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
(div (div
("style" "display: contents") ("style" "display: contents")
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header) }}")) (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}"))
(text "{%- endif %} {{ macros::profile_nav(selected=\"outbox\") }}") (text "{%- endif %} {{ macros::profile_nav(selected=\"outbox\") }}")
(div (div

View file

@ -1,7 +1,7 @@
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}") (text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
(div (div
("style" "display: contents") ("style" "display: contents")
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header) }}")) (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}"))
(text "{%- endif %} {% if not tag and pinned|length != 0 -%}") (text "{%- endif %} {% if not tag and pinned|length != 0 -%}")
(div (div

View file

@ -1,7 +1,7 @@
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}") (text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
(div (div
("style" "display: contents") ("style" "display: contents")
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header) }}")) (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}"))
(text "{%- endif %} {{ macros::profile_nav(selected=\"replies\") }}") (text "{%- endif %} {{ macros::profile_nav(selected=\"replies\") }}")
(div (div

View file

@ -1376,6 +1376,14 @@
\"{{ profile.settings.allow_anonymous_questions }}\", \"{{ profile.settings.allow_anonymous_questions }}\",
\"checkbox\", \"checkbox\",
], ],
[
[
\"enable_drawings\",
\"Allow users to create drawings and submit them with questions\",
],
\"{{ profile.settings.enable_drawings }}\",
\"checkbox\",
],
[ [
[\"motivational_header\", \"Motivational header\"], [\"motivational_header\", \"Motivational header\"],
settings.motivational_header, settings.motivational_header,

View file

@ -39,6 +39,7 @@ media_theme_pref();
// init // init
use("me", () => {}); use("me", () => {});
use("streams", () => {}); use("streams", () => {});
use("carp", () => {});
// env // env
self.DEBOUNCE = []; self.DEBOUNCE = [];

View 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);
}
}
})();

View file

@ -24,7 +24,7 @@ globalThis.ns = (ns) => {
if (!res) { if (!res) {
return console.error( 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), Object.keys(globalThis._app_base.ns_store),
); );
} }

View file

@ -15,6 +15,7 @@ use tetratto_core::model::{
}; };
use crate::{ use crate::{
get_user_from_token, get_user_from_token,
image::JsonMultipart,
routes::{api::v1::CreateQuestion, pages::PaginatedQuery}, routes::{api::v1::CreateQuestion, pages::PaginatedQuery},
State, State,
}; };
@ -23,7 +24,7 @@ pub async fn create_request(
jar: CookieJar, jar: CookieJar,
headers: HeaderMap, headers: HeaderMap,
Extension(data): Extension<State>, Extension(data): Extension<State>,
Json(req): Json<CreateQuestion>, JsonMultipart(drawings, req): JsonMultipart<CreateQuestion>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let data = &(data.read().await).0; let data = &(data.read().await).0;
let user = get_user_from_token!(jar, data, oauth::AppScope::UserCreateQuestions); let user = get_user_from_token!(jar, data, oauth::AppScope::UserCreateQuestions);
@ -70,7 +71,10 @@ pub async fn create_request(
} }
} }
match data.create_question(props).await { match data
.create_question(props, drawings.iter().map(|x| x.to_vec()).collect())
.await
{
Ok(id) => Json(ApiReturn { Ok(id) => Json(ApiReturn {
ok: true, ok: true,
message: "Question created".to_string(), message: "Question created".to_string(),

View file

@ -57,10 +57,18 @@ pub async fn get_css_request(
let note = match data.get_note_by_journal_title(id, "journal.css").await { let note = match data.get_note_by_journal_title(id, "journal.css").await {
Ok(x) => x, Ok(x) => x,
Err(e) => return ([("Content-Type", "text/plain")], format!("/* {e} */")), Err(e) => {
return (
[("Content-Type", "text/css"), ("Cache-Control", "no-cache")],
format!("/* {e} */"),
);
}
}; };
([("Content-Type", "text/css")], note.content) (
[("Content-Type", "text/css"), ("Cache-Control", "no-cache")],
note.content,
)
} }
pub async fn list_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse { pub async fn list_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {

View file

@ -4,7 +4,7 @@ use axum_extra::extract::CookieJar;
use pathbufd::PathBufD; use pathbufd::PathBufD;
use crate::{get_user_from_token, State}; use crate::{get_user_from_token, State};
use super::auth::images::read_image; use super::auth::images::read_image;
use tetratto_core::model::{oauth, ApiReturn, Error}; use tetratto_core::model::{carp::CarpGraph, oauth, uploads::MediaType, ApiReturn, Error};
pub async fn get_request( pub async fn get_request(
Path(id): Path<usize>, Path(id): Path<usize>,
@ -39,10 +39,17 @@ pub async fn get_request(
)); ));
} }
Ok(( let bytes = read_image(path);
[("Content-Type", upload.what.mime())],
Body::from(read_image(path)), if upload.what == MediaType::Carpgraph {
)) // conver to svg and return
return Ok((
[("Content-Type", "image/svg+xml".to_string())],
Body::from(CarpGraph::from_bytes(bytes).to_svg()),
));
}
Ok(([("Content-Type", upload.what.mime())], Body::from(bytes)))
} }
pub async fn delete_request( pub async fn delete_request(

View file

@ -18,3 +18,4 @@ serve_asset!(loader_js_request: LOADER_JS("text/javascript"));
serve_asset!(atto_js_request: ATTO_JS("text/javascript")); serve_asset!(atto_js_request: ATTO_JS("text/javascript"));
serve_asset!(me_js_request: ME_JS("text/javascript")); serve_asset!(me_js_request: ME_JS("text/javascript"));
serve_asset!(streams_js_request: STREAMS_JS("text/javascript")); serve_asset!(streams_js_request: STREAMS_JS("text/javascript"));
serve_asset!(carp_js_request: CARP_JS("text/javascript"));

View file

@ -19,6 +19,7 @@ pub fn routes(config: &Config) -> Router {
.route("/js/atto.js", get(assets::atto_js_request)) .route("/js/atto.js", get(assets::atto_js_request))
.route("/js/me.js", get(assets::me_js_request)) .route("/js/me.js", get(assets::me_js_request))
.route("/js/streams.js", get(assets::streams_js_request)) .route("/js/streams.js", get(assets::streams_js_request))
.route("/js/carp.js", get(assets::carp_js_request))
.nest_service( .nest_service(
"/public", "/public",
get_service(tower_http::services::ServeDir::new(&config.dirs.assets)), get_service(tower_http::services::ServeDir::new(&config.dirs.assets)),

View file

@ -216,7 +216,7 @@ impl DataManager {
&0_i32, &0_i32,
&(data.last_seen as i64), &(data.last_seen as i64),
&String::new(), &String::new(),
&"[]", "[]",
&0_i32, &0_i32,
&0_i32, &0_i32,
&serde_json::to_string(&data.connections).unwrap(), &serde_json::to_string(&data.connections).unwrap(),

View file

@ -1952,6 +1952,15 @@ impl DataManager {
self.delete_poll(y.poll_id, &user).await?; self.delete_poll(y.poll_id, &user).await?;
} }
// delete question (if not global question)
if y.context.answering != 0 {
let question = self.get_question_by_id(y.context.answering).await?;
if !question.is_global {
self.delete_question(question.id, &user).await?;
}
}
// return // return
Ok(()) Ok(())
} }
@ -2031,6 +2040,15 @@ impl DataManager {
for upload in y.uploads { for upload in y.uploads {
self.delete_upload(upload).await?; self.delete_upload(upload).await?;
} }
// delete question (if not global question)
if y.context.answering != 0 {
let question = self.get_question_by_id(y.context.answering).await?;
if !question.is_global {
self.delete_question(question.id, &user).await?;
}
}
} else { } else {
// incr parent comment count // incr parent comment count
if let Some(replying_to) = y.replying_to { if let Some(replying_to) = y.replying_to {

View file

@ -2,6 +2,7 @@ use std::collections::HashMap;
use oiseau::cache::Cache; use oiseau::cache::Cache;
use tetratto_shared::unix_epoch_timestamp; use tetratto_shared::unix_epoch_timestamp;
use crate::model::communities_permissions::CommunityPermission; use crate::model::communities_permissions::CommunityPermission;
use crate::model::uploads::{MediaType, MediaUpload};
use crate::model::{ use crate::model::{
Error, Result, Error, Result,
communities::Question, communities::Question,
@ -33,6 +34,7 @@ impl DataManager {
// ... // ...
context: serde_json::from_str(&get!(x->10(String))).unwrap(), context: serde_json::from_str(&get!(x->10(String))).unwrap(),
ip: get!(x->11(String)), ip: get!(x->11(String)),
drawings: serde_json::from_str(&get!(x->12(String))).unwrap(),
} }
} }
@ -333,13 +335,20 @@ impl DataManager {
Ok(res.unwrap()) Ok(res.unwrap())
} }
const MAXIMUM_DRAWING_SIZE: usize = 32768; // 32 KiB
/// Create a new question in the database. /// Create a new question in the database.
/// ///
/// # Arguments /// # Arguments
/// * `data` - a mock [`Question`] object to insert /// * `data` - a mock [`Question`] object to insert
pub async fn create_question(&self, mut data: Question) -> Result<usize> { pub async fn create_question(
&self,
mut data: Question,
drawings: Vec<Vec<u8>>,
) -> Result<usize> {
// check if we can post this // check if we can post this
if data.is_global { if data.is_global {
// global
if data.community > 0 { if data.community > 0 {
// posting to community // posting to community
data.receiver = 0; data.receiver = 0;
@ -370,6 +379,7 @@ impl DataManager {
} }
} }
} else { } else {
// single
let receiver = self.get_user_by_id(data.receiver).await?; let receiver = self.get_user_by_id(data.receiver).await?;
if !receiver.settings.enable_questions { if !receiver.settings.enable_questions {
@ -380,6 +390,10 @@ impl DataManager {
return Err(Error::NotAllowed); return Err(Error::NotAllowed);
} }
if !receiver.settings.enable_drawings && drawings.len() > 0 {
return Err(Error::DrawingsDisabled);
}
// check for ip block // check for ip block
if self if self
.get_ipblock_by_initiator_receiver(receiver.id, &data.ip) .get_ipblock_by_initiator_receiver(receiver.id, &data.ip)
@ -390,6 +404,28 @@ impl DataManager {
} }
} }
// create uploads
if drawings.len() > 2 {
return Err(Error::MiscError(
"Too many uploads. Please use a maximum of 2".to_string(),
));
}
for drawing in &drawings {
// this is the initial iter to check sizes, we'll do uploads after
if drawing.len() > Self::MAXIMUM_DRAWING_SIZE {
return Err(Error::FileTooLarge);
}
}
for _ in 0..drawings.len() {
data.drawings.push(
self.create_upload(MediaUpload::new(MediaType::Carpgraph, data.owner))
.await?
.id,
);
}
// ... // ...
let conn = match self.0.connect().await { let conn = match self.0.connect().await {
Ok(c) => c, Ok(c) => c,
@ -398,7 +434,7 @@ impl DataManager {
let res = execute!( let res = execute!(
&conn, &conn,
"INSERT INTO questions VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", "INSERT INTO questions VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)",
params![ params![
&(data.id as i64), &(data.id as i64),
&(data.created as i64), &(data.created as i64),
@ -411,7 +447,8 @@ impl DataManager {
&0_i32, &0_i32,
&0_i32, &0_i32,
&serde_json::to_string(&data.context).unwrap(), &serde_json::to_string(&data.context).unwrap(),
&data.ip &data.ip,
&serde_json::to_string(&data.drawings).unwrap(),
] ]
); );
@ -430,6 +467,23 @@ impl DataManager {
.await?; .await?;
} }
// write to uploads
for (i, drawing_id) in data.drawings.iter().enumerate() {
let drawing = match drawings.get(i) {
Some(d) => d,
None => {
self.delete_upload(*drawing_id).await?;
continue;
}
};
let upload = self.get_upload_by_id(*drawing_id).await?;
if let Err(e) = std::fs::write(&upload.path(&self.0.0).to_string(), drawing.to_vec()) {
return Err(Error::MiscError(e.to_string()));
}
}
// return // return
Ok(data.id) Ok(data.id)
} }
@ -495,6 +549,11 @@ impl DataManager {
return Err(Error::DatabaseError(e.to_string())); return Err(Error::DatabaseError(e.to_string()));
} }
// delete uploads
for upload in y.drawings {
self.delete_upload(upload).await?;
}
// return // return
Ok(()) Ok(())
} }

View file

@ -234,6 +234,9 @@ pub struct UserSettings {
/// If timelines are paged instead of infinitely scrolled. /// If timelines are paged instead of infinitely scrolled.
#[serde(default)] #[serde(default)]
pub paged_timelines: bool, pub paged_timelines: bool,
/// If drawings are enabled for questions sent to the user.
#[serde(default)]
pub enable_drawings: bool,
} }
fn mime_avif() -> String { fn mime_avif() -> String {

View file

@ -0,0 +1,285 @@
use serde::{Serialize, Deserialize};
/// Starting at the beginning of the file, the header details specific information
/// about the file.
///
/// 1. `CG` tag (2 bytes)
/// 2. version number (2 bytes)
/// 3. width of graph (4 bytes)
/// 4. height of graph (4 bytes)
/// 5. `END_OF_HEADER`
///
/// The header has a total of 13 bytes. (12 of info, 1 of `END_OF_HEADER)
///
/// Everything after `END_OF_HEADER` should be another command and its parameters.
pub const END_OF_HEADER: u8 = 0x1a;
/// The color command marks the beginning of a hex-encoded color **string**.
///
/// The hastag character should **not** be included.
pub const COLOR: u8 = 0x1b;
/// The size command marks the beginning of a integer brush size.
pub const SIZE: u8 = 0x2b;
/// Marks the beginning of a new line.
pub const LINE: u8 = 0x3b;
/// A point marks the coordinates (relative to the previous `DELTA_ORIGIN`, or `(0, 0)`)
/// in which a point should be drawn.
///
/// The size and color are that of the previous `COLOR` and `SIZE` commands.
///
/// Points are two `u32`s (or 8 bytes in length).
pub const POINT: u8 = 0x4b;
/// An end-of-file marker.
pub const EOF: u8 = 0x1f;
/// A type of [`Command`].
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum CommandType {
/// [`END_OF_HEADER`]
EndOfHeader = END_OF_HEADER,
/// [`COLOR`]
Color = COLOR,
/// [`SIZE`]
Size = SIZE,
/// [`LINE`]
Line = LINE,
/// [`POINT`]
Point = POINT,
/// [`EOF`]
Eof = EOF,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Command {
/// The type of the command.
pub r#type: CommandType,
/// Raw data as bytes.
pub data: Vec<u8>,
}
impl From<Command> for Vec<u8> {
fn from(val: Command) -> Self {
let mut d = val.data;
d.insert(0, val.r#type as u8);
d
}
}
/// A graph is CarpGraph's representation of an image. It's essentially just a
/// reproducable series of commands which a renderer can traverse to reconstruct
/// an image.
#[derive(Serialize, Deserialize, Debug)]
pub struct CarpGraph {
pub header: Vec<u8>,
pub dimensions: (u32, u32),
pub commands: Vec<Command>,
}
macro_rules! select_bytes {
($count:literal, $from:ident) => {{
let mut data: Vec<u8> = Vec::new();
let mut seen_bytes = 0;
while let Some((_, byte)) = $from.next() {
seen_bytes += 1;
data.push(byte.to_owned());
if seen_bytes == $count {
// we only need <count> bytes, stop just before we eat the next byte
break;
}
}
data
}};
}
macro_rules! spread {
($into:ident, $from:expr) => {
for byte in &$from {
$into.push(byte.to_owned())
}
};
}
impl CarpGraph {
pub fn to_bytes(&self) -> Vec<u8> {
let mut out: Vec<u8> = Vec::new();
// reconstruct header
spread!(out, self.header);
spread!(out, self.dimensions.0.to_be_bytes()); // width
spread!(out, self.dimensions.1.to_be_bytes()); // height
out.push(END_OF_HEADER);
// reconstruct commands
for command in &self.commands {
out.push(command.r#type as u8);
spread!(out, command.data);
}
// ...
out.push(EOF);
out
}
pub fn from_bytes(bytes: Vec<u8>) -> Self {
let mut header: Vec<u8> = Vec::new();
let mut dimensions: (u32, u32) = (0, 0);
let mut commands: Vec<Command> = Vec::new();
let mut in_header: bool = true;
let mut byte_buffer: Vec<u8> = Vec::new(); // storage for bytes which need to construct a bigger type (like `u32`)
let mut bytes_iter = bytes.iter().enumerate();
while let Some((i, byte)) = bytes_iter.next() {
let byte = byte.to_owned();
match byte {
END_OF_HEADER => in_header = false,
COLOR => {
let data = select_bytes!(6, bytes_iter);
commands.push(Command {
r#type: CommandType::Color,
data,
});
}
SIZE => {
let data = select_bytes!(2, bytes_iter);
commands.push(Command {
r#type: CommandType::Size,
data,
});
}
POINT => {
let data = select_bytes!(8, bytes_iter);
commands.push(Command {
r#type: CommandType::Point,
data,
});
}
LINE => commands.push(Command {
r#type: CommandType::Line,
data: Vec::new(),
}),
EOF => break,
_ => {
if in_header {
if (0..2).contains(&i) {
// tag
header.push(byte);
} else if (2..4).contains(&i) {
// version
header.push(byte);
} else if (4..8).contains(&i) {
// width
byte_buffer.push(byte);
if i == 7 {
// end, construct from byte buffer
let (bytes, _) = byte_buffer.split_at(size_of::<u32>());
dimensions.0 = u32::from_be_bytes(bytes.try_into().unwrap());
byte_buffer = Vec::new();
}
} else if (8..12).contains(&i) {
// height
byte_buffer.push(byte);
if i == 11 {
// end, construct from byte buffer
let (bytes, _) = byte_buffer.split_at(size_of::<u32>());
dimensions.1 = u32::from_be_bytes(bytes.try_into().unwrap());
byte_buffer = Vec::new();
}
}
} else {
// misc byte
println!("extraneous byte at {i}");
}
}
}
}
Self {
header,
dimensions,
commands,
}
}
pub fn to_svg(&self) -> String {
let mut out: String = String::new();
out.push_str(&format!(
"<svg viewBox=\"0 0 {} {}\" xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\" style=\"background: white; width: {}px; height: {}px\" class=\"carpgraph\">",
self.dimensions.0, self.dimensions.1, self.dimensions.0, self.dimensions.1, self.dimensions.0, self.dimensions.1
));
// add lines
let mut stroke_size: u16 = 2;
let mut stroke_color: String = "000000".to_string();
let mut previous_x_y: Option<(u32, u32)> = None;
let mut line_path = String::new();
for command in &self.commands {
match command.r#type {
CommandType::Size => {
let (bytes, _) = command.data.split_at(size_of::<u16>());
stroke_size = u16::from_be_bytes(bytes.try_into().unwrap_or([0, 0]));
}
CommandType::Color => {
stroke_color =
String::from_utf8(command.data.to_owned()).unwrap_or("#000000".to_string())
}
CommandType::Line => {
if !line_path.is_empty() {
out.push_str(&format!(
"<path d=\"{line_path}\" stroke=\"#{stroke_color}\" stroke-width=\"{stroke_size}\" />"
));
}
previous_x_y = None;
line_path = String::new();
}
CommandType::Point => {
let (x, y) = command.data.split_at(size_of::<u32>());
let point = ({ u32::from_be_bytes(x.try_into().unwrap()) }, {
u32::from_be_bytes(y.try_into().unwrap())
});
// add to path string
line_path.push_str(&format!(
" M{} {}{}",
point.0,
point.1,
if let Some(pxy) = previous_x_y {
// line to there
format!(" L{} {}", pxy.0, pxy.1)
} else {
String::new()
}
));
previous_x_y = Some((point.0, point.1));
// add circular point
out.push_str(&format!(
"<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"#{stroke_color}\" />",
point.0,
point.1,
stroke_size / 2 // the size is technically the diameter of the circle
));
}
_ => unreachable!("never pushed to commands"),
}
}
if !line_path.is_empty() {
out.push_str(&format!(
"<path d=\"{line_path}\" stroke=\"#{stroke_color}\" stroke-width=\"{stroke_size}\" />"
));
}
// return
format!("{out}</svg>")
}
}

View file

@ -345,6 +345,9 @@ pub struct Question {
/// The IP of the question creator for IP blocking and identifying anonymous users. /// The IP of the question creator for IP blocking and identifying anonymous users.
#[serde(default)] #[serde(default)]
pub ip: String, pub ip: String,
/// The IDs of all uploads which hold this question's drawings.
#[serde(default)]
pub drawings: Vec<usize>,
} }
impl Question { impl Question {
@ -369,6 +372,7 @@ impl Question {
dislikes: 0, dislikes: 0,
context: QuestionContext::default(), context: QuestionContext::default(),
ip, ip,
drawings: Vec::new(),
} }
} }
} }

View file

@ -1,6 +1,7 @@
pub mod addr; pub mod addr;
pub mod apps; pub mod apps;
pub mod auth; pub mod auth;
pub mod carp;
pub mod channels; pub mod channels;
pub mod communities; pub mod communities;
pub mod communities_permissions; pub mod communities_permissions;
@ -46,6 +47,7 @@ pub enum Error {
TitleInUse, TitleInUse,
QuestionsDisabled, QuestionsDisabled,
RequiresSupporter, RequiresSupporter,
DrawingsDisabled,
Unknown, Unknown,
} }
@ -68,6 +70,7 @@ impl Display for Error {
Self::TitleInUse => "Title in use".to_string(), Self::TitleInUse => "Title in use".to_string(),
Self::QuestionsDisabled => "You are not allowed to ask questions there".to_string(), Self::QuestionsDisabled => "You are not allowed to ask questions there".to_string(),
Self::RequiresSupporter => "Only site supporters can do this".to_string(), Self::RequiresSupporter => "Only site supporters can do this".to_string(),
Self::DrawingsDisabled => "You are not allowed to submit drawings there".to_string(),
_ => format!("An unknown error as occurred: ({:?})", self), _ => format!("An unknown error as occurred: ({:?})", self),
}) })
} }

View file

@ -5,7 +5,7 @@ use crate::config::Config;
use std::fs::{write, exists, remove_file}; use std::fs::{write, exists, remove_file};
use super::{Error, Result}; use super::{Error, Result};
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize, PartialEq, Eq)]
pub enum MediaType { pub enum MediaType {
#[serde(alias = "image/webp")] #[serde(alias = "image/webp")]
Webp, Webp,
@ -17,6 +17,8 @@ pub enum MediaType {
Jpg, Jpg,
#[serde(alias = "image/gif")] #[serde(alias = "image/gif")]
Gif, Gif,
#[serde(alias = "image/carpgraph")]
Carpgraph,
} }
impl MediaType { impl MediaType {
@ -27,6 +29,7 @@ impl MediaType {
Self::Png => "png", Self::Png => "png",
Self::Jpg => "jpg", Self::Jpg => "jpg",
Self::Gif => "gif", Self::Gif => "gif",
Self::Carpgraph => "carpgraph",
} }
} }

View file

@ -0,0 +1,2 @@
ALTER TABLE questions
ADD COLUMN drawings TEXT NOT NULL DEFAULT '[]';