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>
- WORKLOG.md +5 -0
- 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 |
-
|
| 1687 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1710 |
-
|
| 1711 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1712 |
obj.set(prop, val);
|
| 1713 |
-
|
| 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;
|