1// Pointer interactions for drawing and moving 2function roundTo(n,g){ const gg = Math.max(1,g||1); return Math.round(n/gg)*gg; } 3 4function svgPointClient(svg, clientX, clientY){ 5 const pt = svg.createSVGPoint(); pt.x = clientX; pt.y = clientY; 6 const ctm = svg.getScreenCTM(); if (!ctm) return { x: pt.x, y: pt.y }; 7 const p = pt.matrixTransform(ctm.inverse()); return { x: p.x, y: p.y }; 8} 9 10// Guides (local to container) 11function showGuides(body, vX=null, hY=null){ 12 let v = body.querySelector(":scope>.align-guide-v"); 13 let h = body.querySelector(":scope>.align-guide-h"); 14 if(!v){ v = document.createElement("div"); v.className="align-guide align-guide-v"; body.appendChild(v); } 15 if(!h){ h = document.createElement("div"); h.className="align-guide align-guide-h"; body.appendChild(h); } 16 v.style.display = vX==null ? "none":"block"; 17 h.style.display = hY==null ? "none":"block"; 18 if(vX!=null) v.style.left = `${Math.round(vX)}px`; 19 if(hY!=null) h.style.top = `${Math.round(hY)}px`; 20} 21function hideGuides(body){ body?.querySelectorAll(":scope>.align-guide").forEach(n=>n.remove()); } 22 23function collectBBoxCenters(svg, except){ 24 return Array.from(svg.querySelectorAll(".elt-wb")) 25 .filter(n=>n!==except).map(n=>{ 26 const r = n.getBBox?.(); if(!r) return null; 27 return { l:r.x, r:r.x+r.width, cx:r.x+r.width/2, t:r.y, b:r.y+r.height, cy:r.y+r.height/2 }; 28 }).filter(Boolean); 29} 30function snapAlign(svg, body, node, x, y, tol=6, grid=10){ 31 x = roundTo(x, grid); y = roundTo(y, grid); 32 const cands = collectBBoxCenters(svg, node); 33 const bb = node.getBBox?.(); if (!bb) return { x, y }; 34 const t = { l:x, r:x+bb.width, cx:x+bb.width/2, t:y, b:y+bb.height, cy:y+bb.height/2 }; 35 let vGuide=null, hGuide=null; 36 const maybe=(val, vals)=>{ let best=val, bd=tol; for(const c of vals){ const d=Math.abs(c-val); if(d<bd){bd=d; best=c;} } return {v:best, hit:best!==val}; }; 37 const vVals=cands.flatMap(c=>[c.l,c.cx,c.r]); const hVals=cands.flatMap(c=>[c.t,c.cy,c.b]); 38 const v1=maybe(t.l,vVals), v2=maybe(t.cx,vVals), v3=maybe(t.r,vVals); 39 if(v1.hit){ x=v1.v; vGuide=v1.v; } else if(v2.hit){ x=v2.v-bb.width/2; vGuide=v2.v; } else if(v3.hit){ x=v3.v-bb.width; vGuide=v3.v; } 40 const h1=maybe(t.t,hVals), h2=maybe(t.cy,hVals), h3=maybe(t.b,hVals); 41 if(h1.hit){ y=h1.v; hGuide=h1.v; } else if(h2.hit){ y=h2.v-bb.height/2; hGuide=h2.v; } else if(h3.hit){ y=h3.v-bb.height; hGuide=h3.v; } 42 const r=svg.getBoundingClientRect(); 43 showGuides(body, vGuide!=null ? (r.left+vGuide)-r.left : null, hGuide!=null ? (r.top+hGuide)-r.top : null); 44 return { x, y }; 45} 46 47function selectNode(svg, node){ 48 svg.querySelectorAll(".selected-wb").forEach(n=>n.classList.remove("selected-wb")); 49 node?.classList?.add?.("selected-wb"); 50} 51 52function setNodePosition(node, x, y){ 53 // Gallery items stamped from the shared gallery use the storyboard's tgroup-sb class 54 // and are positioned via SVG transform rather than x/y attributes. 55 if (node.classList?.contains("tgroup-sb")) { 56 const t = node.getAttribute("transform") || ""; 57 const sm = t.match(/scale\([^)]+\)/); 58 const rm = t.match(/rotate\([^)]+\)/); 59 let newT = `translate(${Math.round(x)},${Math.round(y)})`; 60 if (sm) newT += ` ${sm[0]}`; 61 if (rm) newT += ` ${rm[0]}`; 62 node.setAttribute("transform", newT); 63 return; 64 } 65 if (node.tagName === "rect" || node.tagName === "image"){ 66 node.setAttribute("x", x); node.setAttribute("y", y); 67 } else if (node.tagName === "ellipse"){ 68 const rx = parseFloat(node.getAttribute("rx")||"0"); 69 const ry = parseFloat(node.getAttribute("ry")||"0"); 70 node.setAttribute("cx", x+rx); node.setAttribute("cy", y+ry); 71 } else if (node.tagName === "text"){ 72 node.setAttribute("x", x); node.setAttribute("y", y); 73 } else if (node.tagName === "g" && node.classList.contains("sticky")){ 74 const r = node.querySelector("rect"); 75 const dx = x - parseFloat(r.getAttribute("x")); 76 const dy = y - parseFloat(r.getAttribute("y")); 77 r.setAttribute("x", x); r.setAttribute("y", y); 78 node.querySelectorAll("text").forEach(t=>{
79 t.setAttribute("x", (parseFloat(t.getAttribute("x"))+dx)); 80 t.setAttribute("y", (parseFloat(t.getAttribute("y"))+dy)); 81 }); 82 } else { 83 node.setAttribute("transform", `translate(${Math.round(x)},${Math.round(y)})`); 84 } 85} 86 87function createStickySVG(svg, x, y, w=200, h=140, color="#fef08a"){ 88 const g = document.createElementNS(svg.namespaceURI,"g"); g.setAttribute("class","elt-wb sticky"); 89 const r = document.createElementNS(svg.namespaceURI,"rect"); 90 r.setAttribute("x",x); r.setAttribute("y",y); r.setAttribute("rx",12); r.setAttribute("ry",12); 91 r.setAttribute("width",w); r.setAttribute("height",h); 92 r.setAttribute("fill",color); r.setAttribute("stroke","rgba(0,0,0,.08)"); r.setAttribute("stroke-width","1"); 93 const t = document.createElementNS(svg.namespaceURI,"text"); 94 t.setAttribute("x",x+12); t.setAttribute("y",y+24); t.setAttribute("fill","#0f172a"); 95 t.setAttribute("font-weight","700"); t.setAttribute("font-size","14"); t.textContent="Idea"; 96 const body = document.createElementNS(svg.namespaceURI,"text"); 97 body.setAttribute("x",x+12); body.setAttribute("y",y+44); 98 body.setAttribute("fill","#0f172a"); body.setAttribute("font-size","13"); body.textContent="Write somethingâ¦"; 99 100 // Create SVG foreignObject to embed HTML tag buttons 101 const fo = document.createElementNS(svg.namespaceURI, "foreignObject"); 102 fo.setAttribute("x", x); 103 fo.setAttribute("y", y + h - 32); 104 fo.setAttribute("width", w); 105 fo.setAttribute("height", "32"); 106 107 const tagBar = document.createElement("div"); 108 tagBar.className = "sticky-tags-wb"; 109 tagBar.style.cssText = "display:flex;gap:4px;padding:4px 8px;background:rgba(255,255,255,0.9);border-top:1px solid rgba(0,0,0,.05);height:100%;box-sizing:border-box;"; 110 111 const taskBtn = document.createElement("button"); 112 taskBtn.className = "tag-btn-wb"; 113 taskBtn.textContent = "ð Task"; 114 taskBtn.title = "Tag as Task (shows in Task Board)"; 115 taskBtn.style.cssText = "flex:1;padding:4px 8px;font-size:11px;border:1px solid #d1d5db;border-radius:4px;background:#fff;cursor:pointer;"; 116 117 const noteBtn = document.createElement("button"); 118 noteBtn.className = "tag-btn-wb"; 119 noteBtn.textContent = "ð Note"; 120 noteBtn.title = "Tag as Note (shows in Documenter)"; 121 noteBtn.style.cssText = "flex:1;padding:4px 8px;font-size:11px;border:1px solid #d1d5db;border-radius:4px;background:#fff;cursor:pointer;"; 122 123 tagBar.append(taskBtn, noteBtn); 124 fo.appendChild(tagBar); 125 g.append(r,t,body,fo); 126 return g; 127} 128 129// --------------------------------------------------------------------------- 130// Eraser helpers â partial erasure of pen-path elements 131// --------------------------------------------------------------------------- 132const ERASER_RADIUS = 14; // SVG-space half-width of the eraser 133 134function parseSVGPathPoints(d) { 135 const pts = []; 136 const re = /[ML]\s*([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)\s+([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)/g; 137 let m; 138 while ((m = re.exec(d)) !== null) { 139 const x = parseFloat(m[1]), y = parseFloat(m[2]); 140 if (!isNaN(x) && !isNaN(y)) pts.push({ x, y }); 141 } 142 return pts; 143} 144 145function buildPathD(pts) { 146 if (pts.length === 0) return ""; 147 const fmt = (n) => Math.round(n * 100) / 100; 148 let d = `M ${fmt(pts[0].x)} ${fmt(pts[0].y)}`; 149 for (let i = 1; i < pts.length; i++) d += ` L ${fmt(pts[i].x)} ${fmt(pts[i].y)}`; 150 return d; 151} 152 153function _lerp2(ax, ay, bx, by, t) { 154 return { x: ax + t * (bx - ax), y: ay + t * (by - ay) }; 155} 156 157function _ptInCircle(px, py, cx, cy, r2) { 158 const dx = px - cx, dy = py - cy; 159 return dx * dx + dy * dy <= r2; 160} 161 162// Returns sorted tâ(0,1) values where segment AB intersects circle(cx,cy,r) 163function _segCircleTs(ax, ay, bx, by, cx, cy, r) { 164 const dx = bx - ax, dy = by - ay; 165 const fx = ax - cx, fy = ay - cy; 166 const a = dx * dx + dy * dy; 167 if (a < 1e-12) return []; 168 const b = 2 * (fx * dx + fy * dy); 169 const c = fx * fx + fy * fy - r * r; 170 const disc = b * b - 4 * a * c; 171 if (disc < 0) return []; 172 const sq = Math.sqrt(disc); 173 const t1 = (-b - sq) / (2 * a); 174 const t2 = (-b + sq) / (2 * a); 175 const ts = []; 176 if (t1 > 1e-9 && t1 < 1 - 1e-9) ts.push(t1); 177 if (t2 > 1e-9 && t2 < 1 - 1e-9 && Math.abs(t2 - t1) > 1e-9) ts.push(t2); 178 return ts; 179} 180 181// Returns array of sub-path point arrays after erasing circle (cx,cy,r) from path 182function _computeErasedPath(pts, cx, cy, r) { 183 if (pts.length === 0) return []; 184 const r2 = r * r; 185 const subPaths = []; 186 let cur = null; 187 188 const endCur = () => { if (cur && cur.length >= 2) subPaths.push(cur); cur = null; }; 189 190 if (!_ptInCircle(pts[0].x, pts[0].y, cx, cy, r2)) cur = [{ x: pts[0].x, y: pts[0].y }]; 191 192 for (let i = 1; i < pts.length; i++) { 193 const A = pts[i - 1], B = pts[i]; 194 const aIn = _ptInCircle(A.x, A.y, cx, cy, r2); 195 const bIn = _ptInCircle(B.x, B.y, cx, cy, r2); 196 197 if (!aIn && !bIn) { 198 const ts = _segCircleTs(A.x, A.y, B.x, B.y, cx, cy, r); 199 if (ts.length < 2) { 200 // Fully outside (or tangent) â keep segment 201 if (!cur) cur = [{ x: A.x, y: A.y }]; 202 cur.push({ x: B.x, y: B.y }); 203 } else { 204 // Segment passes through circle: keep parts on each side 205 const p1 = _lerp2(A.x, A.y, B.x, B.y, ts[0]); 206 const p2 = _lerp2(A.x, A.y, B.x, B.y, ts[1]); 207 if (!cur) cur = [{ x: A.x, y: A.y }]; 208 cur.push({ x: p1.x, y: p1.y }); 209 endCur(); 210 cur = [{ x: p2.x, y: p2.y }, { x: B.x, y: B.y }]; 211 } 212 } else if (aIn && bIn) { 213 endCur(); // both inside â erase segment 214 } else if (!aIn) { 215 // A outside â B inside: keep A up to entry point 216 const ts = _segCircleTs(A.x, A.y, B.x, B.y, cx, cy, r); 217 if (!cur) cur = [{ x: A.x, y: A.y }]; 218 if (ts.length > 0) { const p = _lerp2(A.x, A.y, B.x, B.y, ts[0]);
218 cur.push({ x: p.x, y: p.y }); } 219 endCur(); 220 } else { 221 // A inside â B outside: start new sub-path from exit point 222 const ts = _segCircleTs(A.x, A.y, B.x, B.y, cx, cy, r); 223 const p = ts.length > 0 ? _lerp2(A.x, A.y, B.x, B.y, ts[ts.length - 1]) : B; 224 cur = [{ x: p.x, y: p.y }, { x: B.x, y: B.y }]; 225 } 226 } 227 endCur(); 228 return subPaths; 229} 230 231// Partially erase pathEl where it overlaps the eraser circle (ex, ey, radius) 232function erasePathNear(svg, gContent, pathEl, ex, ey, radius) { 233 try { 234 const bb = pathEl.getBBox(); 235 if (ex + radius < bb.x || ex - radius > bb.x + bb.width || 236 ey + radius < bb.y || ey - radius > bb.y + bb.height) return; 237 } catch (_) { /* getBBox unavailable â proceed */ } 238 239 const d = pathEl.getAttribute("d") || ""; 240 const pts = parseSVGPathPoints(d); 241 if (pts.length === 0) { pathEl.remove(); return; } 242 243 const subPaths = _computeErasedPath(pts, ex, ey, radius); 244 245 // Detect true no-op: single sub-path whose points are identical to the original 246 if (subPaths.length === 1 && subPaths[0].length === pts.length && 247 subPaths[0].every((p, i) => p.x === pts[i].x && p.y === pts[i].y)) return; 248 249 const parent = pathEl.parentNode; 250 if (!parent) return; 251 for (const sp of subPaths) { 252 const newEl = document.createElementNS(svg.namespaceURI, "path"); 253 for (const attr of pathEl.attributes) { 254 if (attr.name !== "id") newEl.setAttribute(attr.name, attr.value); 255 } 256 newEl.setAttribute("d", buildPathD(sp)); 257 parent.insertBefore(newEl, pathEl); 258 } 259 pathEl.remove(); 260} 261// --------------------------------------------------------------------------- 262 263export function attachDrawing(svg, body, gContent, state, actions = {}){ 264 let drawing = null; // { type, node, start:{x,y}, id } 265 let moving = null; // { node, dx, dy, id } 266 267 // Prevent container drag only when locked (handled in index via body listener) 268 body.addEventListener("pointerdown", (e)=>{ /* noop; lock handled in index */ }, false); 269 270 svg.addEventListener("pointerdown", (e)=>{ 271 const p0 = svgPointClient(svg, e.clientX, e.clientY); 272 const grid = state.snap ? state.grid : 1; 273 const x0 = state.snap ? roundTo(p0.x, grid) : p0.x; 274 const y0 = state.snap ? roundTo(p0.y, grid) : p0.y; 275 276 // Eraser 277 if (state.tool === "eraser"){ 278 drawing = { type: "eraser", id: e.pointerId }; 279 svg.setPointerCapture?.(e.pointerId); 280 // Partially erase nearby pen-path elements 281 Array.from(gContent.querySelectorAll("path.elt-wb")).forEach(pathEl => { 282 if (svg.contains(pathEl)) erasePathNear(svg, gContent, pathEl, p0.x, p0.y, ERASER_RADIUS); 283 }); 284 // Remove non-path elements on direct contact 285 const hit = e.target.closest(".elt-wb"); 286 if (hit && svg.contains(hit) && hit.tagName !== "path") hit.remove(); 287 return; 288 } 289 290 const hit = e.target.closest(".elt-wb"); 291 292 // Select / move 293 if (state.tool === "select"){ 294 if (hit){ 295 selectNode(svg, hit); 296 // For tgroup-sb gallery items, use the translate offset as origin 297 if (hit.classList?.contains("tgroup-sb")) { 298 const m = (hit.getAttribute("transform")||"").match(/translate\(([^,)]+),?\s*([^)]*)\)/); 299 const tx = parseFloat(m?.[1]) || 0; 300 const ty = parseFloat(m?.[2]) || 0; 301 moving = { node: hit, dx: p0.x - tx, dy: p0.y - ty, id: e.pointerId }; 302 } else { 303 const bb = hit.getBBox?.(); 304 moving = { node: hit, dx: p0.x - (bb?.x ?? p0.x), dy: p0.y - (bb?.y ?? p0.y), id: e.pointerId }; 305 } 306 svg.setPointerCapture?.(e.pointerId); 307 } else { 308 selectNode(svg, null); 309 } 310 return; 311 } 312 313 // Gallery stamp tools (click to place an item from the shared gallery) 314 if (state.galleryTool) { 315 const stamped = actions?.stampGalleryOnCanvas?.(svg, gContent, x0, y0); 316 if (stamped) { 317 selectNode(svg, stamped); 318 // galleryTool cleared by toolbar's activate(SELECT) inside stampGalleryOnCanvas 319 } 320 return; 321 } 322 323 // Pen 324 if (state.tool === "pen"){ 325 const path = document.createElementNS(svg.namespaceURI,"path"); 326 path.setAttribute("d", `M ${x0} ${y0}`); 327 path.setAttribute("fill","none"); 328 path.setAttribute("stroke", state.stroke); 329 path.setAttribute("stroke-width", String(state.strokeWidth)); 330 path.setAttribute("stroke-linecap","round"); 331 path.setAttribute("stroke-linejoin","round"); 332 path.setAttribute("stroke-opacity", String(state.brush==="highlighter" ? Math.min(0.5, state.strokeOpacity) : state.strokeOpacity)); 333 path.setAttribute("style", state.brush==="highlighter" ? "mix-blend-mode:multiply" : ""); 334 path.setAttribute("class","elt-wb"); 335 gContent.appendChild(path); 336 drawing = { type: "pen", node: path, start:{x:x0,y:y0}, id: e.pointerId }; 337 svg.setPointerCapture?.(e.pointerId); 338 selectNode(svg, path); 339 return; 340 } 341 342 // Rect 343 if (state.tool === "rect"){ 344 const r = document.createElementNS(svg.namespaceURI,"rect"); 345 r.setAttribute("x",x0); r.setAttribute("y",y0); 346 r.setAttribute("width",1); r.setAttribute("height",1); 347 r.setAttribute("fill","transparent"); 348 r.setAttribute("stroke", state.stroke); 349 r.setAttribute("stroke-width", String(state.strokeWidth)); 350 r.setAttribute("stroke-opacity", String(state.strokeOpacity)); 351 r.setAttribute("class","elt-wb"); 352 gContent.appendChild(r); 353 drawing = { type: "rect", node: r, start:{x:x0,y:y0}, id: e.pointerId }; 354 svg.setPointerCapture?.(e.pointerId); 355 selectNode(svg, r); 356 return; 357 } 358 359 // Ellipse 360 if (state.tool === "ellipse"){ 361 const epx = document.createElementNS(svg.namespaceURI,"ellipse"); 362 epx.setAttribute("cx",x0); epx.setAttribute("cy",y0); 363 epx.setAttribute("rx",1); epx.setAttribute("ry",1); 364 epx.setAttribute("fill","transparent"); 365 epx.setAttribute("stroke", state.stroke); 366 epx.setAttribute("stroke-width", String(state.strokeWidth)); 367 epx.setAttribute("stroke-opacity", String(state.strokeOpacity)); 368 epx.setAttribute("class","elt-wb"); 369 gContent.appendChild(epx); 370 drawing = { type: "ellipse", node: epx, start:{x:x0,y:y0}, id: e.pointerId }; 371 svg.setPointerCapture?.(e.pointerId); 372 selectNode(svg, epx); 373 return; 374 } 375 376 // Text 377 if (state.tool === "text"){ 378 const textVal = prompt("Text:"); 379 if (!textVal) return; 380 const t = document.createElementNS(svg.namespaceURI,"text"); 381 t.setAttribute("x",x0); t.setAttribute("y",y0); 382 t.setAttribute("fill", state.stroke); 383 t.setAttribute("font-size","14"); 384 t.setAttribute("font-family","system-ui,-apple-system,Segoe UI,Roboto,sans-serif"); 385 t.setAttribute("class","elt-wb"); 386 t.textContent = textVal; 387 gContent.appendChild(t); 388 selectNode(svg, t); 389 return; 390 } 391 392 // Sticky 393 if (state.tool === "sticky"){ 394 // Don't create a new sticky if clicking on a tag button 395 if (e.target.closest("button")) { 396 return; 397 } 398 const s = createStickySVG(svg, x0, y0, 200, 140, "#fef08a"); 399 s.querySelectorAll("*").forEach(n=>n.setAttribute("class",(n.getAttribute("class")||"")+" elt-wb")); 400 gContent.appendChild(s); 401 selectNode(svg, s); 402 403 // Get button references 404 const taskBtn = s.querySelector(".tag-btn-wb"); 405 const noteBtn = s.querySelectorAll(".tag-btn-wb")[1]; 406 407 // Update button visual states based on current tag 408 const updateTagButtons = () => { 409 const isTask = s.dataset.stickyType === "task"; 410 const isNote = s.dataset.stickyType === "note"; 411 412 if (taskBtn) { 413 taskBtn.style.background = isTask ? "#dbeafe" : "#fff"; 414 taskBtn.style.borderColor = isTask ? "#3b82f6" : "#d1d5db"; 415 taskBtn.style.fontWeight = isTask ? "600" : "400"; 416 } 417 if (noteBtn) { 418 noteBtn.style.background = isNote ? "#fef3c7" : "#fff"; 419 noteBtn.style.borderColor = isNote ? "#f59e0b" : "#d1d5db"; 420 noteBtn.style.fontWeight = isNote ? "600" : "400"; 421 } 422 }; 423 424 // Handle task tag toggle 425 if (taskBtn) { 426 taskBtn.addEventListener("click", (ev) => { 427 ev.stopPropagation(); 428 const wasTask = s.dataset.stickyType === "task"; 429 430 if (wasTask) { 431 // Untag as task - remove from shared DB 432 delete s.dataset.stickyType; 433 if (s.dataset.taskId) { 434 import("../stickies/taskDb.js").then(({ removeTaskSticky }) => { 435 removeTaskSticky(s.dataset.taskId); 436 }); 437 delete s.dataset.taskId; 438 } 439 } else { 440 // Tag as task - add to shared DB 441 import("../stickies/taskDb.js").then(({ saveTaskSticky, generateTaskId }) => { 442 import("../stickies/projectUtils.js").then(({ getProjectContext }) => { 443 const taskId = s.dataset.taskId || generateTaskId(); 444 s.dataset.taskId = taskId; 445 s.dataset.stickyType = "task"; 446 447 const titleEl = s.querySelector("text:first-of-type"); 448 const bodyEl = s.querySelectorAll("text")[1]; 449 450 // Get project context from shell 451 const context = getProjectContext(state?.shell); 452 453 saveTaskSticky({ 454 id: taskId, 455 title: titleEl?.textContent || "Idea", 456 text: bodyEl?.textContent || "Write somethingâ¦", 457 background: "#fef08a", 458 sourceApp: "whiteboard", 459 sourceShellId: context.sourceShellId, 460 sourceTitle: context.sourceTitle, 461 projectId: context.projectId, 462 projectName: context.projectName, 463 }); 464 465 updateTagButtons(); 466 }); 467 }); 468 return; 469 } 470 updateTagButtons(); 471 }); 472 } 473 474 // Handle note tag toggle 475 if (noteBtn) { 476 noteBtn.addEventListener("click", (ev) => { 477 ev.stopPropagation(); 478 const wasNote = s.dataset.stickyType === "note"; 479 480 if (wasNote) { 481 // Untag as note - remove from shared DB 482 delete s.dataset.stickyType; 483 if (s.dataset.noteId) { 484 import("../stickies/noteDb.js").then(({ removeNoteSticky }) => { 485 removeNoteSticky(s.dataset.noteId); 486 }); 487 delete s.dataset.noteId; 488 } 489 } else { 490 // Tag as note - add to shared DB 491 import("../stickies/noteDb.js").then(({ saveNoteSticky, generateNoteId }) => { 492 import("../stickies/projectUtils.js").then(({ getProjectContext }) => { 493 const noteId = s.dataset.noteId || generateNoteId(); 494 s.dataset.noteId = noteId; 495 s.dataset.stickyType = "note"; 496 497 const titleEl = s.querySelector("text:first-of-type"); 498 const bodyEl = s.querySelectorAll("text")[1]; 499 500 // Get project context from shell 501 const context = getProjectContext(state?.shell); 502 503 saveNoteSticky({ 504 id: noteId, 505 title: titleEl?.textContent || "Idea", 506 text: bodyEl?.textContent || "Write somethingâ¦", 507 background: "#fef08a", 508 sourceApp: "whiteboard", 509 sourceShellId: context.sourceShellId, 510 sourceTitle: context.sourceTitle, 511 projectId: context.projectId, 512 projectName: context.projectName, 513 }); 514 515 updateTagButtons(); 516 }); 517 }); 518 return; 519 } 520 updateTagButtons(); 521 }); 522 } 523 524 // Attach per-sticky dblclick editing (not once; supports repeated edits) 525 s.addEventListener("dblclick", (ev) => { 526 const hitText = ev.target.closest("text"); 527 if (!hitText) return; 528 ev.stopPropagation(); 529 const newText = prompt("Edit text:", hitText.textContent ?? ""); 530 if (newText != null) { 531 hitText.textContent = newText; 532 533 // Sync to appropriate DB if tagged 534 const titleEl = s.querySelector("text:first-of-type"); 535 const bodyEl = s.querySelectorAll("text")[1]; 536 537 if (s.dataset.stickyType === "task" && s.dataset.taskId) { 538 import("../stickies/taskDb.js").then(({ saveTaskSticky }) => { 539 import("../stickies/projectUtils.js").then(({ getProjectContext }) => { 540 const context = getProjectContext(state?.shell); 541 saveTaskSticky({ 542 id: s.dataset.taskId, 543 title: titleEl?.textContent || "Idea", 544 text: bodyEl?.textContent || "", 545 background: "#fef08a", 546 sourceApp: "whiteboard", 547 sourceShellId: context.sourceShellId, 548 sourceTitle: context.sourceTitle, 549 projectId: context.projectId, 550 projectName: context.projectName, 551 }); 552 }); 553 }).catch(() => {}); 554 } else if (s.dataset.stickyType === "note" && s.dataset.noteId) { 555 import("../stickies/noteDb.js").then(({ saveNoteSticky }) => { 556 import("../stickies/projectUtils.js").then(({ getProjectContext }) => { 557 const context = getProjectContext(state?.shell); 558 saveNoteSticky({ 559 id: s.dataset.noteId, 560 title: titleEl?.textContent || "Idea", 561 text: bodyEl?.textContent || "", 562 background: "#fef08a", 563 sourceApp: "whiteboard", 564 sourceShellId: context.sourceShellId, 565 sourceTitle: context.sourceTitle, 566 projectId: context.projectId, 567 projectName: context.projectName, 568 }); 569 }); 570 }).catch((err) => { 571 console.error("[whiteboard] Failed to save note sticky:", err); 572 }); 573 } 574 } 575 }, true); 576 577 updateTagButtons(); 578 return; 579 } 580 }, true); 581 582 svg.addEventListener("pointermove", (e)=>{ 583 const p = svgPointClient(svg, e.clientX, e.clientY); 584 const grid = state.snap ? state.grid : 1; 585 let x = state.snap ? roundTo(p.x, grid) : p.x; 586 let y = state.snap ? roundTo(p.y, grid) : p.y; 587 588 if (drawing?.type === "eraser"){ 589 // Partially erase nearby pen-path elements 590 Array.from(gContent.querySelectorAll("path.elt-wb")).forEac
590h(pathEl => { 591 if (svg.contains(pathEl)) erasePathNear(svg, gContent, pathEl, p.x, p.y, ERASER_RADIUS); 592 }); 593 // Remove non-path elements on direct contact 594 const hit = e.target.closest(".elt-wb"); 595 if (hit && svg.contains(hit) && hit.tagName !== "path") hit.remove(); 596 return; 597 } 598 599 if (moving){ 600 const { node, dx, dy } = moving; 601 x = p.x - dx; y = p.y - dy; 602 if (state.snap){ ({ x, y } = snapAlign(svg, body, node, x, y, 6, state.grid)); } 603 setNodePosition(node, x, y); 604 return; 605 } 606 607 if (!drawing) return; 608 609 const { type, node, start } = drawing; 610 if (type === "pen"){ 611 node.setAttribute("d", node.getAttribute("d") + ` L ${x} ${y}`); 612 return; 613 } 614 if (type === "rect"){ 615 const w = x - start.x, h = y - start.y; 616 node.setAttribute("width", Math.abs(w)); 617 node.setAttribute("height", Math.abs(h)); 618 if (w < 0) node.setAttribute("x", x); 619 if (h < 0) node.setAttribute("y", y); 620 return; 621 } 622 if (type === "ellipse"){ 623 node.setAttribute("rx", Math.abs(x - start.x)); 624 node.setAttribute("ry", Math.abs(y - start.y)); 625 return; 626 } 627 }, true); 628 629 const stop = ()=>{ 630 if (drawing?.id != null) svg.releasePointerCapture?.(drawing.id); 631 if (moving?.id != null) svg.releasePointerCapture?.(moving.id); 632 drawing = null; moving = null; hideGuides(body); 633 }; 634 svg.addEventListener("pointerup", stop, true); 635 svg.addEventListener("pointercancel", stop, true); 636 637 // Delete selection 638 window.addEventListener("keydown", (e)=>{ 639 if (e.key === "Delete" || e.key === "Backspace"){ 640 const sel = svg.querySelector(".selected-wb"); 641 if (sel && svg.contains(sel)) sel.remove(); 642 } 643 }, true); 644}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.