diff --git a/src/index.html b/src/index.html
index 0faa708..f895658 100644
--- a/src/index.html
+++ b/src/index.html
@@ -1396,6 +1396,10 @@
outline-offset: 2px;
}
+ .freeLayer.multiSelected {
+ outline-style: dashed;
+ }
+
/* Drag-resize handles on the selected layer — 4 corners + 4 edges. */
.layerHandle {
position: absolute;
@@ -1606,6 +1610,13 @@
background: #eff6ff;
}
+ .layerItem.multiSelected {
+ border-color: var(--accent);
+ color: var(--accent);
+ background: rgba(0,184,230,.08);
+ box-shadow: inset 3px 0 0 var(--accent);
+ }
+
.textTools {
display: flex;
gap: 8px;
@@ -1890,6 +1901,7 @@
body.dark .feedTrustToggle select{background:#0b1220}
body.nostr-logged-in .feedTrustToggle{display:flex}
.layerLockTag{display:inline-block;font-size:.58rem;font-weight:800;letter-spacing:.06em;color:var(--muted);background:rgba(127,127,127,.15);border:1px solid var(--line);border-radius:3px;padding:0 4px;margin-right:6px;vertical-align:1px}
+ .layerGroupTag{display:inline-block;font-size:.58rem;font-weight:800;letter-spacing:.06em;color:var(--accent);background:rgba(0,184,230,.1);border:1px solid rgba(0,184,230,.25);border-radius:3px;padding:0 4px;margin-right:6px;vertical-align:1px}
.feedTrustBadge{font-size:.62rem;background:rgba(0,184,230,.12);color:var(--accent);border:1px solid rgba(0,184,230,.25);border-radius:4px;padding:1px 5px;margin-left:5px;white-space:nowrap;flex-shrink:0}
.feedCardZapBadge{font-size:.72rem;font-weight:600;color:#b45309;background:rgba(251,191,36,.18);border:1px solid rgba(251,191,36,.4);border-radius:10px;padding:2px 8px;margin-left:auto;white-space:nowrap}
body.dark .feedCardZapBadge{color:#fbbf24;background:rgba(251,191,36,.12);border-color:rgba(251,191,36,.3)}
@@ -2808,7 +2820,7 @@
- Selected layer
+ Selected layer
Name
@@ -3092,11 +3104,13 @@
Duplicate
Copy
Cut
+ Group
+ Ungroup
Delete layer
- Click a layer on the preview to select it. Drag selected image or text layers directly on the page. Text layers are editable in place.
+ Click a layer to select it. Shift/Ctrl/Cmd-click to select multiple layers, then drag any selected layer to move the set together. Text layers are editable in place.
@@ -4216,7 +4230,9 @@
tray: { src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 }
},
layers: [],
+ layerGroups: {},
selectedLayerId: '',
+ selectedLayerIds: [],
community: {
relays: DEFAULT_NOSTR_RELAYS.join(', '),
query: '',
@@ -4515,6 +4531,7 @@
templateMode: 'disc-design',
templateType: 'disc-design',
discDesign: { ...design },
+ layerGroups: { ...ensureLayerGroups() },
layers: layers.map(l => ({ ...l, target: 'designer-disc' })),
discTemplate: { config: discTemplate },
community: { relays: state.community.relays, query: state.community.query, kind: state.community.kind }
@@ -5509,8 +5526,8 @@
'jewelInserts', 'jewelSpineLabels',
'customArt',
'discDesign',
- 'layers',
- 'selectedLayerId',
+ 'layers', 'layerGroups',
+ 'selectedLayerId', 'selectedLayerIds',
'editingDTag', 'editingKind', 'editingEventId', 'editingTitle',
'showGuides', 'showBleed', 'showCrops', 'printGuides', 'printCrops', 'zoom'
];
@@ -7411,30 +7428,31 @@
function snapshotLayerForClipboard(layer) {
// Strip identity / stack fields; keep visual/positional ones.
- const { id, z, _generatedDiscLayer, _staleGenerated, target, ...rest } = layer;
+ const { id, z, groupId, _generatedDiscLayer, _staleGenerated, target, ...rest } = layer;
return { ...rest };
}
function copySelectedLayerToClipboard() {
- const layer = selectedLayer();
- if (!layer) { toastError('No layer selected.'); return; }
- _layerClipboard.items = [snapshotLayerForClipboard(layer)];
+ const layers = selectedLayers();
+ if (!layers.length) { toastError('No layer selected.'); return; }
+ _layerClipboard.items = layers.map(snapshotLayerForClipboard);
_layerClipboard.fromLabel = modeLabelForClipboard();
updateLayerClipboardUI();
- toastSuccess('Copied layer.');
+ toastSuccess(layers.length === 1 ? 'Copied layer.' : `Copied ${layers.length} layers.`);
}
function cutSelectedLayerToClipboard() {
- const layer = selectedLayer();
- if (!layer) { toastError('No layer selected.'); return; }
- pushHistory('cut layer');
- _layerClipboard.items = [snapshotLayerForClipboard(layer)];
+ const layers = selectedLayers();
+ if (!layers.length) { toastError('No layer selected.'); return; }
+ pushHistory(layers.length === 1 ? 'cut layer' : 'cut selected layers');
+ _layerClipboard.items = layers.map(snapshotLayerForClipboard);
_layerClipboard.fromLabel = modeLabelForClipboard();
- state.layers = state.layers.filter(l => l.id !== layer.id);
- state.selectedLayerId = '';
+ const selected = new Set(layers.map(l => l.id));
+ state.layers = state.layers.filter(l => !selected.has(l.id));
+ clearLayerSelection();
updateLayerClipboardUI();
render();
- toastSuccess('Cut layer to clipboard.');
+ toastSuccess(layers.length === 1 ? 'Cut layer to clipboard.' : `Cut ${layers.length} layers to clipboard.`);
}
function copyAllLayersToClipboard() {
@@ -7456,6 +7474,7 @@
const target = currentPasteTarget();
const bounds = targetBounds(target);
let newLast = null;
+ const newIds = [];
for (const item of items) {
const w = Math.max(0.1, Math.min(item.w || bounds.w * 0.7, bounds.w));
const h = Math.max(0.1, Math.min(item.h || bounds.h * 0.7, bounds.h));
@@ -7467,8 +7486,9 @@
const layer = { ...item, id: makeLayerId(), target, x, y, w, h, z: nextLayerZ() };
state.layers.push(layer);
newLast = layer;
+ newIds.push(layer.id);
}
- if (newLast) state.selectedLayerId = newLast.id;
+ if (newLast) setLayerSelection(newIds, newLast.id);
render();
toastSuccess(`Pasted ${items.length} layer${items.length === 1 ? '' : 's'} into ${modeLabelForClipboard()}.`);
}
@@ -7538,7 +7558,7 @@
function addLayer(layer) {
state.layers.push(layer);
- state.selectedLayerId = layer.id;
+ setLayerSelection([layer.id], layer.id);
render();
}
@@ -7817,7 +7837,7 @@
pushHistory('update design object');
layer.design = { mode: normalizeTemplateMode(updatedPayload), payload: clonePayload(updatedPayload) };
layer.name = updatedPayload?.meta?.title || edit.layerName || layer.name;
- state.selectedLayerId = layer.id;
+ setLayerSelection([layer.id], layer.id);
render();
toastSuccess('Embedded design object updated.');
} else {
@@ -9494,8 +9514,10 @@
if (layer.hidden) continue;
if (state.appMode !== 'designer' && !isLayerForCurrentMode(layer)) continue;
+ const layerSelected = isLayerSelected(layer.id);
+ const multiSelected = layerSelected && selectedLayerIds().length > 1;
const el = document.createElement('div');
- el.className = `freeLayer${layer.id === state.selectedLayerId ? ' selected' : ''}`;
+ el.className = `freeLayer${layerSelected ? ' selected' : ''}${multiSelected ? ' multiSelected' : ''}`;
el.dataset.layerId = layer.id;
if (layer.clip && layer.clip.kind !== 'none' && resolveClipSourceShape(layer, state.layers)) {
el.style.clipPath = `url(#${clipIdForLayer(layer)})`;
@@ -9569,7 +9591,8 @@
}
el.addEventListener('click', (event) => {
event.stopPropagation();
- state.selectedLayerId = layer.id;
+ if (el.dataset.dragMoved === '1') return;
+ selectLayerWithEvent(layer.id, event);
if (event.target.closest('.textLayer')) {
renderLayerList();
syncLayerInputs();
@@ -9579,7 +9602,7 @@
});
// Drag-resize handles — only on the selected, unlocked layer.
- if (layer.id === state.selectedLayerId && !layer.locked) {
+ if (layer.id === state.selectedLayerId && selectedLayerIds().length <= 1 && !layer.locked) {
for (const dir of ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']) {
const handle = document.createElement('div');
handle.className = `layerHandle lh-${dir}`;
@@ -9601,15 +9624,18 @@
row.className = `layerItemRow${layer.hidden ? ' layerHiddenRow' : ''}`;
const item = document.createElement('button');
item.type = 'button';
- item.className = `layerItem${layer.id === state.selectedLayerId ? ' active' : ''}`;
+ const layerSelected = isLayerSelected(layer.id);
+ item.className = `layerItem${layer.id === state.selectedLayerId ? ' active' : ''}${layerSelected && selectedLayerIds().length > 1 ? ' multiSelected' : ''}`;
const rawName = layer.type === 'text'
? (layerHtmlToPlainText(layer.html).split('\n')[0].trim() || layer.name || 'Text layer')
: (layer.name || layer.type);
const displayName = rawName.length > 32 ? rawName.slice(0, 29) + '...' : rawName;
const lockTag = layer.locked ? 'LOCK ' : '';
- item.innerHTML = `${lockTag}${escapeHtml(displayName)} ${escapeHtml(layer.type)} `;
- item.addEventListener('click', () => {
- state.selectedLayerId = layer.id;
+ const groupName = layerGroupName(layer);
+ const groupTag = groupName ? `GRP ` : '';
+ item.innerHTML = `${lockTag}${groupTag}${escapeHtml(displayName)} ${escapeHtml(layer.type)} `;
+ item.addEventListener('click', (event) => {
+ selectLayerWithEvent(layer.id, event);
render();
});
const eye = document.createElement('button');
@@ -10975,6 +11001,87 @@
return getLayer(state.selectedLayerId);
}
+ function selectedLayerIds() {
+ const raw = Array.isArray(state.selectedLayerIds) ? state.selectedLayerIds : [];
+ const ids = [];
+ const seen = new Set();
+ for (const id of raw) {
+ if (!id || seen.has(id) || !getLayer(id)) continue;
+ ids.push(id);
+ seen.add(id);
+ }
+ if (state.selectedLayerId && !seen.has(state.selectedLayerId) && getLayer(state.selectedLayerId)) {
+ ids.push(state.selectedLayerId);
+ }
+ return ids;
+ }
+
+ function selectedLayers() {
+ return selectedLayerIds().map(getLayer).filter(Boolean);
+ }
+
+ function ensureLayerGroups() {
+ if (!state.layerGroups || typeof state.layerGroups !== 'object' || Array.isArray(state.layerGroups)) state.layerGroups = {};
+ return state.layerGroups;
+ }
+
+ function layerGroup(layer) {
+ if (!layer?.groupId) return null;
+ return ensureLayerGroups()[layer.groupId] || null;
+ }
+
+ function layerGroupName(layer) {
+ return layerGroup(layer)?.name || '';
+ }
+
+ function groupLayerIds(groupId) {
+ if (!groupId) return [];
+ return (state.layers || []).filter(l => {
+ if (l.groupId !== groupId || l.hidden || l._generatedDiscLayer) return false;
+ const isDesignerLayer = l.target === 'designer-disc';
+ if (state.appMode === 'designer') return isDesignerLayer;
+ if (isDesignerLayer) return false;
+ return isLayerForCurrentMode(l);
+ }).map(l => l.id);
+ }
+
+ function isLayerSelected(layerId) {
+ return selectedLayerIds().includes(layerId);
+ }
+
+ function setLayerSelection(ids, primaryId = '') {
+ const clean = [];
+ const seen = new Set();
+ for (const id of ids || []) {
+ if (!id || seen.has(id) || !getLayer(id)) continue;
+ clean.push(id);
+ seen.add(id);
+ }
+ const primary = primaryId && seen.has(primaryId) ? primaryId : (clean[clean.length - 1] || '');
+ state.selectedLayerIds = clean;
+ state.selectedLayerId = primary;
+ }
+
+ function selectLayerWithEvent(layerId, event) {
+ const additive = !!(event && (event.shiftKey || event.ctrlKey || event.metaKey));
+ if (!additive) {
+ const layer = getLayer(layerId);
+ const groupIds = layer?.groupId ? groupLayerIds(layer.groupId) : [];
+ setLayerSelection(groupIds.length ? groupIds : [layerId], layerId);
+ return;
+ }
+ const ids = selectedLayerIds();
+ if (ids.includes(layerId)) {
+ setLayerSelection(ids.filter(id => id !== layerId));
+ } else {
+ setLayerSelection([...ids, layerId], layerId);
+ }
+ }
+
+ function clearLayerSelection() {
+ setLayerSelection([]);
+ }
+
function syncShapeLayerInputs(layer) {
const kindIsSvg = layer?.shape?.kind === 'svg';
renderSvgElementsList(layer);
@@ -11390,6 +11497,7 @@
function syncLayerInputs() {
const layer = selectedLayer();
+ const selectionCount = selectedLayerIds().length;
const layerControlsEl = $('layerControls');
if (layerControlsEl) {
// Hide the whole "Selected layer" panel when nothing is selected so
@@ -11398,6 +11506,8 @@
layerControlsEl.style.display = layer ? '' : 'none';
layerControlsEl.open = Boolean(layer);
}
+ const summary = $('layerControlsSummary');
+ if (summary) summary.textContent = selectionCount > 1 ? `${selectionCount} selected layers` : 'Selected layer';
$('textLayerControls').style.display = layer && layer.type === 'text' ? 'block' : 'none';
$('layerImageControls').style.display = layer && layer.type === 'image' ? 'block' : 'none';
const colorPanel = $('colorLayerControls');
@@ -11434,6 +11544,10 @@
const el = $(id);
if (el) el.disabled = !layer;
}
+ const groupBtn = $('layerGroupBtn');
+ if (groupBtn) groupBtn.disabled = selectionCount < 2;
+ const ungroupBtn = $('layerUngroupBtn');
+ if (ungroupBtn) ungroupBtn.disabled = !selectedLayers().some(l => !!l.groupId);
const lockEl = $('layerLocked');
if (lockEl) {
lockEl.disabled = !layer;
@@ -11516,8 +11630,13 @@
const el = event.currentTarget;
const layer = getLayer(el.dataset.layerId);
if (!layer) return;
- if (layer.locked) { state.selectedLayerId = layer.id; render(); return; }
- state.selectedLayerId = layer.id;
+ if (layer.locked) { setLayerSelection([layer.id], layer.id); render(); return; }
+ const additiveSelect = !!(event.shiftKey || event.ctrlKey || event.metaKey);
+ if (additiveSelect) {
+ if (!isLayerSelected(layer.id)) selectLayerWithEvent(layer.id, event);
+ } else if (!isLayerSelected(layer.id)) {
+ setLayerSelection([layer.id], layer.id);
+ }
const textTarget = event.target.closest('.textLayer');
if (textTarget && (document.activeElement === textTarget || event.detail > 1)) {
@@ -11533,8 +11652,9 @@
const sheetRect = dragHost.getBoundingClientRect();
const startX = event.clientX;
const startY = event.clientY;
- const originX = layer.x;
- const originY = layer.y;
+ const dragLayers = selectedLayers().filter(l => !l.locked && (l.target === 'designer-disc') === isDesignerLayer);
+ if (!dragLayers.includes(layer)) dragLayers.push(layer);
+ const origins = dragLayers.map(l => ({ layer: l, x: Number(l.x) || 0, y: Number(l.y) || 0 }));
let moved = false;
let snapshotted = false;
document.body.classList.add('dragging-layer');
@@ -11544,20 +11664,24 @@
if (!snapshotted) {
// Capture the pre-drag state on the first pointermove so a click
// without movement doesn't pollute the undo stack.
- pushHistory('move layer');
+ pushHistory(dragLayers.length > 1 ? 'move selected layers' : 'move layer');
snapshotted = true;
}
moved = true;
const dx = (moveEvent.clientX - startX) / sheetRect.width * (isDesignerLayer ? discTemplate.discD : sheetW());
const dy = (moveEvent.clientY - startY) / sheetRect.height * (isDesignerLayer ? discTemplate.discD : sheetH());
- layer.x = originX + dx;
- layer.y = originY + dy;
- if (isDesignerLayer) {
- el.style.left = `${layer.x / discTemplate.discD * 100}%`;
- el.style.top = `${layer.y / discTemplate.discD * 100}%`;
- } else {
- el.style.left = inches(layer.x);
- el.style.top = inches(layer.y);
+ for (const origin of origins) {
+ origin.layer.x = origin.x + dx;
+ origin.layer.y = origin.y + dy;
+ const layerEl = document.querySelector(`.freeLayer[data-layer-id="${origin.layer.id}"]`);
+ if (!layerEl) continue;
+ if (isDesignerLayer) {
+ layerEl.style.left = `${origin.layer.x / discTemplate.discD * 100}%`;
+ layerEl.style.top = `${origin.layer.y / discTemplate.discD * 100}%`;
+ } else {
+ layerEl.style.left = inches(origin.layer.x);
+ layerEl.style.top = inches(origin.layer.y);
+ }
}
syncLayerInputs();
};
@@ -11567,6 +11691,10 @@
el.removeEventListener('pointermove', move);
el.removeEventListener('pointerup', stop);
el.removeEventListener('pointercancel', stop);
+ if (moved) {
+ el.dataset.dragMoved = '1';
+ setTimeout(() => { delete el.dataset.dragMoved; }, 0);
+ }
localStorage.setItem('case-cover-template-maker-autosave', JSON.stringify(state));
};
@@ -11596,7 +11724,7 @@
const el = handle.parentElement;
const layer = getLayer(el?.dataset.layerId);
if (!layer || layer.locked) return;
- state.selectedLayerId = layer.id;
+ setLayerSelection([layer.id], layer.id);
const isDesignerLayer = layer.target === 'designer-disc';
const dragHost = isDesignerLayer ? $('designerDiscWrap') : $('sheet');
@@ -11706,6 +11834,7 @@
meta: state.meta,
ui: { zoom: state.zoom, showGuides: state.showGuides, showBleed: state.showBleed, showCrops: state.showCrops, printGuides: state.printGuides, printCrops: state.printCrops },
coverTemplate: { preset: state.preset, templateType, panelW: state.panelW, trimH: state.trimH, spineW: state.spineW, bleed: state.bleed, safe: state.safe, artworkMode: state.artworkMode, slots: state.slots, combined: state.combined },
+ layerGroups: { ...ensureLayerGroups() },
layers: nonGeneratedLayers().filter(l => {
const target = String(l.target || '');
return !target.startsWith('disc') && !target.startsWith('jewel');
@@ -11723,6 +11852,7 @@
ui: { zoom: state.zoom },
discMeta: state.discMeta,
discTemplate: { templateType, config: discTemplate, discs: state.discs, spineLabels: state.discSpineLabels, meta: state.discMeta },
+ layerGroups: { ...ensureLayerGroups() },
layers: nonGeneratedLayers().filter(l => String(l.target || '').startsWith('disc'))
};
}
@@ -11736,6 +11866,7 @@
meta: state.meta,
ui: { zoom: state.zoom, showGuides: state.showGuides, printGuides: state.printGuides },
jewelTemplate: { templateType, preset: 'jewel8693', config: jewelTemplate, inserts: state.jewelInserts, spineLabels: state.jewelSpineLabels },
+ layerGroups: { ...ensureLayerGroups() },
layers: nonGeneratedLayers().filter(l => String(l.target || '').startsWith('jewel'))
};
}
@@ -11758,6 +11889,7 @@
background: ca.background || '#ffffff',
notes: ca.notes || ''
},
+ layerGroups: { ...ensureLayerGroups() },
layers
};
}
@@ -11768,6 +11900,7 @@
meta: state.meta,
ui: { zoom: state.zoom, showGuides: state.showGuides },
customArt: ca,
+ layerGroups: { ...ensureLayerGroups() },
layers
};
}
@@ -11838,6 +11971,7 @@
jewelTemplate: { templateType: templateTypeForMode('jewel'), preset: 'jewel8693', config: jewelTemplate, inserts: state.jewelInserts, spineLabels: state.jewelSpineLabels },
customArt: { ...state.customArt },
layers: nonGeneratedLayers(),
+ layerGroups: { ...ensureLayerGroups() },
community: { relays: state.community.relays, query: state.community.query, kind: state.community.kind }
};
}
@@ -12400,8 +12534,9 @@
if (!_isRestoring) pushHistory('close disc design');
state.discDesign = { name: '', src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 };
state.layers = (state.layers || []).filter(l => l.target !== 'designer-disc');
+ state.layerGroups = {};
state.meta = blankMeta();
- state.selectedLayerId = '';
+ clearLayerSelection();
exitEditMode();
syncInputs();
syncDesignerInputs();
@@ -12440,7 +12575,8 @@
state.privateDTag = '';
state.meta = blankMeta();
state.layers = (state.layers || []).filter(l => l.target === 'designer-disc');
- state.selectedLayerId = '';
+ state.layerGroups = {};
+ clearLayerSelection();
exitEditMode();
syncInputs();
syncAllSlotInputs();
@@ -12798,7 +12934,9 @@
}),
...migrateLegacyLayerTargets(parsed.layers, 'cover')
],
- selectedLayerId: ''
+ layerGroups: { ...ensureLayerGroups(), ...(parsed.layerGroups || {}) },
+ selectedLayerId: '',
+ selectedLayerIds: []
});
syncInputs(); syncAllSlotInputs(); syncCombinedInputs(); render();
return;
@@ -12818,7 +12956,9 @@
...state.layers.filter(l => !String(l.target || '').startsWith('disc')),
...migrateLegacyLayerTargets(parsed.layers, 'disc')
],
- selectedLayerId: ''
+ layerGroups: { ...ensureLayerGroups(), ...(parsed.layerGroups || {}) },
+ selectedLayerId: '',
+ selectedLayerIds: []
});
normalizeDiscState();
syncProjectMetaFromDiscMeta(true);
@@ -12840,7 +12980,9 @@
...state.layers.filter(l => !String(l.target || '').startsWith('jewel')),
...migrateLegacyLayerTargets(parsed.layers, 'jewel')
],
- selectedLayerId: ''
+ layerGroups: { ...ensureLayerGroups(), ...(parsed.layerGroups || {}) },
+ selectedLayerId: '',
+ selectedLayerIds: []
});
normalizeJewelState();
syncInputs(); syncAllJewelInputs(); render();
@@ -12884,7 +13026,9 @@
},
// Deck slides own their layers; clear any editor canvas layers.
layers: state.layers.filter(l => l.target !== 'canvas'),
- selectedLayerId: ''
+ layerGroups: { ...ensureLayerGroups(), ...(parsed.layerGroups || {}) },
+ selectedLayerId: '',
+ selectedLayerIds: []
});
syncInputs(); render();
return;
@@ -12910,7 +13054,9 @@
...state.layers.filter(l => l.target !== 'canvas'),
...migrateLegacyLayerTargets(parsed.layers, 'customart')
],
- selectedLayerId: ''
+ layerGroups: { ...ensureLayerGroups(), ...(parsed.layerGroups || {}) },
+ selectedLayerId: '',
+ selectedLayerIds: []
});
syncInputs(); render();
return;
@@ -12961,7 +13107,9 @@
jewelInserts: { ...state.jewelInserts, ...(source.jewelInserts || {}) },
community: { ...state.community, ...(source.community || {}) },
layers: migratedLayers,
- selectedLayerId: ''
+ layerGroups: source.layerGroups || {},
+ selectedLayerId: '',
+ selectedLayerIds: []
};
Object.assign(state, next);
normalizeDiscState();
@@ -17257,7 +17405,7 @@
...(s.layers || []).map(l => ({ ...l, target: 'canvas' }))
];
state.templateMode = 'slide';
- state.selectedLayerId = '';
+ clearLayerSelection();
syncInputs();
render();
}
@@ -17370,7 +17518,7 @@
deck.editingIndex = null;
state.templateMode = 'deck';
state.layers = state.layers.filter(l => l.target !== 'canvas');
- state.selectedLayerId = '';
+ clearLayerSelection();
syncInputs();
render();
}
@@ -18557,7 +18705,7 @@
if (addTextLayerBtn) addTextLayerBtn.addEventListener('click', () => addTextLayer());
$('sheet').addEventListener('click', () => {
- state.selectedLayerId = '';
+ clearLayerSelection();
render();
});
@@ -18732,11 +18880,44 @@
const duplicateBtn = $('layerDuplicate');
if (duplicateBtn) duplicateBtn.addEventListener('click', duplicateSelectedLayer);
+ function groupSelectedLayers() {
+ const layers = selectedLayers();
+ if (layers.length < 2) { toastError('Select at least two layers to group.'); return; }
+ const name = (prompt('Group name', 'Layer group') || '').trim();
+ if (!name) return;
+ pushHistory('group layers');
+ const groupId = `group-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ ensureLayerGroups()[groupId] = { id: groupId, name };
+ for (const layer of layers) layer.groupId = groupId;
+ setLayerSelection(layers.map(l => l.id), layers[layers.length - 1].id);
+ render();
+ toastSuccess(`Grouped ${layers.length} layers.`);
+ }
+
+ function ungroupSelectedLayers() {
+ const layers = selectedLayers();
+ const groupIds = [...new Set(layers.map(l => l.groupId).filter(Boolean))];
+ if (!groupIds.length) { toastError('Select a grouped layer first.'); return; }
+ pushHistory('ungroup layers');
+ const groups = ensureLayerGroups();
+ for (const layer of state.layers || []) {
+ if (groupIds.includes(layer.groupId)) delete layer.groupId;
+ }
+ for (const groupId of groupIds) delete groups[groupId];
+ render();
+ toastSuccess(groupIds.length === 1 ? 'Ungrouped layer group.' : `Ungrouped ${groupIds.length} groups.`);
+ }
+
+ on('layerGroupBtn', 'click', groupSelectedLayers);
+ on('layerUngroupBtn', 'click', ungroupSelectedLayers);
+
function deleteSelectedLayer() {
- if (!state.selectedLayerId) return;
- pushHistory('delete layer');
- state.layers = state.layers.filter((layer) => layer.id !== state.selectedLayerId);
- state.selectedLayerId = '';
+ const ids = selectedLayerIds();
+ if (!ids.length) return;
+ pushHistory(ids.length > 1 ? 'delete selected layers' : 'delete layer');
+ const selected = new Set(ids);
+ state.layers = state.layers.filter((layer) => !selected.has(layer.id));
+ clearLayerSelection();
render();
}
@@ -18780,7 +18961,9 @@
jewelInserts: { ...state.jewelInserts, ...(parsed.jewelInserts || parsed.jewelTemplate?.inserts || {}) },
community: { ...state.community, ...(parsed.community || {}) },
layers: Array.isArray(parsed.layers) ? parsed.layers : [],
- selectedLayerId: ''
+ layerGroups: parsed.layerGroups || {},
+ selectedLayerId: '',
+ selectedLayerIds: []
});
normalizeDiscState();
normalizeJewelState();