WEBSITE - WIP - icon shapes - merge and multi-select svg shapes

This commit is contained in:
Alex Sim committed 2026-10-11 18:03:25 +02:00
1 parent 2426a8ad77
commit b0a8363ac2
3 files changed
+208 -76

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@ description: >-
url: "https://alesimula.github.io"
baseurl: ""
version: "1.0.24" #Website version - increase to invalidate cached resources
version: "1.0.25" #Website version - increase to invalidate cached resources
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_freeform_url: "https://raw.githubusercontent.com/alesimula/Murine-launcher/refs/heads/main/image/app_icon/icon_freeform.svg"
+158 -68
View File
@@ -528,11 +528,14 @@
// Each shape and mirrored copy is filled and outlined on its own. The selected one is drawn last: while
// selected it sits on top, and clicks on it reach it first (see shapeAt).
const order = shapes.map((_, i) => i).filter((i) => i !== active).concat(active);
const strokes = order.flatMap((i) => lines[i].map((d, c) => ({ d, cls: 'se-line' + (i === active ? ' active' : '') + (c ? ' copy' : '') })));
const layer = $('se-lines');
resize(layer, strokes.length, () => el('path', {}));
strokes.forEach((s, k) => { layer.children[k].setAttribute('d', s.d); layer.children[k].setAttribute('class', s.cls); });
// The others go in two layers, fills (whose overlaps are capped, see #se-cap-*) and outlines.
const back = shapes.flatMap((_, i) => (i === active ? [] : lines[i].map((d, c) => ({ d, cls: 'se-line' + (c ? ' copy' : '') }))));
const front = lines[active].map((d, c) => ({ d, cls: 'se-line active' + (c ? ' copy' : '') }));
for (const [id, list] of [['se-back-fill', back], ['se-back-stroke', back], ['se-front', front]]) {
const layer = $(id);
resize(layer, list.length, () => el('path', {}));
list.forEach((s, k) => { layer.children[k].setAttribute('d', s.d); layer.children[k].setAttribute('class', s.cls); });
}
$('se-outline').setAttribute('points', pts && mode === 'edit' ? pts.map(wpt).map((p) => p.x + ',' + p.y).join(' ') : '');
// Neighbours whose rounding the selected corner is currently eating into.
@@ -691,19 +694,20 @@
}).join('') + `<button type="button" class="se-card se-card-add" data-act="add" data-card="-1" title="Add a shape" aria-label="Add a shape">${icon('e145')}</button>`;
if (refocus) cards.querySelector(`[data-act="${refocus.split(':')[0]}"][data-card="${refocus.split(':')[1]}"]`)?.focus();
}
function deleteShape(i) {
if (!confirm(`Delete ${shapeName(shapes[i], i)}? Undo can still bring it back.`)) return;
checkpoint();
shapes.splice(i, 1);
if (active >= i && active > 0) active--;
sel = -1; sync(); render();
}
$('se-cards').addEventListener('click', (e) => {
const b = e.target.closest('[data-act]');
if (!b) return;
const i = +b.dataset.card;
if (b.dataset.act === 'pick') { active = i; sel = -1; sync(); render(); } // keeps the mode, like a tap on the canvas
else if (b.dataset.act === 'edit' || b.dataset.act === 'move') { mode = b.dataset.act; sel = -1; sync(); render(); }
else if (b.dataset.act === 'del') {
if (!confirm(`Delete ${shapeName(shapes[i], i)}? Undo can still bring it back.`)) return;
checkpoint();
shapes.splice(i, 1);
if (active >= i && active > 0) active--;
sel = -1; sync(); render();
} else if (b.dataset.act === 'add') { resetImport(); addDialog.open(); }
else if (b.dataset.act === 'del') deleteShape(i); else if (b.dataset.act === 'add') { picked.clear(); renderAddGrid(); addDialog.open(); }
});
// ---------- canvas interaction ----------
@@ -845,13 +849,17 @@
// unless you're typing in a field or a dialog is open. Arrows still need the canvas focused, so elsewhere
// they keep moving sliders and scrolling the page.
const typing = e.target.matches('input:not([type=range]):not([type=checkbox]), textarea, select');
const pointKey = (sel >= 0 && mode === 'edit' && ['Delete', 'Backspace', 'Escape'].includes(e.key)
|| pickedCorner >= 0 && e.key === 'Escape') && !typing && !e.target.closest('.contact-modal-overlay');
const del = e.key === 'Delete' || e.key === 'Backspace';
// In Move mode with no handle selected, Delete asks to delete the selected shape (like its card's bin).
const shapeKey = mode === 'move' && pickedCorner < 0 && del;
const pointKey = (sel >= 0 && mode === 'edit' && (del || e.key === 'Escape') || pickedCorner >= 0 && e.key === 'Escape' || shapeKey)
&& !typing && !e.target.closest('.contact-modal-overlay');
if (!pointKey && !svg.contains(document.activeElement)) return;
const step = grid.snap ? grid.step : e.shiftKey ? 5 : 0.5;
const mv = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];
if (mode === 'move') { // arrows move the whole shape, Esc deselects the corner
if (mode === 'move') { // arrows move the whole shape, Esc deselects the corner, Delete deletes the shape
if (e.key === 'Escape') { pickedCorner = -1; e.preventDefault(); return render(); }
if (shapeKey) { e.preventDefault(); return shapes.length > 1 ? deleteShape(active) : shake(); }
if (!mv) return;
checkpoint();
pickedCorner = -1;
@@ -1068,27 +1076,14 @@
// ---------- presets ----------
// A preset in the grid replaces the whole icon; picked from the "+" card it is added as new shapes,
// full canvas size and in Move mode, ready to be resized and placed.
const tile = (name) => {
// A preset in the page's grid replaces the whole icon; the Add dialog's grid (see renderAddGrid) adds shapes.
for (const name of [...Object.keys(PRESETS), ...Object.keys(FIXED)]) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'se-preset';
b.innerHTML = `<svg viewBox="0 0 100 100" aria-hidden="true"><path d="${presetShapes(name).map(shapePath).join(' ')}"/></svg><span>${name}</span>`;
return b;
};
for (const name of [...Object.keys(PRESETS), ...Object.keys(FIXED)]) {
const b = tile(name);
b.onclick = () => { loadIcon(presetShapes(name)); svg.scrollIntoView({ behavior: 'smooth', block: 'center' }); };
$('se-presets').append(b);
const a = tile(name);
a.onclick = () => {
checkpoint();
shapes.push(...presetShapes(name));
active = shapes.length - 1; sel = -1; mode = 'move';
sync(); addDialog.close(); render();
};
$('se-add-grid').append(a);
}
// ---------- SVG import ----------
@@ -1307,28 +1302,132 @@
}
let importNote = ''; // shown under the result until the next edit
let pending = null; // pieces waiting for "separate or merge"
// The Add dialog's grid shows the presets, or the pieces of a loaded SVG file until it's unloaded (closing
// the dialog keeps it). A tap adds one tile; a long press 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
const viewList = () => (loaded.view === 'merged' ? loaded.merged.pieces : loaded.pieces);
const picked = new Set(); // keys of picked tiles: preset names, or piece indices
const ROOM = 20; // most shapes an icon can have (restore() rejects more)
const room = () => ROOM - shapes.length;
const plural = (k) => (k === 1 ? 'one shape' : `${k} shapes`);
const importMsg = (text) => { $('se-import-msg').textContent = text; $('se-import-msg').hidden = !text; };
const resetImport = () => {
pending = null; $('se-import-choice').hidden = true; $('se-import-file').value = '';
const full = shapes.length >= 20;
$('se-import-btn').disabled = full;
importMsg(full ? 'The icon already has the most shapes it can (20): delete one to import.' : '');
};
function addImported(pieces, notes) {
checkpoint();
const first = shapes.length;
shapes.push(...pieces.map((path) => ({ path, a: 1, x: 0, y: 0, ang: 0, mirror: 'none', rot: 0 })));
active = first; sel = -1; mode = 'move';
importNote = notes.filter(Boolean).join(' ');
sync(); addDialog.close(); resetImport(); render();
}
const holesNote = (n) => (n ? `${n} hole${n > 1 ? 's were' : ' was'} filled in: the launcher needs outlines without holes.` : '');
const apartNote = (n) => (n > 1 ? `Not all pieces touch, so they became ${n} shapes instead of one.` : '');
function addShapes(list, notes = []) {
checkpoint();
active = shapes.length;
shapes.push(...list);
sel = -1; mode = 'move';
importNote = notes.filter(Boolean).join(' ');
picked.clear();
sync(); addDialog.close(); render();
}
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 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>`;
function renderAddGrid() {
const tiles = $('se-add-grid'), full = room() <= 0;
if (loaded) {
// Each piece shown in place, over the faded rest of the file, so it's clear which part it is.
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">
${tileSvg(all + `<path d="${d}"/>`)}<span>${name} ${k + 1}</span></button>`).join('');
} else {
tiles.innerHTML = [...Object.keys(PRESETS), ...Object.keys(FIXED)].map((name) => `<button type="button" class="se-preset" data-key="${name}" aria-pressed="false">
${tileSvg(`<path d="${presetShapes(name).map(shapePath).join(' ')}"/>`)}<span>${name}</span></button>`).join('');
}
for (const b of tiles.children) b.disabled = full;
refreshAddUi();
}
// Everything around the grid that depends on what's loaded, what's picked and the room left.
function refreshAddUi() {
const r = room(), full = r <= 0;
for (const b of $('se-add-grid').children) {
const on = picked.has(b.dataset.key);
b.classList.toggle('picked', on); b.setAttribute('aria-pressed', on);
}
$('se-add-desc').textContent = (loaded ? `Tap a${loaded.view === 'merged' ? ' merged group' : ' piece'} to add it where it sits in the file` : "Pick a starting point. It's added at full size")
+ '. Long-press or Ctrl+click to pick several.';
$('se-import-btn').disabled = full;
$('se-import-unload').hidden = !loaded;
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('');
$('se-import-msg').dataset.full = full ? '1' : '';
// "Add all" and "merge all" for a loaded file.
$('se-import-choice').hidden = !loaded;
if (loaded) {
const n = loaded.pieces.length, m = loaded.merged.pieces.length;
// Only worth switching when merging changes something.
$('se-import-view').hidden = m >= n;
for (const b of $('se-import-view').children) {
b.setAttribute('aria-pressed', b.dataset.view === loaded.view);
b.textContent = (b.dataset.view === 'merged' ? 'Merged' : 'Pieces') + ` (${b.dataset.view === 'merged' ? m : n})`;
}
$('se-import-count').textContent = `${n} pieces` + (m === 1 ? ', which merge into one shape.' : `; not all of them touch, so merged they'd still be ${plural(m)}.`)
+ (n > r || m > r ? ` There's room for ${Math.max(r, 0)} more shape${r === 1 ? '' : 's'} (${ROOM} at most).` : '');
$('se-import-separate').textContent = `Add all ${n} separately`;
$('se-import-separate').disabled = n > r;
$('se-import-merge').textContent = m === 1 ? 'Merge all into one shape' : `Merge all into ${m} shapes`;
$('se-import-merge').disabled = m > r;
}
// The bar for picked tiles.
const k = picked.size;
$('se-pick-bar').hidden = !k;
if (k) {
// Merging picked groups that are already merged wouldn't change anything.
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;
$('se-pick-count').textContent = `${k} picked` + (k > r ? `, room for ${Math.max(r, 0)}` : '');
$('se-pick-add').textContent = loaded ? `Add ${k} separately` : `Add ${plural(k)}`;
$('se-pick-add').disabled = k > r;
$('se-pick-merge').hidden = !merged;
if (merged) {
const m = merged.pieces.length;
$('se-pick-merge').textContent = m === 1 ? 'Merge into one' : `Merge (${m} shapes)`;
$('se-pick-merge').disabled = m > r;
$('se-pick-merge').onclick = () => addMerged(merged);
}
}
}
$('se-pick-add').onclick = () => {
const keys = [...picked];
if (loaded) addPieces(keys.map(Number).sort((a, b) => a - b).map((i) => viewList()[i]));
else addShapes(Object.keys({ ...PRESETS, ...FIXED }).filter((n) => picked.has(n)).flatMap(presetShapes));
};
$('se-pick-clear').onclick = () => { picked.clear(); refreshAddUi(); };
// Taps, long presses and Ctrl+clicks on the tiles.
let press = null, longPressed = false;
const togglePick = (key) => { if (!picked.delete(key)) picked.add(key); refreshAddUi(); };
const addGrid = $('se-add-grid');
addGrid.addEventListener('pointerdown', (e) => {
const b = e.target.closest('.se-preset');
if (!b || b.disabled || e.button > 0) return;
longPressed = false;
press = { x: e.clientX, y: e.clientY, timer: setTimeout(() => { longPressed = true; navigator.vibrate?.(15); togglePick(b.dataset.key); }, 450) };
});
addGrid.addEventListener('pointermove', (e) => { if (press && Math.hypot(e.clientX - press.x, e.clientY - press.y) > 10) { clearTimeout(press.timer); press = null; } });
for (const t of ['pointerup', 'pointercancel', 'pointerleave']) addGrid.addEventListener(t, () => { if (press) clearTimeout(press.timer); press = null; });
addGrid.addEventListener('contextmenu', (e) => { if (e.target.closest('.se-preset')) e.preventDefault(); }); // a long press isn't a right-click here
addGrid.addEventListener('click', (e) => {
const b = e.target.closest('.se-preset');
if (!b) return;
if (longPressed) { longPressed = false; return; } // the long press already picked it
const key = b.dataset.key;
if (picked.size || e.ctrlKey || e.metaKey) return togglePick(key);
if (loaded) addPieces([viewList()[+key]]);
else addShapes(presetShapes(key));
});
$('se-import-btn').onclick = () => $('se-import-file').click();
$('se-import-unload').onclick = () => { loaded = null; picked.clear(); importMsg(''); renderAddGrid(); };
$('se-import-file').onchange = async (e) => {
const file = e.target.files[0];
resetImport();
e.target.value = ''; // the same file can be picked again
if (!file) return;
importMsg('');
if (file.size > 2e6) return importMsg('That file is over 2 MB, too big for an icon.');
let res;
try { res = svgPieces(await file.text()); } catch (err) { return importMsg(err.message === 'bad path' ? "That file's paths couldn't be read." : err.message); }
@@ -1337,29 +1436,20 @@
: 'No filled shapes were found in that file.');
}
const notes = [holesNote(res.holes), res.strokes ? `${res.strokes} line-only element${res.strokes > 1 ? 's were' : ' was'} left out.` : ''];
if (res.pieces.length === 1) return addImported(res.pieces, notes);
// The merge is worked out now, so the choice can say how many shapes each option makes.
const merged = mergedPieces(res.pieces);
pending = { ...res, notes, merged };
const room = 20 - shapes.length, n = res.pieces.length, m = merged?.pieces.length;
const plural = (k) => (k === 1 ? 'one shape' : `${k} shapes`);
$('se-import-count').textContent = `This file has ${n} separate pieces. Keep each one as its own shape, or merge them`
+ (m === 1 ? ' into one?' : `? Not all of them touch, so merged they'd still be ${plural(m)}.`)
+ (n > room || !(m <= room) ? ` There's room for ${room} more shape${room === 1 ? '' : 's'} (20 at most)`
+ (n > room && !(m <= room) ? ', so neither fits.' : n > room ? ', so only merging fits.' : ', so only keeping them separate fits.') : '');
$('se-import-separate').disabled = n > room;
$('se-import-merge').disabled = !(m <= room);
$('se-import-merge').textContent = m === 1 ? 'Merge into one shape' : `Merge into ${m} shapes`;
$('se-import-separate').textContent = `Keep as ${n} separate shapes`;
$('se-import-choice').hidden = false;
};
$('se-import-separate').onclick = () => pending && addImported(pending.pieces, pending.notes);
$('se-import-merge').onclick = () => {
const m = pending?.merged;
if (!m) return;
addImported(m.pieces, [...pending.notes, holesNote(m.holes),
m.pieces.length > 1 ? `Not all pieces touch, so they became ${m.pieces.length} shapes instead of one.` : '']);
if (res.pieces.length === 1) { loaded = null; picked.clear(); renderAddGrid(); return addShapes(asShapes(res.pieces), notes); } // nothing to choose
// The merge is worked out now, so the buttons can say how many shapes it makes.
loaded = { pieces: res.pieces, merged: mergedPieces(res.pieces), notes, view: 'pieces' };
picked.clear();
importMsg(notes.filter(Boolean).join(' '));
renderAddGrid();
};
$('se-import-view').addEventListener('click', (e) => {
const b = e.target.closest('[data-view]');
if (!b || !loaded || b.dataset.view === loaded.view) return;
loaded.view = b.dataset.view; picked.clear(); renderAddGrid();
});
$('se-import-separate').onclick = () => loaded && addPieces(loaded.pieces);
$('se-import-merge').onclick = () => loaded && addMerged(loaded.merged);
// ---------- start ----------
+49 -7
View File
@@ -38,6 +38,10 @@ noindex: true # and out of search engines
.se-line.copy { stroke-dasharray: .4 .8; }
.se-line.active { stroke: var(--md-primary); stroke-width: .5; stroke-dasharray: none; }
.se-line.active.copy { stroke-width: .35; stroke-dasharray: 1.2 1; }
#se-back-fill { filter: url(#se-cap-light); }
[data-theme="dark"] #se-back-fill { filter: url(#se-cap-dark); }
#se-back-fill .se-line { stroke: none; }
#se-back-stroke .se-line { fill: none; }
.se-move-box { fill: transparent; stroke: var(--md-primary); stroke-width: .3; stroke-dasharray: 1 1; cursor: move; }
.se-resize { fill: var(--md-surface-container-lowest); stroke: var(--md-primary); stroke-width: .5; }
.se-resize.selected { fill: var(--md-primary); }
@@ -183,10 +187,30 @@ noindex: true # and out of search engines
#se-add h2 { margin: 0; }
#se-add .contact-modal { max-width: 640px; }
#se-add .contact-modal-desc { margin: 0 0 16px; }
#se-add .se-presets { margin: 0; max-height: 60vh; overflow-y: auto; }
/* The dialog never grows past the screen: only the grid scrolls, so the buttons above it and the bar
under it stay in view (the whole dialog scrolls only if even a one-row grid doesn't fit). */
#se-add .contact-modal { max-height: 100%; display: flex; flex-direction: column; overflow-y: auto; }
#se-add .contact-modal > * { flex: none; }
#se-add .contact-modal > .se-presets { flex: 1 1 auto; min-height: 110px; margin: 0; overflow-y: auto; }
@media (max-width: 480px) { #se-add { padding: 12px; } }
/* On a very short screen the whole dialog scrolls: the bar then sticks to its bottom edge. */
#se-add .se-pick-bar { position: sticky; bottom: 0; padding: 8px 0; margin-top: 4px; background: var(--md-surface-container-low); }
.se-import { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; margin: 0 0 16px; }
.se-import p { margin: 0; }
#se-import-choice[hidden], #se-import-msg[hidden] { display: none; }
#se-import-choice[hidden], #se-import-msg[hidden], #se-import-unload[hidden], .se-pick-bar[hidden] { display: none; }
.se-preset.picked { border-color: var(--md-primary); background: var(--md-primary-container); box-shadow: inset 0 0 0 1px var(--md-primary); }
.se-preset:disabled { opacity: .45; cursor: default; }
.se-preset { -webkit-touch-callout: none; user-select: none; } /* long-press picks, it doesn't open the callout or select text */
.se-preset .se-tile-ghost { opacity: .18; }
.se-seg { display: inline-flex; margin: 8px 0; border: 1px solid var(--md-outline-variant); border-radius: var(--md-shape-full); overflow: hidden; }
.se-seg[hidden] { display: none; }
.se-seg button { font: inherit; font-size: .82rem; padding: 6px 14px; border: 0; background: transparent; color: var(--md-on-surface); cursor: pointer; }
.se-seg button + button { border-left: 1px solid var(--md-outline-variant); }
.se-seg button[aria-pressed="true"] { background: var(--md-primary-container); color: var(--md-on-primary-container); }
.se-pick-bar { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.se-pick-bar span { margin-right: auto; font-size: .85rem; color: var(--md-on-surface-variant); }
.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-measure { position: absolute; width: 0; height: 0; overflow: hidden; }
</style>
@@ -200,7 +224,13 @@ noindex: true # and out of search engines
<svg id="se-canvas" viewBox="-20 -20 140 140" tabindex="0" aria-label="Shape editor. In Edit mode drag points to move them and the plus marks to add points; in Move mode drag the shape or its corners.">
<g id="se-grid"></g>
<rect class="se-bounds" x="0" y="0" width="100" height="100"/>
<g id="se-lines"></g>
<defs>
<!-- The other shapes' fills add up where they overlap, but only up to a cap, so a pile of them never
reaches the selected shape's colour: alpha becomes min(alpha, cap). -->
<filter id="se-cap-light" filterUnits="userSpaceOnUse" x="-1000" y="-1000" width="2200" height="2200"><feComponentTransfer><feFuncA type="table" tableValues="0 .1 .2 .3 .4 .5 .6 .7 .8 .9 .9"/></feComponentTransfer></filter>
<filter id="se-cap-dark" filterUnits="userSpaceOnUse" x="-1000" y="-1000" width="2200" height="2200"><feComponentTransfer><feFuncA type="table" tableValues="0 .5 .5"/></feComponentTransfer></filter>
</defs>
<g id="se-lines"><g id="se-back-fill"></g><g id="se-back-stroke"></g><g id="se-front"></g></g>
<polygon id="se-outline" class="se-outline"/>
<g id="se-stretch-mark" class="se-pivot-mark" style="display: none"><circle r=".8" fill="var(--md-primary)" stroke="none"/><path/></g>
<g id="se-adders"></g>
@@ -358,20 +388,32 @@ noindex: true # and out of search engines
<span class="material-symbols-outlined">&#xE5CD;</span> <!-- close -->
</button>
</div>
<p class="contact-modal-desc">Pick a starting point. It's added at full size, ready to move and resize.</p>
<p id="se-add-desc" class="contact-modal-desc"></p>
<div class="se-import">
<button id="se-import-btn" class="se-btn" type="button">Import an SVG file…</button>
<div class="se-row">
<button id="se-import-btn" class="se-btn" type="button">Import an SVG file…</button>
<button id="se-import-unload" class="se-btn" type="button" hidden>Back to the presets</button>
</div>
<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>
<div id="se-import-choice" hidden>
<p id="se-import-count" class="se-hint"></p>
<div id="se-import-view" class="se-seg" role="group" aria-label="Show in the grid">
<button type="button" data-view="pieces">Pieces</button><button type="button" data-view="merged">Merged</button>
</div>
<div class="se-row">
<button id="se-import-separate" class="se-btn" type="button">Keep as separate shapes</button>
<button id="se-import-merge" class="se-btn" type="button">Merge into one shape</button>
<button id="se-import-separate" class="se-btn" type="button">Add all separately</button>
<button id="se-import-merge" class="se-btn" type="button">Merge all</button>
</div>
</div>
</div>
<div id="se-add-grid" class="se-presets"></div>
<div id="se-pick-bar" class="se-pick-bar" hidden>
<span id="se-pick-count"></span>
<button id="se-pick-clear" class="se-btn" type="button">Cancel</button>
<button id="se-pick-merge" class="se-btn" type="button" hidden>Merge</button>
<button id="se-pick-add" class="se-btn se-btn-primary" type="button">Add</button>
</div>
</div>
</div>