use z/x/c instead of shift/command/option
Hunter
Thursday, July 16th, 2026 at 5:24 PM
711 }
712
713 async function importFile(file) {
- if (dirty && !confirm('Importing will discard the current animation. Continue?')) return;
714 + if (dirty && !confirm('Importing will discard the current painting. Continue?')) return;
715 if (playing) stopPlayback();
716 // ImageDecoder gives us every frame of an animated gif; fall back to
717 // single-image import where unsupported
765 // modifier-drag: shift/cmd/RGB held -> vertical motion from the anchor
766 // drives brush size / roundness / color channels. drag up (dy negative)
767 // -> increase. when a value hits its bound and the drag continues past,
- // re-anchor so reversing direction responds immediately.
- if (dragMode !== null) {
- const dyPx = (dragAnchorY - e.clientY) * dpr;
- if (dragMode === 'brush') {
- const targetSteps = Math.trunc(dyPx / BRUSH_DRAG_PX_PER_STEP);
- const target = dragBrushStart + targetSteps;
- const clamped = clamp(target, 1, 256);
- brush = clamped;
- if (target !== clamped) {
- dragBrushStart = clamped;
- dragAnchorY = e.clientY;
- }
- } else if (dragMode === 'roundness') {
- const target = dragRoundnessStart - dyPx / ROUNDNESS_DRAG_FULL_PX;
- const clamped = clamp(target, 0, 1);
- roundness = clamped;
- if (target !== clamped) {
- dragRoundnessStart = clamped;
- dragAnchorY = e.clientY;
- }
- } else if (dragMode === 'color') {
- const delta = (dyPx / COLOR_DRAG_FULL_PX) * 255;
- let nr = r, ng = g, nb = b;
- let overshoot = 0;
- if (keys['r']) {
- const t = dragRStart + delta;
- nr = clamp(t, 0, 255);
- if (t !== nr) overshoot = Math.max(overshoot, Math.abs(t - nr));
- }
- if (keys['g']) {
- const t = dragGStart + delta;
- ng = clamp(t, 0, 255);
- if (t !== ng) overshoot = Math.max(overshoot, Math.abs(t - ng));
- }
- if (keys['b']) {
- const t = dragBStart + delta;
- nb = clamp(t, 0, 255);
- if (t !== nb) overshoot = Math.max(overshoot, Math.abs(t - nb));
- }
- setColor(Math.round(nr), Math.round(ng), Math.round(nb));
- if (overshoot > 0) {
- dragRStart = nr; dragGStart = ng; dragBStart = nb;
- dragAnchorY = e.clientY;
- }
768 + // re-anchor so reversing direction responds immediately. each drag is
769 + // independent so any combination can run at once.
770 + if (dragBrush) {
771 + const dyPx = (dragBrush.anchorY - e.clientY) * dpr;
772 + const target = dragBrush.start + Math.trunc(dyPx / BRUSH_DRAG_PX_PER_STEP);
773 + const clamped = clamp(target, 1, 256);
774 + brush = clamped;
775 + if (target !== clamped) {
776 + dragBrush.start = clamped;
777 + dragBrush.anchorY = e.clientY;
778 + }
779 + }
780 + if (dragRoundness) {
781 + const dyPx = (dragRoundness.anchorY - e.clientY) * dpr;
782 + const target = dragRoundness.start - dyPx / ROUNDNESS_DRAG_FULL_PX;
783 + const clamped = clamp(target, 0, 1);
784 + roundness = clamped;
785 + if (target !== clamped) {
786 + dragRoundness.start = clamped;
787 + dragRoundness.anchorY = e.clientY;
788 + }
789 + }
790 + if (dragColor) {
791 + const dyPx = (dragColor.anchorY - e.clientY) * dpr;
792 + const delta = (dyPx / COLOR_DRAG_FULL_PX) * 255;
793 + let nr = r, ng = g, nb = b;
794 + let overshoot = 0;
795 + if (keys['r']) {
796 + const t = dragColor.r0 + delta;
797 + nr = clamp(t, 0, 255);
798 + if (t !== nr) overshoot = Math.max(overshoot, Math.abs(t - nr));
799 + }
800 + if (keys['g']) {
801 + const t = dragColor.g0 + delta;
802 + ng = clamp(t, 0, 255);
803 + if (t !== ng) overshoot = Math.max(overshoot, Math.abs(t - ng));
804 + }
805 + if (keys['b']) {
806 + const t = dragColor.b0 + delta;
807 + nb = clamp(t, 0, 255);
808 + if (t !== nb) overshoot = Math.max(overshoot, Math.abs(t - nb));
809 + }
810 + setColor(Math.round(nr), Math.round(ng), Math.round(nb));
811 + if (overshoot > 0) {
812 + dragColor.r0 = nr; dragColor.g0 = ng; dragColor.b0 = nb;
813 + dragColor.anchorY = e.clientY;
814 }
815 }
816
838 window.addEventListener('mousedown', (e) => {
839 if (e.button !== 0) return;
840 e.preventDefault();
- if (e.altKey) {
841 + if (keys['c']) {
842 picking = true;
843 pickAt(curX, curY);
844 requestDraw();
861 });
862
863 function startDragMode(mode) {
- if (dragMode === mode) return;
- dragMode = mode;
- dragAnchorY = curClientY !== null ? curClientY : 0;
- dragBrushStart = brush;
- dragRoundnessStart = roundness;
- dragRStart = r; dragGStart = g; dragBStart = b;
864 + const anchorY = curClientY !== null ? curClientY : 0;
865 + if (mode === 'brush' && !dragBrush) dragBrush = { anchorY, start: brush };
866 + else if (mode === 'roundness' && !dragRoundness) dragRoundness = { anchorY, start: roundness };
867 + else if (mode === 'color' && !dragColor) dragColor = { anchorY, r0: r, g0: g, b0: b };
868 }
869 function endDragMode(mode) {
- if (dragMode !== mode) return;
- dragMode = null;
870 + if (mode === 'brush') dragBrush = null;
871 + else if (mode === 'roundness') dragRoundness = null;
872 + else if (mode === 'color') dragColor = null;
873 }
874
875 window.addEventListener('keydown', (e) => {
900 const k = e.key.toLowerCase();
901 const wasDown = keys[k];
902 keys[k] = true;
- // keep Firefox from focusing the menu bar on a bare Alt tap
- if (e.key === 'Alt') e.preventDefault();
- // z/x are drag modifiers; block browser undo/cut when cmd is also held
- if (k === 'z' || k === 'x') e.preventDefault();
- if (e.key === 'Meta' || k === 'x') { metaSeq = ++seqCounter; startDragMode('roundness'); }
- else if (e.key === 'Shift' || k === 'z') { shiftSeq = ++seqCounter; startDragMode('brush'); }
- else if (!wasDown && (k === 'r' || k === 'g' || k === 'b')) startDragMode('color');
903 + if (!e.metaKey && !e.ctrlKey) {
904 + if (k === 'z' || k === 'x' || k === 'c' || k === 'r' || k === 'g' || k === 'b') e.preventDefault();
905 + if (k === 'x') startDragMode('roundness');
906 + else if (k === 'z') startDragMode('brush');
907 + else if (!wasDown && (k === 'r' || k === 'g' || k === 'b')) startDragMode('color');
908 + }
909 });
910 window.addEventListener('keyup', (e) => {
911 const k = e.key.toLowerCase();
912 keys[k] = false;
- if (e.key === 'Alt') e.preventDefault();
- if (e.key === 'Meta' || k === 'x') {
- if (!keys['meta'] && !keys['x']) { metaSeq = 0; endDragMode('roundness'); }
- }
- else if (e.key === 'Shift' || k === 'z') {
- if (!keys['shift'] && !keys['z']) { shiftSeq = 0; endDragMode('brush'); }
- }
913 + if (k === 'x') endDragMode('roundness');
914 + else if (k === 'z') endDragMode('brush');
915 else if (k === 'r' || k === 'g' || k === 'b') {
916 if (!keys['r'] && !keys['g'] && !keys['b']) endDragMode('color');
- else {
917 + else if (dragColor) {
918 // still holding at least one rgb key - re-anchor from current
919 // values so the remaining keys don't jump based on released key's history
- dragAnchorY = curClientY !== null ? curClientY : dragAnchorY;
- dragRStart = r; dragGStart = g; dragBStart = b;
920 + dragColor.anchorY = curClientY !== null ? curClientY : dragColor.anchorY;
921 + dragColor.r0 = r; dragColor.g0 = g; dragColor.b0 = b;
922 }
923 }
- // releasing alt mid-pick-drag -> seamlessly switch to painting
- if ((e.key === 'Alt' || e.key === 'AltGraph') && picking) {
924 + // releasing c mid-pick-drag -> seamlessly switch to painting
925 + if (k === 'c' && picking) {
926 picking = false;
927 painting = true;
928 lastPaintX = curX;
933 });
934 window.addEventListener('blur', () => {
935 for (const k in keys) keys[k] = false;
- metaSeq = 0; shiftSeq = 0;
936 + dragBrush = null; dragRoundness = null; dragColor = null;
937 painting = false;
938 picking = false;
939 lastPaintX = null;
946 // subsequent events in the same burst stay in that mode until the wheel
947 // goes idle or the controlling modifier set changes. this keeps trackpad
948 // momentum from leaking into pan after a modifier release, while still
- // letting the user swap between modifier-driven modes (shift <-> cmd)
- // mid-flick without stale state. between shift and cmd, the one pressed
- // most recently wins so they never apply simultaneously.
949 + // letting the user swap between modes mid-flick without stale state.
950 let wheelMode = null;
951 let wheelIdleTimer = null;
952 let panning = false;
- let metaSeq = 0, shiftSeq = 0, seqCounter = 0;
953
954 const BRUSH_DRAG_PX_PER_STEP = 4; // device px per +/- 1 brush px
955 const ROUNDNESS_DRAG_FULL_PX = 200; // device px to traverse 0 to 1
956 const COLOR_DRAG_FULL_PX = 256; // device px to traverse 0 to 255
- let dragMode = null; // 'brush' | 'roundness' | 'color' | null
- let dragAnchorY = 0;
- let dragBrushStart = 1;
- let dragRoundnessStart = 0;
- let dragRStart = 0, dragGStart = 0, dragBStart = 0;
957 + // each drag carries its own anchor so any combination can run at once
958 + let dragBrush = null; // {anchorY, start}
959 + let dragRoundness = null; // {anchorY, start}
960 + let dragColor = null; // {anchorY, r0, g0, b0}
961 function touchWheelGesture() {
962 if (wheelIdleTimer) clearTimeout(wheelIdleTimer);
963 wheelIdleTimer = setTimeout(() => {
20 ### mark-making
21
22 - click and drag to make marks
-- hold `shift` (or `Z`) and move your cursor up/down to resize the brush
-- hold `⌘` (or `X`) and move your cursor up/down to change the shape of the brush
23 +- hold `Z` and move your cursor up/down to resize the brush
24 +- hold `X` and move your cursor up/down to change the shape of the brush
25 - hold any of the , , and/or  keys and move your cursor up/down to change the , , and/or  of the active color
-- hold `option` (or `alt`) and click anywhere to pick up the color underneath your brush
26 +- hold any combination of the above to change size, shape, and/or color at the same time
27 +- hold `C` and click anywhere to pick up the color underneath your brush
28 - pinch with two fingers to zoom in/out
29 - drag with two fingers to pan around the canvas
30