PageSourceSearch

https://smartcone.ai/src/shell/types/whiteboard/draw.js

js smartcone.ai collected 2026-10-05 15:07:15 UTC 26,295 bytes, 644 lines download raw bytes

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.