mirror of
https://github.com/alesimula/Murine-launcher.git
synced 2026-10-28 09:43:42 +00:00
WEBSITE - WIP - icon shapes - save custom icon shapes
This commit is contained in:
1 parent
950326b239
commit
49404153ec
4 files changed
+225
-29
No files matched your search
+1
-1
@@ -5,7 +5,7 @@ description: >-
|
|||||||
url: "https://alesimula.github.io"
|
url: "https://alesimula.github.io"
|
||||||
baseurl: ""
|
baseurl: ""
|
||||||
|
|
||||||
version: "1.0.39" #Website version - increase to invalidate cached resources
|
version: "1.0.40" #Website version - increase to invalidate cached resources
|
||||||
github_repo: "alesimula/Murine-launcher"
|
github_repo: "alesimula/Murine-launcher"
|
||||||
icon_url: "https://raw.githubusercontent.com/alesimula/Murine-launcher/refs/heads/main/image/app_icon/icon_browser_fullsize.svg"
|
icon_url: "https://raw.githubusercontent.com/alesimula/Murine-launcher/refs/heads/main/image/app_icon/icon_browser_fullsize.svg"
|
||||||
icon_freeform_url: "https://raw.githubusercontent.com/alesimula/Murine-launcher/refs/heads/main/image/app_icon/icon_freeform.svg"
|
icon_freeform_url: "https://raw.githubusercontent.com/alesimula/Murine-launcher/refs/heads/main/image/app_icon/icon_freeform.svg"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<!-- Material Symbols: only the icons in use (~4 KB instead of the ~4 MB variable font). TODO Add every new icon here, list must stay alphabetical -->
|
<!-- Material Symbols: only the icons in use (~4 KB instead of the ~4 MB variable font). TODO Add every new icon here, list must stay alphabetical -->
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&icon_names=add,android,calendar_today,chat,close,cloud_off,code,dark_mode,delete,description,download,edit,favorite,filter_center_focus,fit_screen,history,home,hourglass_top,info,light_mode,mail,new_releases,open_with,palette,policy,rotate_right,search,sell,smartphone&display=block">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&icon_names=add,android,calendar_today,chat,close,cloud_off,code,dark_mode,delete,description,download,edit,favorite,filter_center_focus,fit_screen,history,home,hourglass_top,info,light_mode,mail,new_releases,open_with,palette,policy,rotate_right,save,search,sell,smartphone&display=block">
|
||||||
|
|
||||||
<link rel="icon" type="image/svg+xml" href="{{ site.icon_url }}">
|
<link rel="icon" type="image/svg+xml" href="{{ site.icon_url }}">
|
||||||
<link rel="stylesheet" href="{{ '/assets/css/style.css' | relative_url }}?v={{ site.version }}">
|
<link rel="stylesheet" href="{{ '/assets/css/style.css' | relative_url }}?v={{ site.version }}">
|
||||||
|
|||||||
+165
-25
@@ -298,10 +298,12 @@
|
|||||||
// A preset's picture: one path per outline (mirrored copies wind the other way, so one path would leave holes).
|
// A preset's picture: one path per outline (mirrored copies wind the other way, so one path would leave holes).
|
||||||
// Same scale as the canvas and cut off at the 0-100 box, as the launcher draws the icon: parts reaching outside
|
// Same scale as the canvas and cut off at the 0-100 box, as the launcher draws the icon: parts reaching outside
|
||||||
// it don't show (the inner svg clips them), and nothing draws over the tile.
|
// it don't show (the inner svg clips them), and nothing draws over the tile.
|
||||||
function presetSvg(name, pad = 0) {
|
// Saved shapes (see library) are drawn the same way. w is the list's whole-icon transform, null for none.
|
||||||
const w = presetWhole(name), paths = presetShapes(name).flatMap(copies).map((d) => `<path d="${wholeMap(d, w)}"/>`).join('');
|
function iconSvg(list, w, pad = 0) {
|
||||||
|
const paths = list.flatMap(copies).map((d) => `<path d="${wholeMap(d, w)}"/>`).join('');
|
||||||
return `<svg viewBox="${-pad} ${-pad} ${100 + 2 * pad} ${100 + 2 * pad}" aria-hidden="true"><svg width="100" height="100" viewBox="0 0 100 100">${paths}</svg></svg>`;
|
return `<svg viewBox="${-pad} ${-pad} ${100 + 2 * pad} ${100 + 2 * pad}" aria-hidden="true"><svg width="100" height="100" viewBox="0 0 100 100">${paths}</svg></svg>`;
|
||||||
}
|
}
|
||||||
|
const presetSvg = (name, pad = 0) => iconSvg(presetShapes(name), presetWhole(name), pad);
|
||||||
// The presets in the data file's order, minus any that don't make a valid icon.
|
// The presets in the data file's order, minus any that don't make a valid icon.
|
||||||
const presetNames = Object.keys(PRESETS).filter((name) => {
|
const presetNames = Object.keys(PRESETS).filter((name) => {
|
||||||
try {
|
try {
|
||||||
@@ -893,6 +895,7 @@
|
|||||||
<path d="${wholeMap(iconPath().d)}"/></svg>
|
<path d="${wholeMap(iconPath().d)}"/></svg>
|
||||||
<span>Global</span>
|
<span>Global</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="se-card-del se-card-save" data-act="save" data-card="-1" title="Save the whole shape to Custom" aria-label="Save the whole shape">${icon('e161')}</button>
|
||||||
${whole ? `<button type="button" class="se-card-del" data-act="reset-whole" data-card="-1" title="Reset Global (undo can bring it back)" aria-label="Reset Global">${icon('e889')}</button>` : ''}
|
${whole ? `<button type="button" class="se-card-del" data-act="reset-whole" data-card="-1" title="Reset Global (undo can bring it back)" aria-label="Reset Global">${icon('e889')}</button>` : ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
cards.innerHTML = wholeCard + shapes.map((sh, i) => {
|
cards.innerHTML = wholeCard + shapes.map((sh, i) => {
|
||||||
@@ -903,6 +906,7 @@
|
|||||||
${copies(sh).map((d, c) => `<path class="${c ? 'copy' : ''}" d="${d}"/>`).join('')}</svg>
|
${copies(sh).map((d, c) => `<path class="${c ? 'copy' : ''}" d="${d}"/>`).join('')}</svg>
|
||||||
<span>${name}</span>
|
<span>${name}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="se-card-del se-card-save" data-act="save" data-card="${i}" title="Save ${name} to Custom" aria-label="Save ${name}">${icon('e161')}</button>
|
||||||
${shapes.length > 1 ? `<button type="button" class="se-card-del" data-act="del" data-card="${i}" title="Delete ${name} (undo can bring it back)" aria-label="Delete ${name}">${icon('e92e')}</button>` : ''}
|
${shapes.length > 1 ? `<button type="button" class="se-card-del" data-act="del" data-card="${i}" title="Delete ${name} (undo can bring it back)" aria-label="Delete ${name}">${icon('e92e')}</button>` : ''}
|
||||||
${on && !many ? `<div class="se-card-modes" role="group" aria-label="Mode">
|
${on && !many ? `<div class="se-card-modes" role="group" aria-label="Mode">
|
||||||
<button type="button" data-act="edit" data-card="${i}" aria-pressed="${mode === 'edit'}" title="Edit points" ${sh.pts ? '' : 'disabled'}>${icon('f097')}</button>
|
<button type="button" data-act="edit" data-card="${i}" aria-pressed="${mode === 'edit'}" title="Edit points" ${sh.pts ? '' : 'disabled'}>${icon('f097')}</button>
|
||||||
@@ -961,6 +965,7 @@
|
|||||||
else if (b.dataset.act === 'whole') { mode = 'move'; sync(); wholeOn = true; multi = new Set(); pickedCorner = -1; selectOnly(-1); render(); }
|
else if (b.dataset.act === 'whole') { mode = 'move'; sync(); wholeOn = true; multi = new Set(); pickedCorner = -1; selectOnly(-1); render(); }
|
||||||
else if (b.dataset.act === 'reset-whole') { checkpoint(); whole = null; render(); }
|
else if (b.dataset.act === 'reset-whole') { checkpoint(); whole = null; render(); }
|
||||||
else if (b.dataset.act === 'edit' || b.dataset.act === 'move') { mode = b.dataset.act; selectOnly(-1); sync(); render(); }
|
else if (b.dataset.act === 'edit' || b.dataset.act === 'move') { mode = b.dataset.act; selectOnly(-1); sync(); render(); }
|
||||||
|
else if (b.dataset.act === 'save') saveItem(i);
|
||||||
else if (b.dataset.act === 'del') deleteShape(i); else if (b.dataset.act === 'add') { picked.clear(); renderAddGrid(); addDialog.open(); }
|
else if (b.dataset.act === 'del') deleteShape(i); else if (b.dataset.act === 'add') { picked.clear(); renderAddGrid(); addDialog.open(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1608,13 +1613,19 @@
|
|||||||
b.onclick = () => { loadIcon(presetShapes(name), presetWhole(name)); svg.scrollIntoView({ behavior: 'smooth', block: 'center' }); };
|
b.onclick = () => { loadIcon(presetShapes(name), presetWhole(name)); svg.scrollIntoView({ behavior: 'smooth', block: 'center' }); };
|
||||||
$('se-presets').append(b);
|
$('se-presets').append(b);
|
||||||
});
|
});
|
||||||
$('se-presets-more').hidden = presetNames.length <= presetsShown;
|
// Both grids (Presets and Custom, see renderCustom) show the first `visible` tiles; one button shows the
|
||||||
$('se-presets-more').onclick = (e) => {
|
// rest of whichever grid is open, and the other grid follows when switched to.
|
||||||
const open = e.currentTarget.getAttribute('aria-expanded') !== 'true';
|
let moreOpen = false;
|
||||||
for (const b of $('se-presets').querySelectorAll('[data-more]')) b.hidden = !open;
|
function showMore() {
|
||||||
e.currentTarget.setAttribute('aria-expanded', open);
|
const grid = $('se-presets').hidden ? $('se-custom') : $('se-presets'), more = $('se-presets-more');
|
||||||
e.currentTarget.textContent = open ? 'Fewer shapes' : 'More shapes';
|
for (const b of document.querySelectorAll('#se-presets [data-more], #se-custom [data-more]')) b.hidden = !moreOpen;
|
||||||
};
|
more.hidden = grid.children.length <= presetsShown;
|
||||||
|
more.setAttribute('aria-controls', grid.id);
|
||||||
|
more.setAttribute('aria-expanded', moreOpen);
|
||||||
|
more.textContent = moreOpen ? 'Fewer shapes' : 'More shapes';
|
||||||
|
}
|
||||||
|
$('se-presets-more').onclick = () => { moreOpen = !moreOpen; showMore(); };
|
||||||
|
showMore();
|
||||||
|
|
||||||
// ---------- SVG import ----------
|
// ---------- SVG import ----------
|
||||||
|
|
||||||
@@ -1832,12 +1843,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let importNote = ''; // shown under the result until the next edit
|
let importNote = ''; // shown under the result until the next edit
|
||||||
// The Add dialog's grid shows the presets, or the pieces of a loaded SVG file until it's unloaded (closing
|
// The Add dialog's grid shows the presets or the saved shapes (Custom, see library), or the pieces of a loaded
|
||||||
// the dialog keeps it). A tap adds one tile; a long press or Ctrl/Cmd+click starts picking several, after
|
// SVG file until it's unloaded with that switch (closing the dialog keeps it). A tap adds one tile; a long press
|
||||||
// which taps toggle tiles until none are left picked, and the bar under the grid adds them.
|
// or Ctrl/Cmd+click starts picking several, after which taps toggle tiles until none are left picked, and the
|
||||||
|
// bar under the grid adds them.
|
||||||
let loaded = null; // { pieces, merged, notes, view }; view 'pieces' or 'merged' picks what the grid shows
|
let loaded = null; // { pieces, merged, notes, view }; view 'pieces' or 'merged' picks what the grid shows
|
||||||
|
let addLib = 'presets'; // what the grid shows without a loaded file: 'presets' or 'custom'
|
||||||
const viewList = () => (loaded.view === 'merged' ? loaded.merged.pieces : loaded.pieces);
|
const viewList = () => (loaded.view === 'merged' ? loaded.merged.pieces : loaded.pieces);
|
||||||
const picked = new Set(); // keys of picked tiles: preset names, or piece indices
|
const picked = new Set(); // keys of picked tiles: preset names, or saved shape or piece indices
|
||||||
|
// A tile's shapes and whole-icon transform (null for none), and the picked tiles' keys in grid order.
|
||||||
|
const tileShapes = (key) => (addLib === 'custom' ? clone(library[+key].shapes) : presetShapes(key));
|
||||||
|
const tileWhole = (key) => (addLib === 'custom' ? library[+key].whole ?? null : presetWhole(key));
|
||||||
|
const pickedKeys = () => (addLib === 'custom' ? [...picked].sort((a, b) => a - b) : presetNames.filter((n) => picked.has(n)));
|
||||||
const ROOM = 20; // most shapes an icon can have (restore() rejects more)
|
const ROOM = 20; // most shapes an icon can have (restore() rejects more)
|
||||||
const room = () => ROOM - shapes.length;
|
const room = () => ROOM - shapes.length;
|
||||||
const plural = (k) => (k === 1 ? 'one shape' : `${k} shapes`);
|
const plural = (k) => (k === 1 ? 'one shape' : `${k} shapes`);
|
||||||
@@ -1855,10 +1872,13 @@
|
|||||||
}
|
}
|
||||||
const asShapes = (pieces) => pieces.map((path) => ({ path, a: 1, x: 0, y: 0, ang: 0, mirror: 'none', rot: 0 }));
|
const asShapes = (pieces) => pieces.map((path) => ({ path, a: 1, x: 0, y: 0, ang: 0, mirror: 'none', rot: 0 }));
|
||||||
const addPieces = (pieces) => addShapes(asShapes(pieces), loaded?.notes);
|
const addPieces = (pieces) => addShapes(asShapes(pieces), loaded?.notes);
|
||||||
// Baked: each preset's shapes and mirrored copies merged into one plain outline, as drawn (stretch and turn
|
// Baked: each tile's shapes and mirrored copies merged into one plain outline, as its picture draws it (stretch,
|
||||||
// included), the way an imported SVG's pieces merge (parts that don't touch stay apart). A lone outline keeps
|
// turn and whole-icon transform included), the way an imported SVG's pieces merge (parts that don't touch stay
|
||||||
// its exact curves. Corners can't be edited any more.
|
// apart). A lone outline keeps its exact curves. Corners can't be edited any more.
|
||||||
const baked = (names) => asShapes(names.flatMap((n) => { const ds = presetShapes(n).flatMap(copies); return ds.length > 1 ? mergedPieces(ds).pieces : ds; }));
|
const baked = (keys) => asShapes(keys.flatMap((k) => {
|
||||||
|
const w = tileWhole(k), ds = tileShapes(k).flatMap(copies).map((d) => wholeMap(d, w));
|
||||||
|
return ds.length > 1 ? mergedPieces(ds).pieces : ds;
|
||||||
|
}));
|
||||||
const addMerged = (m) => addShapes(asShapes(m.pieces), [...(loaded?.notes || []), holesNote(m.holes), apartNote(m.pieces.length)]);
|
const addMerged = (m) => addShapes(asShapes(m.pieces), [...(loaded?.notes || []), holesNote(m.holes), apartNote(m.pieces.length)]);
|
||||||
|
|
||||||
const tileSvg = (inner) => `<svg viewBox="-4 -4 108 108" aria-hidden="true">${inner}</svg>`;
|
const tileSvg = (inner) => `<svg viewBox="-4 -4 108 108" aria-hidden="true">${inner}</svg>`;
|
||||||
@@ -1869,6 +1889,9 @@
|
|||||||
const all = loaded.pieces.map((d) => `<path class="se-tile-ghost" d="${d}"/>`).join(''), name = loaded.view === 'merged' ? 'Merged' : 'Piece';
|
const all = loaded.pieces.map((d) => `<path class="se-tile-ghost" d="${d}"/>`).join(''), name = loaded.view === 'merged' ? 'Merged' : 'Piece';
|
||||||
tiles.innerHTML = viewList().map((d, k) => `<button type="button" class="se-preset" data-key="${k}" aria-pressed="false">
|
tiles.innerHTML = viewList().map((d, k) => `<button type="button" class="se-preset" data-key="${k}" aria-pressed="false">
|
||||||
${tileSvg(all + `<path d="${d}"/>`)}<span>${name} ${k + 1}</span></button>`).join('');
|
${tileSvg(all + `<path d="${d}"/>`)}<span>${name} ${k + 1}</span></button>`).join('');
|
||||||
|
} else if (addLib === 'custom') {
|
||||||
|
tiles.innerHTML = library.length ? library.map((it, k) => `<button type="button" class="se-preset" data-key="${k}" aria-pressed="false">
|
||||||
|
${iconSvg(it.shapes, it.whole ?? null, 4)}<span>${esc(it.name)}</span></button>`).join('') : `<p class="se-hint">${EMPTY}</p>`;
|
||||||
} else {
|
} else {
|
||||||
tiles.innerHTML = presetNames.map((name) => `<button type="button" class="se-preset" data-key="${name}" aria-pressed="false">
|
tiles.innerHTML = presetNames.map((name) => `<button type="button" class="se-preset" data-key="${name}" aria-pressed="false">
|
||||||
${presetSvg(name, 4)}<span>${name}</span></button>`).join('');
|
${presetSvg(name, 4)}<span>${name}</span></button>`).join('');
|
||||||
@@ -1883,9 +1906,9 @@
|
|||||||
const on = picked.has(b.dataset.key);
|
const on = picked.has(b.dataset.key);
|
||||||
b.classList.toggle('picked', on); b.setAttribute('aria-pressed', on);
|
b.classList.toggle('picked', on); b.setAttribute('aria-pressed', on);
|
||||||
}
|
}
|
||||||
$('se-add-desc').textContent = `Tap to add${loaded ? ' in place' : ' at full size'}. Long-press or Ctrl+click to pick several or see more options.`;
|
$('se-add-desc').textContent = `Tap to add${loaded || addLib === 'custom' ? ' in place' : ' at full size'}. Long-press or Ctrl+click to pick several or see more options.`;
|
||||||
$('se-import-btn').disabled = full;
|
$('se-import-btn').disabled = full;
|
||||||
$('se-import-unload').hidden = !loaded;
|
for (const b of $('se-add-lib').children) b.setAttribute('aria-pressed', !loaded && b.dataset.lib === addLib);
|
||||||
if (full) importMsg(`The icon already has the most shapes it can (${ROOM}): delete one to add more.`);
|
if (full) importMsg(`The icon already has the most shapes it can (${ROOM}): delete one to add more.`);
|
||||||
else if ($('se-import-msg').dataset.full) importMsg('');
|
else if ($('se-import-msg').dataset.full) importMsg('');
|
||||||
$('se-import-msg').dataset.full = full ? '1' : '';
|
$('se-import-msg').dataset.full = full ? '1' : '';
|
||||||
@@ -1919,7 +1942,7 @@
|
|||||||
const sub = loaded && loaded.view === 'pieces' ? [...picked].map(Number).sort((a, b) => a - b).map((i) => loaded.pieces[i]) : null;
|
const sub = loaded && loaded.view === 'pieces' ? [...picked].map(Number).sort((a, b) => a - b).map((i) => loaded.pieces[i]) : null;
|
||||||
const merged = sub && k > 1 ? mergedPieces(sub) : null;
|
const merged = sub && k > 1 ? mergedPieces(sub) : null;
|
||||||
// A preset can hold several shapes: the count is of the shapes added, not of the picks.
|
// A preset can hold several shapes: the count is of the shapes added, not of the picks.
|
||||||
const n = loaded ? k : presetNames.filter((name) => picked.has(name)).flatMap(presetShapes).length;
|
const n = loaded ? k : pickedKeys().flatMap(tileShapes).length;
|
||||||
$('se-pick-count').textContent = `${k} picked` + (n > r ? `, room for ${Math.max(r, 0)}` : '');
|
$('se-pick-count').textContent = `${k} picked` + (n > r ? `, room for ${Math.max(r, 0)}` : '');
|
||||||
$('se-pick-add').textContent = `Add ${n}`;
|
$('se-pick-add').textContent = `Add ${n}`;
|
||||||
$('se-pick-add').title = loaded && k > 1 ? `Add the ${k} picked as separate shapes` : `Add ${plural(n)}`;
|
$('se-pick-add').title = loaded && k > 1 ? `Add the ${k} picked as separate shapes` : `Add ${plural(n)}`;
|
||||||
@@ -1932,8 +1955,8 @@
|
|||||||
$('se-pick-merge').disabled = m > r;
|
$('se-pick-merge').disabled = m > r;
|
||||||
$('se-pick-merge').onclick = () => addMerged(merged);
|
$('se-pick-merge').onclick = () => addMerged(merged);
|
||||||
}
|
}
|
||||||
// Presets only: a loaded file's pieces are outlines already.
|
// Not for a loaded file: its pieces are outlines already.
|
||||||
const bake = !loaded && baked(presetNames.filter((n) => picked.has(n)));
|
const bake = !loaded && baked(pickedKeys());
|
||||||
$('se-pick-bake').hidden = !bake;
|
$('se-pick-bake').hidden = !bake;
|
||||||
if (bake) {
|
if (bake) {
|
||||||
$('se-pick-bake').textContent = `Bake ${bake.length}`;
|
$('se-pick-bake').textContent = `Bake ${bake.length}`;
|
||||||
@@ -1946,7 +1969,7 @@
|
|||||||
$('se-pick-add').onclick = () => {
|
$('se-pick-add').onclick = () => {
|
||||||
const keys = [...picked];
|
const keys = [...picked];
|
||||||
if (loaded) addPieces(keys.map(Number).sort((a, b) => a - b).map((i) => viewList()[i]));
|
if (loaded) addPieces(keys.map(Number).sort((a, b) => a - b).map((i) => viewList()[i]));
|
||||||
else addShapes(presetNames.filter((n) => picked.has(n)).flatMap(presetShapes));
|
else addShapes(pickedKeys().flatMap(tileShapes));
|
||||||
};
|
};
|
||||||
$('se-pick-clear').onclick = () => { picked.clear(); refreshAddUi(); };
|
$('se-pick-clear').onclick = () => { picked.clear(); refreshAddUi(); };
|
||||||
// Taps, long presses and Ctrl+clicks on the tiles.
|
// Taps, long presses and Ctrl+clicks on the tiles.
|
||||||
@@ -1969,11 +1992,16 @@
|
|||||||
const key = b.dataset.key;
|
const key = b.dataset.key;
|
||||||
if (picked.size || e.ctrlKey || e.metaKey) return togglePick(key);
|
if (picked.size || e.ctrlKey || e.metaKey) return togglePick(key);
|
||||||
if (loaded) addPieces([viewList()[+key]]);
|
if (loaded) addPieces([viewList()[+key]]);
|
||||||
else addShapes(presetShapes(key));
|
else addShapes(tileShapes(key));
|
||||||
});
|
});
|
||||||
|
|
||||||
$('se-import-btn').onclick = () => $('se-import-file').click();
|
$('se-import-btn').onclick = () => $('se-import-file').click();
|
||||||
$('se-import-unload').onclick = () => { loaded = null; picked.clear(); importMsg(''); renderAddGrid(); };
|
// Presets or Custom; with a file loaded, either one unloads it.
|
||||||
|
$('se-add-lib').addEventListener('click', (e) => {
|
||||||
|
const b = e.target.closest('[data-lib]');
|
||||||
|
if (!b || (!loaded && b.dataset.lib === addLib)) return;
|
||||||
|
addLib = b.dataset.lib; loaded = null; picked.clear(); importMsg(''); renderAddGrid();
|
||||||
|
});
|
||||||
$('se-import-file').onchange = async (e) => {
|
$('se-import-file').onchange = async (e) => {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
e.target.value = ''; // the same file can be picked again
|
e.target.value = ''; // the same file can be picked again
|
||||||
@@ -2001,6 +2029,118 @@
|
|||||||
$('se-import-separate').onclick = () => loaded && addPieces(loaded.pieces);
|
$('se-import-separate').onclick = () => loaded && addPieces(loaded.pieces);
|
||||||
$('se-import-merge').onclick = () => loaded && addMerged(loaded.merged);
|
$('se-import-merge').onclick = () => loaded && addMerged(loaded.merged);
|
||||||
|
|
||||||
|
// ---------- saved shapes (Custom) ----------
|
||||||
|
|
||||||
|
// Kept in this browser only (localStorage), in the editor's own JSON, the same an edit link holds:
|
||||||
|
// [{ name, shapes, whole? }], checked with cleanShape and cleanWhole when read back, like a link. A built-in
|
||||||
|
// outline is saved as a plain path, so a saved shape doesn't depend on the presets staying as they are.
|
||||||
|
const SAVED = 'murine-shape-editor-saved';
|
||||||
|
const EMPTY = 'Nothing saved yet. Save a shape, or the whole shape, from its card under the editor: it stays in this browser.';
|
||||||
|
const clone = (o) => JSON.parse(JSON.stringify(o));
|
||||||
|
const esc = (t) => t.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
||||||
|
const library = (() => {
|
||||||
|
let list = null;
|
||||||
|
try { list = JSON.parse(localStorage.getItem(SAVED)); } catch (e) { /* private mode or garbage */ }
|
||||||
|
return (Array.isArray(list) ? list : []).map((it) => {
|
||||||
|
const name = typeof it?.name === 'string' ? it.name.trim().slice(0, 40) : '';
|
||||||
|
const got = Array.isArray(it?.shapes) && it.shapes.length && it.shapes.length <= ROOM ? it.shapes.map(cleanShape) : [null];
|
||||||
|
const w = it?.whole === undefined ? null : cleanWhole(it.whole);
|
||||||
|
if (!name || got.includes(null) || (it.whole !== undefined && !w)) return null;
|
||||||
|
return { name, shapes: got, ...(w && { whole: w }) };
|
||||||
|
}).filter(Boolean);
|
||||||
|
})();
|
||||||
|
const storeLibrary = () => { try { localStorage.setItem(SAVED, JSON.stringify(library)); return true; } catch (e) { return false; } };
|
||||||
|
const plainShape = ({ id, fixed, ...sh }) => (fixed ? { path: FIXED[fixed], ...sh } : sh);
|
||||||
|
const moved = (w) => w && (w.a !== 1 || w.x || w.y || w.ang || w.flip || w.st);
|
||||||
|
|
||||||
|
// The name dialog, for saving and renaming. done(name) stores it and says whether that worked.
|
||||||
|
const nameDialog = dialog('se-name');
|
||||||
|
let named = null;
|
||||||
|
function askName(title, value, button, done) {
|
||||||
|
$('se-name-title').textContent = title;
|
||||||
|
$('se-name-ok').textContent = button;
|
||||||
|
$('se-name-input').value = value;
|
||||||
|
$('se-name-msg').hidden = true;
|
||||||
|
named = done;
|
||||||
|
nameDialog.open();
|
||||||
|
$('se-name-input').select();
|
||||||
|
}
|
||||||
|
$('se-name-form').addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const name = $('se-name-input').value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
if (!named(name)) {
|
||||||
|
$('se-name-msg').textContent = "Couldn't save: this browser's storage is full or turned off for this site.";
|
||||||
|
$('se-name-msg').hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nameDialog.close(); renderCustom();
|
||||||
|
});
|
||||||
|
const freeName = () => { let k = library.length + 1; while (library.some((it) => it.name === `Custom ${k}`)) k++; return `Custom ${k}`; };
|
||||||
|
// A shape card saves that shape as it is; Global (i = -1) saves the whole shape, with its whole-icon
|
||||||
|
// transform kept as such (not baked in) when it changes anything.
|
||||||
|
function saveItem(i) {
|
||||||
|
const item = clone(i < 0 ? { shapes: shapes.map(plainShape), ...(moved(whole) && { whole }) } : { shapes: [plainShape(shapes[i])] });
|
||||||
|
askName(i < 0 ? 'Save the whole shape' : `Save ${shapeName(shapes[i], i)}`, freeName(), 'Save', (name) => {
|
||||||
|
library.push({ name, ...item });
|
||||||
|
if (storeLibrary()) return true;
|
||||||
|
library.pop(); return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The page's grid: Presets or Custom. A saved shape replaces the icon, like a preset; its corners rename
|
||||||
|
// and delete it. A deletion can be taken back until the next one.
|
||||||
|
let lastDeleted = null;
|
||||||
|
const customMsg = (text, undo = false) => {
|
||||||
|
$('se-custom-msg').hidden = !text; $('se-custom-msg').firstChild.textContent = text; $('se-custom-undo').hidden = !undo;
|
||||||
|
};
|
||||||
|
function renderCustom() {
|
||||||
|
$('se-custom').innerHTML = library.length ? library.map((it, k) => `<div class="se-saved"${k >= presetsShown ? ' data-more' : ''}>
|
||||||
|
<button type="button" class="se-preset" data-k="${k}" title="${esc(it.name)}">${iconSvg(it.shapes, it.whole ?? null)}<span>${esc(it.name)}</span></button>
|
||||||
|
<button type="button" class="se-tile-btn se-tile-edit" data-rename="${k}" title="Rename" aria-label="Rename ${esc(it.name)}">${icon('e3c9')}</button>
|
||||||
|
<button type="button" class="se-tile-btn se-tile-del" data-del="${k}" title="Delete" aria-label="Delete ${esc(it.name)}">${icon('e92e')}</button>
|
||||||
|
</div>`).join('') : `<p class="se-hint">${EMPTY}</p>`;
|
||||||
|
showMore();
|
||||||
|
}
|
||||||
|
$('se-custom').addEventListener('click', (e) => {
|
||||||
|
const b = e.target.closest('button');
|
||||||
|
if (!b) return;
|
||||||
|
if (b.dataset.k) {
|
||||||
|
const it = library[+b.dataset.k];
|
||||||
|
loadIcon(clone(it.shapes), it.whole ? clone(it.whole) : null);
|
||||||
|
svg.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
} else if (b.dataset.rename) {
|
||||||
|
const it = library[+b.dataset.rename];
|
||||||
|
askName('Rename', it.name, 'Rename', (name) => {
|
||||||
|
const old = it.name;
|
||||||
|
it.name = name;
|
||||||
|
if (storeLibrary()) return true;
|
||||||
|
it.name = old; return false;
|
||||||
|
});
|
||||||
|
} else if (b.dataset.del) {
|
||||||
|
const k = +b.dataset.del;
|
||||||
|
lastDeleted = { k, it: library.splice(k, 1)[0] };
|
||||||
|
storeLibrary(); renderCustom();
|
||||||
|
customMsg(`Deleted “${lastDeleted.it.name}”.`, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$('se-custom-undo').onclick = () => {
|
||||||
|
if (!lastDeleted) return;
|
||||||
|
library.splice(lastDeleted.k, 0, lastDeleted.it);
|
||||||
|
lastDeleted = null;
|
||||||
|
storeLibrary(); renderCustom(); customMsg('');
|
||||||
|
};
|
||||||
|
$('se-lib').addEventListener('click', (e) => {
|
||||||
|
const b = e.target.closest('[data-lib]');
|
||||||
|
if (!b) return;
|
||||||
|
const custom = b.dataset.lib === 'custom';
|
||||||
|
for (const x of $('se-lib').children) x.setAttribute('aria-pressed', x === b);
|
||||||
|
$('se-presets').hidden = custom; $('se-custom').hidden = !custom;
|
||||||
|
showMore();
|
||||||
|
customMsg(''); lastDeleted = null;
|
||||||
|
});
|
||||||
|
renderCustom();
|
||||||
|
|
||||||
// ---------- start ----------
|
// ---------- start ----------
|
||||||
|
|
||||||
let saved = null;
|
let saved = null;
|
||||||
|
|||||||
+58
-2
@@ -270,11 +270,43 @@ noindex: true # and out of search engines
|
|||||||
.se-pick-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-left: auto; }
|
.se-pick-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-left: auto; }
|
||||||
.se-btn-primary { background: var(--md-primary); color: var(--md-on-primary); border-color: var(--md-primary); }
|
.se-btn-primary { background: var(--md-primary); color: var(--md-on-primary); border-color: var(--md-primary); }
|
||||||
.se-btn-primary:hover:not(:disabled) { background: var(--md-primary); filter: brightness(1.08); }
|
.se-btn-primary:hover:not(:disabled) { background: var(--md-primary); filter: brightness(1.08); }
|
||||||
|
/* Saved shapes: the Presets / Custom switch is the section's title; custom tiles get rename and delete corners. */
|
||||||
|
.page-body h2.se-lib-title { margin: 40px 0 0; }
|
||||||
|
.se-seg-title { margin: 0; }
|
||||||
|
.se-seg-title button { font: inherit; padding: 4px 18px; } /* the size of the other section titles */
|
||||||
|
.se-seg-title button[aria-pressed="false"] { color: var(--md-on-surface-variant); }
|
||||||
|
.se-presets[hidden], .se-presets-more[hidden], .se-saved[hidden] { display: none; }
|
||||||
|
#se-add-lib { margin: 0; }
|
||||||
|
.se-saved { position: relative; display: flex; }
|
||||||
|
.se-saved > .se-preset { flex: 1; min-width: 0; padding-top: 22px; }
|
||||||
|
.se-saved > .se-preset span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.se-tile-btn { position: absolute; top: 2px; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center;
|
||||||
|
padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--md-on-surface-variant); cursor: pointer; }
|
||||||
|
.se-tile-btn .material-symbols-outlined { font-size: 18px; }
|
||||||
|
.se-tile-edit { left: 2px; }
|
||||||
|
.se-tile-del { right: 2px; }
|
||||||
|
.se-tile-btn:hover { background: var(--md-surface-container-highest); color: var(--md-primary); }
|
||||||
|
.se-tile-del:hover { color: var(--md-error); }
|
||||||
|
.se-card-save { right: auto; left: 2px; }
|
||||||
|
.se-card-save:hover { color: var(--md-primary); }
|
||||||
|
.se-presets > .se-hint { grid-column: 1 / -1; margin: 0; }
|
||||||
|
.se-custom-msg { display: flex; align-items: center; gap: 8px; margin: -4px 0 16px; }
|
||||||
|
.se-custom-msg[hidden] { display: none; }
|
||||||
|
.se-name-modal { max-width: 400px; }
|
||||||
|
.se-name-input { display: block; width: 100%; box-sizing: border-box; margin: 4px 0 4px; padding: 10px 12px; font: inherit;
|
||||||
|
color: var(--md-on-surface); background: var(--md-surface-container-lowest); border: 1px solid var(--md-outline);
|
||||||
|
border-radius: var(--md-shape-sm, 8px); }
|
||||||
|
.se-name-input:focus { outline: 2px solid var(--md-primary); outline-offset: -1px; }
|
||||||
|
#se-name .se-pick-actions { margin-top: 16px; }
|
||||||
#se-measure { position: absolute; width: 0; height: 0; overflow: hidden; }
|
#se-measure { position: absolute; width: 0; height: 0; overflow: hidden; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<h2>Presets</h2>
|
<h2 class="se-lib-title"><span id="se-lib" class="se-seg se-seg-title" role="group" aria-label="Shapes to start from">
|
||||||
|
<button type="button" data-lib="presets" aria-pressed="true">Presets</button><button type="button" data-lib="custom" aria-pressed="false" title="Shapes you saved, kept in this browser">Custom</button>
|
||||||
|
</span></h2>
|
||||||
<div id="se-presets" class="se-presets"></div>
|
<div id="se-presets" class="se-presets"></div>
|
||||||
|
<div id="se-custom" class="se-presets" hidden></div>
|
||||||
|
<p id="se-custom-msg" class="se-hint se-custom-msg" role="status" hidden><span></span> <button type="button" class="se-btn" id="se-custom-undo" hidden>Undo</button></p>
|
||||||
<button id="se-presets-more" class="se-btn se-presets-more" type="button" aria-expanded="false" aria-controls="se-presets">More shapes</button>
|
<button id="se-presets-more" class="se-btn se-presets-more" type="button" aria-expanded="false" aria-controls="se-presets">More shapes</button>
|
||||||
|
|
||||||
<div class="se-title-row">
|
<div class="se-title-row">
|
||||||
@@ -330,6 +362,8 @@ noindex: true # and out of search engines
|
|||||||
<p><strong>Several shapes:</strong> <span class="se-kb">Ctrl+click (or long-press) a shape or its card to move them
|
<p><strong>Several shapes:</strong> <span class="se-kb">Ctrl+click (or long-press) a shape or its card to move them
|
||||||
together; Esc stops.</span><span class="se-touch">long-press a shape or its card to move them together, then tap
|
together; Esc stops.</span><span class="se-touch">long-press a shape or its card to move them together, then tap
|
||||||
cards to add or remove more. Tap outside the group to stop.</span></p>
|
cards to add or remove more. Tap outside the group to stop.</span></p>
|
||||||
|
<p><strong>Saving:</strong> the save button on a card keeps that shape (on Global, the whole shape) under
|
||||||
|
Custom, above the editor and in Add a shape. It stays in this browser.</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="se-fit-tools" class="se-stage-tools" hidden>
|
<div id="se-fit-tools" class="se-stage-tools" hidden>
|
||||||
<button id="se-fit-shape" class="se-stage-btn" type="button" title="Fit this shape to the box" aria-label="Fit this shape to the box"><span class="material-symbols-outlined" aria-hidden="true"></span></button>
|
<button id="se-fit-shape" class="se-stage-btn" type="button" title="Fit this shape to the box" aria-label="Fit this shape to the box"><span class="material-symbols-outlined" aria-hidden="true"></span></button>
|
||||||
@@ -567,8 +601,10 @@ noindex: true # and out of search engines
|
|||||||
<p id="se-add-desc" class="contact-modal-desc"></p>
|
<p id="se-add-desc" class="contact-modal-desc"></p>
|
||||||
<div class="se-import">
|
<div class="se-import">
|
||||||
<div class="se-row">
|
<div class="se-row">
|
||||||
|
<span id="se-add-lib" class="se-seg" role="group" aria-label="Show in the grid">
|
||||||
|
<button type="button" data-lib="presets">Presets</button><button type="button" data-lib="custom" title="Shapes you saved, kept in this browser">Custom</button>
|
||||||
|
</span>
|
||||||
<button id="se-import-btn" class="se-btn" type="button" title="Import the shapes of an SVG file">Import SVG…</button>
|
<button id="se-import-btn" class="se-btn" type="button" title="Import the shapes of an SVG file">Import SVG…</button>
|
||||||
<button id="se-import-unload" class="se-btn" type="button" title="Unload the SVG file and show the presets again" hidden>Presets</button>
|
|
||||||
</div>
|
</div>
|
||||||
<input id="se-import-file" type="file" accept=".svg,image/svg+xml" hidden>
|
<input id="se-import-file" type="file" accept=".svg,image/svg+xml" hidden>
|
||||||
<p id="se-import-msg" class="se-hint" role="status" hidden></p>
|
<p id="se-import-msg" class="se-hint" role="status" hidden></p>
|
||||||
@@ -596,6 +632,26 @@ noindex: true # and out of search engines
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="contact-modal-overlay" id="se-name" aria-hidden="true">
|
||||||
|
<div class="contact-modal se-name-modal" role="dialog" aria-modal="true" aria-labelledby="se-name-title">
|
||||||
|
<div class="contact-modal-header">
|
||||||
|
<h2 id="se-name-title">Save shape</h2>
|
||||||
|
<button class="contact-modal-close" type="button" aria-label="Close" data-close>
|
||||||
|
<span class="material-symbols-outlined"></span> <!-- close -->
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form id="se-name-form">
|
||||||
|
<label for="se-name-input" class="se-hint">Name</label>
|
||||||
|
<input id="se-name-input" class="se-name-input" type="text" maxlength="40" required autocomplete="off">
|
||||||
|
<p id="se-name-msg" class="se-hint" role="status" hidden></p>
|
||||||
|
<div class="se-pick-actions">
|
||||||
|
<button class="se-btn" type="button" data-close>Cancel</button>
|
||||||
|
<button id="se-name-ok" class="se-btn se-btn-primary" type="submit">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<svg id="se-measure" viewBox="0 0 100 100" aria-hidden="true"><path id="se-measure-path"/></svg>
|
<svg id="se-measure" viewBox="0 0 100 100" aria-hidden="true"><path id="se-measure-path"/></svg>
|
||||||
|
|
||||||
<script src="{{ '/assets/js/polygon-clipping.min.js' | relative_url }}?v={{ site.version }}"></script>
|
<script src="{{ '/assets/js/polygon-clipping.min.js' | relative_url }}?v={{ site.version }}"></script>
|
||||||
|
|||||||
Reference in new issue
Block a user