' + '
' + '
' + '
' + icon + '
' + '
or
browse files
' + '
' + warnIcon + '
This photo needs attribution
' + '
' + '
' + '
' + // Outside .frame, like .spill/.ctl — the frame's overflow:hidden + // border-radius/clip-path would cut the credit off on circle/pill/mask. // A SPAN, not an
: the prescribed Unsplash credit holds two links // (photographer + Unsplash), built per-render in _render(). '
' + '
' + '
' + '
' + '
' + '
' + // data-dc-edit-transparent: the DC editor's edit-mode picker lets // clicks through for chrome marked with it (EDIT_TRANSPARENT_SEL) // — without it, Replace/Edit clicks in Edit mode are swallowed by // element selection and the controls look dead. '
Replace
' + '
Edit
' + '
'; this._frame = root.querySelector('.frame'); this._ring = root.querySelector('.ring'); this._img = root.querySelector('.frame img'); this._empty = root.querySelector('.empty'); this._cap = root.querySelector('.cap'); this._sub = root.querySelector('.sub'); this._spill = root.querySelector('.spill'); this._ctl = root.querySelector('.ctl'); this._credit = root.querySelector('.credit'); this._attrError = root.querySelector('.attr-error'); // Credit clicks open the link, not browse/reframe. this._credit.addEventListener('click', (e) => e.stopPropagation()); this._credit.addEventListener('dblclick', (e) => e.stopPropagation()); this._ghost = root.querySelector('.ghost'); this._err = null; this._input = root.querySelector('input'); this._depth = 0; this._gen = 0; // Encode-in-flight marker (the owning _ingest generation): while set, // the same-src "nothing in flight" clear in _render must not fire — // the stored value still points at the OLD image until the encode // lands, so that clear would unmask the stale image mid-replace. this._swapGen = 0; // Render-owned swap in flight: set when _render assigns a new src, // cleared only by the img's own load/error (or the empty branch). // img.complete CANNOT stand in for this — setting src only QUEUES // the current-request swap (a microtask), so synchronously after an // assignment, complete still reports the OLD settled request. The // pick path does exactly that: the host sets src, credit, and // credit-href back-to-back in one task, and renders #2/#3 would // read the stale complete === true and drop the mask one render // after it was set. this._loadPending = false; // See _render's empty branch: a transient attribution-error wipe of a // showing image must make the follow-up render a replacement (spinner), // not a first fill (blank frame). this._hidShowing = false; this._view = { s: 1, x: 0, y: 0 }; this._subFn = () => this._render(); // Shadow-DOM listeners live with the shadow DOM — bound once here so // disconnect/reconnect (e.g. React remount) doesn't stack handlers. this._empty.addEventListener('click', () => this._input.click()); root.addEventListener('click', (e) => { const act = e.target && e.target.getAttribute && e.target.getAttribute('data-act'); if (!act) return; // The hidden controls are opacity-0 but still tabbable — without // this gate a keyboard user could drive them on a read-only share // link (mirrors the dblclick handler's editable gate). if (!this.hasAttribute('data-editable')) return; if (act === 'replace') { this._exitReframe(true); // Host-owned picker (Unsplash modal; it also offers local import). this.dispatchEvent(new CustomEvent('image-slot:pick', { bubbles: true, composed: true, detail: { id: this.id || null } })); } if (act === 'edit') { if (!this._reframes()) return; if (this.hasAttribute('data-reframe')) this._exitReframe(true); else this._enterReframe(); } }); this._input.addEventListener('change', () => { const f = this._input.files && this._input.files[0]; if (f) this._ingest(f); this._input.value = ''; }); // naturalWidth/Height aren't known until load — re-apply so the cover // baseline is computed from real dimensions, not the 100%×100% fallback. // load/error also release the replacement-in-flight mask (via the // single discipline in _releaseMask): the swap is only revealed once // the new image can actually paint (on error the frame shows its // background, same as a fresh slot with a broken src). this._img.addEventListener('load', () => { this._loadPending = false; this._releaseMask(true); this._applyView(); }); this._img.addEventListener('error', () => { this._loadPending = false; this._releaseMask(true); }); // Gated only on editable — any filled slot can be repositioned/scaled, // regardless of fit. Share links (no writeFile) stay static. this.addEventListener('dblclick', (e) => { if (!this.hasAttribute('data-editable') || !this._reframes()) return; e.preventDefault(); if (this.hasAttribute('data-reframe')) this._exitReframe(true); else this._enterReframe(); }); // Pan + resize both originate on the spill layer. A handle pointerdown // drives an aspect-locked resize anchored at the opposite corner; any // other pointerdown on the spill pans. Offsets are frame-% so a // reframed slot survives responsive resize / PPTX export. this._spill.addEventListener('pointerdown', (e) => { if (e.button !== 0 || !this.hasAttribute('data-reframe')) return; e.preventDefault(); e.stopPropagation(); this._spill.setPointerCapture(e.pointerId); const rect = this.getBoundingClientRect(); const fw = rect.width || 1, fh = rect.height || 1; const corner = e.target.getAttribute && e.target.getAttribute('data-c'); let move; if (corner) { // Resize about the OPPOSITE corner. Viewport-px throughout (rect // fw/fh, not clientWidth) so the math survives a transform:scale() // ancestor — deck_stage renders slides scaled-to-fit. const iw = this._img.naturalWidth || 1, ih = this._img.naturalHeight || 1; const contain = (this.getAttribute('fit') || 'cover').toLowerCase() === 'contain'; const base = contain ? Math.min(fw / iw, fh / ih) : Math.max(fw / iw, fh / ih); const sx = corner.includes('e') ? 1 : -1; const sy = corner.includes('s') ? 1 : -1; const s0 = this._view.s; const w0 = iw * base * s0, h0 = ih * base * s0; const cx0 = (50 + this._view.x) / 100 * fw; const cy0 = (50 + this._view.y) / 100 * fh; const ox = cx0 - sx * w0 / 2, oy = cy0 - sy * h0 / 2; const diag0 = Math.hypot(w0, h0); const ux = sx * w0 / diag0, uy = sy * h0 / diag0; move = (ev) => { const proj = (ev.clientX - rect.left - ox) * ux + (ev.clientY - rect.top - oy) * uy; const s = clampS(s0 * proj / diag0); const d = diag0 * s / s0; this._view.s = s; this._view.x = (ox + ux * d / 2) / fw * 100 - 50; this._view.y = (oy + uy * d / 2) / fh * 100 - 50; this._clampView(); this._applyView(); }; } else { this.setAttribute('data-panning', ''); const start = { px: e.clientX, py: e.clientY, x: this._view.x, y: this._view.y }; move = (ev) => { this._view.x = start.x + (ev.clientX - start.px) / fw * 100; this._view.y = start.y + (ev.clientY - start.py) / fh * 100; this._clampView(); this._applyView(); }; } const up = () => { try { this._spill.releasePointerCapture(e.pointerId); } catch {} this._spill.removeEventListener('pointermove', move); this._spill.removeEventListener('pointerup', up); this._spill.removeEventListener('pointercancel', up); this.removeAttribute('data-panning'); this._dragUp = null; }; // Stashed so _exitReframe (Escape / outside-click mid-drag) can // tear the capture + listeners down synchronously. this._dragUp = up; this._spill.addEventListener('pointermove', move); this._spill.addEventListener('pointerup', up); this._spill.addEventListener('pointercancel', up); }); // Wheel zoom stays available inside reframe mode as a trackpad nicety — // zooms toward the cursor (offset' = cursor·(1-k) + offset·k). this.addEventListener('wheel', (e) => { if (!this.hasAttribute('data-reframe')) return; e.preventDefault(); const r = this.getBoundingClientRect(); const cx = (e.clientX - r.left) / r.width * 100 - 50; const cy = (e.clientY - r.top) / r.height * 100 - 50; const prev = this._view.s; const next = clampS(prev * Math.pow(1.0015, -e.deltaY)); if (next === prev) return; const k = next / prev; this._view.s = next; this._view.x = cx * (1 - k) + this._view.x * k; this._view.y = cy * (1 - k) + this._view.y * k; this._clampView(); this._applyView(); }, { passive: false }); } connectedCallback() { // Warn once per page — an id-less slot works for the session but // cannot persist, and two id-less slots would share nothing. if (!this.id && !ImageSlot._warned) { ImageSlot._warned = true; console.warn('
without an id will not persist its dropped image.'); } this.addEventListener('dragenter', this); this.addEventListener('dragover', this); this.addEventListener('dragleave', this); this.addEventListener('drop', this); subs.add(this._subFn); // The host may inject window.omelette.writeFile AFTER the first render; // re-render on hover so the editable-gated controls reliably appear. this.addEventListener('pointerenter', this._subFn); // width%/height% in _applyView encode the frame aspect at call time — // a host resize (responsive grid, pane divider) would stretch the // image until the next _render. Re-render on size change: _render() // re-seeds _view from stored before clamp/apply, so a shrink→grow // cycle round-trips instead of ratcheting x/y toward the narrower // frame's clamp range. this._ro = new ResizeObserver(() => this._render()); this._ro.observe(this); load(); this._render(); } disconnectedCallback() { subs.delete(this._subFn); this.removeEventListener('pointerenter', this._subFn); this.removeEventListener('dragenter', this); this.removeEventListener('dragover', this); this.removeEventListener('dragleave', this); this.removeEventListener('drop', this); if (this._ro) { this._ro.disconnect(); this._ro = null; } // commit=false: a disconnect is not a user intent — committing here // would persist whatever half-finished drag a React remount or DOM // splice happened to interrupt. Deliberate exits commit on their own // paths (Escape/click-out/toggle), and unloads commit via pagehide. this._exitReframe(false); } _enterReframe() { if (this.hasAttribute('data-reframe')) return; this.setAttribute('data-reframe', ''); this._signalReframe(true); // Best-effort commit when the document unloads mid-reframe (a host // navigation racing the enter signal, a manual reload, tab close): // the sidecar write rides the host bridge, which outlives this // document, so the crop survives even though the mode dies with the // DOM. Held on the instance so _exitReframe detaches exactly what // was attached. this._pagehide = () => { this._exitReframe(true); flushNow(); }; window.addEventListener('pagehide', this._pagehide); // Promote spill to the top layer, then keep it pinned over the frame: // scroll/resize cover the common cases, and a per-frame rect check // catches layout shifts that fire neither (an image above finishing // load, streamed DOM pushing the slot down, an ancestor transform // change) so the overlay can't detach from the frame. try { this._spill.showPopover(); } catch {} // After the spill, so the controls stack above it in the top layer. try { this._ctl.showPopover(); } catch {} this._reposition = () => { if (this.hasAttribute('data-reframe')) this._applyView(); }; window.addEventListener('scroll', this._reposition, true); window.addEventListener('resize', this._reposition); this._lastRect = ''; this._watch = () => { if (!this.hasAttribute('data-reframe')) return; const r = this.getBoundingClientRect(); const key = r.left + ',' + r.top + ',' + r.width + ',' + r.height; if (key !== this._lastRect) { this._lastRect = key; this._applyView(); } this._watchId = requestAnimationFrame(this._watch); }; this._watchId = requestAnimationFrame(this._watch); this._applyView(); // Close on click outside (the spill handler stopPropagation()s so // in-image drags don't reach this) and on Escape. Listeners are held // on the instance so _exitReframe / disconnectedCallback can detach // exactly what was attached. this._outside = (e) => { if (e.composedPath && e.composedPath().includes(this)) return; this._exitReframe(true); }; this._esc = (e) => { if (e.key === 'Escape') this._exitReframe(true); }; document.addEventListener('pointerdown', this._outside, true); document.addEventListener('keydown', this._esc, true); } _exitReframe(commit) { if (!this.hasAttribute('data-reframe')) return; if (this._dragUp) this._dragUp(); this.removeAttribute('data-reframe'); this.removeAttribute('data-panning'); if (this._outside) document.removeEventListener('pointerdown', this._outside, true); if (this._esc) document.removeEventListener('keydown', this._esc, true); this._outside = this._esc = null; if (this._reposition) { window.removeEventListener('scroll', this._reposition, true); window.removeEventListener('resize', this._reposition); this._reposition = null; } if (this._watchId) { cancelAnimationFrame(this._watchId); this._watchId = 0; } if (this._pagehide) { window.removeEventListener('pagehide', this._pagehide); this._pagehide = null; } try { this._spill.hidePopover(); } catch {} try { this._ctl.hidePopover(); } catch {} this._ctl.style.left = ''; this._ctl.style.top = ''; if (commit) this._commitView(); this._signalReframe(false); } // Reframe state lives only in this DOM until commit, invisible to the // host's dirty signals — announce enter/exit so the host can hold // auto-reloads for exactly the gesture (the guest bundle forwards // image-slot:reframe to the host as imageSlotReframe). Dispatched on // the element (composed, so it escapes shadow roots) while connected; // a disconnected exit (disconnectedCallback) falls back to document so // the host still hears it. _signalReframe(active) { const target = this.isConnected ? this : document; target.dispatchEvent(new CustomEvent('image-slot:reframe', { bubbles: true, composed: true, detail: { active: active, id: this.id || null } })); } // Public: host's "Import from computer" calls this to run local browse. openFilePicker() { this._exitReframe(true); this._input.click(); } // A src write is a newer intent for this slot's content — the host // pick path (setImageSlotImage) or an agent edit — so it must win // over any encode still in flight from an earlier drop: left live, // that encode lands later, passes _ingest's gen guard, and its // setSlot silently overwrites the pick (the stored value shadows // src in _render). Bumping _gen kills the encode before its own // _swapGen clear runs, so clear the dead claim here too — otherwise // _releaseMask (gated on !_swapGen) never fires and the pick's // spinner is stranded. src ONLY: the pick sets credit/credit-href // in the same task, and clearing _swapGen on those would let the // same-src branch unmask the old image mid-encode. attributeChangedCallback(name, oldVal, newVal) { if (name === 'src' && oldVal !== newVal) { this._gen++; this._swapGen = 0; } if (this.shadowRoot) this._render(); } // handleEvent — one listener object for all four drag events keeps the // add/remove symmetric and the depth counter correct. handleEvent(e) { if (e.type === 'dragenter' || e.type === 'dragover') { // Without preventDefault the browser never fires 'drop'. e.preventDefault(); e.stopPropagation(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; if (e.type === 'dragenter') this._depth++; this.setAttribute('data-over', ''); } else if (e.type === 'dragleave') { // dragenter/leave fire for every descendant crossing — count depth // so hovering the icon inside the empty state doesn't flicker. if (--this._depth <= 0) { this._depth = 0; this.removeAttribute('data-over'); } } else if (e.type === 'drop') { e.preventDefault(); e.stopPropagation(); this._depth = 0; this.removeAttribute('data-over'); const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; if (f) this._ingest(f); } } async _ingest(file) { this._setError(null); if (!file || ACCEPT.indexOf(file.type) < 0) { this._setError('Drop a PNG, JPEG, WebP, or AVIF image.'); return; } // toDataUrl can take hundreds of ms on a large photo. A Clear or a // newer drop during that window would be clobbered when this await // resumes — bump + capture a generation so stale encodes bail. const gen = ++this._gen; // Replacing a shown image: surface the swap through the encode too, // not just the decode — otherwise the old photo sits there with no // feedback while the canvas re-encode runs. An empty slot keeps its // placeholder (no spinner) until the encode lands, as before. // _swapGen guards the mask against re-renders DURING the encode // (pointerenter, ResizeObserver, another slot's store write): the // stored value still resolves to the old image there, so _render's // same-src clear would otherwise unmask it mid-replace. if (this.hasAttribute('data-filled')) { this.setAttribute('data-swapping', ''); this._swapGen = gen; } try { const w = this.clientWidth || this.offsetWidth || MAX_DIM; const url = await toDataUrl(file, w); if (gen !== this._gen) return; // Only exit reframe once the new image is in hand — a rejected type // or decode failure leaves the in-progress crop untouched. this._exitReframe(false); // Clear BEFORE setSlot: its synchronous re-render must see no // pending encode, so a byte-identical re-upload (same data URL, no // load event coming) still clears the mask via the complete branch. this._swapGen = 0; const val = { u: url, s: 1, x: 0, y: 0 }; setSlot(this.id || '', val); // Keep a session-local copy for id-less slots so the drop still // shows, even though it cannot persist. if (!this.id) { this._local = val; this._render(); } } catch (err) { if (gen !== this._gen) return; this._swapGen = 0; // Reveal the kept old image — unless another replacement (a // remote pick's src swap) is still in flight, in which case the // mask stays until THAT image settles (its load/error releases). this._releaseMask(); this._setError('Could not read that image.'); console.warn('
ingest failed:', err); } } _setError(msg) { if (this._err) { this._err.remove(); this._err = null; } if (!msg) return; const d = document.createElement('div'); d.className = 'err'; d.textContent = msg; this.shadowRoot.appendChild(d); this._err = d; setTimeout(() => { if (this._err === d) { d.remove(); this._err = null; } }, 3000); } // Reframing (pan/resize) is available on any filled slot — the user can // always reposition/scale. `fit` only sets the initial baseline (see // _geom): contain starts fully-visible, cover starts frame-filling. _reframes() { return this.hasAttribute('data-filled'); } // The single release discipline for the replacement-in-flight mask // (data-swapping). The mask comes off only when BOTH hold: // - no encode is pending (_swapGen) — mid-encode the stored value // still resolves to the old image, so any reveal paints it; // - the frame img has settled on its current src — an unsettled src // means some replacement is still in flight (e.g. a remote pick), // whoever started it, and revealing would paint the previous // frame. The load/error listeners pass settled=true (the event IS // the settlement signal, per spec complete is true by then); // other callers rely on the complete flag (covers loaded AND // failed). // Every release path funnels through here EXCEPT _render's empty // branch (the img is being cleared — nothing will ever settle). _releaseMask(settled) { if ( !this._swapGen && !this._loadPending && (settled || this._img.complete) ) { this.removeAttribute('data-swapping'); } } // Baseline geometry, shared by clamp/apply/resize. `base` is the scale at // view-scale s=1: cover = fill the frame (overflow on the looser axis), // contain = fit fully inside (letterboxed). Zooming a contain image past // s where it overflows naturally becomes a crop. Null until the img has // loaded (naturalWidth is 0 before that) or when the slot has no layout // box — ResizeObserver fires with a 0×0 rect under display:none, and // clamping against a degenerate 1×1 frame would silently pull the stored // pan toward zero. _geom() { const iw = this._img.naturalWidth, ih = this._img.naturalHeight; const fw = this.clientWidth, fh = this.clientHeight; if (!iw || !ih || !fw || !fh) return null; const contain = (this.getAttribute('fit') || 'cover').toLowerCase() === 'contain'; const base = contain ? Math.min(fw / iw, fh / ih) : Math.max(fw / iw, fh / ih); return { iw, ih, fw, fh, base }; } _clampView() { // Pan range on each axis is half the overflow past the frame edge. const g = this._geom(); if (!g) return; const mx = Math.max(0, (g.iw * g.base * this._view.s / g.fw - 1) * 50); const my = Math.max(0, (g.ih * g.base * this._view.s / g.fh - 1) * 50); this._view.x = Math.max(-mx, Math.min(mx, this._view.x)); this._view.y = Math.max(-my, Math.min(my, this._view.y)); } _applyView() { const g = this._geom(); // Top-layer controls: pin to the frame's top-right in viewport px // (the same 8px inset as the in-frame layout; unscaled — top-layer UI // reads as chrome, not page content). BEFORE the geometry branch: // placement needs only the frame rect, and a not-yet-loaded or broken // src must not leave the promoted strip floating unpositioned. Gated // on the popover actually being open: without the Popover API, // showPopover() threw (swallowed in _enterReframe), .ctl stays in // its in-frame absolute layout, and viewport-px coordinates would // shove it off-frame — and matches(':popover-open') itself throws // there (unknown pseudo-class), hence the try/catch. if (this.hasAttribute('data-reframe')) { let onTop = false; try { onTop = this._ctl.matches(':popover-open'); } catch {} if (onTop) { const r = this.getBoundingClientRect(); this._ctl.style.left = (r.right - 8) + 'px'; this._ctl.style.top = (r.top + 8) + 'px'; } } if (!g) { // Dimensions not known yet (before img load) — centered fit so there // is no flash of an unpositioned image before the geometry lands. const contain = (this.getAttribute('fit') || 'cover').toLowerCase() === 'contain'; this._img.style.width = '100%'; this._img.style.height = '100%'; this._img.style.left = '50%'; this._img.style.top = '50%'; this._img.style.objectFit = contain ? 'contain' : 'cover'; return; } // Baseline (cover-fill or contain-fit) × view scale. Width/height and // left/top are all frame-% — depends only on the frame aspect ratio, so // a responsive resize keeps the same crop. The spill layer mirrors the // same box so its corners = image corners. const k = g.base * this._view.s; const w = (g.iw * k / g.fw * 100) + '%'; const h = (g.ih * k / g.fh * 100) + '%'; const l = (50 + this._view.x) + '%'; const t = (50 + this._view.y) + '%'; this._img.style.width = w; this._img.style.height = h; this._img.style.left = l; this._img.style.top = t; this._img.style.objectFit = ''; if (this.hasAttribute('data-reframe')) { // Top-layer spill: position in viewport px over the frame. The top // layer escapes ancestor transforms entirely, so EVERY term must be // in viewport units: getBoundingClientRect gives the frame's scaled // origin AND size, and the rect/layout ratio rescales the ghost — // sizing from layout px alone renders it 1/scale too large under a // scaled deck slide. Inner ghost + handles stay box-relative. const r = this.getBoundingClientRect(); const sx = g.fw ? r.width / g.fw : 1; const sy = g.fh ? r.height / g.fh : 1; this._spill.style.width = (g.iw * k * sx) + 'px'; this._spill.style.height = (g.ih * k * sy) + 'px'; this._spill.style.left = (r.left + (50 + this._view.x) / 100 * r.width) + 'px'; this._spill.style.top = (r.top + (50 + this._view.y) / 100 * r.height) + 'px'; } } _commitView() { const v = { s: this._view.s, x: this._view.x, y: this._view.y }; if (this._userUrl) v.u = this._userUrl; // Framing-only (no u) persists too so an author-src slot remembers its // crop; clearing the sidecar still falls through to src=. if (this.id) setSlot(this.id, v); else { this._local = v; } } _render() { // Shape / mask. Presets use border-radius so the dashed ring can // follow the rounded outline; clip-path is only applied for an // explicit `mask` (the ring is hidden there since a rectangle // dashed border chopped by an arbitrary polygon looks broken). const mask = this.getAttribute('mask'); const shape = (this.getAttribute('shape') || 'rounded').toLowerCase(); let radius = ''; if (shape === 'circle') radius = '50%'; else if (shape === 'pill') radius = '9999px'; else if (shape === 'rounded') { const n = parseFloat(this.getAttribute('radius')); radius = (Number.isFinite(n) ? n : 12) + 'px'; } this._frame.style.borderRadius = mask ? '' : radius; this._frame.style.clipPath = mask || ''; this._ring.style.borderRadius = mask ? '' : radius; this._ring.style.display = mask ? 'none' : ''; // Controls and reframe entry gate on this so share links stay read-only. const editable = !!(window.omelette && window.omelette.writeFile); this.toggleAttribute('data-editable', editable); this._sub.style.display = editable ? '' : 'none'; // Content. The sidecar is also writable by the agent's write_file // tool, so its value isn't guaranteed canvas-originated — only accept // data:image/ URLs from it. The `src` attribute is author-controlled // (Claude wrote it into the HTML) so it passes through unchanged. let stored = this.id ? getSlot(this.id) : this._local; if (stored && stored.u && !/^data:image//i.test(stored.u)) stored = null; const srcAttr = this.getAttribute('src') || ''; this._userUrl = (stored && stored.u) || null; const url = this._userUrl || srcAttr; // Don't clobber an in-flight reframe with a store-triggered re-render. if (!this.hasAttribute('data-reframe')) { this._view = { s: stored && Number.isFinite(stored.s) ? clampS(stored.s) : 1, x: stored && Number.isFinite(stored.x) ? stored.x : 0, y: stored && Number.isFinite(stored.y) ? stored.y : 0, }; } this._cap.textContent = this.getAttribute('placeholder') || 'Drop an image'; // Toggle via style.display — the [hidden] attribute alone loses to // the display:flex / display:block rules in the stylesheet above. // An Unsplash src with no credit attribute must NOT render — showing // the photo uncredited is the Unsplash-terms violation itself. The // error tile replaces the photo until the credit is written. A // user-dropped image is the user's own content and always renders. // Trimmed: credit is agent/user-editable content, and a whitespace- // only value must count as missing — otherwise it would suppress the // error tile AND render an empty credit box (no text, no links), // exactly the unattributed state this gate exists to prevent. const credit = (this.getAttribute('credit') || '').trim(); const attrError = !!( !credit && !this._userUrl && srcAttr && isUnsplashHost(srcAttr) ); this.toggleAttribute('data-attribution-error', attrError); if (url && !attrError) { const prev = this._img.getAttribute('src'); if (prev !== url) { // Replacing an already-shown image: mark the swap BEFORE setting // src so the stale frame is never revealed (see the data-swapping // stylesheet rules). First fill (prev empty) keeps the existing // placeholder-until-load behavior — no spinner. _hidShowing // covers the pick path's transient attribution-error wipe: prev // is gone, but an image WAS showing, so this is a replacement. if (prev || this._hidShowing) this.setAttribute('data-swapping', ''); // Mark the swap BEFORE assigning src: complete keeps reporting // the old settled request until the browser's // update-the-image-data microtask runs, so same-task re-renders // (the pick path's credit/credit-href setAttributes) need this // flag, not complete, to know a load is in flight. this._loadPending = true; this._img.src = url; this._ghost.src = url; } else { // Same-src re-render — release if settled, so an ingest-set // spinner can't stick after a byte-identical re-upload (same // data URL, no further load event ever fires). this._releaseMask(); } this._hidShowing = false; this._img.style.display = 'block'; this._empty.style.display = 'none'; this.setAttribute('data-filled', ''); this._clampView(); this._applyView(); } else { this.removeAttribute('data-swapping'); // The src is being removed — no load/error will ever fire for it. this._loadPending = false; // A transient attribution-error wipe of a showing image happens on // the pick path: the host sets src one setAttribute before credit, // so render N hides the old image (attrError) and render N+1 // restores a URL. Remember the wipe so that restore renders as a // replacement (spinner), not a first fill (blank frame). this._hidShowing = attrError && !!this._img.getAttribute('src'); this._img.style.display = 'none'; this._img.removeAttribute('src'); this._ghost.removeAttribute('src'); // The error tile owns the blocked-photo state; .empty stays for // the genuinely-empty slot. this._empty.style.display = attrError ? 'none' : 'flex'; this.removeAttribute('data-filled'); } // Credit belongs to the author src, so a user drop hides it. // textContent + the http(s)-only funnel keep external strings inert. const showCredit = !!(url && credit && !this._userUrl && !attrError); this._credit.textContent = ''; if (showCredit) { // Validate once (resolved against the document, http(s) only), // then append the terms-required utm referral params to links // that point back at unsplash.com. let href = ''; const rawHref = this.getAttribute('credit-href') || ''; if (rawHref) { try { const u = new URL(rawHref, document.baseURI); if (u.protocol === 'http:' || u.protocol === 'https:') { href = withReferral(u.href); } } catch {} } const mkLink = (text, linkHref) => { const a = document.createElement('a'); a.setAttribute('target', '_blank'); a.setAttribute('rel', 'noopener noreferrer'); a.setAttribute('href', linkHref); a.textContent = text; return a; }; // Unsplash's prescribed credit is TWO links — the photographer's // name to their profile (credit-href) and 'Unsplash' to the // homepage. Render that split whenever the text has the canonical // shape; other text keeps the legacy single-link rendering. const m = /^Photo by (.+) on Unsplash$/.exec(credit); if (m) { this._credit.appendChild(document.createTextNode('Photo by ')); this._credit.appendChild( href ? mkLink(m[1], href) : document.createTextNode(m[1]) ); this._credit.appendChild(document.createTextNode(' on ')); this._credit.appendChild(mkLink('Unsplash', UNSPLASH_HOMEPAGE_HREF)); } else if (href) { this._credit.appendChild(mkLink(credit, href)); } else { this._credit.textContent = credit; } } this.toggleAttribute('data-credit', showCredit); } } if (!customElements.get('image-slot')) { customElements.define('image-slot', ImageSlot); } })();
İçeriğe geç
Ara
Arama:
Ara
Home
Where My Mind Speaks My Soul
What I create
My memories
Architectural works
My digital journey
Başlangıç
Architectural works
My memories
Product Design Works
What I create
Where My Mind Speaks My Soul
Where My Mind Speaks My Soul
Where My Mind Speaks My Soul