mlmihjaz Claude Opus 4.8 commited on
Commit
02807ea
·
1 Parent(s): dede77e

Add deep color on groups/vectors + Pathfinder boolean ops

Browse files

- Color now applies recursively into groups, traced vectors and
multi-selections (setStyleDeep) so every child path recolors at once,
even while grouped; right panel + panel-switch updated for groups.
- Pathfinder (paper.js): Unite / Subtract / Intersect / Exclude in the
Arrange menu. Combines 2+ shapes into one editable fabric.Path; subtract
is bottom-minus-top (Illustrator "Minus Front").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files changed (2) hide show
  1. WORKLOG.md +5 -0
  2. index.html +83 -9
WORKLOG.md CHANGED
@@ -58,6 +58,11 @@ curl -sL https://mlmihjaz-ltm.static.hf.space/ | grep -c <some-unique-string-you
58
  - **My Library (persistent):** new **Library** tab (`sec-library`), `localStorage df_library`. `addToLibrary()` serializes the active object (auto-groups an activeSelection) via `toObject(['_name','_type'])` + a PNG thumbnail (`obj.toDataURL`). `renderLibrary()` grid (click=add via `fabric.util.enlivenObjects`, hover ×=delete). **Export/Import** the whole library as JSON ("for future versions"). Quota errors are caught.
59
  - Verified: `node --check` clean, all new IDs/functions resolve, ImageTracer CDN 200. Browser-test the trace quality / tune detault detail.
60
 
 
 
 
 
 
61
  ## Known notes / next ideas
62
  - Templates are placeholders — biggest remaining "real Canva" feature.
63
  - Counter relies on a 3rd-party free service; if it dies, swap for a tiny Gradio/Docker Space hosting a JSON counter.
 
58
  - **My Library (persistent):** new **Library** tab (`sec-library`), `localStorage df_library`. `addToLibrary()` serializes the active object (auto-groups an activeSelection) via `toObject(['_name','_type'])` + a PNG thumbnail (`obj.toDataURL`). `renderLibrary()` grid (click=add via `fabric.util.enlivenObjects`, hover ×=delete). **Export/Import** the whole library as JSON ("for future versions"). Quota errors are caught.
59
  - Verified: `node --check` clean, all new IDs/functions resolve, ImageTracer CDN 200. Browser-test the trace quality / tune detault detail.
60
 
