Compare commits

...

27 Commits

Author SHA1 Message Date
A.Olokhtonov ad9cded350 Sweet juicy svg cursor 1 year ago
A.Olokhtonov 6f446b7d13 Fwidth in the shader for "more gooder" AA 1 year ago
A.Olokhtonov 8b3f28337e SDF town 1 year ago
A.Olokhtonov 8557c5d47e Caps and joints via fragment shader 1 year ago
A.Olokhtonov 0271e38dbc Fix wrong buffered points 1 year ago
A.Olokhtonov 31b18e69a0 Images moving around, paste image from clipboard 1 year ago
A.Olokhtonov 3b8232e196 Image broadcast 1 year ago
A.Olokhtonov 1edcc6725b Moar better color/width sync 1 year ago
A.Olokhtonov ac0d8f7605 Betta websocket reconnect 1 year ago
A.Olokhtonov 33898ab27a Client code cleanup p1 1 year ago
A.Olokhtonov dec07b4edc Remove users from backend, leave only sessions. Simplify storage 1 year ago
A.Olokhtonov cb783db614 Per-user stroke width and color (for dynamic strokes) kinda work 1 year ago
A.Olokhtonov 165d9235ce Wrote what to do NEXT: 1 year ago
A.Olokhtonov 7c2ba5ff72 Do not schedule RAF if it already is scheduled (somewhat fixes event spam from chrome!) 1 year ago
A.Olokhtonov 110afe123f Brush preview 1 year ago
A.Olokhtonov 45c3af9c67 Brush width control. Phone "zen mode" button 1 year ago
A.Olokhtonov bf273a9424 Fix HiDPI support on non-touch devices 1 year ago
A.Olokhtonov 59cb197e58 I don't even know anymore (colors?) 1 year ago
A.Olokhtonov 343008c0af f32 coordinates 1 year ago
A.Olokhtonov f24e8d386b Pictures good 1 year ago
A.Olokhtonov 29f697dceb Texture test 1 year ago
A.Olokhtonov 04c11e23f3 Nice touch! 1 year ago
A.Olokhtonov 5c0d9e1537 The gooder biba 1 year ago
A.Olokhtonov 5593536485 Working rounded lines! 1 year ago
A.Olokhtonov ab152b2d0a Investigating rounded corners (buggy!) 1 year ago
A.Olokhtonov e056d6c698 Round caps! 1 year ago
A.Olokhtonov 7011cc86be Some kind of shitty webgl line renderer 1 year ago
  1. 100
      client/aux.js
  2. 412
      client/client_recv.js
  3. 140
      client/client_send.js
  4. 20
      client/cursor.js
  5. 288
      client/default.css
  6. 66
      client/draw.js
  7. BIN
      client/favicon2.png
  8. BIN
      client/favicon_old.png
  9. 8
      client/icons/cheeseburga.svg
  10. 50
      client/icons/cursor.svg
  11. 59
      client/icons/draw.svg
  12. 79
      client/icons/erase.svg
  13. 54
      client/icons/favicon.svg
  14. 54
      client/icons/redo.svg
  15. 53
      client/icons/ruler.svg
  16. 59
      client/icons/undo.svg
  17. 84
      client/index.html
  18. 397
      client/index.js
  19. 77
      client/math.js
  20. 345
      client/recv.js
  21. 95
      client/tools.js
  22. 92
      client/webgl_draw.js
  23. 244
      client/webgl_geometry.js
  24. 410
      client/webgl_listeners.js
  25. 193
      client/webgl_shaders.js
  26. 56
      client/websocket.js
  27. 3
      server/config.js
  28. 43
      server/deserializer.js
  29. 7
      server/enums.js
  30. 8
      server/http.js
  31. 126
      server/recv.js
  32. 67
      server/send.js
  33. 25
      server/serializer.js
  34. 10
      server/server.js
  35. 156
      server/storage.js
  36. 21
      server/texput.log

100
client/aux.js

@ -0,0 +1,100 @@ @@ -0,0 +1,100 @@
function ui_offline() {
document.body.classList.add('offline');
document.querySelector('.offline-toast').classList.remove('hidden');
}
function ui_online() {
document.body.classList.remove('offline');
document.querySelector('.offline-toast').classList.add('hidden');
}
async function insert_image(state, context, file) {
const bitmap = await createImageBitmap(file);
const p = { 'x': state.cursor.x, 'y': state.cursor.y };
const canvasp = screen_to_canvas(state, p);
canvasp.x -= bitmap.width / 2;
canvasp.y -= bitmap.height / 2;
const form_data = new FormData();
form_data.append('file', file);
const resp = await fetch(`/api/image?deskId=${state.desk_id}`, {
method: 'post',
body: form_data,
})
if (resp.ok) {
const image_id = await resp.text();
const event = image_event(image_id, canvasp.x, canvasp.y);
await queue_event(state, event);
}
}
function event_size(event) {
let size = 1 + 3; // type + padding
switch (event.type) {
case EVENT.PREDRAW: {
size += 4 * 2;
break;
}
case EVENT.SET_COLOR: {
size += 4;
break;
}
case EVENT.SET_WIDTH: {
size += 2;
break;
}
case EVENT.STROKE: {
size += 4 + 2 + 2 + 4 + event.points.length * 4 * 2; // u32 stroke id + u16 (count) + u16 (width) + u32 (color + count * (f32, f32) points
break;
}
case EVENT.UNDO:
case EVENT.REDO: {
break;
}
case EVENT.IMAGE:
case EVENT.IMAGE_MOVE: {
size += 4 + 4 + 4; // file id + x + y
break;
}
case EVENT.ERASER: {
size += 4; // stroke id
break;
}
default: {
console.error('fuck');
}
}
return size;
}
function find_touch(touchlist, id) {
for (const touch of touchlist) {
if (touch.identifier === id) {
return touch;
}
}
return null;
}
function find_image(state, image_id) {
for (let i = state.events.length - 1; i >= 0; --i) {
const event = state.events[i];
if (event.type === EVENT.IMAGE && !event.deleted && event.image_id === image_id) {
return event;
}
}
}

412
client/client_recv.js

@ -0,0 +1,412 @@ @@ -0,0 +1,412 @@
function deserializer_create(buffer, dataview) {
return {
'offset': 0,
'size': buffer.byteLength,
'buffer': buffer,
'view': dataview,
'strview': new Uint8Array(buffer),
};
}
function des_u8(d) {
const value = d.view.getUint8(d.offset);
d.offset += 1;
return value;
}
function des_u16(d) {
const value = d.view.getUint16(d.offset, true);
d.offset += 2;
return value;
}
function des_u32(d) {
const value = d.view.getUint32(d.offset, true);
d.offset += 4;
return value;
}
function des_f32(d) {
const value = d.view.getFloat32(d.offset, true);
d.offset += 4;
return value;
}
function des_f32array(d, count) {
const result = [];
for (let i = 0; i < count; ++i) {
const item = d.view.getFloat32(d.offset, true);
d.offset += 4;
result.push(item);
}
return result;
}
function des_event(d) {
const event = {};
event.type = des_u8(d);
event.user_id = des_u32(d);
switch (event.type) {
case EVENT.PREDRAW: {
event.x = des_f32(d);
event.y = des_f32(d);
break;
}
case EVENT.SET_COLOR: {
event.color = des_u32(d);
break;
}
case EVENT.SET_WIDTH: {
event.width = des_u16(d);
break;
}
case EVENT.STROKE: {
const stroke_id = des_u32(d);
const point_count = des_u16(d);
const width = des_u16(d);
const color = des_u32(d);
const coords = des_f32array(d, point_count * 2);
event.stroke_id = stroke_id;
event.points = [];
for (let i = 0; i < point_count; ++i) {
const x = coords[2 * i + 0];
const y = coords[2 * i + 1];
event.points.push({'x': x, 'y': y});
}
event.color = color;
event.width = width;
break;
}
case EVENT.IMAGE:
case EVENT.IMAGE_MOVE: {
event.image_id = des_u32(d);
event.x = des_f32(d);
event.y = des_f32(d);
break;
}
case EVENT.UNDO:
case EVENT.REDO: {
break;
}
case EVENT.ERASER: {
event.stroke_id = des_u32(d);
break;
}
default: {
console.error('fuck');
}
}
return event;
}
function bitmap_bbox(event) {
const bbox = {
'xmin': event.x,
'xmax': event.x + event.bitmap.width,
'ymin': event.y,
'ymax': event.y + event.bitmap.height
};
return bbox;
}
function init_player_defaults(state, player_id, color = config.default_color, width = config.default_width) {
state.players[player_id] = {
'color': color,
'width': width,
'points': [],
};
}
function handle_event(state, context, event) {
if (config.debug_print) console.debug(`event type ${event.type} from user ${event.user_id}`);
let need_draw = false;
if (!(event.user_id in state.players)) {
init_player_defaults(state, event.user_id);
}
switch (event.type) {
case EVENT.PREDRAW: {
geometry_add_point(state, context, event.user_id, {'x': event.x, 'y': event.y});
need_draw = true;
break;
}
case EVENT.SET_COLOR: {
state.players[event.user_id].color = event.color;
break;
}
case EVENT.SET_WIDTH: {
state.players[event.user_id].width = event.width;
break;
}
case EVENT.STROKE: {
if (event.user_id != state.me) {
geometry_clear_player(state, context, event.user_id);
need_draw = true;
}
geometry_add_stroke(state, context, event);
break;
}
case EVENT.UNDO: {
need_draw = true;
console.error('todo');
// for (let i = state.events.length - 1; i >=0; --i) {
// const other_event = state.events[i];
// // Users can only undo their own, undeleted (not already undone) events
// if (other_event.user_id === event.user_id && !other_event.deleted) {
// if (other_event.type === EVENT.STROKE) {
// other_event.deleted = true;
// const stats = stroke_stats(other_event.points, state.cursor.width);
// redraw_region(stats.bbox);
// break;
// } else if (other_event.type === EVENT.IMAGE) {
// other_event.deleted = true;
// const item = document.querySelector(`img[data-image-id="${other_event.image_id}"]`);
// if (item) item.remove();
// break;
// } else if (other_event.type === EVENT.ERASER) {
// other_event.deleted = true;
// const erased = find_stroke_backwards(other_event.stroke_id);
// if (erased) {
// erased.deleted = false;
// const stats = stroke_stats(erased.points, state.cursor.width);
// redraw_region(stats.bbox);
// }
// break;
// } else if (other_event.type === EVENT.IMAGE_MOVE) {
// const item = document.querySelector(`img[data-image-id="${other_event.image_id}"]`);
// const ix = state.images[other_event.image_id].x -= other_event.x;
// const iy = state.images[other_event.image_id].y -= other_event.y;
// item.style.transform = `translate(${ix}px, ${iy}px)`;
// break;
// }
// }
// }
break;
}
case EVENT.IMAGE: {
try {
(async () => {
const url = config.image_url + event.image_id;
const r = await fetch(config.image_url + event.image_id);
const blob = await r.blob();
const bitmap = await createImageBitmap(blob);
const p = {'x': event.x, 'y': event.y};
event.width = bitmap.width;
event.height = bitmap.height;
add_image(context, event.image_id, bitmap, p);
// God knows when this will actually complete (it loads the image from the server)
// so do not set need_draw. Instead just schedule the draw ourselves when done
schedule_draw(state, context);
})();
} catch (e) {
console.log('Could not load image bitmap:', e);
}
break;
}
case EVENT.IMAGE_MOVE: {
// Already moved due to local prediction
if (event.user_id !== state.me) {
const image_id = event.image_id;
const image_event = find_image(state, image_id);
if (image_event) {
// if (config.debug_print) console.debug('move image', image_id, 'to', image_event.x, image_event.y);
image_event.x = event.x;
image_event.y = event.y;
move_image(context, image_event);
need_draw = true;
}
}
break;
}
case EVENT.ERASER: {
need_draw = true;
console.error('todo');
// if (event.deleted) {
// break;
// }
// for (const other_event of state.events) {
// if (other_event.type === EVENT.STROKE && other_event.stroke_id === event.stroke_id) {
// // Might already be deleted because of local prediction
// if (!other_event.deleted) {
// other_event.deleted = true;
// const stats = stroke_stats(other_event.points, state.cursor.width);
// redraw_region(stats.bbox);
// }
// break;
// }
// }
break;
}
default: {
console.error('fuck');
}
}
return need_draw;
}
async function handle_message(state, context, d) {
const message_type = des_u8(d);
let do_draw = false;
// if (config.debug_print) console.debug(message_type);
switch (message_type) {
case MESSAGE.JOIN:
case MESSAGE.INIT: {
state.server_lsn = des_u32(d);
state.online = true;
if (state.server_lsn > state.lsn) {
// Server knows something that we don't
state.lsn = state.server_lsn;
}
let color = config.default_color;
let width = config.default_width;
if (message_type === MESSAGE.JOIN) {
localStorage.setItem('sessionId', des_u32(d));
if (config.debug_print) console.debug('join in');
} else {
color = des_u32(d);
width = des_u16(d);
state.me = parseInt(localStorage.getItem('sessionId'));
if (config.debug_print) console.debug('init in');
}
init_player_defaults(state, state.me);
const color_code = color_from_u32(color).substring(1);
switch_color(state, document.querySelector(`.color[data-color="${color_code}"]`));
document.querySelector('#stroke-width').value = width;
fire_event(state, width_event(width));
const event_count = des_u32(d);
const user_count = des_u32(d);
if (config.debug_print) console.debug(`${event_count} events in init`);
state.events.length = 0;
for (let i = 0; i < user_count; ++i) {
const user_id = des_u32(d);
const user_color = des_u32(d);
const user_width = des_u16(d);
init_player_defaults(state, user_id, user_color, user_width);
}
for (let i = 0; i < event_count; ++i) {
const event = des_event(d);
handle_event(state, context, event);
state.events.push(event);
}
do_draw = true;
send_ack(event_count);
sync_queue(state);
break;
}
case MESSAGE.FIRE: {
const event = des_event(d);
const need_draw = handle_event(state, context, event);
do_draw = do_draw || need_draw;
break;
}
case MESSAGE.ACK: {
const lsn = des_u32(d);
if (config.debug_print) console.debug(`ack ${lsn} in`);
if (lsn > state.server_lsn) {
// ACKs may arrive out of order
state.server_lsn = lsn;
}
break;
}
case MESSAGE.SYN: {
const sn = des_u32(d);
const count = des_u32(d);
const we_expect = sn - state.sn;
const first = count - we_expect;
if (config.debug_print) console.debug(`syn ${sn} in`);
for (let i = 0; i < count; ++i) {
const event = des_event(d);
if (i >= first) {
const need_draw = handle_event(state, context, event);
do_draw = do_draw || need_draw;
state.events.push(event);
}
}
state.sn = sn;
send_ack(sn); // await?
break;
}
default: {
console.error('fuck');
return;
}
}
if (do_draw) {
schedule_draw(state, context);
}
}

140
client/send.js → client/client_send.js

