StylusCanvas
Draw with a pen by pressure and tilt, palm rejected, with undo, eraser, zoom, and an image or strokes out.
Demo
Draw with a pen. Two fingers zoom.
2 strokes.
Draw with a pen. Two fingers zoom.
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/stylus_canvas.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/stylus_canvas.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/stylus_canvas.qwe
let stylus_canvas_demo_start = [ { color: "ink", points: [[120, 620, 3], [260, 560, 5], [420, 600, 6], [560, 540, 5], [700, 590, 3]] }, { color: "#0b6bcb", points: [[200, 300, 4], [300, 240, 7], [420, 260, 9], [520, 330, 6]] },]component StylusCanvasDemoLive() { state note = "2 strokes." state out = "" state last = "" <div.demo-stack> <div.demo-tablet> <StylusCanvas strokes={stylus_canvas_demo_start} name="stylus-demo" label="Sketch" on_change={(s) => { note = `${len(s)} strokes.` }} on_export={(kind, data) => { out = `${kind}: ${slice(data, 0, 22)}` last = data }}/> </div> <p.demo-note id="stylus-canvas-note">{note}</p> <p.demo-note id="stylus-canvas-out" data-out={last}>{out}</p> <div id="stylus-canvas-bad" hidden="true"><StylusCanvas strokes={[null, 3, { points: "x" }, { points: [[1], "a"] }, { color: 9, points: [[1, 2, 3]] }]} name="stylus-bad"/></div> </div>}view StylusCanvasDemo() = <StylusCanvasDemoLive/>Source
ui/stylus_canvas.qwe
// StylusCanvas: a page to draw on with a pen, as on a tablet. The line follows the pen's// pressure and tilt, a palm or a finger resting on the glass draws nothing once a pen has// been seen, and two fingers zoom and move the page. Strokes are kept as data, so undo and// the eraser work on whole strokes and the page stays sharp at any zoom. What is drawn// comes out as a PNG or as the strokes themselves.//// import ui/stylus_canvas.qwe use StylusCanvas//// component Sketch() {// state saved = load_strokes()// <div style="height: 80dvh">// <StylusCanvas strokes={saved} on_change={(s) => { save_strokes(s) }}// on_export={(kind, data) => { keep(kind, data) }}/>// </div>// }//// width, height: the page's size in its own units (1200 by 800); strokes are in these// units, so they do not change when the tablet turns. strokes: drawing to start from,// each { color, points: [[x, y, w], ...] }, w being the line's width at that point; what// is not of that shape is left out. size: the pen's width at middling pressure (4).// colors: the pens to choose from, "ink" being the theme's text colour. palm: "auto"// lets a finger draw until a pen is seen, then only the pen does; "pen" keeps fingers off// from the start; "any" lets everything draw. A button changes this by hand, for a// tablet with no pen. Fingers that do not draw move the page when it is zoomed. paper:// the PNG's background ("#ffffff"). label: its name for screen readers. name: a hidden// input carries the strokes as JSON, so a plain <form> sends them. class.// on_change(strokes): inside one of your components, called after a stroke, an erasure,// an undo or a redo. on_export(kind, data): called with "png" and a data URL, or "data"// and JSON text; without it a link to save the file appears. In a phone app a web view may// ignore that link, so an app passes on_export.//// A pen's own eraser, on a pen that reports one, as a Surface pen's end does, erases while it// is used, with no tool chosen. Keys on the page: Ctrl or Cmd with Z undoes, with Shift// redoes; + and - zoom, 0 fits, the arrows move a zoomed page. Drawing itself has no// equal on a keyboard; on_change and the strokes prop are the way in for anyone who cannot draw.//// stylus_canvas_width and stylus_canvas_paint are what it draws with, for other components:// handwritten_note needs this file.import ui/theme.qwe use ui_clslet stylus_canvas_css = css`.ui-stylus-canvas { position: relative; display: flex; flex-direction: column; box-sizing: border-box; width: 100%; height: 100%; min-height: 320px; container-type: inline-size; color: var(--ui-fg); background: var(--ui-subtle); font: 14px/1.4 var(--ui-font); }.ui-stylus-canvas-body { display: flex; flex-direction: column; flex: 1; min-height: 0; }.ui-stylus-canvas-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; flex: none; padding: 6px max(8px, env(safe-area-inset-right)) 6px max(8px, env(safe-area-inset-left)); border-bottom: 1px solid var(--ui-border); background: var(--ui-surface); }.ui-stylus-canvas-group { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; }.ui-stylus-canvas-group + .ui-stylus-canvas-group { padding-left: 6px; border-left: 1px solid var(--ui-border); }.ui-stylus-canvas-btn { min-width: 44px; height: 44px; padding: 0 10px; border: 1px solid transparent; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-fg); font: 500 14px/1 var(--ui-font); cursor: pointer; }.ui-stylus-canvas-btn[aria-pressed="true"] { border-color: var(--ui-accent); background: var(--ui-accent-soft); }.ui-stylus-canvas-btn:disabled { color: var(--ui-muted); opacity: .55; cursor: default; }.ui-stylus-canvas-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-stylus-canvas-swatch { display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 1px solid transparent; border-radius: var(--ui-radius-sm); background: transparent; cursor: pointer; }.ui-stylus-canvas-swatch[aria-pressed="true"] { border-color: var(--ui-accent); }.ui-stylus-canvas-swatch:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-stylus-canvas-swatch span { width: 24px; height: 24px; border: 1px solid var(--ui-border); border-radius: 50%; }.ui-stylus-canvas-zoom { min-width: 48px; text-align: center; font-variant-numeric: tabular-nums; color: var(--ui-muted); }@media (hover: hover) { .ui-stylus-canvas-btn:not(:disabled):hover, .ui-stylus-canvas-swatch:hover { background: var(--ui-subtle); } }.ui-stylus-canvas-view { position: relative; flex: 1; min-height: 0; overflow: hidden; outline: none; touch-action: none; overscroll-behavior: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; cursor: crosshair; }.ui-stylus-canvas-view:focus-visible { box-shadow: inset 0 0 0 2px var(--ui-accent); }.ui-stylus-canvas-page { position: absolute; left: 0; top: 0; display: block; transform-origin: 0 0; background: var(--ui-surface); box-shadow: var(--ui-shadow); color: var(--ui-fg); }.ui-stylus-canvas-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; flex: none; min-height: 28px; padding: 4px max(10px, env(safe-area-inset-right)) calc(4px + env(safe-area-inset-bottom)) max(10px, env(safe-area-inset-left)); border-top: 1px solid var(--ui-border); background: var(--ui-surface); color: var(--ui-muted); font-size: 13px; }.ui-stylus-canvas-save { display: inline-flex; align-items: center; min-height: 44px; padding: 0 8px; color: var(--ui-accent); font-weight: 500; }/* With the width of a turned tablet the tools stand down the side, and the page keeps its height. */@container (min-width: 1000px) { .ui-stylus-canvas-body { flex-direction: row; } .ui-stylus-canvas-bar { flex-direction: column; flex-wrap: nowrap; width: 108px; overflow-y: auto; padding: 8px 6px; border-right: 1px solid var(--ui-border); border-bottom: 0; } .ui-stylus-canvas-group { justify-content: center; } .ui-stylus-canvas-group + .ui-stylus-canvas-group { padding: 6px 0 0; border-top: 1px solid var(--ui-border); border-left: 0; }}`fn stylus_canvas_is_num(v) -> bool { let t = type_of(v) t == "int" || t == "float"}// Drawing from outside: strokes with points, each point three numbers; the rest is left out.fn stylus_canvas_clean(strokes) -> list { if type_of(strokes) != "list" { return [] } let shaped = strokes | filter((s) => type_of(s) == "map" && type_of(get(s, "points")) == "list") let kept = shaped | map((s) => { let pts = s.points | filter((p) => type_of(p) == "list" && len(p) >= 3 && stylus_canvas_is_num(p[0]) && stylus_canvas_is_num(p[1]) && stylus_canvas_is_num(p[2])) { color: type_of(get(s, "color")) == "str" ? s.color : "ink", points: pts } }) kept | filter((s) => len(s.points) > 0)}// The line's width at a point. Pressure gives a quarter of the size at nothing to about// twice at full. A pen laid over past 30 degrees draws with its side, as a pencil does, up// to three times as wide at 70. A pointer that reports no pressure counts as pressing evenly.fn stylus_canvas_width(size: num, pressure: num, tilt_x: num, tilt_y: num) -> num { let p = pressure <= 0 ? 0.5 : min([pressure, 1]) let tilt = max([abs(tilt_x), abs(tilt_y)]) let side = tilt <= 30 ? 1 : 1 + (min([tilt, 70]) - 30) / 40 * 2 round(size * (0.25 + 1.5 * p) * side * 100) / 100}// Whose touch is this? The pen and a mouse draw. A touch while a pen is down, or as broad as// a palm, does nothing. A finger draws when it may, else it moves the page.fn stylus_canvas_role(kind: str, finger: bool, pen_down: bool, broad: bool) -> str { if kind != "touch" { return "draw" } if pen_down || broad { return "none" } finger ? "draw" : "pan"}// From a point to the segment a..b.fn stylus_canvas_gap(ax: num, ay: num, bx: num, by: num, x: num, y: num) -> num { let dx = bx - ax let dy = by - ay let across = dx * dx + dy * dy let at = across == 0 ? 0 : max([0, min([1, ((x - ax) * dx + (y - ay) * dy) / across])]) let ex = ax + at * dx - x let ey = ay + at * dy - y math.sqrt(ex * ex + ey * ey)}// Does a round eraser of radius r at x, y touch this stroke?fn stylus_canvas_hit(stroke, x: num, y: num, r: num) -> bool { let pts = stroke.points let n = len(pts) for i in range(0, n) { let a = pts[i] let b = pts[i + 1 < n ? i + 1 : i] if stylus_canvas_gap(a[0], a[1], b[0], b[1], x, y) <= r + a[2] / 2 { return true } } false}// The zoom's next stop up or down, between 1 (the page fits) and 6.fn stylus_canvas_step(zoom: num, by: int) -> num { let next = by > 0 ? zoom * 1.5 : zoom / 1.5 let near = round(next * 100) / 100 max([1, min([6, near])])}// Where the page's corner may be. A page narrower than the view stands in its middle;// a wider one keeps the view covered.fn stylus_canvas_pan(at: num, shown: num, room: num) -> num { if shown <= room { return round((room - shown) / 2) } max([room - shown, min([0, at])])}// A curve from the middle of a..b to the middle of b..c, bending at b and as wide as b says.fn stylus_canvas_bit(ctx, a, b, c) { ctx.lineWidth = b[2] ctx.beginPath() ctx.moveTo((a[0] + b[0]) / 2, (a[1] + b[1]) / 2) ctx.quadraticCurveTo(b[0], b[1], (b[0] + c[0]) / 2, (b[1] + c[1]) / 2) ctx.stroke()}// A stroke on a canvas whose units are the page's. ink: the colour "ink" stands for.fn stylus_canvas_paint(ctx, stroke, ink: str) { let pts = stroke.points let n = len(pts) let color = stroke.color == "ink" ? ink : stroke.color ctx.strokeStyle = color ctx.fillStyle = color ctx.lineCap = "round" ctx.lineJoin = "round" if n == 1 { ctx.beginPath() ctx.arc(pts[0][0], pts[0][1], pts[0][2] / 2, 0, 6.2832) ctx.fill() } else { for i in range(0, n) { let a = pts[i > 0 ? i - 1 : 0] let c = pts[i + 1 < n ? i + 1 : i] stylus_canvas_bit(ctx, a, pts[i], c) } }}component StylusCanvas(width: int = 1200, height: int = 800, strokes = [], size: num = 4, colors: list = ["ink", "#c62a2f", "#0b6bcb", "#18794e"], palm: str = "auto", paper: str = "#ffffff", label: str = "Drawing", name: str? = null, on_change = null, on_export = null, class: str? = null) { style global stylus_canvas_css state kept = stylus_canvas_clean(strokes) state past = [] state ahead = [] state tool = "pen" state color = colors[0] ?? "ink" state weight = size state finger = palm != "pen" state pen_seen = false state zoom = 1 state shown = 100 // the zoom as a percentage, for the bar state stage = null state page = null state ctx = null state win = null state saved = null // { href, file, text } once something is exported without a callback state said = "" // What the handlers keep to themselves. Nothing in the markup reads these, so setting // one on each move costs nothing. state g_fit = 1 state g_zoom = 1 state g_base = 1 // the zoom the canvas was last made for state g_px = 0 state g_py = 0 state g_res = 1 state g_mode = "" state g_pid = null state g_color = "" state g_done = [] // points of the stroke under way, in blocks, as adding to one long list copies it state g_tail = [] state g_a = null // its last two points state g_b = null state g_pen = false state g_pen_down = false state g_touches = [] state g_pinch = null state g_drag = null state g_before = null state g_rubbed = false let sheet_w = max([1, width]) let sheet_h = max([1, height]) // Through `win`, as js() would make these wait, and a move cannot be late. let ink = () => win.getComputedStyle(page).color let repaint = () => { ctx.setTransform(1, 0, 0, 1, 0, 0) ctx.clearRect(0, 0, page.width, page.height) ctx.setTransform(g_res, 0, 0, g_res, 0, 0) let color_now = ink() for s in kept { stylus_canvas_paint(ctx, s, color_now) } } // The page's place and size in the stage: all set by hand, as during a pinch. let place = () => { let z = g_fit * g_zoom let w = sheet_w * z let h = sheet_h * z g_px = stylus_canvas_pan(g_px, w, stage.clientWidth) g_py = stylus_canvas_pan(g_py, h, stage.clientHeight) page.style.width = `${round(w)}px` page.style.height = `${round(h)}px` page.style.transform = `translate(${g_px}px, ${g_py}px)` g_base = g_zoom } // Sharp at this zoom, as far as a canvas may be large. let sharpen = () => { let dpr = win.devicePixelRatio ?? 1 let res = min([dpr * g_fit * g_zoom, 4096 / sheet_w, 4096 / sheet_h]) g_res = res page.width = round(sheet_w * res) page.height = round(sheet_h * res) repaint() } let fit_view = () => { g_fit = min([stage.clientWidth / sheet_w, stage.clientHeight / sheet_h]) place() sharpen() } let set_zoom = (next: num, fx: num, fy: num) => { let by = next / g_zoom g_px = fx - (fx - g_px) * by g_py = fy - (fy - g_py) * by g_zoom = next zoom = next shown = round(next * 100) place() sharpen() } let middle = () => [stage.clientWidth / 2, stage.clientHeight / 2] let nudge = (by: int) => { let c = middle() set_zoom(stylus_canvas_step(g_zoom, by), c[0], c[1]) } let told = () => { on_change && on_change(kept) } let change = (next: list) => { past = slice(past, -99, len(past)) | push(kept) ahead = [] kept = next } let undo = () => { if len(past) > 0 { ahead = ahead | push(kept) kept = past[len(past) - 1] past = slice(past, 0, len(past) - 1) repaint() said = "Undone." told() } } let redo = () => { if len(ahead) > 0 { past = past | push(kept) kept = ahead[len(ahead) - 1] ahead = slice(ahead, 0, len(ahead) - 1) repaint() said = "Done again." told() } } let wipe = () => { if len(kept) > 0 { change([]) repaint() said = "Page cleared." told() } } let to_page = (x: num, y: num) => { let r = page.getBoundingClientRect() [(x - r.left) * sheet_w / r.width, (y - r.top) * sheet_h / r.height] } let rub = (x: num, y: num) => { let at = to_page(x, y) let left = kept | filter((s) => !stylus_canvas_hit(s, at[0], at[1], 12)) if len(left) != len(kept) { kept = left g_rubbed = true repaint() } } // A point of the stroke under way, drawn at once. let ink_point = (e) => { let at = to_page(e.clientX, e.clientY) let w = stylus_canvas_width(weight, g_pen ? e.pressure : 0.5, e.tiltX ?? 0, e.tiltY ?? 0) let p = [round(at[0] * 10) / 10, round(at[1] * 10) / 10, w] g_tail = g_tail | push(p) if len(g_tail) >= 200 { g_done = g_done | push(g_tail) g_tail = [] } if g_a != null { stylus_canvas_bit(ctx, g_a, g_b, p) } g_a = g_b g_b = p } let begin = (e, erasing: bool) => { stage.setPointerCapture(e.pointerId) g_pid = e.pointerId g_pen = e.pointerType == "pen" if erasing { g_mode = "erase" g_before = kept g_rubbed = false rub(e.clientX, e.clientY) } else { g_mode = "draw" g_color = color g_done = [] g_tail = [] g_a = null g_b = null let c = color == "ink" ? ink() : color ctx.strokeStyle = c ctx.fillStyle = c ctx.lineCap = "round" ctx.lineJoin = "round" ink_point(e) } } let finish = () => { let mode = g_mode g_mode = "" g_pid = null if mode == "draw" { change(kept | push({ color: g_color, points: flatten(g_done) + g_tail })) repaint() told() } if mode == "erase" && g_rubbed { let now = kept kept = g_before change(now) told() } } // A stroke that was not meant: a palm that came before the pen, or one the system took. let drop = () => { let mode = g_mode g_mode = "" g_pid = null if mode == "erase" && g_rubbed { kept = g_before } if mode != "" { repaint() } } let spread = () => { let t = g_touches if len(t) < 2 { return null } let a = t[0] let b = t[1] { gap: math.sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y)), x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 } } let touch_set = (e) => { let v = stage.getBoundingClientRect() g_touches = (g_touches | filter((t) => t.id != e.pointerId)) | push({ id: e.pointerId, x: e.clientX - v.left, y: e.clientY - v.top }) } let touch_count = () => len(g_touches) let down = (e) => { e.preventDefault() stage.focus({ preventScroll: true }) let kind = e.pointerType if kind == "pen" { if !pen_seen { pen_seen = true if palm == "auto" { finger = false } } g_pen_down = true // A palm can land before the pen does: what it drew goes, and the pen's stroke starts clean. if g_mode != "" { drop() } g_pinch = null g_drag = null } if kind == "touch" { let broad = max([e.width, e.height]) >= 60 let role = stylus_canvas_role(kind, finger, g_pen_down, broad) if role != "none" { touch_set(e) stage.setPointerCapture(e.pointerId) if touch_count() >= 2 { if g_mode != "" { drop() } let s = spread() g_drag = null g_pinch = { gap: s.gap, x: s.x, y: s.y, zoom: g_zoom, px: g_px, py: g_py } } if touch_count() == 1 { if role == "draw" { begin(e, tool == "eraser") } else { g_drag = { x: e.clientX, y: e.clientY, px: g_px, py: g_py } } } } } else if e.button == 0 || e.button == 5 { let erasing = tool == "eraser" || e.button == 5 || math.floor(e.buttons / 32) % 2 == 1 begin(e, erasing) } } let moved = (e) => { if e.pointerType == "touch" && find(g_touches, (t) => t.id == e.pointerId) != null { touch_set(e) } if g_mode != "" && e.pointerId == g_pid { if g_mode == "draw" { let many = e.getCoalescedEvents != null ? e.getCoalescedEvents() : [] if len(many) > 0 { for one in many { ink_point(one) } } else { ink_point(e) } } else { rub(e.clientX, e.clientY) } } else if g_pinch != null { let s = spread() if s != null { let by = s.gap / g_pinch.gap let z = max([1, min([6, g_pinch.zoom * by])]) let ratio = z / g_pinch.zoom let x = s.x - (g_pinch.x - g_pinch.px) * ratio let y = s.y - (g_pinch.y - g_pinch.py) * ratio g_zoom = z g_px = x g_py = y // The picture only, until the fingers lift: a canvas remade on each move would stutter. page.style.transform = `translate(${x}px, ${y}px) scale(${z / g_base})` } } else if g_drag != null { g_px = g_drag.px + e.clientX - g_drag.x g_py = g_drag.py + e.clientY - g_drag.y place() } } let lifted = (e, cancel: bool) => { if e.pointerType == "pen" { g_pen_down = false } if e.pointerType == "touch" { g_touches = g_touches | filter((t) => t.id != e.pointerId) } if g_mode != "" && e.pointerId == g_pid { if cancel { drop() } else { finish() } } if g_pinch != null && touch_count() < 2 { g_pinch = null zoom = g_zoom shown = round(g_zoom * 100) place() sharpen() g_drag = null } if touch_count() == 0 { g_drag = null } } let export_png = () => { let out = js("document.createElement", "canvas") out.width = sheet_w out.height = sheet_h let c = out.getContext("2d") c.fillStyle = paper c.fillRect(0, 0, sheet_w, sheet_h) for s in kept { stylus_canvas_paint(c, s, "#17161f") } let png = out.toDataURL("image/png") if on_export != null { on_export("png", png) } else { saved = { href: png, file: "drawing.png", text: "Save the image" } } said = `Image ready, ${sheet_w} by ${sheet_h}.` } let export_data = () => { let text = json.stringify({ width: sheet_w, height: sheet_h, strokes: kept }) if on_export != null { on_export("data", text) } else { saved = { href: `data:application/json,${url_encode(text)}`, file: "drawing.json", text: "Save the strokes" } } said = `Strokes ready, ${len(kept)} of them.` } let on_key = (e) => { let command = e.ctrlKey || e.metaKey if command && lower(e.key) == "z" { e.preventDefault() if e.shiftKey { redo() } else { undo() } } else if e.key == "+" || e.key == "=" { e.preventDefault() nudge(1) } else if e.key == "-" { e.preventDefault() nudge(-1) } else if e.key == "0" { e.preventDefault() let c = middle() set_zoom(1, c[0], c[1]) } else { let by = match e.key { "ArrowLeft" => [40, 0] "ArrowRight" => [-40, 0] "ArrowUp" => [0, 40] "ArrowDown" => [0, -40] _ => null } if by != null { e.preventDefault() g_px += by[0] g_py += by[1] place() } } } <div class={ui_cls(["ui-stylus-canvas", class])}> <div.ui-stylus-canvas-body> <div.ui-stylus-canvas-bar role="toolbar" aria-label="Drawing tools"> <div.ui-stylus-canvas-group> <button.ui-stylus-canvas-btn type="button" aria-pressed={tool == "pen" ? "true" : "false"} @click={() => { tool = "pen" }}>Pen</button> <button.ui-stylus-canvas-btn type="button" aria-pressed={tool == "eraser" ? "true" : "false"} @click={() => { tool = "eraser" }}>Eraser</button> <button.ui-stylus-canvas-btn type="button" aria-pressed={finger ? "true" : "false"} aria-label="Fingers draw" @click={() => { finger = !finger }}>Finger</button> </div> <div.ui-stylus-canvas-group> {for c in colors => <button.ui-stylus-canvas-swatch key={c} type="button" aria-label={c == "ink" ? "Ink" : `Colour ${c}`} aria-pressed={color == c ? "true" : "false"} @click={() => { color = c tool = "pen" }}><span style={`background: ${c == "ink" ? "var(--ui-fg)" : c}`}></span></button>} </div> <div.ui-stylus-canvas-group> <button.ui-stylus-canvas-btn type="button" disabled={len(past) == 0} @click={undo}>Undo</button> <button.ui-stylus-canvas-btn type="button" disabled={len(ahead) == 0} @click={redo}>Redo</button> <button.ui-stylus-canvas-btn type="button" disabled={len(kept) == 0} @click={wipe}>Clear</button> </div> <div.ui-stylus-canvas-group> <button.ui-stylus-canvas-btn type="button" aria-label="Zoom out" disabled={zoom <= 1} @click={() => { nudge(-1) }}>-</button> <span.ui-stylus-canvas-zoom>{shown}%</span> <button.ui-stylus-canvas-btn type="button" aria-label="Zoom in" disabled={zoom >= 6} @click={() => { nudge(1) }}>+</button> </div> <div.ui-stylus-canvas-group> <button.ui-stylus-canvas-btn type="button" @click={export_png}>Image</button> <button.ui-stylus-canvas-btn type="button" @click={export_data}>Data</button> </div> </div> <div.ui-stylus-canvas-view tabindex="0" role="group" aria-label={`${label}, ${len(kept)} strokes. Plus and minus zoom, the arrow keys move the page.`} @mount={(me) => { win = js("window") stage = me let sheet = me.querySelector("canvas") page = sheet ctx = sheet.getContext("2d") fit_view() // Turning the tablet refits the page: strokes are in the page's units, and the zoom is relative to it. let ro = js_new("ResizeObserver", () => { fit_view() }) ro.observe(me) let scheme = js("matchMedia", "(prefers-color-scheme: dark)") let shade = (_e) => { repaint() } scheme.addEventListener("change", shade) () => { ro.disconnect() scheme.removeEventListener("change", shade) } }} @pointerdown={down} @pointermove={moved} @pointerup={(e) => { lifted(e, false) }} @pointercancel={(e) => { lifted(e, true) }} @lostpointercapture={(e) => { if g_mode != "" && e.pointerId == g_pid { drop() } }} @contextmenu={(e) => { e.preventDefault() }} @wheel={(e) => { e.preventDefault() if e.ctrlKey { let v = stage.getBoundingClientRect() let z = max([1, min([6, g_zoom * math.exp(-e.deltaY / 200)])]) set_zoom(z, e.clientX - v.left, e.clientY - v.top) } else { g_px -= e.deltaX g_py -= e.deltaY place() } }} @keydown={on_key}> <canvas.ui-stylus-canvas-page width={sheet_w} height={sheet_h} aria-hidden="true"></canvas> </div> </div> <div.ui-stylus-canvas-foot> <span>{pen_seen ? (finger ? "Pen found. Fingers still draw." : "Pen found. Fingers move the page and zoom; a palm draws nothing.") : "Draw with a pen. Two fingers zoom."}</span> {saved != null && <a.ui-stylus-canvas-save href={saved.href} download={saved.file}>{saved.text}</a>} </div> {name != null && <input type="hidden" name={name} value={json.stringify(kept)}/>} <span.ui-sr role="status">{said}</span> </div>}Related
ActionSheetA list of actions rising from the bottom, with a destructive style and Cancel: swipe down or the phone's back gesture closes it; on a tablet, a card by what opened it.3 views · 9 downloadsAlertInboxAlerts by severity, swiped to acknowledge or snooze, with buttons and keys to the same, and a badge that stays right as snoozes run out.3 views · 8 downloadsAlphaRailListA long list with sticky letter headers and an A to Z rail scrubbed by finger, with a bubble showing the letter and a haptic per step.1 view · 6 downloadsApprovalQueueOne request at a time, swiped to approve or reject, with a required reason, undo and a count of what is left.3 views · 5 downloadsBookSpreadReader pages, one column upright and a two-page spread turned, by swipe or edge tap, the place kept by text and not page number.2 views · 7 downloadsCalendarSheetA month on a sheet that folds to a week by drag, with the day's agenda under it.3 views · 4 downloads
Thread
Thread 0 posts
On StylusCanvas: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.