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('pointerdown', (e) => pointerdown(e, state, context)); context.canvas.addEventListener('pointermove', (e) => pointermove(e, state, context)); context.canvas.addEventListener('pointerup', (e) => pointerup(e, state, context)); context.canvas.addEventListener('pointercancel', (e) => pointerup(e, state, context)); //context.canvas.addEventListener('pointerleave', (e) => pointerup(e, state, context)); context.canvas.addEventListener('pointerleave', (e) => pointerleave(e, state, context)); context.canvas.addEventListener('contextmenu', cancel); context.canvas.addEventListener('wheel', (e) => wheel(e, state, context)); context.canvas.addEventListener('drop', (e) => on_drop(e, state, context)); //context.canvas.addEventListener('dragover', (e) => pointermove(e, state, context)); debug_panel_init(state, context); } function debug_panel_init(state, context) { document.getElementById('debug-red').checked = state.debug.red; document.getElementById('do-snap').checked = state.snap !== null; document.getElementById('debug-print').checked = config.debug_print; document.getElementById('draw-bvh').checked = config.draw_bvh; document.getElementById('debug-red').addEventListener('change', (e) => { state.debug.red = e.target.checked; schedule_draw(state, context); }); document.getElementById('do-snap').addEventListener('change', (e) => { state.snap = e.target.checked ? 'grid' : null; }); document.getElementById('debug-print').addEventListener('change', (e) => { config.debug_print = e.target.checked; }); document.getElementById('draw-bvh').addEventListener('change', (e) => { config.draw_bvh = e.target.checked; schedule_draw(state, context); }); document.getElementById('debug-begin-benchmark').addEventListener('click', (e) => { state.canvas.zoom_level = config.benchmark.zoom_level; state.canvas.offset.x = config.benchmark.offset.x; state.canvas.offset.y = config.benchmark.offset.y; const dz = (state.canvas.zoom_level > 0 ? config.zoom_delta : -config.zoom_delta); state.canvas.target_zoom = Math.pow(1.0 + dz, Math.abs(state.canvas.zoom_level)) state.canvas.zoom = state.canvas.target_zoom; state.debug.benchmark_mode = true; const origin_x = state.canvas.offset.x; const origin_y = state.canvas.offset.y; const original_button_text = e.target.innerText; let frame = 0; state.debug.on_benchmark = () => { if (frame >= config.benchmark.frames) { state.debug.benchmark_mode = false; e.target.disabled = false; e.target.innerText = original_button_text; return false; } state.canvas.offset.x = origin_x + Math.round(100 * Math.cos(frame / 360)); state.canvas.offset.y = origin_y + Math.round(100 * Math.sin(frame / 360)); frame += 1; return true; } e.target.disabled = true; e.target.innerText = 'Benchmark in progress...'; schedule_draw(state, context); }); } function cancel(e) { e.preventDefault(); return false; } function zenmode() { document.querySelector('.pallete-wrapper').classList.toggle('hidden'); document.querySelector('.top-wrapper').classList.toggle('hidden'); } function enter_picker_mode(state, context) { if (state.tools.active === 'pencil') { // or other drawing tools document.querySelector('canvas').classList.add('picker'); document.querySelector('.picker-preview-outer').classList.remove('dhide'); document.querySelector('.brush-dom').classList.add('dhide'); state.colorpicking = true; const canvasp = screen_to_canvas(state, state.cursor); update_color_picker_color(state, context, canvasp); } } function exit_picker_mode(state) { if (state.colorpicking) { document.querySelector('canvas').classList.remove('picker'); document.querySelector('.picker-preview-outer').classList.add('dhide'); document.querySelector('.brush-dom').classList.remove('dhide'); state.colorpicking = false; } } 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 (config.debug_print) { console.debug('keydown', e.code); } const doing_things = (state.moving || state.drawing || state.erasing || state.colorpicking || state.imagemoving || state.imagescaling || state.linedrawing); if (e.code === 'Space' && !state.drawing) { state.spacedown = true; context.canvas.classList.add('movemode'); } else if (e.code === 'Tab') { e.preventDefault(); zenmode(); } else if (e.code === 'ControlLeft' || e.paddingcode === 'ControlRight') { enter_picker_mode(state, context); } else if (e.code === 'Slash') { document.querySelector('.debug-window').classList.toggle('dhide'); e.preventDefault(); } else if (e.code === 'KeyZ') { if (e.ctrlKey) { queue_event(state, undo_event(state)); } else { state.zoomdown = true; } } else if (e.code === 'KeyS') { if (!doing_things) { switch_tool(state, document.querySelector('.tool[data-tool="pointer"]')); } } else if (e.code === 'KeyD') { if (!doing_things) { switch_tool(state, document.querySelector('.tool[data-tool="pencil"]')); } } else if (e.code === 'KeyE') { if (!doing_things) { switch_tool(state, document.querySelector('.tool[data-tool="eraser"]')); } } else if (e.code === 'KeyR') { if (!doing_things) { switch_tool(state, document.querySelector('.tool[data-tool="ruler"]')); } } else if (e.code === 'Esc') { cancel_everything(state, context); } } function keyup(e, state, context) { if (config.debug_print) { console.debug('keydown', e.code); } if (e.code === 'Space' && state.spacedown) { state.spacedown = false; state.moving = false; context.canvas.classList.remove('movemode'); } else if (e.code === 'ControlLeft' || e.code === 'ControlRight') { exit_picker_mode(state); } else if (e.code === 'KeyZ') { state.zoomdown = false; } } function pointerdown(e, state, context) { // e.pointerType can have three values: "mouse", "pen", "touch" // https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent/pointerType if (e.pointerType === "touch") { touchstart(e, state, context); return; } const screenp = {'x': window.devicePixelRatio * e.clientX, 'y': window.devicePixelRatio * e.clientY}; const canvasp = screen_to_canvas(state, screenp); const raw_canvasp = {...canvasp}; if (state.snap === 'grid') { const step = grid_snap_step(state); canvasp.x = Math.round(canvasp.x / step) * step; canvasp.y = Math.round(canvasp.y / step) * step; } if (e.button !== 0 && e.button !== 1) { return; } if (state.zoomdown) { state.zooming = true; state.canvas.zoom_screenp = screenp; return; } if (state.colorpicking) { const color_u32 = color_to_u32(state.color_picked.substring(1)); state.players[state.me].color = color_u32; update_cursor(state); fire_event(state, color_event(color_u32)); return; } if (state.spacedown || e.button === 1) { state.moving = true; context.canvas.classList.add('moving'); if (e.button === 1) { context.canvas.classList.add('mousemoving'); } return; } if (state.tools.active === 'pencil') { canvasp.pressure = Math.ceil(e.pressure * 255); geometry_start_prestroke(state, state.me); geometry_add_prepoint(state, context, state.me, canvasp, e.pointerType === "pen"); state.drawing = true; state.active_image = null; schedule_draw(state, context); } else if (state.tools.active === 'ruler') { state.linedrawing = true; state.ruler_origin = canvasp; geometry_start_prestroke(state, state.me); } else if (state.tools.active === 'eraser') { state.erasing = true; } else if (state.tools.active === 'pointer') { state.imagescaling = false; state.imagemoving = false; if (state.active_image !== null) { // Check for resize first, because it supports // clicking slightly outside of the image const image = get_image(context, state.active_image); const corner = image_corner(state, image, raw_canvasp); if (corner !== null) { // Resize state.imagescaling = true; state.scaling_corner = corner; document.querySelector('canvas').classList.remove('resize-topleft'); document.querySelector('canvas').classList.remove('resize-topright'); if (corner === 0 || corner === 2) { document.querySelector('canvas').classList.add('resize-topleft'); } else if (corner === 1 || corner === 3) { document.querySelector('canvas').classList.add('resize-topright'); } } } // Only do picking logic if we haven't started imagescaling already if (!state.imagescaling) { const image = image_at(context, raw_canvasp.x, raw_canvasp.y); if (image !== null) { state.active_image = image.key; // Allow immediately moving state.imagemoving = true; state.image_actually_moved = false; image.raw_at.x = image.at.x; image.raw_at.y = image.at.y; } else { state.active_image = null; } } schedule_draw(state, context); } } function update_color_picker_color(state, context, canvasp) { const stroke_index = bvh_point(state, canvasp); let color_under_cursor = color_from_rgbdict(context.bgcolor); if (stroke_index != null) { color_under_cursor = color_from_u32(state.events[stroke_index].color); } document.querySelector('.picker-preview-inner').style.background = color_under_cursor; state.color_picked = color_under_cursor; } function pointermove(e, state, context) { if (e.pointerType === "touch") { touchmove(e, state, context); return; } e.preventDefault(); let do_draw = false; const screenp = {'x': window.devicePixelRatio * e.clientX, 'y': window.devicePixelRatio * e.clientY}; const canvasp = screen_to_canvas(state, screenp); const raw_canvasp = {...canvasp}; if (state.snap === 'grid') { const step = grid_snap_step(state); canvasp.x = Math.round(canvasp.x / step) * step; canvasp.y = Math.round(canvasp.y / step) * step; } if (state.tools.active === 'pointer') { if (state.active_image !== null) { const image = get_image(context, state.active_image); const corner = image_corner(state, image, raw_canvasp); if (state.scaling_corner === null) { document.querySelector('canvas').classList.remove('resize-topleft'); document.querySelector('canvas').classList.remove('resize-topright'); if (corner === 0 || corner === 2) { document.querySelector('canvas').classList.add('resize-topleft'); } else if (corner === 1 || corner === 3) { document.querySelector('canvas').classList.add('resize-topright'); } } } } if (state.me in state.players) { const me = state.players[state.me]; const width = Math.max(me.width * state.canvas.zoom, 2.0); const radius = Math.round(width / 2); const brush_screen = canvas_to_screen(state, canvasp); const brush_x = brush_screen.x - radius - 2; const brush_y = brush_screen.y - radius - 2; document.querySelector('.brush-dom').style.transform = `translate(${brush_x}px, ${brush_y}px)`; } if (state.me in state.players && dist_v2(state.players[state.me].cursor, canvasp) > 5) { state.players[state.me].cursor = canvasp; fire_event(state, movecursor_event(canvasp.x, canvasp.y)); } if (state.colorpicking) { update_color_picker_color(state, context, canvasp); } if (state.zooming) { const zooming_in = e.movementY > 0; const zooming_out = e.movementY < 0; let zoom_level = null; if (zooming_in) { zoom_level = state.canvas.zoom_level + 1 } else if (zooming_out) { zoom_level = state.canvas.zoom_level - 1; } else { return; } if (zoom_level < config.min_zoom_level || zoom_level > config.max_zoom_level) { return; } const dz = (zoom_level > 0 ? config.zoom_delta : -config.zoom_delta); state.canvas.zoom_level = zoom_level; state.canvas.target_zoom = Math.pow(1.0 + dz, Math.abs(zoom_level)) do_draw = true; } if (state.moving) { state.canvas.offset.x += e.movementX; state.canvas.offset.y += e.movementY; // If we are moving our canvas, we don't need to follow anymore if (state.following_player !== null) { toggle_follow_player(state, state.following_player); } fire_event(state, movecanvas_event(state)); draw_html(state, context); do_draw = true; } if (state.imagescaling) { const image = get_image(context, state.active_image); scale_image(image, state.scaling_corner, canvasp); do_draw = true; } if (state.imagemoving) { const image = get_image(context, state.active_image); if (image !== null) { const dx = e.movementX / state.canvas.zoom; const dy = e.movementY / state.canvas.zoom; image.raw_at.x += dx; image.raw_at.y += dy; if (state.snap === 'grid') { const step = grid_snap_step(state); image.at.x = Math.round(image.raw_at.x / step) * step; image.at.y = Math.round(image.raw_at.y / step) * step; } else if (state.snap === null) { image.at.x = image.raw_at.x; image.at.y = image.raw_at.y; } state.image_actually_moved = true; do_draw = true; } } if (state.drawing) { canvasp.pressure = Math.ceil(e.pressure * 255); geometry_add_prepoint(state, context, state.me, canvasp, e.pointerType === "pen"); fire_event(state, predraw_event(canvasp.x, canvasp.y)); do_draw = true; } if (state.erasing) { const me = state.players[state.me]; const radius = Math.round(me.width / 2); const last_canvasp = screen_to_canvas(state, state.cursor); const cursor_bbox = { 'x1': Math.min(canvasp.x, last_canvasp.x) - radius, 'y1': Math.min(canvasp.y, last_canvasp.y) - radius, 'x2': Math.max(canvasp.x, last_canvasp.x) + radius, 'y2': Math.max(canvasp.y, last_canvasp.y) + radius, }; tv_ensure(state.erase_candidates, round_to_pow2(state.stroke_count, 4096)); tv_clear(state.erase_candidates); // Rough pass, not all of these might actually need to be erased bvh_intersect_quad(state, state.bvh, cursor_bbox, state.erase_candidates); // Fine pass, actually run expensive capsule vs capsule intersection tests for (let i = 0; i < state.erase_candidates.size; ++i) { const stroke_id = state.erase_candidates.data[i]; const stroke = state.events[stroke_id]; if (!stroke.deleted && stroke_intersects_capsule(state, stroke, last_canvasp, canvasp, radius)) { stroke.deleted = true; bvh_delete_stroke(state, stroke); queue_event(state, eraser_event(stroke_id)); do_draw = true; } } } if (state.linedrawing) { const p1 = {'x': state.ruler_origin.x, 'y': state.ruler_origin.y, 'pressure': 128}; const p2 = {'x': canvasp.x, 'y': canvasp.y, 'pressure': 128}; if (state.online) { const me = state.players[state.me]; const prestroke = me.strokes[me.strokes.length - 1]; // TODO: might as well be me.strokes[0] ? prestroke.points.length = 2; prestroke.points[0] = p1; prestroke.points[1] = p2; recompute_dynamic_data(state, context); do_draw = true; } } if (do_draw) { schedule_draw(state, context); } state.cursor = screenp; return false; } function pointerup(e, state, context) { if (e.pointerType === "touch") { touchend(e, state, context); return; } const screenp = {'x': window.devicePixelRatio * e.clientX, 'y': window.devicePixelRatio * e.clientY}; const canvasp = screen_to_canvas(state, screenp); const raw_canvasp = {...canvasp}; if (state.snap === 'grid') { const step = grid_snap_step(state); canvasp.x = Math.round(canvasp.x / step) * step; canvasp.y = Math.round(canvasp.y / step) * step; } if (e.button !== 0 && e.button !== 1) { return; } if (state.zooming) { state.zooming = false; return; } if (state.imagemoving) { state.imagemoving = false; if (state.image_actually_moved) { state.image_actually_moved = false; const image = get_image(context, state.active_image); image.raw_at.x = image.at.x; image.raw_at.y = image.at.y; queue_event(state, image_move_event(state.active_image, image.at.x, image.at.y)); schedule_draw(state, context); } return; } if (state.imagescaling) { queue_event(state, image_scale_event(state.active_image, state.scaling_corner, canvasp.x, canvasp.y)); state.imagescaling = false; state.scaling_corner = null; return; } if (state.moving || e.button === 1) { state.moving = false; context.canvas.classList.remove('moving'); if (e.button === 1) { context.canvas.classList.remove('mousemoving'); } return; } if (state.drawing) { const stroke = geometry_prepare_stroke(state); if (stroke) { // TODO: be able to add a baked stroke locally queue_event(state, stroke_event(state)); schedule_draw(state, context); } fire_event(state, lift_event()); state.drawing = false; return; } if (state.erasing) { state.erasing = false; return; } if (state.linedrawing) { state.linedrawing = false; queue_event(state, stroke_event(state)); schedule_draw(state, context); return; } } function pointerleave(e, state, context) { if (state.moving) { state.moving = false; context.canvas.classList.remove('movemode'); } //exit_picker_mode(state); // something else? } function update_cursor(state) { if (!(state.me in state.players)) { // we not ready yet return; } const me = state.players[state.me]; const width = Math.max(me.width * state.canvas.zoom, 2.0); const radius = Math.round(width / 2); let svg; if (state.tools.active === 'pencil' || state.tools.active === 'ruler') { const current_color = color_from_u32(me.color); const stroke = (me.color === 0xFFFFFF ? 'black' : 'white'); svg = ` `.replaceAll('\n', ' '); } else if (state.tools.active === 'eraser') { const current_color = '#ffffff'; const stroke = '#000000'; svg = ` `.replaceAll('\n', ' '); } document.querySelector('.brush-dom').innerHTML = svg; const brush_x = state.cursor.x - width / 2 - 2; const brush_y = state.cursor.y - width / 2 - 2; document.querySelector('.brush-dom').style.transform = `translate(${Math.round(brush_x)}px, ${Math.round(brush_y)}px)`; } 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 zooming_in = e.deltaY < 0; const zoom_level = zooming_in ? state.canvas.zoom_level + 2 : state.canvas.zoom_level - 2; if (zoom_level < config.min_zoom_level || zoom_level > config.max_zoom_level) { return; } const dz = (zoom_level > 0 ? config.zoom_delta : -config.zoom_delta); state.canvas.zoom_level = zoom_level; state.canvas.target_zoom = Math.pow(1.0 + dz, Math.abs(zoom_level)) state.canvas.zoom_screenp = screenp; // If we are moving our canvas, we don't need to follow anymore if (state.following_player !== null) { toggle_follow_player(state, state.following_player); } fire_event(state, zoomcanvas_event(state, canvasp.x, canvasp.y)); schedule_draw(state, context); } function touchstart(e, state, context) { // First finger(s) down? const screenp = {'x': window.devicePixelRatio * e.clientX, 'y': window.devicePixelRatio * e.clientY}; if (state.touch.events.length === 0) { // We give a bit of time to add a second finger state.touch.buffered.length = 0; state.touch.events.push({ 'id': e.pointerId, 'x': screenp.x, 'y': screenp.y, }); state.touch.drawing = true; const canvasp = screen_to_canvas(state, screenp); canvasp.pressure = 128; // TODO: check out touch devices' e.pressure geometry_start_prestroke(state, state.me); geometry_add_prepoint(state, context, state.me, canvasp, e.pointerType === "pen"); state.touch.waiting_for_second_finger = true; setTimeout(() => { state.touch.waiting_for_second_finger = false; }, config.second_finger_timeout); } else if (state.touch.waiting_for_second_finger) { state.touch.events.push({ 'id': e.pointerId, 'x': screenp.x, 'y': screenp.y, }); geometry_clear_newest_prestroke(state, context, state.me); fire_event(state, clear_event(state)); // Tell others to hide predraws of this stroke state.touch.drawing = false; state.touch.moving = true; } schedule_draw(state, context); } function touchmove(e, state, context) { const changed_touch = state.touch.events.find(ev => ev.id === e.pointerId); if (!changed_touch) { return; } if (state.touch.drawing) { const screenp = {'x': window.devicePixelRatio * e.clientX, 'y': window.devicePixelRatio * e.clientY}; const canvasp = screen_to_canvas(state, screenp); canvasp.pressure = 128; // TODO: check out touch devices' e.pressure geometry_add_prepoint(state, context, state.me, canvasp, false); fire_event(state, predraw_event(canvasp.x, canvasp.y)); schedule_draw(state, context); return; } if (state.touch.moving) { if (state.touch.events.length === 1) { // Simplified move with one finger const old_f1 = {...state.touch.events[0]}; state.touch.events[0].x = e.clientX * window.devicePixelRatio; state.touch.events[0].y = e.clientY * window.devicePixelRatio; const new_f1 = state.touch.events[0]; const dx = new_f1.x - old_f1.x; const dy = new_f1.y - old_f1.y; state.canvas.offset.x += dx; state.canvas.offset.y += dy; } else { const old_f1 = {...state.touch.events[0]}; const old_f2 = {...state.touch.events[1]}; const old_finger_midpoint = mid_v2(old_f1, old_f2); const old_finger_distance = dist_v2(old_f1, old_f2); const changed_touch = state.touch.events.find(ev => ev.id === e.pointerId); changed_touch.x = e.clientX * window.devicePixelRatio; changed_touch.y = e.clientY * window.devicePixelRatio; const new_f1 = state.touch.events[0]; const new_f2 = state.touch.events[1]; const new_finger_midpoint = mid_v2(new_f1, new_f2); const new_finger_distance = dist_v2(new_f1, new_f2); const new_finger_midpoint_canvas = mid_v2( screen_to_canvas(state, new_f1), screen_to_canvas(state, new_f2) ); // Ideally, here we would solve for both finger positions // remaining the same in canvas coordinates. However, we don't // support canvas rotations, and solving this without rotations // is impossible (imagine two fingers rotating around a common point). // Instead, we treat the movement as translation + scale. The translate // offset is based on the screen-space offset of the midpoint between // the fingers. The scale is based on the distance between the fingers. // QUITE POSSIBLY, THIS COULD BE IMPROVED 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; const scale_by = new_finger_distance / old_finger_distance; const zc = old_finger_midpoint; state.canvas.zoom = state.canvas.target_zoom = old_zoom * scale_by; state.canvas.offset.x = zc.x - (zc.x - state.canvas.offset.x) * state.canvas.zoom / old_zoom; state.canvas.offset.y = zc.y - (zc.y - state.canvas.offset.y) * state.canvas.zoom / old_zoom; } // If we are moving our canvas, we don't need to follow anymore if (state.following_player !== null) { toggle_follow_player(state, state.following_player); } fire_event(state, movecanvas_event(state)); draw_html(state, context); schedule_draw(state, context); return; } } function touchend(e, state, context) { const changed_touch_idx = state.touch.events.findIndex(ev => ev.id === e.pointerId); if (changed_touch_idx === -1) { // This can happen, becase we don't keep track of touches // after two fingers are already active return; } state.touch.events.splice(changed_touch_idx, 1); if (state.touch.drawing) { const stroke = geometry_prepare_stroke(state); if (stroke) { queue_event(state, stroke_event(state)); schedule_draw(state, context); } fire_event(state, lift_event()); state.touch.drawing = false; return; } if (state.touch.moving) { if (state.touch.events.length === 0) { // Only allow drawing again when ALL fingers have been lifted state.touch.moving = false; waiting_for_second_finger = false; } return; } } 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); return false; } function cancel_everything(state, context) { }