61
+ ## Features implemented (2026-06-16 session #3 — deep color + Pathfinder)
62
+ - **Deep color on groups/vectors:** `setStyleDeep(obj,prop,val)` recurses into `group`/`activeSelection` children so a fill applies to every path of a traced vector or grouped shapes (even while grouped). `updateActiveShape` routes groups/selections through it; `updateRightPanel` syncs `sh-fill` for groups (first child with a real fill); `onSelectionChange` opens the Shapes panel for `group` too. Colors survive ungroup→recolor→regroup.
63
+ - **Pathfinder boolean ops (paper.js):** added **paper-core** CDN (`paper@0.12.17`, global `paper`, `ensurePaper()` lazy-inits an offscreen project). `pathfinder(op)` for **unite/subtract/intersect/exclude** in the Arrange menu (`[data-pf]`). Must select 2+ objects; it **discards the activeSelection first** so children are in absolute canvas coords, converts each to a paper path via `obj.toSVG()`→`importSVG` (sub-paths united in `objToPaperPath`), runs the op (subtract = bottom minus the ones above, Illustrator "Minus Front"), then rebuilds one `fabric.Path` from `result.pathData` (fillRule evenodd, inherits bottom shape's style) and removes the originals.
64
+ - Verified: `node --check` clean, both CDNs 200, all fns/wiring resolve. Browser-test boolean edge cases (holes/winding) + tune if needed.
65
+
66
  ## Known notes / next ideas
67
  - Templates are placeholders — biggest remaining "real Canva" feature.
68
  - Counter relies on a 3rd-party free service; if it dies, swap for a tiny Gradio/Docker Space hosting a JSON counter.
index.html CHANGED
@@ -7,6 +7,7 @@
7
  <title>DesignForge Pro</title>
8
  <script src="https://cdn.jsdelivr.net/npm/fabric@5.3.0/dist/fabric.min.js"></script>
9
  <script src="https://cdn.jsdelivr.net/gh/jankovicsandras/imagetracerjs@master/imagetracer_v1.2.6.js"></script>
 
10
  <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
11
  <style>
12
  *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
@@ -352,6 +353,13 @@ input[type="file"]{display:none}
352
  <div class="arr-sep"></div>
353
  <button class="arr-wide" data-arr="group">⛶ Group <span class="arr-kbd">Ctrl+G</span></button>
354
  <button class="arr-wide" data-arr="ungroup">⛗ Ungroup <span class="arr-kbd">Ctrl+Shift+G</span></button>
 
 
 
 
 
 
 
355
  </div>
356
 
357
  <!-- CENTER PROMO STRIP -->
@@ -1344,7 +1352,7 @@ function onSelectionChange() {
1344
  // Auto-switch the left panel to match what's selected
1345
  if (obj && !penMode && obj.type !== 'activeSelection') {
1346
  if (obj.type === 'i-text' || obj.type === 'text') openPanel('text');
1347
- else if (SHAPE_TYPES.includes(obj.type)) openPanel('elements');
1348
  }
1349
  }
1350
 
@@ -1682,9 +1690,12 @@ function updateRightPanel(obj) {
1682
  $i('txt-underline').classList.toggle('active', underlineOn);
1683
  }
1684
 
1685
- // Shape style sync
1686
- if (['rect','circle','ellipse','triangle','polygon','path','line'].includes(obj.type)) {
1687
- if ($i('sh-fill')) $i('sh-fill').value = (obj.fill && obj.fill !== 'transparent') ? obj.fill : '#7c3aed';
 
 
 
1688
  if ($i('sh-no-fill')) $i('sh-no-fill').checked = !obj.fill || obj.fill === 'transparent';
1689
  if ($i('sh-stroke') && obj.stroke) $i('sh-stroke').value = obj.stroke;
1690
  if ($i('sh-stroke-w')) $i('sh-stroke-w').value = obj.strokeWidth || 0;
@@ -1706,14 +1717,27 @@ function updateRightPanel(obj) {
1706
  $i('img-info').innerHTML = `Type: ${obj.type}<br>W: ${Math.round(obj.getScaledWidth())}px<br>H: ${Math.round(obj.getScaledHeight())}px<br>X: ${Math.round(obj.left)} Y: ${Math.round(obj.top)}`;
1707
  }
1708
 
1709
- function updateActiveShape(prop, val) {
1710
- const obj = canvas.getActiveObject();
1711
- if (obj && ['rect','circle','ellipse','triangle','polygon','path','line'].includes(obj.type)) {
 
 
 
 
 
1712
  obj.set(prop, val);
1713
- canvas.renderAll();
1714
- saveHist();
1715
  }
1716
  }
 
 
 
 
 
 
 
 
 
1717
 
1718
  // ============================================================
1719
  // LAYERS
@@ -2210,6 +2234,55 @@ function ungroupSelection() {
2210
  canvas.requestRenderAll(); updateLayers(); saveHist(); toast('Ungrouped', 'ok');
2211
  }
2212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2213
  // ============================================================
2214
  // SAVE / LOAD PROJECTS (autosave to localStorage + .json export/import)
2215
  // ============================================================
@@ -2725,6 +2798,7 @@ document.addEventListener('DOMContentLoaded', () => {
2725
  document.addEventListener('click', e => { if (!arrMenu.contains(e.target) && e.target.id !== 'btn-arrange') arrMenu.classList.remove('open'); });
2726
  qsa('#arrange-menu [data-align]').forEach(b => b.onclick = () => alignObjects(b.dataset.align));
2727
  qsa('#arrange-menu [data-arr]').forEach(b => b.onclick = () => { b.dataset.arr === 'group' ? groupSelection() : ungroupSelection(); arrMenu.classList.remove('open'); });
 
2728
 
2729
  // SAVE / OPEN / NEW PROJECT
2730
  $i('btn-save-proj').onclick = saveProjectFile;
 
7
  <title>DesignForge Pro</title>
8
  <script src="https://cdn.jsdelivr.net/npm/fabric@5.3.0/dist/fabric.min.js"></script>
9
  <script src="https://cdn.jsdelivr.net/gh/jankovicsandras/imagetracerjs@master/imagetracer_v1.2.6.js"></script>
10
+ <script src="https://cdn.jsdelivr.net/npm/paper@0.12.17/dist/paper-core.min.js"></script>
11
  <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
12
  <style>
13
  *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
 
353
  <div class="arr-sep"></div>
354
  <button class="arr-wide" data-arr="group">⛶ Group <span class="arr-kbd">Ctrl+G</span></button>
355
  <button class="arr-wide" data-arr="ungroup">⛗ Ungroup <span class="arr-kbd">Ctrl+Shift+G</span></button>
356
+ <div class="arr-label" style="margin-top:9px">Combine · Pathfinder</div>
357
+ <div class="arr-grid two">
358
+ <button data-pf="unite" title="Merge shapes into one outline">⬣ Unite</button>
359
+ <button data-pf="subtract" title="Bottom shape minus the ones on top">◑ Subtract</button>
360
+ <button data-pf="intersect" title="Keep only the overlapping area">◎ Intersect</button>
361
+ <button data-pf="exclude" title="Keep everything except the overlap">⊗ Exclude</button>
362
+ </div>
363
  </div>
364
 
365
  <!-- CENTER PROMO STRIP -->
 
1352
  // Auto-switch the left panel to match what's selected
1353
  if (obj && !penMode && obj.type !== 'activeSelection') {
1354
  if (obj.type === 'i-text' || obj.type === 'text') openPanel('text');
1355
+ else if (SHAPE_TYPES.includes(obj.type) || obj.type === 'group') openPanel('elements');
1356
  }
1357
  }
1358
 
 
1690
  $i('txt-underline').classList.toggle('active', underlineOn);
1691
  }
1692
 
1693
+ // Shape style sync (groups/vectors included so their colors are editable)
1694
+ const isGroupish = obj.type === 'group' || obj.type === 'activeSelection';
1695
+ if (['rect','circle','ellipse','triangle','polygon','path','line'].includes(obj.type) || isGroupish) {
1696
+ let curFill = obj.fill;
1697
+ if (isGroupish) { const fc = obj.getObjects().find(c => c.fill && c.fill !== 'transparent'); curFill = fc ? fc.fill : null; }
1698
+ if ($i('sh-fill')) $i('sh-fill').value = (curFill && typeof curFill === 'string' && curFill !== 'transparent') ? curFill : '#7c3aed';
1699
  if ($i('sh-no-fill')) $i('sh-no-fill').checked = !obj.fill || obj.fill === 'transparent';
1700
  if ($i('sh-stroke') && obj.stroke) $i('sh-stroke').value = obj.stroke;
1701
  if ($i('sh-stroke-w')) $i('sh-stroke-w').value = obj.strokeWidth || 0;
 
1717
  $i('img-info').innerHTML = `Type: ${obj.type}<br>W: ${Math.round(obj.getScaledWidth())}px<br>H: ${Math.round(obj.getScaledHeight())}px<br>X: ${Math.round(obj.left)} Y: ${Math.round(obj.top)}`;
1718
  }
1719
 
1720
+ const BARE_SHAPES = ['rect','circle','ellipse','triangle','polygon','polyline','path','line'];
1721
+ // Apply a style prop deep into groups/multi-selections so every child (e.g. each
1722
+ // path of a traced vector) gets recolored — even while grouped.
1723
+ function setStyleDeep(obj, prop, val) {
1724
+ if (obj.type === 'group' || obj.type === 'activeSelection') {
1725
+ obj.getObjects().forEach(c => setStyleDeep(c, prop, val));
1726
+ obj.dirty = true;
1727
+ } else {
1728
  obj.set(prop, val);
1729
+ obj.dirty = true;
 
1730
  }
1731
  }
1732
+ function updateActiveShape(prop, val) {
1733
+ const obj = canvas.getActiveObject();
1734
+ if (!obj) return;
1735
+ if (BARE_SHAPES.includes(obj.type)) obj.set(prop, val);
1736
+ else if (obj.type === 'group' || obj.type === 'activeSelection') setStyleDeep(obj, prop, val);
1737
+ else return;
1738
+ canvas.renderAll();
1739
+ saveHist();
1740
+ }
1741
 
1742
  // ============================================================
1743
  // LAYERS
 
2234
  canvas.requestRenderAll(); updateLayers(); saveHist(); toast('Ungrouped', 'ok');
2235
  }
2236
 
2237
+ // ============================================================
2238
+ // PATHFINDER — boolean ops (unite / subtract / intersect / exclude) via paper.js
2239
+ // ============================================================
2240
+ let paperReady = false;
2241
+ function ensurePaper() {
2242
+ if (paperReady) return true;
2243
+ if (typeof paper === 'undefined') { toast('Pathfinder still loading — try again in a second', 'warn'); return false; }
2244
+ paper.setup(document.createElement('canvas')); paperReady = true; return true;
2245
+ }
2246
+ // Turn a Fabric object into ONE paper path (its sub-paths united). The object
2247
+ // must already be in absolute canvas coords (discard the activeSelection first).
2248
+ function objToPaperPath(obj) {
2249
+ try {
2250
+ const svg = '<svg xmlns="http://www.w3.org/2000/svg">' + obj.toSVG() + '</svg>';
2251
+ const item = paper.project.importSVG(svg, { expandShapes: true, insert: true });
2252
+ const arr = [];
2253
+ (function gather(it) { if (!it) return; const cn = it.className; if (cn === 'Path' || cn === 'CompoundPath') arr.push(it); if (it.children) it.children.forEach(gather); })(item);
2254
+ if (!arr.length) return null;
2255
+ let r = arr[0]; for (let i = 1; i < arr.length; i++) r = r.unite(arr[i]);
2256
+ return r;
2257
+ } catch (e) { console.error(e); return null; }
2258
+ }
2259
+ function pathfinder(op) {
2260
+ const ao = canvas.getActiveObject();
2261
+ if (!ao || ao.type !== 'activeSelection' || ao.getObjects().length < 2) { toast('Select 2 or more shapes first', 'warn'); return; }
2262
+ if (!ensurePaper()) return;
2263
+ const objs = ao.getObjects().slice(); // bottom-to-top stacking order
2264
+ const style = { fill: (objs[0].fill && objs[0].fill !== 'transparent') ? objs[0].fill : '#7c3aed', stroke: objs[0].stroke || null, strokeWidth: objs[0].strokeWidth || 0 };
2265
+ canvas.discardActiveObject(); canvas.requestRenderAll(); // restore absolute coords
2266
+ paper.project.clear();
2267
+ const paths = objs.map(objToPaperPath).filter(Boolean);
2268
+ if (paths.length < 2) { toast('Could not read those shapes', 'err'); return; }
2269
+ let result;
2270
+ try {
2271
+ if (op === 'unite') result = paths.reduce((a, b) => a.unite(b));
2272
+ else if (op === 'intersect') result = paths.reduce((a, b) => a.intersect(b));
2273
+ else if (op === 'exclude') result = paths.reduce((a, b) => a.exclude(b));
2274
+ else { result = paths[0]; for (let i = 1; i < paths.length; i++) result = result.subtract(paths[i]); } // subtract = bottom minus the ones above
2275
+ } catch (e) { console.error(e); toast('Operation failed on these shapes', 'err'); return; }
2276
+ const d = result && result.pathData;
2277
+ if (!d) { toast('Result was empty', 'warn'); return; }
2278
+ objs.forEach(o => canvas.remove(o));
2279
+ const path = new fabric.Path(d, { fill: style.fill, stroke: style.stroke, strokeWidth: style.strokeWidth, fillRule: 'evenodd' });
2280
+ path._name = 'Combined'; path._type = 'shape';
2281
+ canvas.add(path); canvas.setActiveObject(path); canvas.requestRenderAll();
2282
+ updateLayers(); saveHist();
2283
+ toast(({ unite: 'United', subtract: 'Subtracted', intersect: 'Intersected', exclude: 'Excluded' })[op] + ' into one shape', 'ok');
2284
+ }
2285
+
2286
  // ============================================================
2287
  // SAVE / LOAD PROJECTS (autosave to localStorage + .json export/import)
2288
  // ============================================================
 
2798
  document.addEventListener('click', e => { if (!arrMenu.contains(e.target) && e.target.id !== 'btn-arrange') arrMenu.classList.remove('open'); });
2799
  qsa('#arrange-menu [data-align]').forEach(b => b.onclick = () => alignObjects(b.dataset.align));
2800
  qsa('#arrange-menu [data-arr]').forEach(b => b.onclick = () => { b.dataset.arr === 'group' ? groupSelection() : ungroupSelection(); arrMenu.classList.remove('open'); });
2801
+ qsa('#arrange-menu [data-pf]').forEach(b => b.onclick = () => { pathfinder(b.dataset.pf); arrMenu.classList.remove('open'); });
2802
 
2803
  // SAVE / OPEN / NEW PROJECT
2804
  $i('btn-save-proj').onclick = saveProjectFile;