@@ -3178,7 +3362,8 @@
{ value: 'wii', label: 'Wii' },
{ value: 'sega-saturn', label: 'Sega Saturn' },
{ value: 'sega-cd', label: 'Sega CD' },
- { value: 'audio-cd', label: 'Audio CD' }
+ { value: 'audio-cd', label: 'Audio CD' },
+ { value: 'custom-art', label: 'Custom art' }
];
const DEFAULT_NOSTR_RELAYS = [
@@ -3461,7 +3646,8 @@
'sega-saturn': new Set(['upc']),
'sega-cd': new Set(['upc']),
'cd': new Set(['upc', 'mb']),
- 'audio-cd': new Set(['upc', 'mb'])
+ 'audio-cd': new Set(['upc', 'mb']),
+ 'custom-art': new Set()
};
function relevantIdentifiersForCategory(category) {
@@ -3489,9 +3675,13 @@
document.querySelectorAll('.categorySelect').forEach((select) => {
select.innerHTML = options;
});
+ // The two project-metadata category selects use a different empty
+ // label ("Uncategorized") and have hardcoded id, so populate them
+ // explicitly with the canonical mediaCategories list.
const designerMetaSelect = $('designerCategoryField');
if (designerMetaSelect) designerMetaSelect.innerHTML = categoryOptions('Uncategorized');
const metaSelect = $('categoryField');
+ if (metaSelect) metaSelect.innerHTML = categoryOptions('Uncategorized');
if (metaSelect) {
const current = metaSelect.value || state.meta?.category || '';
metaSelect.value = mediaCategories.some((item) => item.value === current) ? current : '';
@@ -4385,6 +4575,9 @@
}
function render() {
+ // Rebuild global clip-path
before any renderer so layer
+ // wrappers can reference them via clip-path: url(#β¦).
+ rebuildClipDefs(state.layers);
renderDimensions();
renderSlots();
renderDiscSlots();
@@ -5314,6 +5507,1058 @@
});
}
+ // ---- Vector shape layers (Phase A: primitives + solid fill) ----
+
+ const SHAPE_KINDS = ['rect', 'rounded-rect', 'circle', 'ellipse', 'triangle', 'polygon', 'star', 'line', 'path'];
+ const SHAPE_LABELS = {
+ 'rect': 'Rectangle',
+ 'rounded-rect': 'Rounded rectangle',
+ 'circle': 'Circle',
+ 'ellipse': 'Ellipse',
+ 'triangle': 'Triangle',
+ 'polygon': 'Polygon',
+ 'star': 'Star',
+ 'line': 'Line',
+ 'path': 'Upload SVGβ¦',
+ 'svg': 'SVG import'
+ };
+ const SHAPE_DEFAULT_FILL = '#3b82f6';
+ const SHAPE_PATH_MAX_LEN = 100 * 1024; // generous cap; reject pasted blobs above this
+ const SHAPE_SVG_MAX_LEN = 256 * 1024; // multi-element SVGs are bigger; still bounded
+ const SVG_ALLOWED_TAGS = new Set([
+ 'svg', 'g', 'path', 'rect', 'circle', 'ellipse', 'polygon', 'polyline', 'line',
+ 'defs', 'lineargradient', 'radialgradient', 'stop', 'clippath', 'mask',
+ 'title', 'desc', 'use', 'symbol'
+ ]);
+ const SVG_FORBIDDEN_TAGS = new Set(['script', 'foreignobject', 'iframe', 'embed', 'object', 'image', 'animate', 'animatetransform', 'animatemotion', 'set', 'a']);
+
+ function defaultShapeData(kind) {
+ const out = { kind };
+ if (kind === 'rounded-rect') out.cornerRadius = 12;
+ if (kind === 'polygon') out.sides = 6;
+ if (kind === 'star') { out.points = 5; out.innerRadiusRatio = 0.45; }
+ if (kind === 'line') { out.x1 = 0; out.y1 = 50; out.x2 = 100; out.y2 = 50; out.strokeWidth = 6; }
+ if (kind === 'path') {
+ // Default heart shape so a fresh path layer is immediately useful.
+ out.d = 'M 50 90 C 0 60 0 20 30 10 C 40 5 50 20 50 30 C 50 20 60 5 70 10 C 100 20 100 60 50 90 Z';
+ out.viewBox = { x: 0, y: 0, w: 100, h: 100 };
+ }
+ return out;
+ }
+
+ // Walk a parsed SVG DOM, removing disallowed elements and dangerous
+ // attributes. Returns true if the node was kept, false if removed.
+ function sanitizeSvgNode(el) {
+ const tag = el.nodeName.toLowerCase();
+ if (SVG_FORBIDDEN_TAGS.has(tag)) { el.remove(); return false; }
+ if (!SVG_ALLOWED_TAGS.has(tag)) { el.remove(); return false; }
+ for (const attr of Array.from(el.attributes || [])) {
+ const name = attr.name.toLowerCase();
+ const value = String(attr.value || '');
+ // Strip every event handler
+ if (name.startsWith('on')) { el.removeAttribute(attr.name); continue; }
+ // External hrefs / xlink:href: only allow intra-SVG '#id' refs
+ if (name === 'href' || name === 'xlink:href') {
+ if (!value.startsWith('#')) el.removeAttribute(attr.name);
+ continue;
+ }
+ // style="..." with javascript: or url(http...) is rejected
+ if (name === 'style' && /(javascript|url\s*\(\s*['"]?\s*(?:https?:|data:|\/\/))/i.test(value)) {
+ el.removeAttribute(attr.name);
+ }
+ }
+ for (const child of Array.from(el.childNodes)) {
+ if (child.nodeType === 1) sanitizeSvgNode(child);
+ else if (child.nodeType !== 3) child.remove(); // strip comments / processing instructions
+ }
+ return true;
+ }
+
+ // Parse and sanitize a full string. Returns { inner, viewBox }
+ // where `inner` is the serialized inner content (children of