scripts/counter.js
scripts/counter.jsBrowse 4 files
1,273 tokens
4,251 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1// Interactive Counter — a tldraw offline document script.2//3// HOW TO RUN IT ON YOUR MACHINE:4// 1. Open tldraw offline, create or open a document.5// 2. Develop → Reveal Script… (creates script/main.js + a workspace folder)6// 3. Replace the contents of script/main.js with THIS file, and save.7// 4. The app reruns the script automatically. You'll see a "Counter" panel8// with MINUS / RESET / PLUS buttons — click them; the number updates live.9// 5. File → Save to persist the script into the .tldraw file. Now the file10// *is* a little app: reopen it anywhere and the buttons still work.11//12// This is the document-script contract (from the app's script-context.d.ts):13// export default function ({ editor, helpers, signal }) { ... }14// editor — the live tldraw Editor15// helpers — editor-bound conveniences (richTextToPlainText, etc.)16// signal — an AbortSignal fired before the script reruns / on close;17// register ALL cleanup on it so re-saving never leaks listeners.18 19import { createShapeId, toRichText } from 'tldraw'20 21export default function ({ editor, helpers, signal }) {22 // Stable ids => idempotent: re-running reuses shapes instead of duplicating.23 const IDS = {24 title: createShapeId('counter-title'),25 display: createShapeId('counter-display'),26 dec: createShapeId('counter-btn-dec'),27 reset: createShapeId('counter-btn-reset'),28 inc: createShapeId('counter-btn-inc'),29 }30 31 // Create-if-missing helper (leaves user edits intact on rerun).32 function ensure(partial) {33 if (editor.getShape(partial.id)) return34 editor.createShape(partial)35 }36 37 editor.run(() => {38 ensure({39 id: IDS.title, type: 'text', x: 40, y: 20,40 props: { richText: toRichText('Counter'), size: 'xl', font: 'draw', color: 'black' },41 })42 ensure({43 id: IDS.display, type: 'geo', x: 40, y: 80,44 props: { geo: 'rectangle', w: 360, h: 160, color: 'black', fill: 'none', richText: toRichText('0'), size: 'xl' },45 meta: { ui: 'display', count: 0 },46 })47 // Button labels are load-bearing — the click handler finds buttons by text.48 ensure({49 id: IDS.dec, type: 'geo', x: 40, y: 270,50 props: { geo: 'rectangle', w: 100, h: 80, color: 'red', fill: 'solid', richText: toRichText('MINUS'), size: 'l' },51 meta: { ui: 'button', action: 'MINUS' },52 })53 ensure({54 id: IDS.reset, type: 'geo', x: 170, y: 270,55 props: { geo: 'rectangle', w: 100, h: 80, color: 'grey', fill: 'solid', richText: toRichText('RESET'), size: 'l' },56 meta: { ui: 'button', action: 'RESET' },57 })58 ensure({59 id: IDS.inc, type: 'geo', x: 300, y: 270,60 props: { geo: 'rectangle', w: 100, h: 80, color: 'green', fill: 'solid', richText: toRichText('PLUS'), size: 'l' },61 meta: { ui: 'button', action: 'PLUS' },62 })63 })64 65 const STEP = { MINUS: -1, PLUS: +1 }66 67 function displayShape() {68 return editor.getCurrentPageShapes().find((s) => s.meta && s.meta.ui === 'display')69 }70 function setCount(n) {71 const d = displayShape()72 editor.run(73 () =>74 editor.updateShape({75 id: d.id, type: 'geo',76 props: { richText: toRichText(String(n)) },77 meta: { ...d.meta, count: n },78 }),79 { history: 'ignore' } // keep script writes out of the user's undo stack80 )81 }82 function runAction(label) {83 const d = displayShape()84 const cur = d.meta && typeof d.meta.count === 'number' ? d.meta.count : 085 if (label === 'RESET') setCount(0)86 else if (label in STEP) setCount(cur + STEP[label])87 }88 89 function bounds(s) {90 return { x: s.x, y: s.y, w: s.props.w ?? 0, h: s.props.h ?? 0 }91 }92 function inside(b, p) {93 return p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h94 }95 96 function onEvent(info) {97 if (!info || info.name !== 'pointer_down') return98 let p = null99 try {100 if (info.point && editor.screenToPage) p = editor.screenToPage(info.point)101 } catch {}102 p = p ?? editor.inputs?.currentPagePoint103 if (!p) return104 const hit = editor105 .getCurrentPageShapes()106 .find((s) => s.meta && s.meta.ui === 'button' && inside(bounds(s), p))107 if (hit) runAction(hit.meta.action)108 }109 110 editor.on('event', onEvent)111 signal.addEventListener('abort', () => editor.off('event', onEvent)) // required cleanup112 113 editor.zoomToFit({ animation: { duration: 200 } })114}115