PageSourceSearch

https://radicalimaging.com/image-slot.js

js radicalimaging.com collected 2026-10-03 20:54:45 UTC 31,364 bytes, 643 lines download raw bytes

1// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
2/* BEGIN USAGE */
3/**
4 * <image-slot> — user-fillable image placeholder.
5 *
6 * Drop this into a deck, mockup, or page wherever you want the user to
7 * supply an image. You control the slot's shape and size; the user fills it
8 * by dragging an image file onto it (or clicking to browse). The dropped
9 * image persists across reloads via a .image-slots.state.json sidecar —
10 * same read-via-fetch / write-via-window.omelette pattern as
11 * design_canvas.jsx, so the filled slot shows on share links, downloaded
12 * zips, and PPTX export. Outside the omelette runtime the slot is read-only.
13 *
14 * The host bridge only allows sidecar writes at the project root, so the
15 * HTML that uses this component is assumed to live at the project root too
16 * (same constraint as design_canvas.jsx).
17 *
18 * Attributes:
19 *   id           Persistence key. REQUIRED for the drop to survive reload —
20 *                every slot on the page needs a distinct id.
21 *   shape        'rect' | 'rounded' | 'circle' | 'pill'   (default 'rounded')
22 *                'circle' applies 50% border-radius; on a non-square slot
23 *                that's an ellipse — set equal width and height for a true
24 *                circle.
25 *   radius       Corner radius in px for 'rounded'.       (default 12)
26 *   mask         Any CSS clip-path value. Overrides `shape` — use this for
27 *                hexagons, blobs, arbitrary polygons.
28 *   fit          object-fit: cover | contain | fill.       (default 'cover')
29 *                With cover (the default) double-clicking the filled slot
30 *                enters a reframe mode: the whole image spills past the mask
31 *                (translucent outside, opaque inside), drag to reposition,
32 *                corner-drag to scale. The crop persists alongside the image
33 *                in the sidecar. contain/fill stay static.
34 *   position     object-position for fit=contain|fill.     (default '50% 50%')
35 *   placeholder  Empty-state caption.                      (default 'Drop an image')
36 *   src          Optional initial/fallback image URL. A user drop overrides
37 *                it; clearing the drop reveals src again.
38 *
39 * Size and layout come from ordinary CSS on the element — width/height
40 * inline or from a parent grid — so it composes with any layout.
41 *
42 * Usage:
43 *   <image-slot id="hero"   style="width:800px;height:450px" shape="rounded" radius="20"
44 *               placeholder="Drop a hero image"></image-slot>
45 *   <image-slot id="avatar" style="width:120px;height:120px" shape="circle"></image-slot>
46 *   <image-slot id="kite"   style="width:300px;height:300px"
47 *               mask="polygon(50% 0, 100% 50%, 50% 100%, 0 50%)"></image-slot>
48 */
49/* END USAGE */
50
51(() => {
52  const STATE_FILE = '.image-slots.state.json';
53  // 2× a ~600px slot in a 1920-wide deck — retina-sharp without making the
54  // sidecar enormous. A 1200px WebP at q=0.85 is ~150-300KB.
55  const MAX_DIM = 1200;
56  // Raster formats only. SVG is excluded (can carry script; createImageBitmap
57  // on SVG blobs is inconsistent). GIF is excluded because the canvas
58  // re-encode keeps only the first frame, so an animated GIF would silently
59  // go still — better to reject than surprise.
60  const ACCEPT = ['image/png', 'image/jpeg', 'image/webp', 'image/avif'];
61
62  // ── Shared sidecar store ────────────────────────────────────────────────
63  // One fetch + immediate write-on-change for every <image-slot> on the
64  // page. Reads via fetch() so viewing works anywhere the HTML and sidecar
65  // are served together; writes go through window.omelette.writeFile, which
66  // the host allowlists to *.state.json basenames only.
67  const subs = new Set();
68  let slots = {};
69  // ids explicitly cleared before the sidecar fetch resolved — otherwise
70  // the merge below can't tell "never set" from "just deleted" and would
71  // resurrect the sidecar's stale value.
72  const tombstones = new Set();
73  let loaded = false;
74  let loadP = null;
75
76  function load() {
77    if (loadP) return loadP;
78    loadP = fetch(STATE_FILE)
79      .then((r) => (r.ok ? r.json() : null))
80      .then((j) => {
81        // Merge: sidecar loses to any in-memory change that raced ahead of
82        // the fetch (drop or clear) so neither is clobbered by hydration.
83        if (j && typeof j === 'object') {
84          const merged = Object.assign({}, j, slots);
85          // A framing-only write that raced ahead of hydration must not
86          // drop a user image that's only on disk — inherit u from the
87          // sidecar for any in-memory entry that lacks one.
88          for (const k in slots) {
89            if (merged[k] && !merged[k].u && j[k]) {
90              merged[k].u = typeof j[k] === 'string' ? j[k] : j[k].u;
91            }
92          }
93          for (const id of tombstones) delete merged[id];
94          slots = merged;
95        }
96        tombstones.clear();
97      })
98      .catch(() => {})
99      .then(() => { loaded = true; subs.forEach((fn) => fn()); });
100    return loadP;
101  }
102
103  // Serialize writes so two near-simultaneous drops on different slots
104  // can't reorder at the backend and leave the sidecar with only the
105  // first. A save requested mid-flight just marks dirty and re-fires on
106  // completion with the then-current slots.
107  let saving = false;
108  let saveDirty = false;
109  function save() {
110    if (saving) { saveDirty = true; return; }
111    const w = window.omelette && window.omelette.writeFile;
112    if (!w) return;
113    saving = true;
114    Promise.resolve(w(STATE_FILE, JSON.stringify(slots)))
115      .catch(() => {})
116      .then(() => { saving = false; if (saveDirty) { saveDirty = false; save(); } });
117  }
118
119  const S_MAX = 5;
120  const clampS = (s) => Math.max(1, Math.min(S_MAX, s));
121
122  // Normalize a stored slot value. Pre-reframe sidecars stored a bare
123  // data-URL string; newer ones store {u, s, x, y}. Either shape is valid.
124  function getSlot(id) {
125    const v = slots[id];
126    if (!v) return null;
127    return typeof v === 'string' ? { u: v, s: 1, x: 0, y: 0 } : v;
128  }
129
130  function setSlot(id, val) {
131    if (!id) return;
132    if (val) { slots[id] = val; tombstones.delete(id); }
133    else { delete slots[id]; if (!loaded) tombstones.add(id); }
134    subs.forEach((fn) => fn());
135    // A drop is rare + high-value — write immediately so nav-away can't lose
136    // it. Gate on the initial read so we don't overwrite a sidecar we haven't
137    // merged yet; the merge in load() keeps this change once the read lands.
138    if (loaded) save(); else load().then(save);
139  }
140
141  // ── Image downscale ─────────────────────────────────────────────────────
142  // Encode through a canvas so the sidecar carries resized bytes, not the
143  // raw upload. Longest side is capped at 2× the slot's rendered width
144  // (retina) and at MAX_DIM. WebP keeps alpha and is ~10× smaller than PNG
145  // for photos, so there's no need for per-image format picking.
146  async function toDataUrl(file, targetW) {
147    const bitmap = await createImageBitmap(file);
148    try {
149      const cap = Math.min(MAX_DIM, Math.max(1, Math.round(targetW * 2)) || MAX_DIM);
150      const scale = Math.min(1, cap / Math.max(bitmap.width, bitmap.height));
151      const w = Math.max(1, Math.round(bitmap.width * scale));
152      const h = Math.max(1, Math.round(bitmap.height * scale));
153      const canvas = document.createElement('canvas');
154      canvas.width = w; canvas.height = h;
155      canvas.getContext('2d').drawImage(bitmap, 0, 0, w, h);
156      return canvas.toDataURL('image/webp', 0.85);
157    } finally {
158      bitmap.close && bitmap.close();
159    }
160  }
161
162  // ── Custom element ──────────────────────────────────────────────────────
163  const stylesheet =
164    ':host{display:inline-block;position:relative;vertical-align:top;' +
165    '  font:13px/1.3 system-ui,-apple-system,sans-serif;color:rgba(0,0,0,.55);width:240px;height:160px}' +
166    '.frame{position:absolute;inset:0;overflow:hidden;background:rgba(0,0,0,.04)}' +
167    // .frame img (clipped) and .spill (unclipped ghost + handles) share the
168    // same left/top/width/height in frame-%, computed by _applyView(), so the
169    // inside-mask crop and the outside-mask spill stay pixel-aligned.
170    '.frame img{position:absolute;max-width:none;transform:translate(-50%,-50%);' +
171    '  -webkit-user-drag:none;user-select:none;touch-action:none}' +
172    // Reframe mode (double-click): the full image spills past the mask. The
173    // spill layer is sized to the IMAGE bounds so its corners are where the
174    // resize handles belong. The ghost <img> inside is translucent; the real
175    // clipped <img>
175 underneath shows the opaque in-mask crop.
176    '.spill{position:absolute;transform:translate(-50%,-50%);display:none;z-index:1;' +
177    '  cursor:grab;touch-action:none}' +
178    ':host([data-panning]) .spill{cursor:grabbing}' +
179    '.spill .ghost{position:absolute;inset:0;width:100%;height:100%;opacity:.35;' +
180    '  pointer-events:none;-webkit-user-drag:none;user-select:none;' +
181    '  box-shadow:0 0 0 1px rgba(0,0,0,.2),0 12px 32px rgba(0,0,0,.2)}' +
182    '.spill .handle{position:absolute;width:12px;height:12px;border-radius:50%;' +
183    '  background:#fff;box-shadow:0 0 0 1.5px #c96442,0 1px 3px rgba(0,0,0,.3);' +
184    '  transform:translate(-50%,-50%)}' +
185    '.spill .handle[data-c=nw]{left:0;top:0;cursor:nwse-resize}' +
186    '.spill .handle[data-c=ne]{left:100%;top:0;cursor:nesw-resize}' +
187    '.spill .handle[data-c=sw]{left:0;top:100%;cursor:nesw-resize}' +
188    '.spill .handle[data-c=se]{left:100%;top:100%;cursor:nwse-resize}' +
189    ':host([data-reframe]){z-index:10}' +
190    ':host([data-reframe]) .spill{display:block}' +
191    ':host([data-reframe]) .frame{box-shadow:0 0 0 2px #c96442}' +
192    '.empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;' +
193    '  justify-content:center;gap:6px;text-align:center;padding:12px;box-sizing:border-box;' +
194    '  cursor:pointer;user-select:none}' +
195    '.empty svg{opacity:.45}' +
196    '.empty .cap{max-width:90%;font-weight:500;letter-spacing:.01em}' +
197    '.empty .sub{font-size:11px}' +
198    '.empty .sub u{text-underline-offset:2px;text-decoration-color:rgba(0,0,0,.25)}' +
199    '.empty:hover .sub u{color:rgba(0,0,0,.75);text-decoration-color:currentColor}' +
200    ':host([data-over]) .frame{outline:2px solid #c96442;outline-offset:-2px;' +
201    '  background:rgba(201,100,66,.10)}' +
202    '.ring{position:absolute;inset:0;pointer-events:none;border:1.5px dashed rgba(0,0,0,.25);' +
203    '  transition:border-color .12s}' +
204    ':host([data-over]) .ring{border-color:#c96442}' +
205    ':host([data-filled]) .ring{display:none}' +
206    // Controls sit BELOW the mask (top:100%), absolutely positioned so the
207    // author-declared slot height is unaffected. The gap is padding, not a
208    // top offset, so the hover target stays contiguous with the frame.
209    '.ctl{position:absolute;top:100%;left:50%;transform:translateX(-50%);padding-top:8px;' +
210    '  display:flex;gap:6px;opacity:0;pointer-events:none;transition:opacity .12s;z-index:2;' +
211    '  white-space:nowrap}' +
212    ':host([data-filled][data-editable]:hover) .ctl,:host([data-reframe]) .ctl' +
213    '  {opacity:1;pointer-events:auto}' +
214    '.ctl button{appearance:none;border:0;border-radius:6px;padding:5px 10px;cursor:pointer;' +
215    '  background:rgba(0,0,0,.65);color:#fff;font:11px/1 system-ui,-apple-system,sans-serif;' +
216    '  backdrop-filter:blur(6px)}' +
217    '.ctl button:hover{background:rgba(0,0,0,.8)}' +
218    '.err{position:absolute;left:8px;bottom:8px;right:8px;color:#b3261e;font-size:11px;' +
219    '  background:rgba(255,255,255,.85);padding:4px 6px;border-radius:5px;pointer-events:none}';
220
221  const icon =
222    '<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
223    'stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">' +
224    '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/>' +
225    '<path d="m21 15-5-5L5 21"/></svg>';
226
227  class ImageSlot extends HTMLElement {
228    static get observedAttributes() {
229      return ['shape', 'radius', 'mask', 'fit', 'position', 'placeholder', 'src', 'id'];
230    }
231
232    constructor() {
233      super();
234      const root = this.attachShadow({ mode: 'open' });
235      // .spill and .ctl sit OUTSIDE .frame so overflow:hidden + border-radius
236      // on the frame (circle, pill, rounded) can't clip them.
237      root.innerHTML =
238        '<style>' + stylesheet + '</style>' +
239        '<div class="frame" part="frame">' +
240        '  <img part="image" alt="" draggable="false" style="display:none">' +
241        '  <div class="empty" part="empty">' + icon +
242        '    <div class="cap"></div>' +
243        '    <div class="sub">or <u>browse files</u></div></div>' +
244        '  <div class="ring" part="ring"></div>' +
245        '</div>' +
246        '<div class="spill">' +
247        '  <img class="ghost" alt="" draggable="false">' +
248        '  <div class="handle" data-c="nw"></div><div class="handle" data-c="ne"></div>' +
249        '  <div class="handle" data-c="sw"></div><div class="handle" data-c="se"></div>' +
250        '</div>' +
251        '<div class="ctl"><button data-act="replace" title="Replace image">Replace</button>' +
252        '  <button data-act="clear" title="Remove image">Remove</button></div>' +
253        '<input type="file" accept="' + ACCEPT.join(',') + '" hidden>';
254      this._frame = root.querySelector('.frame');
255      this._ring = root.querySelector('.ring');
256      this._img = root.querySelector('.frame img');
257      this._empty = root.querySelector('.empty');
258      this._cap = root.querySelector('.cap');
259      this._sub = root.querySelector('.sub');
260      this._spill = root.querySelector('.spill');
261      this._ghost = root.querySelector('.ghost');
262      this._err = null;
263      this._input = root.querySelector('input');
264      this._depth = 0;
265      this._gen = 0;
266      this._view = { s: 1, x: 0, y: 0 };
267      this._subFn = () => this._render();
268      // Shadow-DOM listeners live with the shadow DOM — bound once here so
269      // disconnect/reconnect (e.g. React remount) doesn't stack handlers.
270      this._empty.addEventListener('click', () => this._input.click());
271      root.addEventListener('click', (e) => {
272        const act = e.target && e.target.getAttribute && e.target.getAttribute('data-act');
273        if (act === 'replace') { this._exitReframe(true); this._input.click(); }
274        if (act === 'clear') {
275          this._exitReframe(false);
276          this._gen++;
277          this._local = null;
278          if (this.id) setSlot(this.id, null); else this._render();
279        }
280      });
281      this._input.addEventListener('change', () => {
282        const f = this._input.files && this._input.files[0];
283        if (f) this._ingest(f);
284        this._input.value = '';
285      });
286      // naturalWidth/Height aren't known until load — re-apply so the cover
287      // baseline is computed from real dimensions, not the 100%×100% fallback.
288      this._img.addEventListener('load', () => this._applyView());
289      // Gated on editable + fit=cover so share links and contain/fill slots
290      // stay static.
291      this.addEventListener('dblclick', (e) => {
292        if (!this.hasAttribute('data-editable') || !this._reframes()) return;
293        e.preventDefault();
294        if (this.hasAttribute('data-reframe')) this._exitReframe(true);
295        else this._enterReframe();
296      });
297      // Pan + resize both originate on the spill layer. A handle pointerdown
298      // drives an aspect-locked resize anchored at the opposite corner; any
299      // other pointerdown on the spill pans. Offsets are frame-% so a
300      // reframed slot survives responsive resize / PPTX export.
301      this._spill.addEventListener('pointerdown', (e) => {
302        if (e.button !== 0 || !this.hasAttribute('data-reframe')) return;
303        e.preventDefault();
304        e.stopPropagation();
305        this._spill.setPointerCapture(e.pointerId);
306        const rect = this.getBoundingClientRect();
307        const fw = rect.width || 1, fh = rect.height || 1;
308        const corner = e.target.getAttribute && e.target.getAttribute('data-c');
309        let move;
310        if (corner) {
311          // Resize about the OPPOSITE corner. Viewport-px throughout (rect
312          // fw/fh, not clientWidth) so the math survives a transform:scale()
313          // ancestor — deck_stage renders slides scaled-to-fit.
314          const iw = this._img.naturalWidth || 1, ih = this._img.naturalHeight || 1;
315          const base = Math.max(fw / iw, fh / ih);
316          const sx = corner.includes('e') ? 1 : -1;
317          const sy = corner.includes('s') ? 1 : -1;
318          const s0 = this._view.s;
319          const w0 = iw * base * s0, h0 = ih * base * s0;
320          const cx0 = (50 + this._view.x) / 100 * fw;
321          const cy0 = (50 + this._view.y) / 100 * fh;
322          const ox = cx0 - sx * w0 / 2, oy = cy0 - sy * h0 / 2;
323          const diag0 = Math.hypot(w0, h0);
324          const ux = sx * w0 / diag0, uy = sy * h0 / diag0;
325          move = (ev) => {
326            const proj = (ev.clientX - rect.left - ox) * ux +
327                         (ev.clientY - rect.top - oy) * uy;
328            const s = clampS(s0 * proj / diag0);
329            const d = diag0 * s / s0;
330            this._view.s = s;
331            this._view.x = (ox + ux * d / 2) / fw * 100 - 50;
332            this._view.y = (oy + uy * d / 2) / fh * 100 - 50;
333            this._clampView();
334            this._applyView();
335          };
336        } else {
337          this.setAttribute('data-panning', '');
338          const start = { px: e.clientX, py: e.clientY, x: this._view.x, y: this._view.y };
339          move = (ev) => {
340            this._view.x = start.x + (ev.clientX - start.px) / fw * 100;
341            this._view.y = start.y + (ev.clientY - start.py) / fh * 100;
342            this._clampView();
343            this._applyView();
344          };
345        }
346        const up = () => {
347          try { this._spill.releasePointerCapture(e.pointerId); } catch {}
348          this._spill.removeEventListener('pointermove', move);
349          this._spill.removeEventListener('pointerup', up);
350          this._spill.removeEventListener('pointercancel', up);
351          this.removeAttribute('data-panning');
352          this._dragUp = null;
353        };
354        // Stashed so _exitReframe (Escape / outside-click mid-drag) can
355        // tear the capture + listeners down synchronously.
356        this._dragUp = up;
357        this._spill.addEventListener('pointermove', move);
358        this._spill.addEventListener('pointerup', up);
359        this._spill.addEventListener('pointercancel', up);
360      });
361      // Wheel zoom stays available inside reframe mode as a trackpad nicety —
362      // zooms toward the cursor (offset' = cursor·(1-k) + offset·k).
363      this.addEventListener('wheel', (e) => {
364        if (!this.hasAttribute('data-reframe')) return;
365        e.preventDefault();
366        const r = this.getBoundingClientRect();
367        const cx = (e.clientX - r.left) / r.width * 100 - 50;
368        const cy = (e.clientY - r.top) / r.height * 100 - 50;
369        const prev = this._view.s;
370        const next = clampS(prev * Math.pow(1.0015, -e.deltaY));
371        if (next === prev) return;
372        const k = next / prev;
373        this._view.s = next;
374        this._view.x = cx * (1 - k) + this._view.x * k;
375        this._view.y = cy * (1 - k) + this._view.y * k;
376        this._clampView();
377        this._applyView();
378      }, { passive: false });
379    }
380
381    connectedCallback() {
382      // Warn once per page — an id-less slot works for the session but
383      // cannot persist, and two id-less slots would share nothing.
384      if (!this.id && !ImageSlot._warned) {
385        ImageSlot._warned = true;
386        console.warn('<image-slot> without an id will not persist its dropped image.');
387      }
388      this.addEventListener('dragenter', this);
389      this.addEventListener('dragover', this);
390      this.addEventListener('dragleave', this);
391      this.addEventListener('drop', this);
392      subs.add(this._subFn);
393      // width%/height% in _applyView encode the frame aspect at call time —
394      // a host resize (responsive grid, pane divider) would stretch the
395      // image until the next _render. Re-render on size change: _render()
396      // re-seeds _view from stored before clamp/apply, so a shrink→grow
397      // cycle round-trips instead of ratcheting x/y toward the narrower
398      // frame's clamp range.
399      this._ro = new ResizeObserver(() => this._render());
400      this._ro.observe(this);
401      load();
402      this._render();
403    }
404
405    disconnectedCallback() {
406      subs.delete(this._subFn);
407      this.removeEventListener('dragenter', this);
408      this.removeEventListener('dragover', this);
409      this.removeEventListener('dragleave', this);
410      this.removeEventListener('drop', this);
411      if (this._ro) { this._ro.disconnect(); this._ro = null; }
412      this._exitReframe(false);
413    }
414
415    _enterReframe() {
416      if (this.hasAttribute('data-reframe')) return;
417      this.setAttribute('data-reframe', '');
418      this._applyView();
419      // Close on click outside (the spill handler stopPropagation()s so
420      // in-image drags don't reach this) and on Escape. Listeners are held
421      // on the instance so _exitReframe / disconnectedCallback can detach
422      // exactly what was attached.
423      this._outside = (e) => {
424        if (e.composedPath && e.composedPath().includes(this)) return;
425        this._exitReframe(true);
426      };
427      this._esc = (e) => { if (e.key === 'Escape') this._exitReframe(true); };
428      document.addEventListener('pointerdown', this._outside, true);
429      document.addEventListener('keydown', this._esc, true);
430    }
431
432    _exitReframe(commit) {
433      if (!this.hasAttribute('data-reframe')) return;
434      if (this._dragUp) this._dragUp();
435      this.removeAttribute('data-reframe');
436      this.removeAttribute('data-panning');
437      if (this._outside) document.removeEventListener('pointerdown', this._outside, true);
438      if (this._esc) document.removeEventListener('keydown', this._esc, true);
439      this._outside = this._esc = null;
440      if (commit) this._commitView();
441    }
442
443    attributeChangedCallback() { if (this.shadowRoot) this._render(); }
444
445    // handleEvent — one listener object for all four drag events keeps the
446    // add/remove symmetric and the depth counter correct.
447    handleEvent(e) {
448      if (e.type === 'dragenter' || e.type === 'dragover') {
449        // Without preventDefault the browser never fires 'drop'.
450        e.preventDefault();
451        e.stopPropagation();
452        if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
453        if (e.type === 'dragenter') this._depth++;
454        this.setAttribute('data-over', '');
455      } else if (e.type === 'dragleave') {
456        // dragenter/leave fire for every descendant crossing — count depth
457        // so hovering the icon inside the empty state doesn't flicker.
458        if (--this._depth <= 0) { this._depth = 0; this.removeAttribute('data-over'); }
459      } else if (e.type === 'drop') {
460        e.preventDefault();
461        e.stopPropagation();
462        this._depth = 0;
463        this.removeAttribute('data-over');
464        const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
465        if (f) this._ingest(f);
466      }
467    }
468
469    async _ingest(file) {
470      this._setError(null);
471      if (!file || ACCEPT.indexOf(file.type) < 0) {
472        this._setError('Drop a PNG, JPEG, WebP, or AVIF image.');
473        return;
474      }
475      // toDataUrl can take hundreds of ms on a large photo. A Clear or a
476      // newer drop during that window would be clobbered when this await
477      // resumes — bump + capture a generation so stale encodes bail.
478      const gen = ++this._gen;
479      try {
480        const w = this.clientWidth || this.offsetWidth || MAX_DIM;
481        const url = await toDataUrl(file, w);
482        if (gen !== this._gen) return;
483        // Only exit reframe once the new image is in hand — a rejected type
484        // or decode failure leaves the in-progress crop untouched.
485        this._exitReframe(false);
486        const val = { u: url, s: 1, x: 0, y: 0 };
487        setSlot(this.id || '', val);
488        // Keep a session-local copy for id-less slots so the drop still
489        // shows, even though it cannot persist.
490        if (!this.id) { this._local = val; this._render(); }
491      } catch (err) {
492        if (gen !== this._gen) return;
493        this._setError('Could not read that image.');
494        console.warn('<image-slot> ingest failed:', err);
495      }
496    }
497
498    _setError(msg) {
499      if (this._err) { this._err.remove(); this._err = null; }
500      if (!msg) return;
501      const d = document.createElement('div');
502      d.className = 'err'; d.textContent = msg;
503      this.shadowRoot.appendChild(d);
504      this._err = d;
505      setTimeout(() => { if (this._err === d) { d.remove(); this._err = null; } }, 3000);
506    }
507
508    // Reframing (pan/resize) is only meaningful for fit=cover — contain/fill
509    // keep the old object-fit path and double-click is a no-op.
510    _reframes() {
511      return this.hasAttribute('data-filled') &&
512        (this.getAttribute('fit') || 'cover') === 'cover';
513    }
514
515    // Cover-baseline geometry, shared by clamp/apply/resize. Null until the
516    // img has loaded (naturalWidth is 0 before that) or when the slot has no
517    // layout box — ResizeObserver fires with a 0×0 rect under display:none,
518    // and clamping against a degenerate 1×1 frame would silently pull the
519    // stored pan toward zero.
520    _geom() {
521      const iw = this._img.naturalWidth, ih = this._img.naturalHeight;
522      const fw = this.clientWidth, fh = this.clientHeight;
523      if (!iw || !ih || !fw || !fh) return null;
524      return { iw, ih, fw, fh, base: Math.max(fw / iw, fh / ih) };
525    }
526
527    _clampView() {
528      // Pan range on each axis is half the overflow past the frame edge.
529      const g = this._geom();
530      if (!g) return;
531      const mx = Math.max(0, (g.iw * g.base * this._view.s / g.fw - 1) * 50);
532      const my = Math.max(0, (g.ih * g.base * this._view.s / g.fh - 1) * 50);
533      this._view.x = Math.max(-mx, Math.min(mx, this._view.x));
534      this._view.y = Math.max(-my, Math.min(my, this._view.y));
535    }
536
537    _applyView() {
538      const g = this._geom();
539      const fit = this.getAttribute('fit') || 'cover';
540      if (fit !== 'cover' || !g) {
541        // Non-cover, or dimensions not known yet (before img load).
542        this._img.style.width = '100%';
543        this._img.style.height = '100%';
544        this._img.style.left = '50%';
545        this._img.style.top = '50%';
546        this._img.style.objectFit = fit;
547        this._img.style.objectPosition = this.getAttribute('position') || '50% 50%';
548        return;
549      }
550      // Cover baseline: img fills the frame on its tighter axis at s=1, so
551      // pan works immediately on the overflowing axis without zooming first.
552      // Width/height and left/top are all frame-% — depends only on the
553      // frame aspect ratio, so a responsive resize keeps the same crop. The
554      // spill layer mirrors the same box so its corners = image corners.
555      const k = g.base * this._view.s;
556      const w = (g.iw * k / g.fw * 100) + '%';
557      const h = (g.ih * k / g.fh * 100) + '%';
558      const l = (50 + this._view.x) + '%';
559      const t = (50 + this._view.y) + '%';
560      this._img.style.width = w; this._img.style.height = h;
561      this._img.style.left = l; this._img.style.top = t;
562      this._img.style.objectFit = '';
563      this._spill.style.width = w; this._spill.style.height = h;
564      this._spill.style.left = l; this._spill.style.top = t;
565    }
566
567    _commitView() {
568      const v = { s: this._view.s, x: this._view.x, y: this._view.y };
569      if (this._userUrl) v.u = this._userUrl;
570      // Framing-only (no u) persists too so an author-src slot remembers its
571      // crop; clearing the sidecar still falls through to src=.
572      if (this.id) setSlot(this.id, v);
573      else { this._local = v; }
574    }
575
576    _render() {
577      // Shape / mask. Presets use border-radius so the dashed ring can
578      // follow the rounded outline; clip-path is only applied for an
579      // explicit `mask` (the ring is hidden there since a rectangle
580      // dashed border chopped by an arbitrary polygon looks broken).
581      const mask = this.getAttribute('mask');
582      const shape = (this.getAttribute('shape') || 'rounded').toLowerCase();
583      let radius = '';
584      if (shape === 'circle') radius = '50%';
585      else if (shape === 'pill') radius = '9999px';
586      else if (shape === 'rounded') {
587        const n = parseFloat(this.getAttribute('radius'));
588        radius = (Number.isFinite(n) ? n : 12) + 'px';
589      }
590      this._frame.style.borderRadius = mask ? '' : radius;
591      this._frame.style.clipPath = mask || '';
592      this._ring.style.borderRadius = mask ? '' : radius;
593      this._ring.style.display = mask ? 'none' : '';
594
595      // Controls and reframe entry gate on this so share links stay read-only.
596      const editable = !!(window.omelette && window.omelette.writeFile);
597      this.toggleAttribute('data-editable', editable);
598      this._sub.style.display = editable ? '' : 'none';
599
600      // Content. The sidecar is also writable by the agent's write_file
601      // tool, so its value isn't guaranteed canvas-originated — only accept
602      // data:image/ URLs from it. The `src` attribute is author-controlled
603      // (Claude wrote it into the HTML) so it passes through unchanged.
604      let stored = this.id ? getSlot(this.id) : this._local;
605      if (stored && stored.u && !/^data:image\//i.test(stored.u)) stored = null;
606      const srcAttr = this.getAttribute('src') || '';
607      this._userUrl = (stored && stored.u) || null;
608      const url = this._userUrl || srcAttr;
609      // Don't clobber an in-flight reframe with a store-triggered re-render.
610      if (!this.hasAttribute('data-reframe')) {
611        this._view = {
612          s: stored && Number.isFinite(stored.s) ? clampS(stored.s) : 1,
613          x: stored && Number.isFinite(stored.x) ? stored.x : 0,
614          y: stored && Number.isFinite(stored.y) ? stored.y : 0,
615        };
616      }
617      this._cap.textContent = this.getAttribute('placeholder') || 'Drop an image';
618      // Toggle via style.display — the [hidden] attribute alone loses to
619      // the display:flex / display:block rules in the stylesheet above.
620      if (url) {
621        if (this._img.getAttribute('src') !== url) {
622          this._img.src = url;
623          this._ghost.src = url;
624        }
625        this._img.style.display = 'block';
626        this._empty.style.display = 'none';
627        this.setAttribute('data-filled', '');
628        this._clampView();
629        this._applyView();
630      } else {
631        this._img.style.display = 'none';
632        this._img.removeAttribute('src');
633        this._ghost.removeAttribute('src');
634        this._empty.style.display = 'flex';
635        this.removeAttribute('data-filled');
636      }
637    }
638  }
639
640  if (!customElements.get('image-slot')) {
641    customElements.define('image-slot', ImageSlot);
642  }
643})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.