@ -19,19 +19,39 @@ function ser_u16(s, value) { @@ -19,19 +19,39 @@ function ser_u16(s, value) {
s.offset += 2;
}
function ser_f32(s, value) {
s.view.setFloat32(s.offset, value, true);
s.offset += 4;
}
function ser_u32(s, value) {
s.view.setUint32(s.offset, value, true);
s.offset += 4;
}
function ser_align(s, to) {
while (s.offset % to != 0) {
s.offset++;
}
}
function ser_event(s, event) {
ser_u8(s, event.type);
ser_u8(s, 0); // padding for 16bit alignment
switch (event.type) {
case EVENT.PREDRAW: {
ser_u16(s, event.x);
ser_u16(s, event.y);
ser_f32(s, event.x);
ser_f32(s, event.y);
break;
}
case EVENT.SET_COLOR: {
ser_u32(s, event.color);
break;
}
case EVENT.SET_WIDTH: {
ser_u16(s, event.width);
break;
}
@ -42,9 +62,11 @@ function ser_event(s, event) { @@ -42,9 +62,11 @@ function ser_event(s, event) {
if (config.debug_print) console.debug('original', event.points);
ser_align(s, 4);
for (const point of event.points) {
ser_u16(s, point.x);
ser_u16(s, point.y);
ser_f32(s, point.x);
ser_f32(s, point.y);
}
break;
@ -54,8 +76,8 @@ function ser_event(s, event) { @@ -54,8 +76,8 @@ function ser_event(s, event) {
case EVENT.IMAGE_MOVE: {
const image_id = parseInt(event.image_id);
ser_u32(s, image_id);
ser_u16(s, event.x);
ser_u16(s, event.y);
ser_f32(s, event.x);
ser_f32(s, event.y);
break;
}
@ -90,39 +112,38 @@ async function send_ack(sn) { @@ -90,39 +112,38 @@ async function send_ack(sn) {
}
}
async function sync_queue() {
async function sync_queue(state) {
if (ws === null) {
if (config.debug_print) console.debug('socket has closed, stopping SYNs');
return;
}
let size = 1 + 1 + 4 + 4; // opcode + lsn + event count
let count = storage.lsn - storage.server_lsn;
let size = 1 + 3 + 4 + 4; // opcode + lsn + event count
let count = state.lsn - state.server_lsn;
if (count === 0) {
if (config.debug_print) console.debug('server ACKed all events, clearing queue');
storage.queue.length = 0;
state.queue.length = 0;
return;
}
for (let i = count - 1; i >= 0; --i) {
const event = storage.queue[storage.queue.length - 1 - i];
const event = state.queue[state.queue.length - 1 - i];
size += event_size(event);
}
const s = serializer_create(size);
ser_u8(s, MESSAGE.SYN);
ser_u8(s, 0); // padding for 16bit alignment
ser_u32(s, storage.lsn);
ser_u32(s, state.lsn);
ser_u32(s, count);
for (let i = count - 1; i >= 0; --i) {
const event = storage.queue[storage.queue.length - 1 - i];
const event = state.queue[state.queue.length - 1 - i];
ser_event(s, event);
}
if (config.debug_print) console.debug(`syn ${storage.lsn} out`);
if (config.debug_print) console.debug(`syn ${state.lsn} out`);
try {
if (ws) await ws.send(s.buffer);
@ -130,19 +151,17 @@ async function sync_queue() { @@ -130,19 +151,17 @@ async function sync_queue() {
ws.close();
}
setTimeout(sync_queue, config.sync_timeout);
setTimeout(() => sync_queue(state), config.sync_timeout);
}
function push_event(event) {
storage.lsn += 1;
function push_event(state, event) {
state.lsn += 1;
switch (event.type) {
case EVENT.STROKE: {
const points = process_stroke(event.points);
storage.queue.push({
state.queue.push({
'type': EVENT.STROKE,
'points': points,
'points': event.points,
'width': event.width,
'color': event.color,
});
@ -152,7 +171,7 @@ function push_event(event) { @@ -152,7 +171,7 @@ function push_event(event) {
case EVENT.RULER: {
event.type = EVENT.STROKE;
storage.queue.push(event);
state.queue.push(event);
break;
}
@ -161,7 +180,7 @@ function push_event(event) { @@ -161,7 +180,7 @@ function push_event(event) {
case EVENT.IMAGE_MOVE:
case EVENT.UNDO:
case EVENT.REDO: {
storage.queue.push(event);
state.queue.push(event);
break;
}
@ -172,22 +191,26 @@ function push_event(event) { @@ -172,22 +191,26 @@ function push_event(event) {
}
// Queue an event and initialize repated sends until ACKed
function queue_event(event, skip = false) {
push_event(event);
function queue_event(state, event, skip = false) {
if (!state.online) { return; }
push_event(state, event);
if (skip) {
return;
}
if (storage.timers.queue_sync) {
clearTimeout(storage.timers.queue_sync);
if (state.timers.queue_sync) {
clearTimeout(state.timers.queue_sync);
}
sync_queue();
sync_queue(state);
}
// Fire and forget. Doesn't do anything if we are offline
async function fire_event(event) {
async function fire_event(state, event) {
if (!state.online) { return; }
const s = serializer_create(1 + event_size(event));
ser_u8(s, MESSAGE.FIRE);
@ -198,4 +221,55 @@ async function fire_event(event) { @@ -198,4 +221,55 @@ async function fire_event(event) {
} catch(e) {
ws.close();
}
}
}
function predraw_event(x, y) {
return {
'type': EVENT.PREDRAW,
'x': x,
'y': y
};
}
function color_event(color_u32) {
return {
'type': EVENT.SET_COLOR,
'color': color_u32,
};
}
function width_event(width) {
return {
'type': EVENT.SET_WIDTH,
'width': width,
};
}
function image_event(image_id, x, y) {
return {
'type': EVENT.IMAGE,
'image_id': image_id,
'x': x,
'y': y,
};
}
function image_move_event(image_id, x, y) {
return {
'type': EVENT.IMAGE_MOVE,
'image_id': image_id,
'x': x,
'y': y,
};
}
function stroke_event(state) {
const stroke = geometry_prepare_stroke(state);
return {
'type': EVENT.STROKE,
'points': stroke.points,
'width': stroke.width,
'color': stroke.color,
};
}

20
client/cursor.js

@ -4,8 +4,8 @@ function on_down(e) { @@ -4,8 +4,8 @@ function on_down(e) {
// Scroll wheel (mouse button 3)
if (e.button === 1) {
storage.state.moving = true;
storage.state.mousedown = true;
// storage.state.moving = true;
// storage.state.mousedown = true;
return;
}
@ -225,8 +225,18 @@ function on_leave(e) { @@ -225,8 +225,18 @@ function on_leave(e) {
}
function on_resize(e) {
storage.canvas.max_scroll_x = storage.canvas.width - window.innerWidth;
storage.canvas.max_scroll_y = storage.canvas.height - window.innerHeight;
const width = window.innerWidth;
const height = window.innerHeight;
elements.canvas0.width = elements.canvas1.width = width;
elements.canvas0.height = elements.canvas1.height = height;
storage.ctx1.lineJoin = storage.ctx1.lineCap = storage.ctx0.lineJoin = storage.ctx0.lineCap = 'round';
storage.ctx1.lineWidth = storage.ctx0.lineWidth = storage.cursor.width;
redraw_region({'xmin': 0, 'xmax': width, 'ymin': 0, 'ymax': width});
// storage.canvas.max_scroll_x = storage.canvas.width - window.innerWidth;
// storage.canvas.max_scroll_y = storage.canvas.height - window.innerHeight;
}
async function on_drop(e) {
@ -257,6 +267,8 @@ async function on_drop(e) { @@ -257,6 +267,8 @@ async function on_drop(e) {
}
function on_wheel(e) {
return;
const x = Math.round((e.clientX + storage.canvas.offset_x) / storage.canvas.zoom);
const y = Math.round((e.clientY + storage.canvas.offset_y) / storage.canvas.zoom);

288
client/default.css

@ -1,60 +1,51 @@ @@ -1,60 +1,51 @@
:root {
--dark-blue: #2f343d;
--dark-hover: #888;
--radius: 5px;
--hgap: 5px;
--gap: 10px;
--transform-amimate: transform .1s ease-in-out;
}
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
touch-action: none;
}
.dhide {
display: none !important;
body .main {
height: 100%;
}
.canvas {
position: absolute;
top: 0;
left: 0;
opacity: 1;
transition: opacity .2s;
transform-origin: top left;
pointer-events: none;
body.offline .main {
filter: brightness(50%);
}
#toucher {
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
z-index: 5; /* above all canvases, but below tools */
cursor: crosshair;
.dhide {
display: none !important;
}
.canvas.white {
opacity: 0;
canvas {
width: 100%;
height: 100%;
display: block;
cursor: url('icons/cursor.svg') 7 7, crosshair;
}
#canvas-images {
z-index: 0;
}
#canvas0 {
z-index: 1;
background: #eee;
background-position: 0px 0px;
background-size: 32px 32px;
background-image: radial-gradient(rgba(0, 0, 0, 0.1) 1px, transparent 10%);
canvas.movemode {
cursor: grab;
}
#canvas1 {
z-index: 2;
opacity: 0.3;
canvas.movemode.moving {
cursor: grabbing;
}
.tools-wrapper {
position: fixed;
bottom: 0;
width: 100%;
height: 32px;
display: flex;
justify-content: center;
align-items: end;
@ -62,41 +53,116 @@ html, body { @@ -62,41 +53,116 @@ html, body {
pointer-events: none;
}
.tools {
.pallete-wrapper,
.sizer-wrapper {
position: fixed;
top: 0;
left: 0;
height: 100%;
pointer-events: none;
display: flex;
flex-direction: column;
justify-content: center;
transition: var(--transform-amimate);
}
.sizer-wrapper {
height: unset;
width: 100%;
flex-direction: row;
}
.pallete {
pointer-events: all;
display: grid;
flex-direction: column;
align-items: center;
background: var(--dark-blue);
border-top-right-radius: var(--radius);
border-bottom-right-radius: var(--radius);
/* border-bottom-left-radius: var(--radius);*/
padding-top: var(--gap);
padding-bottom: var(--gap);
}
.pallete-wrapper.hidden {
transform: translateX(-125%); /* to account for a selected color, which is also offset to the right */
}
.sizer-wrapper.hidden {
transform: translateY(-125%);
}
.pallete .color {
padding: var(--gap);
cursor: pointer;
background: var(--dark-blue);
transition: var(--transform-amimate);
}
.pallete .color:hover {
background: var(--dark-hover);
}
.pallete .color.active {
transform: translateX(10px);
border-top-right-radius: var(--radius);
border-bottom-right-radius: var(--radius);
}
.pallete .color.active:hover {
background: var(--dark-blue);
}
.pallete .color-pane {
width: 24px;
height: 24px;
box-sizing: border-box;
border-radius: var(--radius);
}
.tools,
.sizer {
pointer-events: all;
display: flex;
align-items: center;
justify-content: center;
background: #333;
border-radius: 5px;
background: var(--dark-blue);
border-radius: var(--radius);
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
height: 42px;
padding-left: 10px;
padding-right: 10px;
padding-left: var(--gap);
padding-right: var(--gap);
}
.sizer {
border-radius: 0;
border-bottom-right-radius: var(--radius);
border-bottom-left-radius: var(--radius);
}
.tool {
cursor: pointer;
padding-left: 10px;
padding-right: 10px;
padding-left: var(--gap);
padding-right: var(--gap);
height: 100%;
display: flex;
align-items: center;
background: #333;
transition: transform .1s ease-in-out;
background: var(--dark-blue);
transition: var(--transform-amimate);
user-select: none;
}
.tool:hover {
background: #888;
background: var(--dark-hover);
}
.tool.active {
transform: translateY(-10px);
border-top-right-radius: 5px;
border-top-left-radius: 5px;
background: #333;
border-top-right-radius: var(--radius);
border-top-left-radius: var(--radius);
background: var(--dark-blue);
}
.tool img {
@ -105,18 +171,126 @@ html, body { @@ -105,18 +171,126 @@ html, body {
filter: invert(100%);
}
.toolbar {
visibility: hidden;
input[type=range] {
-webkit-appearance: none;
width: 200px;
background: transparent;
}
input[type=range]:focus {
outline: none;
}
.floating-image {
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
border: none;
background: white;
height: 20px;
width: 20px;
border-radius: 50%;
cursor: pointer;
border: 2px solid var(--dark-blue);
margin-top: -6px; /* You need to specify a margin in Chrome, but in Firefox and IE it is automatic */
}
input[type=range]::-moz-range-thumb {
border: none;
background: white;
height: 16px;
width: 16px;
border-radius: 50%;
cursor: pointer;
border: 2px solid var(--dark-blue);
}
input[type=range]::-webkit-slider-runnable-track {
width: 100%;
height: 8px;
cursor: pointer;
background: white;
border-radius: 2px;
border: none;
}
input[type=range]:focus::-webkit-slider-runnable-track {
width: 100%;
height: 8px;
cursor: pointer;
background: white;
border-radius: 2px;
border: none;
}
input[type=range]::-moz-range-track {
width: 100%;
height: 8px;
cursor: pointer;
background: white;
border-radius: 2px;
border: none;
}
.phone-extra-controls {
display: none;
cursor: pointer; /* for click events on mobile */
pointer-events: all;
position: absolute;
user-drag: none;
right: 0;
background: var(--dark-blue);
height: 42px;
justify-content: center;
align-items: center;
padding-left: var(--gap);
padding-right: var(--gap);
border-top-left-radius: var(--radius);
}
.phone-extra-controls img {
height: 24px;
width: 24px;
filter: invert(100%);
}
@media (hover: none) and (pointer: coarse) {
.phone-extra-controls {
display: flex;
}
}
#stroke-preview {
position: absolute;
border-radius: 50%;
left: 50%;
top: 96px;
transform: translate(-50%, -50%);
}
.offline-toast {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 999;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
padding: 10px;
border-radius: var(--radius);
box-shadow: 0px 2px 3px 0px rgba(155, 150, 100, 0.2);
transition: transform .1s ease-in-out, opacity .1s;
font-size: 14px;
color: white;
font-weight: bold;
user-select: none;
transition: transform .1s ease-in-out, opacity .1s;
pointer-events: none;
}
.floating-image.activated {
outline: 5px solid #5286ff;
z-index: 999999 !important;
cursor: grab;
}
.offline-toast.hidden {
transform: translate(-50%, -5px);
opacity: 0;
}
body.offline * {
pointer-events: none;
}

66
client/draw.js

@ -1,66 +0,0 @@ @@ -1,66 +0,0 @@
function draw_stroke(stroke) {
const points = stroke.points;
if (points.length === 0) {
return;
}
// if (config.debug_print) console.debug(points)
storage.ctx0.beginPath();
storage.ctx0.moveTo(points[0].x, points[0].y);
storage.ctx0.strokeStyle = color_from_u32(stroke.color);
storage.ctx0.lineWidth = stroke.width;
for (let i = 1; i < points.length; ++i) {
const p = points[i];
storage.ctx0.lineTo(p.x, p.y);
}
storage.ctx0.stroke();
}
function redraw_predraw() {
storage.ctx1.clearRect(0, 0, storage.ctx1.canvas.width, storage.ctx1.canvas.height);
}
function predraw_user(user_id, event) {
if (!(user_id in storage.predraw)) {
storage.predraw[user_id] = [];
}
storage.ctx1.beginPath();
if (storage.predraw[user_id].length > 0) {
const last = storage.predraw[user_id][storage.predraw[user_id].length - 1];
storage.ctx1.moveTo(last.x, last.y);
storage.ctx1.lineTo(event.x, event.y);
} else {
storage.ctx1.moveTo(event.x, event.y);
}
storage.ctx1.stroke();
storage.predraw[user_id].push({ 'x': event.x, 'y': event.y });
}
function redraw_region(bbox) {
if (bbox.xmin === bbox.xmax || bbox.ymin === bbox.ymax) {
return;
}
storage.ctx0.save();
storage.ctx0.clearRect(bbox.xmin, bbox.ymin, bbox.xmax - bbox.xmin, bbox.ymax - bbox.ymin);
storage.ctx0.beginPath();
storage.ctx0.rect(bbox.xmin, bbox.ymin, bbox.xmax - bbox.xmin, bbox.ymax - bbox.ymin);
storage.ctx0.clip();
for (const event of storage.events) {
if (event.type === EVENT.STROKE && !event.deleted) {
if (stroke_intersects_region(event.points, bbox)) {
draw_stroke(event);
}
}
}
storage.ctx0.restore();
}

BIN
client/favicon2.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 671 B

BIN
client/favicon_old.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 957 B

8
client/icons/cheeseburga.svg

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="7.9173mm" height="6.8706mm" version="1.1" viewBox="0 0 7.9173 6.8706" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-130.27 -83.243)" fill="none" stroke="#000" stroke-linecap="round">
<path d="m130.77 83.743h6.9173"/>
<path d="m130.77 89.613h6.9173"/>
<path d="m130.77 86.678h6.9173"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 371 B

50
client/icons/cursor.svg

@ -0,0 +1,50 @@ @@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="3.7139902mm"
height="3.7139902mm"
viewBox="0 0 3.7139902 3.7139902"
version="1.1"
id="svg5"
sodipodi:docname="cursor.svg"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="38.057741"
inkscape:cx="6.358759"
inkscape:cy="8.5659315"
inkscape:window-width="2558"
inkscape:window-height="1412"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs2" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-133.51747,-126.4196)">
<circle
style="fill:none;stroke:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
id="path236"
cy="128.2766"
cx="135.37447"
r="1.6" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

59
client/icons/draw.svg

@ -1,54 +1,7 @@ @@ -1,54 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="29.716999mm"
height="30.635mm"
viewBox="0 0 29.716999 30.635"
version="1.1"
id="svg5"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="draw.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="6.7277216"
inkscape:cx="45.334813"
inkscape:cy="49.273739"
inkscape:window-width="2558"
inkscape:window-height="1413"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs2" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-74.769935,-147.21149)">
<path
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 95.762931,151.36667 -1.459957,1.45995 -4.171293,-4.17129 -7.480518,7.48052 1.501665,1.50166 6.020569,-6.02056 2.669628,2.66962 -3.164437,3.16444 5.325352,5.32535 6.08434,-6.08434 z"
id="path1057"
sodipodi:nodetypes="ccccccccccc" />
<path
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 87.28337,159.84623 5.401522,5.40152 -10.91227,11.04308 H 76.3711 v -5.53233 z"
id="path1785"
sodipodi:nodetypes="cccccc" />
</g>
<?xml version="1.0" encoding="UTF-8"?>
<svg width="29.717mm" height="30.635mm" version="1.1" viewBox="0 0 29.717 30.635" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-74.77 -147.21)" stroke="#000" stroke-linecap="round" stroke-linejoin="round">
<path d="m95.763 151.37-1.46 1.46-4.1713-4.1713-7.4805 7.4805 1.5017 1.5017 6.0206-6.0206 2.6696 2.6696-3.1644 3.1644 5.3254 5.3254 6.0843-6.0843z"/>
<path d="m87.283 159.85 5.4015 5.4015-10.912 11.043h-5.4015v-5.5323z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 500 B

79
client/icons/erase.svg

@ -1,74 +1,7 @@ @@ -1,74 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="18.310799mm"
height="13.563546mm"
viewBox="0 0 18.310798 13.563546"
version="1.1"
id="svg4722"
sodipodi:docname="erase.svg"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview4724"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="9.5144352"
inkscape:cx="25.697794"
inkscape:cy="26.223312"
inkscape:window-width="2558"
inkscape:window-height="1413"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs4719">
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect26362"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0.79375,0,1 @ F,0,0,1,0,0.79375,0,1 @ F,0,0,1,0,0.79375,0,1 @ F,0,0,1,0,0.79375,0,1 @ F,0,0,1,0,0.79375,0,1"
unit="px"
method="auto"
mode="F"
radius="3"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-71.863816,-97.210284)">
<path
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 80.965463,98.271529 c -2.680135,2.680131 -5.360269,5.360271 -8.040401,8.040401 -0.309979,0.30998 -0.316694,0.8191 -0.015,1.13714 l 2.133265,2.24889 a 1.8470002,1.8470002 23.255659 0 0 1.340018,0.57587 h 3.418044 a 1.909119,1.909119 157.42404 0 0 1.353526,-0.56275 l 5.610134,-5.63997 c 0.309156,-0.3108 0.308489,-0.81404 -0.0015,-1.12402 -1.55852,-1.55852 -3.117043,-3.11704 -4.675566,-4.675561 -0.309979,-0.309979 -0.812554,-0.309979 -1.122532,0 z"
id="path5932"
sodipodi:nodetypes="cccccc"
inkscape:path-effect="#path-effect26362"
inkscape:original-d="m 81.526729,97.710263 c -3.054312,3.054307 -6.108624,6.108627 -9.162933,9.162937 l 3.225801,3.40063 h 5.005544 l 6.729686,-6.76547 c -1.932697,-1.9327 -3.865398,-3.865399 -5.798098,-5.798097 z" />
<path
style="fill:none;stroke:#000000;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="m 85.793244,110.02383 h 3.63137"
id="path6432"
sodipodi:nodetypes="cc" />
</g>
<?xml version="1.0" encoding="UTF-8"?>
<svg width="18.311mm" height="13.564mm" version="1.1" viewBox="0 0 18.311 13.564" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-71.864 -97.21)" stroke="#000" stroke-linecap="round">
<path d="m80.965 98.272c-2.6801 2.6801-5.3603 5.3603-8.0404 8.0404-0.30998 0.30998-0.31669 0.8191-0.015 1.1371l2.1333 2.2489a1.847 1.847 23.256 0 0 1.34 0.57587h3.418a1.9091 1.9091 157.42 0 0 1.3535-0.56275l5.6101-5.64c0.30916-0.3108 0.30849-0.81404-0.0015-1.124-1.5585-1.5585-3.117-3.117-4.6756-4.6756-0.30998-0.30998-0.81255-0.30998-1.1225 0z" stroke-linejoin="round"/>
<path d="m85.793 110.02h3.6314" fill="none" stroke-width="1.5"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 691 B

54
client/icons/favicon.svg

@ -1,49 +1,7 @@ @@ -1,49 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="10mm"
height="10mm"
viewBox="0 0 9.9999997 10"
version="1.1"
id="svg31118"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="favicon.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview31120"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="19.02887"
inkscape:cx="21.730139"
inkscape:cy="19.62807"
inkscape:window-width="2558"
inkscape:window-height="1413"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs31115" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-102.35789,-119.40706)">
<path
style="fill:none;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 103.07394,121.49803 c 0,0 1.8926,-1.50652 2.48887,-0.86207 0.80348,0.8684 -2.24958,2.41185 -1.41823,3.25361 1.77046,1.79265 5.16721,-4.69796 6.96607,-2.9338 1.82345,1.78827 -4.48394,5.16682 -2.83582,7.11786 0.74072,0.87686 3.36701,-0.72189 3.36701,-0.72189"
id="path33421"
sodipodi:nodetypes="caaaac" />
</g>
<?xml version="1.0" encoding="UTF-8"?>
<svg width="10mm" height="10mm" version="1.1" viewBox="0 0 10 10" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-102.36 -119.41)" stroke-linecap="round" stroke-linejoin="round">
<rect x="102.36" y="119.41" width="10" height="10" ry="1.5851" fill="#fff" style="paint-order:stroke fill markers"/>
<path d="m103.62 121.96s1.6506-1.3139 2.1706-0.75183c0.70073 0.75735-1.9619 2.1034-1.2369 2.8375 1.544 1.5634 4.5064-4.0972 6.0752-2.5586 1.5903 1.5596-3.9105 4.5061-2.4732 6.2076 0.64599 0.76473 2.9364-0.62957 2.9364-0.62957" fill="none" stroke="#000" stroke-width=".87212"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 643 B

54
client/icons/redo.svg

@ -1,54 +0,0 @@ @@ -1,54 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="7.3109999mm"
height="7.8540001mm"
viewBox="0 0 7.3109999 7.8540001"
version="1.1"
id="svg14027"
sodipodi:docname="redo.svg"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview14029"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="26.910887"
inkscape:cx="11.129325"
inkscape:cy="18.7099"
inkscape:window-width="2558"
inkscape:window-height="1413"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs14024" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-119.35516,-62.667286)">
<path
style="fill:none;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 125.71554,68.856558 h -2.53401 c -2.08565,0 -2.77743,-0.237506 -2.77743,-1.919928 0,-1.682421 0.59993,-1.919928 2.77743,-1.919928 h 2.53401"
id="path14513"
sodipodi:nodetypes="cszsc" />
<path
style="fill:none;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 124.57539,63.936869 1.14015,1.079833 -1.13791,1.077584"
id="path14515"
sodipodi:nodetypes="ccc" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

53
client/icons/ruler.svg

@ -1,49 +1,6 @@ @@ -1,49 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="27.691999mm"
height="27.691999mm"
viewBox="0 0 27.691999 27.691999"
version="1.1"
id="svg10490"
sodipodi:docname="ruler.svg"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview10492"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="9.5144352"
inkscape:cx="50.922623"
inkscape:cy="49.976692"
inkscape:window-width="2558"
inkscape:window-height="1413"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs10487" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-71.559908,-125.1583)">
<path
id="path10611"
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 90.427939,127.32683 -16.699494,16.6995 6.65543,6.65543 2.08721,-2.08721 1.205669,-1.20567 -2.90901,-2.90901 1.414211,-1.41421 2.90901,2.90901 1.555266,-1.55527 1.206692,-1.20669 -2.90901,-2.90901 1.414008,-1.41401 2.90901,2.90901 1.554079,-1.55408 1.205965,-1.20596 -2.91047,-2.91048 1.414007,-1.414 2.910471,2.91047 1.555178,-1.55518 2.087209,-2.08721 z"
sodipodi:nodetypes="ccccccccccccccccccccc" />
</g>
<?xml version="1.0" encoding="UTF-8"?>
<svg width="27.692mm" height="27.692mm" version="1.1" viewBox="0 0 27.692 27.692" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-71.56 -125.16)">
<path d="m90.428 127.33-16.699 16.7 6.6554 6.6554 3.2929-3.2929-2.909-2.909 1.4142-1.4142 2.909 2.909 2.762-2.762-2.909-2.909 1.414-1.414 2.909 2.909 2.76-2.76-2.9105-2.9105 1.414-1.414 2.9105 2.9105 3.6424-3.6424z" stroke="#000" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 494 B

59
client/icons/undo.svg

@ -1,54 +1,7 @@ @@ -1,54 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="7.3109999mm"
height="7.8540001mm"
viewBox="0 0 7.3109999 7.8540001"
version="1.1"
id="svg14027"
sodipodi:docname="undo.svg"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview14029"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="26.910887"
inkscape:cx="11.129325"
inkscape:cy="18.7099"
inkscape:window-width="2558"
inkscape:window-height="1413"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs14024" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-119.35516,-62.667286)">
<path
style="fill:none;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 120.4041,68.856558 h 2.53401 c 2.08565,0 2.77743,-0.237506 2.77743,-1.919928 0,-1.682421 -0.59993,-1.919928 -2.77743,-1.919928 h -2.53401"
id="path14513"
sodipodi:nodetypes="cszsc" />
<path
style="fill:none;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 121.54425,63.936869 -1.14015,1.079833 1.13791,1.077584"
id="path14515"
sodipodi:nodetypes="ccc" />
</g>
<?xml version="1.0" encoding="UTF-8"?>
<svg width="7.311mm" height="7.854mm" version="1.1" viewBox="0 0 7.311 7.854" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-119.36 -62.667)" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round">
<path d="m120.4 68.857h2.534c2.0856 0 2.7774-0.23751 2.7774-1.9199 0-1.6824-0.59993-1.9199-2.7774-1.9199h-2.534"/>
<path d="m121.54 63.937-1.1402 1.0798 1.1379 1.0776"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 457 B

84
client/index.html

@ -3,42 +3,66 @@ @@ -3,42 +3,66 @@
<head>
<meta charset="utf-8">
<title>Desk</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<link rel="shortcut icon" href="icons/favicon.svg" id="favicon">
<link rel="stylesheet" type="text/css" href="default.css?v=6">
<link rel="stylesheet" type="text/css" href="touch.css?v=4">
<script type="text/javascript" src="index.js?v=10"></script>
<script type="text/javascript" src="cursor.js?v=5"></script>
<script type="text/javascript" src="touch.js?v=20"></script>
<script type="text/javascript" src="websocket.js?v=6"></script>
<script type="text/javascript" src="send.js?v=5"></script>
<script type="text/javascript" src="recv.js?v=5"></script>
<script type="text/javascript" src="math.js?v=5"></script>
<script type="text/javascript" src="draw.js?v=5"></script>
<script type="text/javascript" src="tools.js?v=6"></script>
<link rel="stylesheet" type="text/css" href="default.css?v=28">
<script type="text/javascript" src="aux.js?v=28"></script>
<script type="text/javascript" src="math.js?v=28"></script>
<script type="text/javascript" src="tools.js?v=28"></script>
<script type="text/javascript" src="webgl_geometry.js?v=28"></script>
<script type="text/javascript" src="webgl_shaders.js?v=28"></script>
<script type="text/javascript" src="webgl_listeners.js?v=28"></script>
<script type="text/javascript" src="webgl_draw.js?v=28"></script>
<script type="text/javascript" src="index.js?v=28"></script>
<script type="text/javascript" src="client_send.js?v=28"></script>
<script type="text/javascript" src="client_recv.js?v=28"></script>
<script type="text/javascript" src="websocket.js?v=28"></script>
</head>
<body>
<div class="toolbar">
<input type="color" id="brush-color">
<input type="number" min="1" id="brush-width">
</div>
<div class="main">
<canvas id="c"></canvas>
<div class="tools-wrapper">
<div class="tools">
<div class="tool" data-tool="pencil"><img draggable="false" src="icons/draw.svg"></div>
<div class="tool" data-tool="ruler"><img draggable="false" src="icons/ruler.svg"></div>
<div class="tool" data-tool="eraser"><img draggable="false" src="icons/erase.svg"></div>
<div class="tool" data-tool="undo"><img draggable="false" src="icons/undo.svg"></div>
<!-- <div class="tool" data-tool="redo"><img draggable="false" src="icons/redo.svg"></div> -->
<div class="sizer-wrapper">
<div class="sizer">
<input type="range" class="slider" id="stroke-width" min="1" max="64">
</div>
<div id="stroke-preview" class="dhide"></div>
</div>
</div>
<div id="brush-preview" class="dhide"></div>
<canvas class="canvas white" id="canvas0"></canvas>
<canvas class="canvas" id="canvas1"></canvas>
<div class="canvas" id="canvas-images"></div>
<div class="pallete-wrapper">
<div class="pallete">
<div class="color" data-color="000000"><div class="color-pane" style="background: #000000;"></div></div>
<div class="color" data-color="ffffff"><div class="color-pane" style="background: #ffffff;"></div></div>
<div class="color" data-color="d65c5c"><div class="color-pane" style="background: #d65c5c;"></div></div>
<div class="color" data-color="d6835c"><div class="color-pane" style="background: #d6835c;"></div></div>
<div class="color" data-color="72d65c"><div class="color-pane" style="background: #72d65c;"></div></div>
<div class="color" data-color="5cd6ce"><div class="color-pane" style="background: #5cd6ce;"></div></div>
<div class="color" data-color="5c89d6"><div class="color-pane" style="background: #5c89d6;"></div></div>
<div class="color" data-color="6e5cd6"><div class="color-pane" style="background: #6e5cd6;"></div></div>
</div>
</div>
<div class="tools-wrapper">
<div class="tools">
<div class="tool active" data-tool="pencil"><img draggable="false" src="icons/draw.svg"></div>
<div class="tool" data-tool="ruler"><img draggable="false" src="icons/ruler.svg"></div>
<div class="tool" data-tool="eraser"><img draggable="false" src="icons/erase.svg"></div>
<div class="tool" data-tool="undo"><img draggable="false" src="icons/undo.svg"></div>
<!-- <div class="tool" data-tool="redo"><img draggable="false" src="icons/redo.svg"></div> -->
</div>
<div class="phone-extra-controls">
<img draggable="false" src="icons/cheeseburga.svg">
</div>
</div>
</div>
<div id="toucher"></div>
<div class="offline-toast hidden">
Whiteboard offline
</div>
</body>
</html>
</html>

397
client/index.js

@ -1,16 +1,38 @@ @@ -1,16 +1,38 @@
let ws = null;
let ls = window.localStorage;
document.addEventListener('DOMContentLoaded', main);
const config = {
ws_url: 'ws://192.168.100.2/ws/',
ping_url: 'http://192.168.100.2/api/ping',
image_url: 'http://192.168.100.2/images/',
sync_timeout: 1000,
ws_reconnect_timeout: 2000,
brush_preview_timeout: 1000,
second_finger_timeout: 500,
buffer_first_touchmoves: 5,
debug_print: true,
min_zoom: 0.05,
max_zoom: 10.0,
initial_offline_timeout: 1000,
default_color: 0x00,
default_width: 8,
bytes_per_point: 20,
initial_static_bytes: 4096,
};
const EVENT = Object.freeze({
PREDRAW: 10,
SET_COLOR: 11,
SET_WIDTH: 12,
STROKE: 20,
RULER: 21, /* gets re-written with EVENT.STROKE before sending to server */
UNDO: 30,
REDO: 31,
IMAGE: 40,
IMAGE_MOVE: 41,
ERASER: 50,
});
@ -23,324 +45,97 @@ const MESSAGE = Object.freeze({ @@ -23,324 +45,97 @@ const MESSAGE = Object.freeze({
JOIN: 105,
});
const config = {
ws_url: 'ws://192.168.100.2/ws/',
image_url: 'http://192.168.100.2/images/',
sync_timeout: 1000,
ws_reconnect_timeout: 2000,
second_finger_timeout: 500,
buffer_first_touchmoves: 5,
debug_print: false,
};
function main() {
const state = {
'online': false,
'me': null,
'canvas': {
'offset': { 'x': 0, 'y': 0 },
'zoom': 1.0,
},
'cursor': {
'x': 0,
'y': 0,
},
'sn': 0,
'lsn': 0,
'server_lsn': 0,
'touch': {
'moves': 0,
'drawing': false,
'moving': false,
'waiting_for_second_finger': false,
'first_finger_position': null,
'second_finger_position': null,
'buffered': [],
'ids': [],
},
const storage = {
'state': {
'drawing': false,
'moving': false,
'moving_image': false,
'mousedown': false,
'spacedown': false,
},
'moving_image_original_x': null,
'moving_image_original_y': null,
'touch': {
'moves': 0,
'drawing': false,
'moving': false,
'waiting_for_second_finger': false,
'position': { 'x': null, 'y': null },
'screen_position': { 'x': null, 'y': null },
'finger_distance': null,
'buffered': [],
'ids': [],
},
'tools': {
'active': null,
'active_element': null,
},
'ruler_origin': {},
'erased': [],
'predraw': {},
'timers': {},
'me': {},
'sn': 0, // what WE think SERVER SN is (we tell this to the server, it uses to decide how much stuff to SYN to us)
'server_lsn': 0, // what SERVER said LSN is (used to decide how much stuff to SYN)
'lsn': 0, // what actual LSN is (can't just use length of local queue because it gets cleared)
'queue': [], // to server
'events': [], // from server
'current_stroke': [],
'desk_id': 123,
'max_zoom': 4,
'min_zoom': 0.2,
'images': {},
'canvas': {
'zoom': 1,
'width': 1500,
'height': 4000,
'offset_x': 0,
'offset_y': 0,
},
'cursor': {
'width': 8,
'color': 'rgb(0, 0, 0)',
'x': 0,
'y': 0,
}
};
const elements = {
'cursor': null,
'canvas0': null,
'canvas1': null,
'active_image': null,
};
function event_size(event) {
let size = 1 + 1; // type + padding
switch (event.type) {
case EVENT.PREDRAW: {
size += 2 * 2;
break;
}
case EVENT.STROKE: {
size += 4 + 2 + 2 + 4 + event.points.length * 2 * 2; // u32 stroke id + u16 (count) + u16 (width) + u32 (color + count * (u16, u16) points
break;
}
case EVENT.UNDO:
case EVENT.REDO: {
break;
}
case EVENT.IMAGE:
case EVENT.IMAGE_MOVE: {
size += 4 + 2 + 2; // file id + x + y
break;
}
case EVENT.ERASER: {
size += 4; // stroke id
break;
}
default: {
console.error('fuck');
}
}
return size;
}
function move_canvas() {
elements.canvas0.style.transform = `translate(${-storage.canvas.offset_x}px, ${-storage.canvas.offset_y}px) scale(${storage.canvas.zoom})`;
elements.canvas1.style.transform = `translate(${-storage.canvas.offset_x}px, ${-storage.canvas.offset_y}px) scale(${storage.canvas.zoom})`;
elements.images.style.transform = `translate(${-storage.canvas.offset_x}px, ${-storage.canvas.offset_y}px) scale(${storage.canvas.zoom})`;
}
function image_at(x, y) {
let image_hit = null;
for (let i = storage.events.length - 1; i >= 0; --i) {
if (!storage.events[i].deleted && storage.events[i].type === EVENT.IMAGE) {
const event = storage.events[i];
const item = document.querySelector(`img[data-image-id="${event.image_id}"]`);
if (item) {
const left = storage.images[event.image_id].x;
const right = left + item.width;
const top = storage.images[event.image_id].y;
const bottom = top + item.height;
if (left <= x && x <= right && top <= y && y <= bottom) {
return item;
}
}
}
}
return null;
}
function activate_image(item) {
if (item === null) {
elements.canvas1.classList.remove('disabled');
if (elements.active_image) {
elements.active_image.classList.remove('activated');
elements.active_image = null;
}
return;
}
elements.canvas1.classList.add('disabled');
if (elements.active_image) {
if (elements.active_image === item) {
return;
}
elements.active_image.classList.remove('activated');
}
'spacedown': false,
elements.active_image = item;
'moving_image': null,
item.classList.add('activated');
}
'current_strokes': {},
function predraw_event(x, y) {
return {
'type': EVENT.PREDRAW,
'x': x,
'y': y
};
}
'queue': [],
'events': [],
function stroke_event() {
return {
'type': EVENT.STROKE,
'points': storage.current_stroke,
'width': storage.cursor.width,
'color': color_to_u32(storage.cursor.color),
};
}
'tools': {
'active': null,
'active_element': null,
},
function ruler_event(x, y) {
const points = [];
'colors': {
'active_element': null,
},
points.push(predraw_event(storage.ruler_origin.x, storage.ruler_origin.y));
points.push(predraw_event(x, y));
'timers': {
'hide_preview': null,
'offline_toast': null,
'raf': false,
},
return {
'type': EVENT.RULER,
'points': points,
'width': storage.cursor.width,
'color': color_to_u32(storage.cursor.color),
'players': {},
};
}
function undo_event() {
return { 'type': EVENT.UNDO };
}
function redo_event() {
return { 'type': EVENT.REDO };
}
function image_event(image_id, x, y) {
return {
'type': EVENT.IMAGE,
'image_id': image_id,
'x': x,
'y': y,
}
}
const context = {
'canvas': null,
'gl': null,
function image_move_event(image_id, x, y) {
return {
'type': EVENT.IMAGE_MOVE,
'image_id': image_id,
'x': x,
'y': y,
}
}
'programs': {},
'buffers': {},
'locations': {},
'textures': {},
'quad_positions': [],
'quad_texcoords': [],
function eraser_events() {
const result = [];
'static_stroke_serializer': serializer_create(config.initial_static_bytes),
'dynamic_stroke_serializer': serializer_create(config.initial_static_bytes),
for (const stroke_id of storage.erased) {
result.push({
'type': EVENT.ERASER,
'stroke_id': stroke_id,
});
}
'bgcolor': {'r': 1.0, 'g': 1.0, 'b': 1.0},
return result;
}
// Generally doesn't return null
function find_stroke_backwards(stroke_id) {
for (let i = storage.events.length - 1; i >= 0; --i) {
const event = storage.events[i];
if (event.type === EVENT.STROKE && event.stroke_id === stroke_id) {
return event;
}
}
return null;
}
function queue_undo() {
const event = undo_event();
queue_event(event);
}
'active_image': null,
};
function main() {
const url = new URL(window.location.href);
const parts = url.pathname.split('/');
storage.desk_id = parts.length > 0 ? parts[parts.length - 1] : 0;
ws_connect(true);
elements.canvas0 = document.getElementById('canvas0');
elements.canvas1 = document.getElementById('canvas1');
elements.images = document.getElementById('canvas-images');
tools_init();
// TODO: remove
elements.brush_color = document.getElementById('brush-color');
elements.brush_width = document.getElementById('brush-width');
elements.brush_preview = document.getElementById('brush-preview');
elements.toucher = document.getElementById('toucher');
elements.brush_color.value = storage.cursor.color;
elements.brush_width.value = storage.cursor.width;
state.desk_id = parts.length > 0 ? parts[parts.length - 1] : 0;
update_brush();
storage.canvas.offset_x = window.scrollX;
storage.canvas.offset_y = window.scrollY;
storage.canvas.max_scroll_x = storage.canvas.width - window.innerWidth;
storage.canvas.max_scroll_y = storage.canvas.height - window.innerHeight;
storage.ctx0 = elements.canvas0.getContext('2d');
storage.ctx1 = elements.canvas1.getContext('2d');
storage.ctx1.canvas.width = storage.ctx0.canvas.width = storage.canvas.width;
storage.ctx1.canvas.height = storage.ctx0.canvas.height = storage.canvas.height;
storage.ctx1.lineJoin = storage.ctx1.lineCap = storage.ctx0.lineJoin = storage.ctx0.lineCap = 'round';
storage.ctx1.lineWidth = storage.ctx0.lineWidth = storage.cursor.width;
elements.toucher.addEventListener('mousedown', on_down)
elements.toucher.addEventListener('mousemove', on_move)
elements.toucher.addEventListener('mouseup', on_up);
elements.toucher.addEventListener('keydown', on_keydown);
elements.toucher.addEventListener('keyup', on_keyup);
elements.toucher.addEventListener('resize', on_resize);
elements.toucher.addEventListener('contextmenu', cancel);
elements.toucher.addEventListener('wheel', on_wheel);
elements.toucher.addEventListener('touchstart', on_touchstart);
elements.toucher.addEventListener('touchmove', on_touchmove);
elements.toucher.addEventListener('touchend', on_touchend);
elements.toucher.addEventListener('touchcancel', on_touchend);
init_webgl(state, context);
init_listeners(state, context);
init_tools(state);
ws_connect(state, context, true);
elements.brush_color.addEventListener('input', update_brush);
elements.brush_width.addEventListener('input', update_brush);
schedule_draw(state, context);
elements.canvas0.addEventListener('dragover', on_move);
elements.canvas0.addEventListener('drop', on_drop);
elements.canvas0.addEventListener('mouseleave', on_leave);
}
state.timers.offline_toast = setTimeout(() => ui_offline(), config.initial_offline_timeout);
}

77
client/math.js

@ -1,5 +1,19 @@ @@ -1,5 +1,19 @@
function rdp_find_max(points, start, end) {
const EPS = 0.5;
function screen_to_canvas(state, p) {
// should be called with coordinates obtained from MouseEvent.clientX/clientY * window.devicePixelRatio
const xc = (p.x - state.canvas.offset.x) / state.canvas.zoom;
const yc = (p.y - state.canvas.offset.y) / state.canvas.zoom;
return {'x': xc, 'y': yc};
}
function point_right_of_line(a, b, p) {
// a bit of cross-product tomfoolery (we check sign of z of the crossproduct)
return ((b.x - a.x) * (a.y - p.y) - (a.y - b.y) * (p.x - a.x)) <= 0;
}
function rdp_find_max(state, points, start, end) {
const EPS = 0.5 / state.canvas.zoom;
// const EPS = 10.0;
let result = -1;
let max_dist = 0;
@ -37,22 +51,22 @@ function rdp_find_max(points, start, end) { @@ -37,22 +51,22 @@ function rdp_find_max(points, start, end) {
return result;
}
function process_rdp_r(points, start, end) {
function process_rdp_r(state, points, start, end) {
let result = [];
const max = rdp_find_max(points, start, end);
const max = rdp_find_max(state, points, start, end);
if (max !== -1) {
const before = process_rdp_r(points, start, max);
const after = process_rdp_r(points, max, end);
const before = process_rdp_r(state, points, start, max);
const after = process_rdp_r(state, points, max, end);
result = [...before, points[max], ...after];
}
return result;
}
function process_rdp(points) {
const result = process_rdp_r(points, 0, points.length - 1);
function process_rdp(state, points) {
const result = process_rdp_r(state, points, 0, points.length - 1);
result.unshift(points[0]);
result.push(points[points.length - 1]);
return result;
@ -60,7 +74,7 @@ function process_rdp(points) { @@ -60,7 +74,7 @@ function process_rdp(points) {
function process_ewmv(points, round = false) {
const result = [];
const alpha = 0.4;
const alpha = 0.5;
result.push(points[0]);
@ -74,9 +88,9 @@ function process_ewmv(points, round = false) { @@ -74,9 +88,9 @@ function process_ewmv(points, round = false) {
return result;
}
function process_stroke(points) {
const result0 = process_ewmv(points);
const result1 = process_rdp(result0, true);
function process_stroke(state, points) {
// const result0 = process_ewmv(points);
const result1 = process_rdp(state, points, true);
return result1;
}
@ -153,9 +167,9 @@ function stroke_intersects_region(points, bbox) { @@ -153,9 +167,9 @@ function stroke_intersects_region(points, bbox) {
}
function color_to_u32(color_str) {
const r = parseInt(color_str.substring(1, 3), 16);
const g = parseInt(color_str.substring(3, 5), 16);
const b = parseInt(color_str.substring(5, 7), 16);
const r = parseInt(color_str.substring(0, 2), 16);
const g = parseInt(color_str.substring(2, 4), 16);
const b = parseInt(color_str.substring(4, 6), 16);
return (r << 16) | (g << 8) | b;
}
@ -222,4 +236,37 @@ function dist_v2(a, b) { @@ -222,4 +236,37 @@ function dist_v2(a, b) {
const dx = a.x - b.x;
const dy = a.y - b.y;
return Math.sqrt(dx * dx + dy * dy);
}
function mid_v2(a, b) {
return {
'x': (a.x + b.x) / 2.0,
'y': (a.y + b.y) / 2.0,
};
}
function perpendicular(ax, ay, bx, by, width) {
// Place points at (stroke_width / 2) distance from the line
const dirx = bx - ax;
const diry = by - ay;
let pdirx = diry;
let pdiry = -dirx;
const pdir_norm = Math.sqrt(pdirx * pdirx + pdiry * pdiry);
pdirx /= pdir_norm;
pdiry /= pdir_norm;
return {
'p1': {
'x': ax + pdirx * width / 2,
'y': ay + pdiry * width / 2,
},
'p2': {
'x': ax - pdirx * width / 2,
'y': ay - pdiry * width / 2,
}
};
}

345
client/recv.js

@ -1,345 +0,0 @@ @@ -1,345 +0,0 @@
function deserializer_create(buffer, dataview) {
return {
'offset': 0,
'size': buffer.byteLength,
'buffer': buffer,
'view': dataview,
'strview': new Uint8Array(buffer),
};
}
function des_u8(d) {
const value = d.view.getUint8(d.offset);
d.offset += 1;
return value;
}
function des_u16(d) {
const value = d.view.getUint16(d.offset, true);
d.offset += 2;
return value;
}
function des_s16(d) {
const value = d.view.getInt16(d.offset, true);
d.offset += 2;
return value;
}
function des_u32(d) {
const value = d.view.getUint32(d.offset, true);
d.offset += 4;
return value;
}
function des_u16array(d, count) {
const result = [];
for (let i = 0; i < count; ++i) {
const item = d.view.getUint16(d.offset, true);
d.offset += 2;
result.push(item);
}
return result;
}
function des_event(d) {
const event = {};
event.type = des_u8(d);
event.user_id = des_u32(d);
switch (event.type) {
case EVENT.PREDRAW: {
event.x = des_u16(d);
event.y = des_u16(d);
break;
}
case EVENT.STROKE: {
const stroke_id = des_u32(d);
const point_count = des_u16(d);
const width = des_u16(d);
const color = des_u32(d);
const coords = des_u16array(d, point_count * 2);
event.stroke_id = stroke_id;
event.points = [];
for (let i = 0; i < point_count; ++i) {
const x = coords[2 * i + 0];
const y = coords[2 * i + 1];
event.points.push({'x': x, 'y': y});
}
event.color = color;
event.width = width;
break;
}
case EVENT.IMAGE:
case EVENT.IMAGE_MOVE: {
event.image_id = des_u32(d);
event.x = des_s16(d); // stored as u16, but actually is s16
event.y = des_s16(d); // stored as u16, but actually is s16
break;
}
case EVENT.UNDO:
case EVENT.REDO: {
break;
}
case EVENT.ERASER: {
event.stroke_id = des_u32(d);
break;
}
default: {
console.error('fuck');
}
}
return event;
}
function bitmap_bbox(event) {
const x = (event.x <= storage.canvas.width ? event.x : event.x - 65536);
const y = (event.y <= storage.canvas.height ? event.y : event.y - 65536);
const bbox = {
'xmin': x,
'xmax': x + event.bitmap.width,
'ymin': y,
'ymax': y + event.bitmap.height
};
return bbox;
}
async function handle_event(event) {
if (config.debug_print) console.debug(`event type ${event.type} from user ${event.user_id}`);
// TODO(@speed): do not handle locally predicted events
switch (event.type) {
case EVENT.STROKE: {
if (event.user_id in storage.predraw || event.user_id === storage.me.id) {
storage.predraw[event.user_id] = [];
redraw_predraw();
}
draw_stroke(event);
break;
}
case EVENT.UNDO: {
for (let i = storage.events.length - 1; i >=0; --i) {
const other_event = storage.events[i];
// Users can only undo their own, undeleted (not already undone) events
if (other_event.user_id === event.user_id && !other_event.deleted) {
if (other_event.type === EVENT.STROKE) {
other_event.deleted = true;
const stats = stroke_stats(other_event.points, storage.cursor.width);
redraw_region(stats.bbox);
break;
} else if (other_event.type === EVENT.IMAGE) {
other_event.deleted = true;
const item = document.querySelector(`img[data-image-id="${other_event.image_id}"]`);
if (item) item.remove();
break;
} else if (other_event.type === EVENT.ERASER) {
other_event.deleted = true;
const erased = find_stroke_backwards(other_event.stroke_id);
if (erased) {
erased.deleted = false;
const stats = stroke_stats(erased.points, storage.cursor.width);
redraw_region(stats.bbox);
}
break;
} else if (other_event.type === EVENT.IMAGE_MOVE) {
const item = document.querySelector(`img[data-image-id="${other_event.image_id}"]`);
const ix = storage.images[other_event.image_id].x -= other_event.x;
const iy = storage.images[other_event.image_id].y -= other_event.y;
item.style.transform = `translate(${ix}px, ${iy}px)`;
break;
}
}
}
break;
}
case EVENT.IMAGE: {
const url = config.image_url + event.image_id;
const item = document.createElement('img');
item.classList.add('floating-image');
item.style['z-index'] = storage.events.length;
item.setAttribute('data-image-id', event.image_id);
item.setAttribute('src', url);
item.style.transform = `translate(${event.x}px, ${event.y}px)`;
elements.images.appendChild(item);
storage.images[event.image_id] = {
'x': event.x, 'y': event.y
};
// const r = await fetch(config.image_url + event.image_id);
// const blob = await r.blob();
// const bitmap = await createImageBitmap(blob);
// event.bitmap = bitmap;
// const bbox = bitmap_bbox(event);
// storage.ctx0.drawImage(bitmap, bbox.xmin, bbox.ymin);
break;
}
case EVENT.IMAGE_MOVE: {
// Already moved due to local prediction
if (event.user_id !== storage.me.id) {
const image_id = event.image_id;
const item = document.querySelector(`.floating-image[data-image-id="${image_id}"]`);
const ix = storage.images[event.image_id].x += event.x;
const iy = storage.images[event.image_id].y += event.y;
if (item) {
item.style.transform = `translate(${ix}px, ${iy}px)`;
}
}
break;
}
case EVENT.ERASER: {
if (event.deleted) {
break;
}
for (const other_event of storage.events) {
if (other_event.type === EVENT.STROKE && other_event.stroke_id === event.stroke_id) {
// Might already be deleted because of local prediction
if (!other_event.deleted) {
other_event.deleted = true;
const stats = stroke_stats(other_event.points, storage.cursor.width);
redraw_region(stats.bbox);
}
break;
}
}
break;
}
default: {
console.error('fuck');
}
}
}
async function handle_message(d) {
const message_type = des_u8(d);
if (config.debug_print) console.debug(message_type);
switch (message_type) {
case MESSAGE.JOIN:
case MESSAGE.INIT: {
elements.canvas0.classList.add('white');
storage.me.id = des_u32(d);
storage.server_lsn = des_u32(d);
if (storage.server_lsn > storage.lsn) {
// Server knows something that we don't
storage.lsn = storage.server_lsn;
}
if (message_type === MESSAGE.JOIN) {
ls.setItem('sessionId', des_u32(d));
if (config.debug_print) console.debug('join in');
} else {
if (config.debug_print) console.debug('init in');
}
const event_count = des_u32(d);
if (config.debug_print) console.debug(`${event_count} events in init`);
storage.ctx0.clearRect(0, 0, storage.ctx0.canvas.width, storage.ctx0.canvas.height);
elements.images.innerHTML = '';
storage.events.length = 0;
for (let i = 0; i < event_count; ++i) {
const event = des_event(d);
await handle_event(event);
storage.events.push(event);
}
elements.canvas0.classList.remove('white');
send_ack(event_count);
sync_queue();
break;
}
case MESSAGE.FIRE: {
const user_id = des_u32(d);
const predraw_event = des_event(d);
predraw_user(user_id, predraw_event);
break;
}
case MESSAGE.ACK: {
const lsn = des_u32(d);
if (config.debug_print) console.debug(`ack ${lsn} in`);
if (lsn > storage.server_lsn) {
// ACKs may arrive out of order
storage.server_lsn = lsn;
}
break;
}
case MESSAGE.SYN: {
const sn = des_u32(d);
const count = des_u32(d);
const we_expect = sn - storage.sn;
const first = count - we_expect;
if (config.debug_print) console.debug(`syn ${sn} in`);
for (let i = 0; i < count; ++i) {
const event = des_event(d);
if (i >= first) {
handle_event(event);
storage.events.push(event);
}
}
storage.sn = sn;
await send_ack(sn);
break;
}
default: {
console.error('fuck');
return;
}
}
}

95
client/tools.js

@ -1,23 +1,86 @@ @@ -1,23 +1,86 @@
function tools_switch(tool) {
if (storage.tools.active_element) {
storage.tools.active_element.classList.remove('active');
function switch_tool(state, item) {
const tool = item.getAttribute('data-tool');
if (tool === 'undo') {
return;
}
if (state.tools.active_element) {
state.tools.active_element.classList.remove('active');
}
state.tools.active = tool;
state.tools.active_element = item;
state.tools.active_element.classList.add('active');
}
function switch_color(state, item) {
const color = item.getAttribute('data-color');
if (state.colors.active_element) {
state.colors.active_element.classList.remove('active');
}
if (state.me in state.players) {
const color_u32 = color_to_u32(color);
state.players[state.me].color = color_u32
fire_event(state, color_event(color_u32));
}
state.colors.active_element = item;
state.colors.active_element.classList.add('active');
}
function show_stroke_preview(state, size) {
const preview = document.querySelector('#stroke-preview');
preview.style.width = size * state.canvas.zoom + 'px';
preview.style.height = size * state.canvas.zoom + 'px';
preview.style.background = color_from_u32(state.players[state.me].color);
preview.classList.remove('dhide');
}
function hide_stroke_preview() {
document.querySelector('#stroke-preview').classList.add('dhide');
}
function switch_stroke_width(e, state) {
if (!state.online) return;
const value = e.target.value;
state.players[state.me].width = value;
show_stroke_preview(state, value);
if (state.hide_preview) {
clearTimeout(state.hide_preview);
}
storage.tools.active = tool;
storage.tools.active_element = document.querySelector(`.tool[data-tool="${tool}"]`);
storage.tools.active_element.classList.add('active');
state.hide_preview = setTimeout(hide_stroke_preview, config.brush_preview_timeout);
}
function broadcast_stroke_width(e, state) {
const value = e.target.value;
fire_event(state, width_event(value));
}
function tools_init() {
const pencil = document.querySelector('.tool[data-tool="pencil"]');
const ruler = document.querySelector('.tool[data-tool="ruler"]');
const eraser = document.querySelector('.tool[data-tool="eraser"]');
const undo = document.querySelector('.tool[data-tool="undo"]');
function init_tools(state) {
const tools = document.querySelectorAll('.tools .tool');
const colors = document.querySelectorAll('.pallete .color');
tools.forEach((item) => { item.addEventListener('click', () => switch_tool(state, item)); });
colors.forEach((item) => { item.addEventListener('click', () => switch_color(state, item)); });
// TODO: from localstorage
switch_tool(state, document.querySelector('.tool[data-tool="pencil"]'));
switch_color(state, document.querySelector('.color[data-color="000000"]'));
const slider = document.querySelector('#stroke-width');
pencil.addEventListener('click', () => tools_switch('pencil'));
ruler.addEventListener('click', () => tools_switch('ruler'));
eraser.addEventListener('click', () => tools_switch('eraser'));
undo.addEventListener('click', queue_undo);
// slider.value = state.players[state.me].width;
slider.addEventListener('input', (e) => switch_stroke_width(e, state));
slider.addEventListener('change', (e) => broadcast_stroke_width(e, state));
tools_switch('pencil');
document.querySelector('.phone-extra-controls').addEventListener('click', zenmode);
}

92
client/webgl_draw.js

@ -0,0 +1,92 @@ @@ -0,0 +1,92 @@
function schedule_draw(state, context) {
if (!state.timers.raf) {
window.requestAnimationFrame(() => draw(state, context));
state.timers.raf = true;
}
}
function draw(state, context) {
state.timers.raf = false;
const gl = context.gl;
const width = window.innerWidth;
const height = window.innerHeight;
let locations;
let buffers;
gl.viewport(0, 0, context.canvas.width, context.canvas.height);
gl.clearColor(context.bgcolor.r, context.bgcolor.g, context.bgcolor.b, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
// Images
locations = context.locations['quad'];
buffers = context.buffers['quad'];
gl.useProgram(context.programs['quad']);
gl.enableVertexAttribArray(locations['a_pos']);
gl.enableVertexAttribArray(locations['a_texcoord']);
gl.uniform2f(locations['u_res'], context.canvas.width, context.canvas.height);
gl.uniform2f(locations['u_scale'], state.canvas.zoom, state.canvas.zoom);
gl.uniform2f(locations['u_translation'], state.canvas.offset.x, state.canvas.offset.y);
gl.uniform1i(locations['u_texture'], 0);
gl.bindBuffer(gl.ARRAY_BUFFER, buffers['b_pos']);
gl.vertexAttribPointer(locations['a_pos'], 2, gl.FLOAT, false, 0, 0);
gl.bufferData(gl.ARRAY_BUFFER, context.quad_positions_f32, gl.STATIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, buffers['b_texcoord']);
gl.vertexAttribPointer(locations['a_texcoord'], 2, gl.FLOAT, false, 0, 0);
gl.bufferData(gl.ARRAY_BUFFER, context.quad_texcoords_f32, gl.STATIC_DRAW);
const count = Object.keys(context.textures).length;
let active_image_index = -1;
gl.uniform1i(locations['u_outline'], 0);
for (let key = 0; key < count; ++key) {
if (context.textures[key].image_id === context.active_image) {
active_image_index = key;
continue;
}
gl.bindTexture(gl.TEXTURE_2D, context.textures[key].texture);
gl.drawArrays(gl.TRIANGLES, key * 6, 6);
}
if (active_image_index !== -1) {
gl.uniform1i(locations['u_outline'], 1);
gl.bindTexture(gl.TEXTURE_2D, context.textures[active_image_index].texture);
gl.drawArrays(gl.TRIANGLES, active_image_index * 6, 6);
}
// Strokes
locations = context.locations['stroke'];
buffers = context.buffers['stroke'];
gl.useProgram(context.programs['stroke']);
gl.enableVertexAttribArray(locations['a_type']);
gl.enableVertexAttribArray(locations['a_pos']);
gl.enableVertexAttribArray(locations['a_texcoord']);
gl.enableVertexAttribArray(locations['a_color']);
gl.uniform2f(locations['u_res'], context.canvas.width, context.canvas.height);
gl.uniform2f(locations['u_scale'], state.canvas.zoom, state.canvas.zoom);
gl.uniform2f(locations['u_translation'], state.canvas.offset.x, state.canvas.offset.y);
gl.bindBuffer(gl.ARRAY_BUFFER, buffers['b_packed']);
gl.vertexAttribPointer(locations['a_pos'], 2, gl.FLOAT, false, config.bytes_per_point, 0);
gl.vertexAttribPointer(locations['a_texcoord'], 2, gl.FLOAT, false, config.bytes_per_point, 8);
gl.vertexAttribPointer(locations['a_color'], 3, gl.UNSIGNED_BYTE, true, config.bytes_per_point, 16);
gl.vertexAttribPointer(locations['a_type'], 1, gl.UNSIGNED_BYTE, false, config.bytes_per_point, 19);
gl.bufferData(gl.ARRAY_BUFFER, context.static_stroke_serializer.buffer, gl.STATIC_DRAW);
gl.drawArrays(gl.TRIANGLES, 0, context.static_stroke_serializer.offset / config.bytes_per_point);
gl.bufferData(gl.ARRAY_BUFFER, context.dynamic_stroke_serializer.buffer, gl.STATIC_DRAW);
gl.drawArrays(gl.TRIANGLES, 0, context.dynamic_stroke_serializer.offset / config.bytes_per_point);
}

244
client/webgl_geometry.js

@ -0,0 +1,244 @@ @@ -0,0 +1,244 @@
function push_point(s, x, y, u, v, r, g, b, type) {
ser_f32(s, x);
ser_f32(s, y);
ser_f32(s, u);
ser_f32(s, v);
// ser_u8(s, Math.floor(Math.random() * 255));
// ser_u8(s, Math.floor(Math.random() * 255));
// ser_u8(s, Math.floor(Math.random() * 255));
ser_u8(s, r);
ser_u8(s, g);
ser_u8(s, b);
ser_u8(s, type);
}
function push_circle(s, cx, cy, radius, r, g, b) {
push_point(s, cx - radius, cy - radius, 0, 0, r, g, b, 1);
push_point(s, cx - radius, cy + radius, 0, 1, r, g, b, 1);
push_point(s, cx + radius, cy - radius, 1, 0, r, g, b, 1);
push_point(s, cx + radius, cy + radius, 1, 1, r, g, b, 1);
push_point(s, cx + radius, cy - radius, 1, 0, r, g, b, 1);
push_point(s, cx - radius, cy + radius, 0, 1, r, g, b, 1);
}
function push_quad(s, p1x, p1y, p2x, p2y, p3x, p3y, p4x, p4y, r, g, b) {
push_point(s, p1x, p1y, 0, 0, r, g, b, 0);
push_point(s, p2x, p2y, 0, 1, r, g, b, 0);
push_point(s, p3x, p3y, 1, 0, r, g, b, 0);
push_point(s, p4x, p4y, 1, 1, r, g, b, 0);
push_point(s, p3x, p3y, 1, 0, r, g, b, 0);
push_point(s, p2x, p2y, 0, 1, r, g, b, 0);
}
function push_stroke(s, stroke) {
const stroke_width = stroke.width;
const points = stroke.points;
const color_u32 = stroke.color;
const r = (color_u32 >> 16) & 0xFF;
const g = (color_u32 >> 8) & 0xFF;
const b = color_u32 & 0xFF;
if (points.length === 0) {
return;
}
if (points.length === 1) {
push_circle(s, points[0].x, points[0].y, stroke_width / 2, r, g, b);
return;
}
for (let i = 0; i < points.length - 1; ++i) {
const px = points[i].x;
const py = points[i].y;
const nextpx = points[i + 1].x;
const nextpy = points[i + 1].y;
const d1x = nextpx - px;
const d1y = nextpy - py;
// Perpendicular to (d1x, d1y), points to the LEFT
let perp1x = -d1y;
let perp1y = d1x;
const perpnorm1 = Math.sqrt(perp1x * perp1x + perp1y * perp1y);
perp1x /= perpnorm1;
perp1y /= perpnorm1;
const s1x = px + perp1x * stroke_width / 2;
const s1y = py + perp1y * stroke_width / 2;
const s2x = px - perp1x * stroke_width / 2;
const s2y = py - perp1y * stroke_width / 2;
const s3x = nextpx + perp1x * stroke_width / 2;
const s3y = nextpy + perp1y * stroke_width / 2;
const s4x = nextpx - perp1x * stroke_width / 2;
const s4y = nextpy - perp1y * stroke_width / 2;
push_quad(s, s2x, s2y, s1x, s1y, s4x, s4y, s3x, s3y, r, g, b);
push_circle(s, px, py, stroke_width / 2, r, g, b);
}
const lastp = points[points.length - 1];
push_circle(s, lastp.x, lastp.y, stroke_width / 2, r, g, b);
}
function geometry_prepare_stroke(state) {
if (!state.online) {
return null;
}
return {
'color': state.players[state.me].color,
'width': state.players[state.me].width,
'points': process_stroke(state, state.players[state.me].points),
'user_id': state.me,
};
}
function geometry_add_stroke(state, context, stroke) {
if (!state.online || !stroke) return;
const bytes_left = context.static_stroke_serializer.size - context.static_stroke_serializer.offset;
const bytes_needed = (stroke.points.length * 12 + 6) * config.bytes_per_point;
if (bytes_left < bytes_needed) {
const old_view = context.static_stroke_serializer.strview;
const old_offset = context.static_stroke_serializer.offset;
const new_size = Math.ceil((context.static_stroke_serializer.size + bytes_needed) * 1.62);
context.static_stroke_serializer = serializer_create(new_size);
context.static_stroke_serializer.strview.set(old_view);
context.static_stroke_serializer.offset = old_offset;
}
push_stroke(context.static_stroke_serializer, stroke);
}
function recompute_dynamic_data(state, context) {
let bytes_needed = 0;
for (const player_id in state.players) {
const player = state.players[player_id];
if (player.points.length > 0) {
bytes_needed += (player.points.length * 12 + 6) * config.bytes_per_point;
}
}
if (bytes_needed > context.dynamic_stroke_serializer.size) {
context.dynamic_stroke_serializer = serializer_create(Math.ceil(bytes_needed * 1.62));
} else {
context.dynamic_stroke_serializer.offset = 0;
}
for (const player_id in state.players) {
// player has the same data as their current stroke: points, color, width
const player = state.players[player_id];
if (player.points.length > 0) {
push_stroke(context.dynamic_stroke_serializer, player);
}
}
}
function geometry_add_point(state, context, player_id, point) {
if (!state.online) return;
state.players[player_id].points.push(point);
recompute_dynamic_data(state, context);
}
function geometry_clear_player(state, context, player_id) {
if (!state.online) return;
state.players[player_id].points.length = 0;
recompute_dynamic_data(state, context);
}
function add_image(context, image_id, bitmap, p) {
const x = p.x;
const y = p.y;
const gl = context.gl;
const id = Object.keys(context.textures).length;
context.textures[id] = {
'texture': gl.createTexture(),
'image_id': image_id
};
gl.bindTexture(gl.TEXTURE_2D, context.textures[id].texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA,gl.UNSIGNED_BYTE, bitmap);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
context.quad_positions.push(...[
x, y,
x, y + bitmap.height,
x + bitmap.width, y + bitmap.height,
x + bitmap.width, y,
x, y,
x + bitmap.width, y + bitmap.height,
]);
context.quad_texcoords.push(...[
0, 0,
0, 1,
1, 1,
1, 0,
0, 0,
1, 1,
]);
context.quad_positions_f32 = new Float32Array(context.quad_positions);
context.quad_texcoords_f32 = new Float32Array(context.quad_texcoords);
}
function move_image(context, image_event) {
const x = image_event.x;
const y = image_event.y;
const count = Object.keys(context.textures).length;
for (let id = 0; id < count; ++id) {
const image = context.textures[id];
if (image.image_id === image_event.image_id) {
context.quad_positions[id * 12 + 0] = x;
context.quad_positions[id * 12 + 1] = y;
context.quad_positions[id * 12 + 2] = x;
context.quad_positions[id * 12 + 3] = y + image_event.height;
context.quad_positions[id * 12 + 4] = x + image_event.width;
context.quad_positions[id * 12 + 5] = y + image_event.height;
context.quad_positions[id * 12 + 6] = x + image_event.width;
context.quad_positions[id * 12 + 7] = y;
context.quad_positions[id * 12 + 8] = x;
context.quad_positions[id * 12 + 9] = y;
context.quad_positions[id * 12 + 10] = x + image_event.width;
context.quad_positions[id * 12 + 11] = y + image_event.height;
context.quad_positions_f32 = new Float32Array(context.quad_positions);
break;
}
}
}
function image_at(state, x, y) {
for (let i = state.events.length - 1; i >= 0; --i) {
const event = state.events[i];
if (event.type === EVENT.IMAGE && !event.deleted) {
if ('height' in event && 'width' in event) {
if (event.x <= x && x <= event.x + event.width && event.y <= y && y <= event.y + event.height) {
return event;
}
}
}
}
return null;
}

410
client/webgl_listeners.js

@ -0,0 +1,410 @@ @@ -0,0 +1,410 @@
function init_listeners(state, context) {
window.addEventListener('keydown', (e) => keydown(e, state, context));
window.addEventListener('keyup', (e) => keyup(e, state, context));
window.addEventListener('paste', (e) => paste(e, state, context));
context.canvas.addEventListener('mousedown', (e) => mousedown(e, state, context));
context.canvas.addEventListener('mousemove', (e) => mousemove(e, state, context));
context.canvas.addEventListener('mouseup', (e) => mouseup(e, state, context));
context.canvas.addEventListener('mouseleave', (e) => mouseup(e, state, context));
context.canvas.addEventListener('contextmenu', cancel);
context.canvas.addEventListener('wheel', (e) => wheel(e, state, context));
context.canvas.addEventListener('touchstart', (e) => touchstart(e, state, context));
context.canvas.addEventListener('touchmove', (e) => touchmove(e, state, context));
context.canvas.addEventListener('touchend', (e) => touchend(e, state, context));
context.canvas.addEventListener('touchcancel', (e) => touchend(e, state, context));
context.canvas.addEventListener('drop', (e) => on_drop(e, state, context));
context.canvas.addEventListener('dragover', (e) => mousemove(e, state, context));
}
function cancel(e) {
e.preventDefault();
return false;
}
function zenmode() {
document.querySelector('.pallete-wrapper').classList.toggle('hidden');
document.querySelector('.sizer-wrapper').classList.toggle('hidden');
}
async function paste(e, state, context) {
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
for (const item of items) {
if (item.kind === 'file') {
const file = item.getAsFile();
await insert_image(state, context, file);
}
}
}
function keydown(e, state, context) {
if (e.code === 'Space' && !state.drawing) {
state.spacedown = true;
context.canvas.classList.add('movemode');
} else if (e.code === 'Tab') {
e.preventDefault();
zenmode();
}
}
function keyup(e, state, context) {
if (e.code === 'Space' && state.spacedown) {
state.spacedown = false;
state.moving = false;
context.canvas.classList.remove('movemode');
}
}
function mousedown(e, state, context) {
const screenp = {'x': window.devicePixelRatio * e.clientX, 'y': window.devicePixelRatio * e.clientY};
const canvasp = screen_to_canvas(state, screenp);
if (e.button === 2) {
// Right click on image to select it
const image_event = image_at(state, canvasp.x, canvasp.y);
if (image_event) {
context.active_image = image_event.image_id;
} else {
context.active_image = null;
}
schedule_draw(state, context);
return;
}
if (e.button !== 0) {
return;
}
if (context.active_image) {
// Move selected image with left click
const image_event = image_at(state, canvasp.x, canvasp.y);
if (image_event && image_event.image_id === context.active_image) {
state.moving_image = image_event;
return;
}
}
if (state.spacedown) {
state.moving = true;
context.canvas.classList.add('moving');
return;
}
geometry_clear_player(state, context, state.me);
geometry_add_point(state, context, state.me, canvasp);
state.drawing = true;
context.active_image = null;
schedule_draw(state, context);
}
function mousemove(e, state, context) {
e.preventDefault();
let do_draw = false;
if (state.moving) {
state.canvas.offset.x += e.movementX;
state.canvas.offset.y += e.movementY;
do_draw = true;
}
if (state.moving_image) {
state.moving_image.x += e.movementX / state.canvas.zoom;
state.moving_image.y += e.movementY / state.canvas.zoom;
move_image(context, state.moving_image);
do_draw = true;
}
const screenp = {'x': window.devicePixelRatio * e.clientX, 'y': window.devicePixelRatio * e.clientY};
const canvasp = screen_to_canvas(state, screenp);
state.cursor = screenp;
if (state.drawing) {
geometry_add_point(state, context, state.me, canvasp);
fire_event(state, predraw_event(canvasp.x, canvasp.y));
do_draw = true;
}
if (do_draw) {
schedule_draw(state, context);
}
return false;
}
function mouseup(e, state, context) {
if (e.button !== 0) {
return;
}
if (state.moving_image) {
schedule_draw(state, context);
queue_event(state, image_move_event(context.active_image, state.moving_image.x, state.moving_image.y));
state.moving_image = null;
return;
}
if (state.moving) {
state.moving = false;
context.canvas.classList.remove('moving');
return;
}
if (state.drawing) {
const stroke = geometry_prepare_stroke(state);
if (stroke) {
geometry_add_stroke(state, context, stroke);
queue_event(state, stroke_event(state));
geometry_clear_player(state, context, state.me);
schedule_draw(state, context);
}
state.drawing = false;
return;
}
}
function wheel(e, state, context) {
const screenp = {'x': window.devicePixelRatio * e.clientX, 'y': window.devicePixelRatio * e.clientY};
const canvasp = screen_to_canvas(state, screenp);
const dz = (e.deltaY < 0 ? 0.1 : -0.1);
const old_zoom = state.canvas.zoom;
state.canvas.zoom *= (1.0 + dz);
if (state.canvas.zoom > config.max_zoom) {
state.canvas.zoom = old_zoom;
return;
}
if (state.canvas.zoom < config.min_zoom) {
state.canvas.zoom = old_zoom;
return;
}
const zoom_offset_x = Math.round((dz * old_zoom) * canvasp.x);
const zoom_offset_y = Math.round((dz * old_zoom) * canvasp.y);
state.canvas.offset.x -= zoom_offset_x;
state.canvas.offset.y -= zoom_offset_y;
schedule_draw(state, context);
}
function touchstart(e, state) {
e.preventDefault();
if (state.touch.drawing) {
// Ingore subsequent touches if we are already drawing
return;
}
// First finger(s) down?
if (state.touch.ids.length === 0) {
if (e.changedTouches.length === 1) {
// We give a bit of time to add a second finger
state.touch.waiting_for_second_finger = true;
state.touch.moves = 0;
state.touch.buffered.length = 0;
state.touch.ids.push(e.changedTouches[0].identifier);
setTimeout(() => {
state.touch.waiting_for_second_finger = false;
}, config.second_finger_timeout);
} else {
console.error('Two touchstarts at the same time are not yet supported');
}
return;
}
// There are touches already
if (state.touch.waiting_for_second_finger) {
if (e.changedTouches.length === 1) {
state.touch.ids.push(e.changedTouches[0].identifier);
for (const touch of e.touches) {
const screenp = {'x': window.devicePixelRatio * touch.clientX, 'y': window.devicePixelRatio * touch.clientY};
if (touch.identifier === state.touch.ids[0]) {
state.touch.first_finger_position = screenp;
} else if (touch.identifier === state.touch.ids[1]) {
state.touch.second_finger_position = screenp;
}
}
}
return;
}
}
function touchmove(e, state, context) {
if (state.touch.ids.length === 1) {
const touch = find_touch(e.changedTouches, state.touch.ids[0]);
if (!touch) {
return;
}
const screenp = {'x': window.devicePixelRatio * touch.clientX, 'y': window.devicePixelRatio * touch.clientY};
const canvasp = screen_to_canvas(state, screenp);
if (state.touch.moving) {
// Can happen if we have been panning the canvas and lifted one finger,
// but not the second one
return;
}
if (!state.touch.drawing) {
// Buffer this move
state.touch.moves += 1;
if (state.touch.moves > config.buffer_first_touchmoves) {
// Start drawing, no more waiting
state.touch.waiting_for_second_finger = false;
state.touch.drawing = true;
} else {
state.touch.buffered.push(canvasp);
}
} else {
// Handle buffered moves
if (state.touch.buffered.length > 0) {
geometry_clear_player(state, context, state.me);
for (const p of state.touch.buffered) {
geometry_add_point(state, context, state.me, p);
fire_event(state, predraw_event(p.x, p.y));
}
state.touch.buffered.length = 0;
}
geometry_add_point(state, context, state.me, canvasp);
fire_event(state, predraw_event(canvasp.x, canvasp.y));
schedule_draw(state, context);
}
return;
}
if (state.touch.ids.length === 2) {
state.touch.moving = true;
let first_finger_position = null;
let second_finger_position = null;
// A separate loop because touches might be in different order ? (question mark)
// IMPORTANT: e.touches, not e.changedTouches!
for (const touch of e.touches) {
const screenp = {'x': window.devicePixelRatio * touch.clientX, 'y': window.devicePixelRatio * touch.clientY};
if (touch.identifier === state.touch.ids[0]) {
first_finger_position = screenp;
} else if (touch.identifier === state.touch.ids[1]) {
second_finger_position = screenp;
}
}
const old_finger_midpoint = mid_v2(state.touch.first_finger_position, state.touch.second_finger_position);
const new_finger_midpoint = mid_v2(first_finger_position, second_finger_position);
const new_finger_midpoint_canvas = mid_v2(
screen_to_canvas(state, first_finger_position),
screen_to_canvas(state, second_finger_position)
);
const old_finger_distance = dist_v2(state.touch.first_finger_position, state.touch.second_finger_position);
const new_finger_distance = dist_v2(first_finger_position, second_finger_position);
const dx = new_finger_midpoint.x - old_finger_midpoint.x;
const dy = new_finger_midpoint.y - old_finger_midpoint.y;
const old_zoom = state.canvas.zoom;
state.canvas.offset.x += dx;
state.canvas.offset.y += dy;
// console.log(new_finger_distance, state.touch.finger_distance);
const scale_by = new_finger_distance / old_finger_distance;
const dz = state.canvas.zoom * (scale_by - 1.0);
const zoom_offset_x = dz * new_finger_midpoint_canvas.x;
const zoom_offset_y = dz * new_finger_midpoint_canvas.y;
if (config.min_zoom <= state.canvas.zoom * scale_by && state.canvas.zoom * scale_by <= config.max_zoom) {
state.canvas.zoom *= scale_by;
state.canvas.offset.x -= zoom_offset_x;
state.canvas.offset.y -= zoom_offset_y;
}
state.touch.first_finger_position = first_finger_position;
state.touch.second_finger_position = second_finger_position;
schedule_draw(state, context);
return;
}
}
function touchend(e, state, context) {
for (const touch of e.changedTouches) {
if (state.touch.drawing) {
if (state.touch.ids[0] == touch.identifier) {
// const event = stroke_event();
// await queue_event(event);
const stroke = geometry_prepare_stroke(state);
if (stroke) {
geometry_add_stroke(state, context, stroke);
queue_event(state, stroke_event(state));
geometry_clear_player(state, context, state.me);
schedule_draw(state, context);
}
state.touch.drawing = false;
}
}
const index = state.touch.ids.indexOf(touch.identifier);
if (index !== -1) {
state.touch.ids.splice(index, 1);
}
}
if (state.touch.ids.length === 0) {
// Only allow drawing again when ALL fingers have been lifted
state.touch.moving = false;
waiting_for_second_finger = false;
}
}
async function on_drop(e, state, context) {
e.preventDefault();
if (e.dataTransfer.files.length !== 1) {
return;
}
const file = e.dataTransfer.files[0];
await insert_image(state, context, file);
schedule_draw(state, context);
return false;
}

193
client/webgl_shaders.js

@ -0,0 +1,193 @@ @@ -0,0 +1,193 @@
const stroke_vs_src = `
attribute float a_type;
attribute vec2 a_pos;
attribute vec2 a_texcoord;
attribute vec3 a_color;
uniform vec2 u_scale;
uniform vec2 u_res;
uniform vec2 u_translation;
varying vec3 v_color;
varying vec2 v_texcoord;
varying float v_type;
void main() {
vec2 screen01 = (a_pos * u_scale + u_translation) / u_res;
vec2 screen02 = screen01 * 2.0;
screen02.y = 2.0 - screen02.y;
v_color = a_color;
v_texcoord = a_texcoord;
v_type = a_type;
gl_Position = vec4(screen02 - 1.0, 0, 1);
}
`;
const stroke_fs_src = `
#extension GL_OES_standard_derivatives : enable
precision mediump float;
varying vec3 v_color;
varying vec2 v_texcoord;
varying float v_type;
void main() {
vec2 uv = v_texcoord * 2.0 - 1.0;
float sdf = 1.0 - mix(abs(uv.y), length(uv), v_type);
float pd = fwidth(sdf);
float alpha = 1.0 - smoothstep(pd, 0.0, sdf);
gl_FragColor = vec4(v_color * alpha, alpha);
}
`;
const tquad_vs_src = `
attribute vec2 a_pos;
attribute vec2 a_texcoord;
uniform vec2 u_scale;
uniform vec2 u_res;
uniform vec2 u_translation;
varying vec2 v_texcoord;
void main() {
vec2 screen01 = (a_pos * u_scale + u_translation) / u_res;
vec2 screen02 = screen01 * 2.0;
screen02.y = 2.0 - screen02.y;
vec2 screen11 = screen02 - 1.0;
v_texcoord = a_texcoord;
gl_Position = vec4(screen11, 0, 1);
}
`;
const tquad_fs_src = `
precision mediump float;
varying vec2 v_texcoord;
uniform sampler2D u_texture;
uniform bool u_outline;
void main() {
if (!u_outline) {
gl_FragColor = texture2D(u_texture, v_texcoord);
} else {
gl_FragColor = mix(texture2D(u_texture, v_texcoord), vec4(0.7, 0.7, 0.95, 1), 0.5);
}
}
`;
function init_webgl(state, context) {
context.canvas = document.querySelector('#c');
context.gl = context.canvas.getContext('webgl', {
'preserveDrawingBuffer': true,
'desynchronized': true,
'antialias': false,
});
const gl = context.gl;
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.getExtension('OES_standard_derivatives');
const stroke_vs = create_shader(gl, gl.VERTEX_SHADER, stroke_vs_src);
const stroke_fs = create_shader(gl, gl.FRAGMENT_SHADER, stroke_fs_src);
const quad_vs = create_shader(gl, gl.VERTEX_SHADER, tquad_vs_src);
const quad_fs = create_shader(gl, gl.FRAGMENT_SHADER, tquad_fs_src);
context.programs['stroke'] = create_program(gl, stroke_vs, stroke_fs);
context.programs['quad'] = create_program(gl, quad_vs, quad_fs);
context.locations['stroke'] = {
'a_type': gl.getAttribLocation(context.programs['stroke'], 'a_type'),
'a_pos': gl.getAttribLocation(context.programs['stroke'], 'a_pos'),
'a_texcoord': gl.getAttribLocation(context.programs['stroke'], 'a_texcoord'),
'a_color': gl.getAttribLocation(context.programs['stroke'], 'a_color'),
'u_res': gl.getUniformLocation(context.programs['stroke'], 'u_res'),
'u_scale': gl.getUniformLocation(context.programs['stroke'], 'u_scale'),
'u_translation': gl.getUniformLocation(context.programs['stroke'], 'u_translation'),
};
context.locations['quad'] = {
'a_pos': gl.getAttribLocation(context.programs['quad'], 'a_pos'),
'a_texcoord': gl.getAttribLocation(context.programs['quad'], 'a_texcoord'),
'u_res': gl.getUniformLocation(context.programs['quad'], 'u_res'),
'u_scale': gl.getUniformLocation(context.programs['quad'], 'u_scale'),
'u_translation': gl.getUniformLocation(context.programs['quad'], 'u_translation'),
'u_outline': gl.getUniformLocation(context.programs['quad'], 'u_outline'),
'u_texture': gl.getUniformLocation(context.programs['quad'], 'u_texture'),
};
context.buffers['stroke'] = {
'b_packed': context.gl.createBuffer(),
};
context.buffers['quad'] = {
'b_pos': context.gl.createBuffer(),
'b_texcoord': context.gl.createBuffer(),
};
const resize_canvas = (entries) => {
// https://www.khronos.org/webgl/wiki/HandlingHighDPI
const entry = entries[0];
let width;
let height;
if (entry.devicePixelContentBoxSize) {
width = entry.devicePixelContentBoxSize[0].inlineSize;
height = entry.devicePixelContentBoxSize[0].blockSize;
} else if (entry.contentBoxSize) {
// fallback for Safari that will not always be correct
width = Math.round(entry.contentBoxSize[0].inlineSize * devicePixelRatio);
height = Math.round(entry.contentBoxSize[0].blockSize * devicePixelRatio);
}
context.canvas.width = width;
context.canvas.height = height;
schedule_draw(state, context);
}
const resize_observer = new ResizeObserver(resize_canvas);
resize_observer.observe(context.canvas);
}
function create_shader(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
return shader;
}
console.error(type, ':', gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
}
function create_program(gl, vs, fs) {
const program = gl.createProgram();
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
if (gl.getProgramParameter(program, gl.LINK_STATUS)) {
return program;
}
console.error('link:', gl.getProgramInfoLog(program));
gl.deleteProgram(program);
}

56
client/websocket.js

@ -6,24 +6,41 @@ @@ -6,24 +6,41 @@
//
// Details best described here: https://github.com/kee-org/KeeFox/issues/189
function ws_connect(first_connect = false) {
const session_id = ls.getItem('sessionId') || '0';
const desk_id = storage.desk_id;
ws = new WebSocket(`${config.ws_url}?deskId=${desk_id}&sessionId=${session_id}`);
ws.addEventListener('open', on_open);
ws.addEventListener('message', on_message);
ws.addEventListener('error', on_error);
ws.addEventListener('close', on_close);
async function ws_connect(state, context, first_connect = false) {
const session_id = localStorage.getItem('sessionId') || '0';
const desk_id = state.desk_id;
try {
const resp = await fetch(config.ping_url);
if (resp.ok) {
const text = await resp.text();
if (text === 'pong') {
ws = new WebSocket(`${config.ws_url}?deskId=${desk_id}&sessionId=${session_id}`);
ws.addEventListener('open', () => on_open(state));
ws.addEventListener('message', (e) => on_message(state, context, e));
ws.addEventListener('error', () => on_error(state, context));
ws.addEventListener('close', () => on_close(state, context));
return;
}
}
} catch (e) {
// console.log('Could not ping the server:', e);
}
state.timers.offline_toast = setTimeout(() => ws_connect(state, context, first_connect), config.ws_reconnect_timeout);
}
function on_open() {
clearTimeout(storage.timers.ws_reconnect);
function on_open(state) {
clearTimeout(state.timers.offline_toast);
ui_online();
if (config.debug_print) console.debug('open')
}
async function on_message(event) {
async function on_message(state, context, event) {
const data = event.data;
let message_data = null;
@ -31,7 +48,7 @@ async function on_message(event) { @@ -31,7 +48,7 @@ async function on_message(event) {
message_data = await data.arrayBuffer();
const view = new DataView(message_data);
const d = deserializer_create(message_data, view);
await handle_message(d);
await handle_message(state, context, d);
} else {
/* For all my Safari < 14 bros out there */
const reader = new FileReader();
@ -39,19 +56,20 @@ async function on_message(event) { @@ -39,19 +56,20 @@ async function on_message(event) {
message_data = e.target.result;
const view = new DataView(message_data);
const d = deserializer_create(message_data, view);
await handle_message(d);
await handle_message(state, context, d);
};
reader.readAsArrayBuffer(data);
}
}
function on_close() {
function on_close(state, context) {
state.timers.offline_toast = setTimeout(() => ui_offline(), config.initial_offline_timeout);
ws = null;
if (config.debug_print) console.debug('close');
storage.timers.ws_reconnect = setTimeout(ws_connect, config.ws_reconnect_timeout);
ws_connect(state, context, false);
}
function on_error() {
ws.close();
function on_error(state, context) {
ws.close(state, context);
}

3
server/config.js

@ -2,4 +2,5 @@ export const HOST = '127.0.0.1'; @@ -2,4 +2,5 @@ export const HOST = '127.0.0.1';
export const PORT = 3003;
export const DATADIR = 'data';
export const SYNC_TIMEOUT = 1000;
export const IMAGEDIR = 'images';
export const IMAGEDIR = 'images';
export const DEBUG_PRINT = true;

43
server/deserializer.js

@ -26,40 +26,63 @@ export function u32(d) { @@ -26,40 +26,63 @@ export function u32(d) {
return value;
}
function u16array(d, count) {
const array = new Uint16Array(d.view.buffer, d.offset, count);
d.offset += count * 2;
export function f32(d) {
const value = d.view.getFloat32(d.offset, true);
d.offset += 4;
return value;
}
function f32array(d, count) {
const array = new Float32Array(d.view.buffer, d.offset, count);
d.offset += count * 4;
return array;
}
export function align(d, to) {
while (d.offset % to !== 0) {
d.offset++;
}
}
export function event(d) {
const event = {};
event.type = u8(d);
u8(d); // padding
switch (event.type) {
case EVENT.PREDRAW: {
event.x = u16(d);
event.y = u16(d);
event.x = f32(d);
event.y = f32(d);
break;
}
case EVENT.SET_COLOR: {
event.color = u32(d);
break;
}
case EVENT.SET_WIDTH: {
event.width = u16(d);
break;
}
case EVENT.STROKE: {
// point_count + width align to 4 bytes :D
const point_count = u16(d);
const width = u16(d);
const color = u32(d);
align(d, 4);
event.width = width;
event.color = color;
event.points = u16array(d, point_count * 2);
event.points = f32array(d, point_count * 2);
break;
}
case EVENT.IMAGE:
case EVENT.IMAGE_MOVE: {
event.image_id = u32(d);
event.x = u16(d);
event.y = u16(d);
event.x = f32(d);
event.y = f32(d);
break;
}
@ -74,7 +97,7 @@ export function event(d) { @@ -74,7 +97,7 @@ export function event(d) {
}
default: {
console.error('fuck');
console.error('fuck', event.type);
console.trace();
process.exit(1);
}

7
server/enums.js

@ -6,6 +6,8 @@ export const SESSION = Object.freeze({ @@ -6,6 +6,8 @@ export const SESSION = Object.freeze({
export const EVENT = Object.freeze({
PREDRAW: 10,
SET_COLOR: 11,
SET_WIDTH: 12,
STROKE: 20,
UNDO: 30,
REDO: 31,
@ -21,9 +23,4 @@ export const MESSAGE = Object.freeze({ @@ -21,9 +23,4 @@ export const MESSAGE = Object.freeze({
FULL: 103,
FIRE: 104,
JOIN: 105,
});
export const SNS = Object.freeze({
DESK: 1,
SESSION: 2,
});

8
server/http.js

@ -7,13 +7,11 @@ export async function route(req) { @@ -7,13 +7,11 @@ export async function route(req) {
if (url.pathname === '/api/image') {
const desk_id = url.searchParams.get('deskId') || '0';
const formData = await req.formData();
const file = formData.get('file');
const formdata = await req.formData();
const file = formdata.get('file');
const image_id = math.fast_random32();
await Bun.write(config.IMAGEDIR + '/' + image_id, file);
storage.put_image(image_id, desk_id);
Bun.write(config.IMAGEDIR + '/' + image_id, file);
return new Response(image_id);
} else if (url.pathname === '/api/ping') {

126
server/recv.js

@ -2,9 +2,10 @@ import * as des from './deserializer'; @@ -2,9 +2,10 @@ import * as des from './deserializer';
import * as send from './send';
import * as math from './math';
import * as storage from './storage';
import * as config from './config';
import { SESSION, MESSAGE, EVENT } from './enums';
import { sessions, desks } from './storage';
import { sessions, desks, queries } from './storage';
// Session ACKed events up to SN
function recv_ack(d, session) {
@ -13,52 +14,26 @@ function recv_ack(d, session) { @@ -13,52 +14,26 @@ function recv_ack(d, session) {
session.state = SESSION.READY;
session.sn = sn;
console.log(`ack ${sn} in`);
}
function handle_event(session, event) {
switch (event.type) {
case EVENT.STROKE: {
event.stroke_id = math.fast_random32();
storage.put_stroke(event.stroke_id, session.desk_id, event.points, event.width, event.color);
storage.put_event(event);
break;
}
case EVENT.ERASER:
case EVENT.IMAGE:
case EVENT.IMAGE_MOVE:
case EVENT.UNDO: {
storage.put_event(event);
break;
}
default: {
console.error('fuck');
console.trace();
process.exit(1);
}
}
if (config.DEBUG_PRINT) console.log(`ack ${sn} in`);
}
async function recv_syn(d, session) {
const padding = des.u8(d);
const lsn = des.u32(d);
const count = des.u32(d);
console.log(`syn ${lsn} in, total size = ${d.size}`);
if (config.DEBUG_PRINT) console.log(`syn ${lsn} in, total size = ${d.size}`);
const we_expect = lsn - session.lsn;
const first = count - we_expect;
const events = [];
console.log(`we expect ${we_expect}, count ${count}`);
if (config.DEBUG_PRINT) console.log(`we expect ${we_expect}, count ${count}`);
for (let i = 0; i < count; ++i) {
const event = des.event(d);
if (i >= first) {
event.desk_id = session.desk_id;
event.user_id = session.user_id;
event.user_id = session.id;
handle_event(session, event);
events.push(event);
}
@ -68,8 +43,15 @@ async function recv_syn(d, session) { @@ -68,8 +43,15 @@ async function recv_syn(d, session) {
desks[session.desk_id].events.push(...events);
session.lsn = lsn;
storage.save_desk_sn(session.desk_id, desks[session.desk_id].sn);
storage.save_session_lsn(session.id, lsn);
storage.queries.update_desk_sn.run({
'$id': session.desk_id,
'$sn': desks[session.desk_id].sn
});
storage.queries.update_session_lsn.run({
'$id': session.id,
'$lsn': lsn
});
send.send_ack(session.ws, lsn);
send.sync_desk(session.desk_id);
@ -78,6 +60,32 @@ async function recv_syn(d, session) { @@ -78,6 +60,32 @@ async function recv_syn(d, session) {
function recv_fire(d, session) {
const event = des.event(d);
event.user_id = session.id;
switch (event.type) {
case EVENT.SET_COLOR: {
session.color = event.color;
storage.queries.update_session_color.run({
'$id': session.id,
'$color': event.color
});
break;
}
case EVENT.SET_WIDTH: {
session.width = event.width;
storage.queries.update_session_width.run({
'$id': session.id,
'$width': event.width
});
break;
}
}
for (const sid in sessions) {
const other = sessions[sid];
@ -93,7 +101,57 @@ function recv_fire(d, session) { @@ -93,7 +101,57 @@ function recv_fire(d, session) {
continue;
}
send.send_fire(other.ws, session.user_id, event);
send.send_fire(other.ws, event);
}
}
function handle_event(session, event) {
switch (event.type) {
case EVENT.STROKE: {
event.stroke_id = math.fast_random32();
storage.queries.insert_stroke.run({
'$id': event.stroke_id,
'$width': event.width,
'$color': event.color,
'$points': event.points
});
storage.queries.insert_event.run({
'$type': event.type,
'$desk_id': session.desk_id,
'$session_id': session.id,
'$stroke_id': event.stroke_id,
'$image_id': 0,
'$x': 0,
'$y': 0,
});
break;
}
case EVENT.ERASER:
case EVENT.IMAGE:
case EVENT.IMAGE_MOVE:
case EVENT.UNDO: {
storage.queries.insert_event.run({
'$type': event.type,
'$desk_id': session.desk_id,
'$session_id': session.id,
'$stroke_id': event.stroke_id || 0,
'$image_id': event.image_id || 0,
'$x': event.x || 0,
'$y': event.y || 0,
});
break;
}
default: {
console.error('fuck');
console.trace();
process.exit(1);
}
}
}

67
server/send.js

@ -11,7 +11,17 @@ function event_size(event) { @@ -11,7 +11,17 @@ function event_size(event) {
switch (event.type) {
case EVENT.PREDRAW: {
size += 2 * 2;
size += 4 * 2;
break;
}
case EVENT.SET_COLOR: {
size += 4;
break;
}
case EVENT.SET_WIDTH: {
size += 2;
break;
}
@ -23,7 +33,7 @@ function event_size(event) { @@ -23,7 +33,7 @@ function event_size(event) {
case EVENT.IMAGE:
case EVENT.IMAGE_MOVE: {
size += 4 + 2 + 2; // file id + x + y
size += 4 + 4 + 4; // file id + x + y
break;
}
@ -48,23 +58,21 @@ function event_size(event) { @@ -48,23 +58,21 @@ function event_size(event) {
}
function create_session(ws, desk_id) {
const user = {
id: math.crypto_random32(),
login: 'unnamed',
};
const session = {
id: math.crypto_random32(),
user_id: user.id,
desk_id: desk_id,
state: SESSION.OPENED,
sn: 0,
lsn: 0,
ws: ws,
color: 0x00,
width: 8,
};
storage.create_user(user);
storage.create_session(session);
storage.queries.insert_session.run({
'$id': session.id,
'$desk_id': desk_id
});
sessions[session.id] = session;
@ -81,11 +89,12 @@ export async function send_init(ws) { @@ -81,11 +89,12 @@ export async function send_init(ws) {
const desk = desks[desk_id];
let opcode = MESSAGE.INIT;
let size = 1 + 4 + 4 + 4 + 4; // opcode + user_id + lsn + event count + stroke count
let size = 1 + 4 + 4 + 4 + 4; // opcode + user_id + lsn + event count + stroke count + user count
let session = null;
if (session_id in sessions && sessions[session_id].desk_id == desk_id) {
session = sessions[session_id];
size += 4 + 2; // color + width
} else {
size += 4; // session id
opcode = MESSAGE.JOIN;
@ -98,7 +107,17 @@ export async function send_init(ws) { @@ -98,7 +107,17 @@ export async function send_init(ws) {
session.sn = 0; // Always re-send everything on reconnect
session.state = SESSION.OPENED;
console.log(`session ${session.id} opened`);
if (config.DEBUG_PRINT) console.log(`session ${session.id} opened`);
let user_count = 0;
for (const sid in sessions) {
const other_session = sessions[sid];
if (other_session.id !== session_id && other_session.desk_id === desk_id) {
++user_count;
size += 4 + 4 + 2; // user id + color + width
}
}
for (const event of desk.events) {
size += event_size(event);
@ -107,14 +126,27 @@ export async function send_init(ws) { @@ -107,14 +126,27 @@ export async function send_init(ws) {
const s = ser.create(size);
ser.u8(s, opcode);
ser.u32(s, session.user_id);
ser.u32(s, session.lsn);
if (opcode === MESSAGE.JOIN) {
ser.u32(s, session.id);
} else {
ser.u32(s, session.color);
ser.u16(s, session.width);
}
ser.u32(s, desk.events.length);
ser.u32(s, user_count);
for (const sid in sessions) {
const other_session = sessions[sid];
if (other_session.id !== session_id && other_session.desk_id === desk_id) {
// console.log(other_session.id, other_session.color, other_session.width);
ser.u32(s, other_session.id);
ser.u32(s, other_session.color);
ser.u16(s, other_session.width);
}
}
for (const event of desk.events) {
ser.event(s, event);
@ -134,12 +166,12 @@ export function send_ack(ws, lsn) { @@ -134,12 +166,12 @@ export function send_ack(ws, lsn) {
ser.u8(s, MESSAGE.ACK);
ser.u32(s, lsn);
console.log(`ack ${lsn} out`);
if (config.DEBUG_PRINT) console.log(`ack ${lsn} out`);
ws.send(s.buffer);
}
export function send_fire(ws, user_id, event) {
export function send_fire(ws, event) {
if (!ws) {
return;
}
@ -147,7 +179,6 @@ export function send_fire(ws, user_id, event) { @@ -147,7 +179,6 @@ export function send_fire(ws, user_id, event) {
const s = ser.create(1 + 4 + event_size(event));
ser.u8(s, MESSAGE.FIRE);
ser.u32(s, user_id);
ser.event(s, event);
ws.send(s.buffer);
@ -173,7 +204,7 @@ async function sync_session(session_id) { @@ -173,7 +204,7 @@ async function sync_session(session_id) {
let count = desk.sn - session.sn;
if (count === 0) {
console.log('client ACKed all events');
if (config.DEBUG_PRINT) console.log('client ACKed all events');
return;
}
@ -193,7 +224,7 @@ async function sync_session(session_id) { @@ -193,7 +224,7 @@ async function sync_session(session_id) {
ser.event(s, event);
}
console.debug(`syn ${desk.sn} out`);
if (config.DEBUG_PRINT) console.log(`syn ${desk.sn} out`);
await session.ws.send(s.buffer);

25
server/serializer.js

@ -21,6 +21,11 @@ export function u16(s, value) { @@ -21,6 +21,11 @@ export function u16(s, value) {
s.offset += 2;
}
export function f32(s, value) {
s.view.setFloat32(s.offset, value, true);
s.offset += 4;
}
export function u32(s, value) {
s.view.setUint32(s.offset, value, true);
s.offset += 4;
@ -37,15 +42,25 @@ export function event(s, event) { @@ -37,15 +42,25 @@ export function event(s, event) {
switch (event.type) {
case EVENT.PREDRAW: {
u16(s, event.x);
u16(s, event.y);
f32(s, event.x);
f32(s, event.y);
break;
}
case EVENT.SET_COLOR: {
u32(s, event.color);
break;
}
case EVENT.SET_WIDTH: {
u16(s, event.width);
break;
}
case EVENT.STROKE: {
const points_bytes = event.points;
u32(s, event.stroke_id);
u16(s, points_bytes.byteLength / 2 / 2); // each point is 2 u16s
u16(s, points_bytes.byteLength / 2 / 4); // each point is 2 * f32
u16(s, event.width);
u32(s, event.color);
bytes(s, points_bytes);
@ -55,8 +70,8 @@ export function event(s, event) { @@ -55,8 +70,8 @@ export function event(s, event) {
case EVENT.IMAGE:
case EVENT.IMAGE_MOVE: {
u32(s, event.image_id);
u16(s, event.x);
u16(s, event.y);
f32(s, event.x);
f32(s, event.y);
break;
}

10
server/server.js

@ -1,13 +1,11 @@ @@ -1,13 +1,11 @@
import * as config from './config';
import * as storage from './storage';
import * as http_server from './http';
import * as math from './math';
import * as ser from './serializer';
import * as des from './deserializer';
import * as send from './send';
import * as recv from './recv';
import { MESSAGE, EVENT, SESSION, SNS } from './enums';
import { MESSAGE, EVENT, SESSION } from './enums';
import { sessions, desks } from './storage';
export function startup() {
@ -29,7 +27,11 @@ export function startup() { @@ -29,7 +27,11 @@ export function startup() {
events: [],
};
storage.create_desk(desk_id);
storage.queries.insert_desk.run({
'$id': desk_id,
'$title': `Desk ${desk_id}`
});
desks[desk_id] = desk;
}

156
server/storage.js

@ -3,11 +3,12 @@ import * as sqlite from 'bun:sqlite'; @@ -3,11 +3,12 @@ import * as sqlite from 'bun:sqlite';
import { EVENT, SESSION } from './enums';
// In-memory views
export const sessions = {};
export const desks = {};
export const queries = {};
let db = null;
const queries = {};
export function startup() {
const path = `${config.DATADIR}/db.sqlite`;
@ -20,31 +21,12 @@ export function startup() { @@ -20,31 +21,12 @@ export function startup() {
title TEXT
);`).run();
db.query(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
login TEXT
);`).run();
db.query(`CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY,
user_id INTEGER,
desk_id INTEGER,
lsn INTEGER,
FOREIGN KEY (user_id)
REFERENCES users (id)
ON DELETE CASCADE
ON UPDATE NO ACTION,
FOREIGN KEY (desk_id)
REFERENCES desks (id)
ON DELETE CASCADE
ON UPDATE NO ACTION
);`).run();
db.query(`CREATE TABLE IF NOT EXISTS images (
id INTEGER PRIMARY KEY,
desk_id INTEGER,
color INTEGER,
width INTEGER,
FOREIGN KEY (desk_id)
REFERENCES desks (id)
@ -54,22 +36,16 @@ export function startup() { @@ -54,22 +36,16 @@ export function startup() {
db.query(`CREATE TABLE IF NOT EXISTS strokes (
id INTEGER PRIMARY KEY,
desk_id INTEGER,
points BLOB,
width INTEGER,
color INTEGER,
FOREIGN KEY (desk_id)
REFERENCES desks (id)
ON DELETE CASCADE
ON UPDATE NO ACTION
points BLOB
);`).run();
db.query(`CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY,
type INTEGER,
desk_id INTEGER,
user_id INTEGER,
session_id INTEGER,
stroke_id INTEGER,
image_id INTEGER,
x INTEGER,
@ -80,71 +56,52 @@ export function startup() { @@ -80,71 +56,52 @@ export function startup() {
ON DELETE CASCADE
ON UPDATE NO ACTION
FOREIGN KEY (user_id)
REFERENCES users (id)
ON DELETE CASCADE
FOREIGN KEY (session_id)
REFERENCES sessions (id)
ON DELETE NO ACTION
ON UPDATE NO ACTION
FOREIGN KEY (stroke_id)
REFERENCES strokes (id)
ON DELETE CASCADE
ON UPDATE NO ACTION
FOREIGN KEY (image_id)
REFERENCES images (id)
ON DELETE CASCADE
ON UPDATE NO ACTION
);`).run();
db.query(`CREATE INDEX IF NOT EXISTS idx_events_desk_id
ON events (desk_id);
`).run();
// INSERT
queries.insert_desk = db.query('INSERT INTO desks (id, title, sn) VALUES ($id, $title, 0)');
queries.insert_stroke = db.query('INSERT INTO strokes (id, width, color, points) VALUES ($id, $width, $color, $points)');
queries.insert_session = db.query('INSERT INTO sessions (id, desk_id, lsn) VALUES ($id, $desk_id, 0)');
queries.insert_event = db.query('INSERT INTO events (type, desk_id, session_id, stroke_id, image_id, x, y) VALUES ($type, $desk_id, $session_id, $stroke_id, $image_id, $x, $y)');
db.query(`CREATE INDEX IF NOT EXISTS idx_strokes_desk_id
ON strokes (desk_id);
`).run();
// UPDATE
queries.update_desk_sn = db.query('UPDATE desks SET sn = $sn WHERE id = $id');
queries.update_session_lsn = db.query('UPDATE sessions SET lsn = $lsn WHERE id = $id');
queries.update_session_color = db.query('UPDATE sessions SET color = $color WHERE id = $id');
queries.update_session_width = db.query('UPDATE sessions SET width = $width WHERE id = $id');
const res1 = db.query('SELECT COUNT(id) as count FROM desks').get();
const res2 = db.query('SELECT COUNT(id) as count FROM events').get();
const res3 = db.query('SELECT COUNT(id) as count FROM strokes').get();
const res4 = db.query('SELECT COUNT(id) as count FROM users').get();
const res5 = db.query('SELECT COUNT(id) as count FROM sessions').get();
const res6 = db.query('SELECT COUNT(id) as count FROM images').get();
queries.desks = db.query('SELECT id, sn FROM desks');
queries.events = db.query('SELECT * FROM events');
queries.sessions = db.query('SELECT id, lsn, user_id, desk_id FROM sessions');
queries.strokes = db.query('SELECT * FROM strokes');
queries.empty_desk = db.query('INSERT INTO desks (id, title, sn) VALUES (?1, ?2, 0)');
queries.desk_strokes = db.query('SELECT id, points FROM strokes WHERE desk_id = ?1');
queries.put_desk_stroke = db.query('INSERT INTO strokes (id, desk_id, points, width, color) VALUES (?1, ?2, ?3, ?4, ?5)');
queries.clear_desk_events = db.query('DELETE FROM events WHERE desk_id = ?1');
queries.set_desk_sn = db.query('UPDATE desks SET sn = ?1 WHERE id = ?2');
queries.save_session_lsn = db.query('UPDATE sessions SET lsn = ?1 WHERE id = ?2');
queries.create_session = db.query('INSERT INTO sessions (id, lsn, user_id, desk_id) VALUES (?1, 0, ?2, ?3)');
queries.create_user = db.query('INSERT INTO users (id, login) VALUES (?1, ?2)');
queries.put_event = db.query('INSERT INTO events (type, desk_id, user_id, stroke_id, image_id, x, y) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)');
queries.put_image = db.query('INSERT INTO images (id, desk_id) VALUES (?1, ?2)');
const res4 = db.query('SELECT COUNT(id) as count FROM sessions').get();
console.log(`Storing data in ${path}`);
console.log(`Entity count at startup:
${res1.count} desks
${res2.count} events
${res3.count} strokes
${res4.count} users
${res5.count} sessions
${res6.count} images`
);
${res4.count} sessions
`);
const stored_desks = get_desks();
const stored_events = get_events();
const stored_strokes = get_strokes();
const stored_sessions = get_sessions();
// Init in-memory view: merge strokes into events, set all sessions to closed
const stored_desks = db.query('SELECT * FROM desks').all();
const stored_events = db.query('SELECT * FROM events').all();
const stored_strokes = db.query('SELECT * FROM strokes').all();
const stored_sessions = db.query('SELECT * FROM sessions').all();
const stroke_dict = {};
for (const stroke of stored_strokes) {
stroke.points = new Uint16Array(stroke.points.buffer);
stroke.points = new Float32Array(stroke.points.buffer);
stroke_dict[stroke.id] = stroke;
}
@ -161,7 +118,6 @@ export function startup() { @@ -161,7 +118,6 @@ export function startup() {
event.width = stroke.width;
}
desks[event.desk_id].events.push(event);
}
@ -170,60 +126,4 @@ export function startup() { @@ -170,60 +126,4 @@ export function startup() {
session.ws = null;
sessions[session.id] = session;
}
}
export function get_strokes() {
return queries.strokes.all();
}
export function get_sessions() {
return queries.sessions.all();
}
export function get_desks() {
return queries.desks.all();
}
export function get_events() {
return queries.events.all();
}
export function get_desk_strokes(desk_id) {
return queries.desk_strokes.all(desk_id);
}
export function put_event(event) {
return queries.put_event.get(event.type, event.desk_id || 0, event.user_id || 0, event.stroke_id || 0, event.image_id || 0, event.x || 0, event.y || 0);
}
export function put_stroke(stroke_id, desk_id, points, width, color) {
return queries.put_desk_stroke.get(stroke_id, desk_id, new Uint8Array(points.buffer, points.byteOffset, points.byteLength), width, color);
}
export function clear_events(desk_id) {
return queries.clear_desk_events.get(desk_id);
}
export function create_desk(desk_id, title = 'untitled') {
return queries.empty_desk.get(desk_id, title);
}
export function save_desk_sn(desk_id, sn) {
return queries.set_desk_sn.get(sn, desk_id);
}
export function create_session(session) {
return queries.create_session.get(session.id, session.user_id, session.desk_id);
}
export function create_user(user) {
return queries.create_user.get(user.id, user.login);
}
export function save_session_lsn(session_id, lsn) {
return queries.save_session_lsn.get(lsn, session_id);
}
export function put_image(image_id, desk_id) {
return queries.put_image.get(image_id, desk_id);
}

21
server/texput.log

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
This is pdfTeX, Version 3.141592653-2.6-1.40.24 (TeX Live 2022/Debian) (preloaded format=pdflatex 2023.4.13) 16 APR 2023 21:20
entering extended mode
restricted \write18 enabled.
%&-line parsing enabled.
**
! Emergency stop.
<*>
End of file on the terminal!
Here is how much of TeX's memory you used:
3 strings out of 476091
111 string characters out of 5794081
1849330 words of memory out of 5000000
20488 multiletter control sequences out of 15000+600000
512287 words of font info for 32 fonts, out of 8000000 for 9000
1141 hyphenation exceptions out of 8191
0i,0n,0p,1b,6s stack positions out of 10000i,1000n,20000p,200000b,200000s
! ==> Fatal error occurred, no output PDF file produced!
Loading…
Cancel
Save