From 81e33fab9fa3cfd9e4ad6ee5cb1e10b007c3cebe Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 31 May 2026 22:31:13 -0400 Subject: [PATCH] Layer extract: edit linked source + transparent group canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs in the freshly-shipped extract flow: 1. Group extract baked customArt.background='#ffffff' so the extracted design lost any transparency the group had. Now defaults to 'transparent' so the extracted design composites cleanly into any host. Users can pick a solid fill later via the customart background control if they want one. 2. Double-clicking a linked layer ran beginDesignObjectEdit which loads layer.design.payload — but linked layers have no local payload, just a ref. loadProjectFromText('{}') then fell back to the default cover/casewrap mode with empty layers, hence "opens the case wrap editor" and "design is missing completely." beginDesignObjectEdit now detects design.kind === 'linked', pulls the resolved payload from linkedDesigns.peek, and opens THAT for editing. The edit session is tagged with { isLinked, linkedContext } so finishDesignObjectEdit knows to take the republish path on save instead of mutating layer.design.payload (which doesn't exist for linked layers). 3. Guard rails on the linked-edit path: - Refuse if the resolver hasn't cached the source yet ("Refresh the layer and try again"). - Refuse if the layer is pinned ("Unpin to edit — pinned versions are frozen"). - Refuse if the source author isn't the current user ("Only the source author can edit; fork or unlink it for a local copy"). Soft permission — relays decide who can replace a kind-30078 by pubkey + d-tag, so non-authors couldn't republish even if we let them try. On save, _republishLinkedDesignSource: - Strips linked-design ephemera + builds attribution tags (same as host publish). - Publishes a fresh kind-30078 under the same author + d-tag, which is NIP-33 in-place replace. - clearCache(key) + seed both resolver caches from the new signed event so every linked layer on this canvas paints the updated version on next render. - Refreshes the layer name from the (possibly edited) payload title with the 🔗 prefix preserved. finishDesignObjectEdit is now async; existing call sites are fire-and-forget which is fine — user feedback flows through toasts, not the return value. --- src/index.html | 135 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 122 insertions(+), 13 deletions(-) diff --git a/src/index.html b/src/index.html index 438a781..eb0ee56 100644 --- a/src/index.html +++ b/src/index.html @@ -11732,7 +11732,10 @@ customArt: { width: Math.round(bbox.w * 96), height: Math.round(bbox.h * 96), - background: '#ffffff', + // Transparent so the extracted design composites cleanly + // into any host. Users can pick a solid fill later via + // the customart background control if they want one. + background: 'transparent', }, layers: clonedLayers, layerGroups: {}, @@ -11939,23 +11942,57 @@ toastError('Finish the current embedded-design edit first.'); return; } + // Linked layers don't carry a payload locally — the source + // lives on relays. Open the cached resolved payload for + // editing and remember the linked context so finishDesignObjectEdit + // can republish the source under the same d-tag on save. + const isLinked = layer.design?.kind === 'linked'; + let editingPayload = null; + let linkedContext = null; + if (isLinked) { + const spec = { ref: layer.design.ref, pinnedEventId: layer.design.pinnedEventId }; + const cached = linkedDesigns.peek(spec); + if (!cached?.payload) { + toastError('Linked source is still loading or unavailable. Refresh the layer and try again.'); + return; + } + if (layer.design.pinnedEventId) { + toastError('Unpin this layer to edit its source — pinned versions are frozen.'); + return; + } + const sourcePubkey = cached.sourceEvent?.pubkey; + const myPubkey = state.community?.myPubkey || ''; + if (sourcePubkey && myPubkey && sourcePubkey !== myPubkey) { + toastError("Only the source author can edit a linked design. Fork or unlink it to edit a local copy."); + return; + } + editingPayload = cached.payload; + linkedContext = { ref: layer.design.ref, sourceEvent: cached.sourceEvent }; + } else { + editingPayload = layer.design?.payload || {}; + } _designObjectEdit = { hostPayload: currentEditableDesignPayload(), layerId: layer.id, - layerName: layer.name || designObjectTitle(layer.design?.payload) + layerName: layer.name || designObjectTitle(editingPayload), + isLinked, + linkedContext, }; try { - loadProjectFromText(JSON.stringify(layer.design?.payload || {})); + loadProjectFromText(JSON.stringify(editingPayload)); } catch (err) { _designObjectEdit = null; - toastError(`Could not open embedded design: ${err?.message || err}`); + toastError(`Could not open design: ${err?.message || err}`); return; } paintDesignObjectBanner(); - toastSuccess('Editing embedded design object. Use Done editing object to write it back.'); + toastSuccess(isLinked + ? '🔗 Editing linked source — Done republishes it to every host that links it.' + : 'Editing embedded design object. Use Done editing object to write it back.' + ); } - function finishDesignObjectEdit(saveChanges) { + async function finishDesignObjectEdit(saveChanges) { if (!_designObjectEdit) return; const edit = _designObjectEdit; const updatedPayload = saveChanges ? currentEditableDesignPayload() : null; @@ -11967,19 +12004,91 @@ } _designObjectEdit = null; const layer = getLayer(edit.layerId); - if (saveChanges && layer && layer.type === 'design') { - pushHistory('update design object'); - layer.design = { mode: normalizeTemplateMode(updatedPayload), payload: clonePayload(updatedPayload) }; - layer.name = updatedPayload?.meta?.title || edit.layerName || layer.name; - setLayerSelection([layer.id], layer.id); - render(); - toastSuccess('Embedded design object updated.'); + if (saveChanges && layer && layer.type === 'design' && updatedPayload) { + if (edit.isLinked && edit.linkedContext) { + await _republishLinkedDesignSource(layer, updatedPayload, edit.linkedContext, edit.layerName); + } else { + pushHistory('update design object'); + layer.design = { mode: normalizeTemplateMode(updatedPayload), payload: clonePayload(updatedPayload) }; + layer.name = updatedPayload?.meta?.title || edit.layerName || layer.name; + setLayerSelection([layer.id], layer.id); + render(); + toastSuccess('Embedded design object updated.'); + } } else { toastSuccess('Returned to parent design.'); } paintDesignObjectBanner(); } + // Republish a linked-design source under the same d-tag with + // the edited payload, then seed the resolver caches with the + // new signed event so every linked layer on this canvas paints + // the updated version on the next render. Only safe for the + // source author — beginDesignObjectEdit blocks non-authors + // from reaching this path. + async function _republishLinkedDesignSource(layer, updatedPayload, linkedContext, prevLayerName) { + const myPubkey = state.community?.myPubkey || ''; + if (!myPubkey) { + toastError('Sign in with Nostr first.'); + return; + } + const ref = linkedContext.ref; + const parsed = linkedDesigns._parseAddressCoord(ref); + if (!parsed || parsed.pubkey !== myPubkey) { + toastError('Only the source author can republish a linked design.'); + return; + } + const dTag = parsed.dTag; + const mode = normalizeTemplateMode(updatedPayload); + const payload = clonePayload(updatedPayload); + stripLinkedDesignEphemera(payload); + let attributionTags = []; + try { + const built = await _buildLinkedAttributionTags(payload, myPubkey); + attributionTags = [...built.contributorTags, ...built.linkTags]; + } catch (err) { + console.warn('[linked-edit] attribution build failed', err); + } + const typeTag = typeTagForMode(mode); + const tags = [ + ['d', dTag], + ...getTemplateTags(payload, typeTag), + ...attributionTags, + ]; + const evt = { + kind: 30078, + created_at: Math.floor(Date.now() / 1000), + tags, + content: JSON.stringify(payload), + }; + const publishingToast = toast('Re-publishing linked source…', { duration: 30000 }); + let signed; + try { signed = await publishCommunityEvent(evt); } + catch (err) { + publishingToast?.dismiss?.(); + toastError(err?.message || 'Could not re-publish the linked source.'); + return; + } + publishingToast?.dismiss?.(); + if (!signed?.id) { + toastError('Republish succeeded but the relay did not return the event id.'); + return; + } + pushHistory('republish linked source'); + const key = linkedDesigns.keyFor({ ref, pinnedEventId: null }); + if (key) linkedDesigns.clearCache(key); + try { await linkedDesigns.seed({ ref, pinnedEventId: null }, signed); } + catch (err) { console.warn('[linked-edit] cache seed failed', err); } + // Refresh the layer name from the (possibly edited) payload title. + const newTitle = updatedPayload?.meta?.title?.trim(); + if (newTitle) layer.name = '🔗 ' + newTitle; + else if (prevLayerName) layer.name = prevLayerName; + setLayerSelection([layer.id], layer.id); + render(); + toastSuccess('🔗 Linked source updated — every host that links it shows the new version on next refresh.'); + } + function paintDesignObjectBanner() { const banner = $('designObjectBanner'); const title = $('designObjectTitle');