WebDev Bites: CodeViewer Component Design

W3C web component

Component Class

The tag <image-viewer> creates an instance of the ImageViewer class. The class's first element is a ShadowDOM that isolates the component's elements and their styles. That prevents an application's styles from affecting the component internals. Attributes allow an application to affect selected styles defined by the designer.

Shadow DOM

A W3C web component is defined entirely in JavaScript as illustrated in the right panel. The shadowDOM provides HTML markup and styling for the component using a JavaScript expression " this.shadowRoot.innerHTML = `[markup goes here]`; .

Structure

This markup defines the structure of the image-viewer component. The outer div provides padding to keep content from touching the image-viewer when it is floated to the left or right. The next div provides the visible part of the component, e.g., its title and image. Note that the img src and width are provided by attributes the application supplies in the <image-viewer> declaration.

Listeners

The primary purpose of this component is to enlarge or diminish the size of an image using button clicks on image (enlarge) or title (diminish). The first listener awaits clicks on the title which contracts the image. The second awaits clicks on the image then enlarges it. The final piece registers the <image-viewer> with this component class. That causes the browser's rendering engine to create an instance of the ImageViewer class for each occurance of the tag.

Attributes and Helpers

The shadowDOM protects component styles by not returning styling queries. That prevents application code or included libraries from changing the way the component looks and reacts to user events. However, the designer may wish to provide an interface to make selected changes to component rendering. That is the purpose of attributes. They provide a controlled window into the component for application specific styling. Helpers are relatively small JavaScript functions defined in the component that are used internally to factor complex code into smaller pieces and to reduce code duplication. This CodeViewer component has several options for how code is supplied: with and without trimming and indenting. It also has narrowing and widening operations that are implemented with application button clicks. Specifications for these resulted in an ecosystem of helper functions.

Complete Code Listing

This view presents the entire component code to help you integrate all of the prior pieces.

  1 class CodeViewer extends HTMLElement {
      ....
 14
 15   constructor() {
 16     super();
 17     this.attachShadow({ mode: 'open' });
 18
 19     this.shadowRoot.innerHTML = `
 20       <style>
 21         :host { display: inline-block; }
       ....
 86         }
 87       </style>
 88
 89       <div class="wrapper">
 90         <div class="component" part="component">
 91           <div class="title" part="title"><slot></slot></div>
 92           <div class="code">
 93             <pre id="pre-internal"></pre>
 94           </div>
 95           <slot name="code" id="code-slot"></slot>
 96         </div>
 97       </div>
 98     `;
 99
      ....
401 }
402
403 customElements.define('code-viewer', CodeViewer);

  1 class CodeViewer extends HTMLElement {
      ....
 19     this.shadowRoot.innerHTML = `
 20       <style>
 21         :host { display: inline-block; }
 22         .wrapper {
 23           padding: 1rem;
 24           box-sizing: border-box;
 25           background-color: var(--wrapper-bg, var(--light, white));
 26         }
 27         .component {
 28           border: 2px solid var(--dark, #333);
 29           padding: 0.5rem;
 30           display: flex;
 31           flex-direction: column;
 32           user-select: none; /* per your app */
 33           width: min-content;
 34           box-shadow: 5px 5px 5px #999;
 35           box-sizing: border-box;
 36           background-color: var(--component-bg, white);
 37         }
 38         .title {
 39           display: flex;
 40           font-family: "Comic Sans MS", cursive, sans-serif;
 41           font-size: 1rem;              /* stable title size */
 42           font-weight: bold;
 43           cursor: pointer;
 44           max-width: 100%;
 45           margin-bottom: 8px;
 46           line-height: 1.0rem;
 47           flex-wrap: wrap;
 48           overflow-wrap: break-word;
 49           white-space: wrap;
 50           color: var(--dark, #333);
 51           background-color: var(--title-bg, transparent);
 52           padding: 0.125rem 0.5rem;      /* compact */
 53         }
 54         .code { display: block; flex: 0 0 auto; }
 55
 56         /* Default (non-Prism): show internal pre; hide slotted content */
 57         #pre-internal { display: block; cursor: pointer; }
 58         slot[name="code"]::slotted(*) { display: none !important; }
 59
 60         /* Prism: hide internal pre; show slotted pre/code */
 61         :host([highlight="prism"]) #pre-internal { display: none; }
 62         :host([highlight="prism"]) slot[name="code"]::slotted(pre),
 63         :host([highlight="prism"]) slot[name="code"]::slotted(code) {
 64           display: block !important;
 65           cursor: pointer;
 66         }
 67
 68         /* Internal pre defaults (non-Prism path) */
 69         #pre-internal {
 70           margin: 0;
 71           /* padding set dynamically to match Prism box */
 72           background-color: var(--code-bg, #333);
 73           color: var(--code-fg, #eee);
 74           border-radius: 4px;
 75           font-family: inherit;  /* overridden by attribute if provided */
 76           font-size: inherit;    /* overridden by attribute if provided */
 77           line-height: 1.4;
 78           white-space: pre;
 79           overflow-y: auto;
 80           overflow-x: var(--code-overflow-x, auto);
 81           width: var(--code-width, auto);
 82           height: var(--code-height, auto);
 83           box-sizing: border-box;
 84           transition: width 0.2s ease;
 85           text-align: left;
 86         }
 87       </style>
 88
 89       <div class="wrapper">
 90         <div class="component" part="component">
 91           <div class="title" part="title"><slot></slot></div>
 92           <div class="code">
 93             <pre id="pre-internal"></pre>
 94           </div>
 95           <slot name="code" id="code-slot"></slot>
 96         </div>
 97       </div>
 98     `;
 99
      ....
401 }
402
403 customElements.define('code-viewer', CodeViewer);

  1 class CodeViewer extends HTMLElement {
       ....   
 14
 15   constructor() {
 16     super();
 17     this.attachShadow({ mode: 'open' });
 18
 19     this.shadowRoot.innerHTML = `
 20       <style>
 21         :host { display: inline-block; }
      ....
 87       </style>
 88
 89       <div class="wrapper">
 90         <div class="component" part="component">
 91           <div class="title" part="title"><slot></slot></div>
 92           <div class="code">
 93             <pre id="pre-internal"></pre>
 94           </div>
 95           <slot name="code" id="code-slot"></slot>
 96         </div>
 97       </div>
 98     `;
 99
       ....
400   }
401 }
402
403 customElements.define('code-viewer', CodeViewer);

  1 class CodeViewer extends HTMLElement {
      ....
119   /* lifecycle */
120
121   connectedCallback() {
122     this._applyBoxColors();
123     this._renderDefaultFromSlot();     // for non-Prism
124     this._maybeSetupPrism();           // if Prism mode, ensure <pre><code> + highlight
125     this._resolveDisplayEl(true);      // sets _displayEl and normalizes its box
126     this._applySizingToDisplay();
127     this._applyTypographyToDisplay();
128     this._bindEvents();
129
130     this.slotEl.addEventListener('slotchange', () => {
131       this._renderDefaultFromSlot();
132       this._maybeSetupPrism();
133       const changed = this._resolveDisplayEl(true);
134       if (changed) this._resetWidthStepping();
135       this._applySizingToDisplay();
136       this._applyTypographyToDisplay();
137     });
138   }
139
140   attributeChangedCallback() {
141     this._applyBoxColors();
142     this._maybeSetupPrism();
143     const changed = this._resolveDisplayEl(true);
144     if (changed) this._resetWidthStepping();
145     this._applySizingToDisplay();
146     this._applyTypographyToDisplay();
147   }
148
149   /* events (single click only) */
150
151   _bindEvents() {
152     this._displayEl.addEventListener('click', this._onBodyClick);
153     this.titleEl.addEventListener('click', this._onTitleClick);
154   }
155
156   _swapBodyListener(nextEl) {
157     if (nextEl === this._displayEl) return;
158     this._displayEl.removeEventListener('click', this._onBodyClick);
159     this._displayEl = nextEl;
160     this._displayEl.addEventListener('click', this._onBodyClick);
161   }
162
163   _resetWidthStepping() {
164     this._originWidthPx = null;
165     this._stepsFromOrigin = 0;
166   }
167
168   _onBodyClick()  { this._bumpWidth(+1); }
169   _onTitleClick() { this._bumpWidth(-1); }
170
171   _bumpWidth(direction) {
172     const el = this._displayEl;
173     if (!el) return;
174
175     if (this._originWidthPx == null) {
176       const rect = el.getBoundingClientRect();
177       this._originWidthPx = rect.width > 0 ? rect.width : 480;
178       this._stepsFromOrigin = 0;
179     }
180
181     let nextSteps = this._stepsFromOrigin + direction;
182     let target = this._originWidthPx + nextSteps * this._stepPx;
183
184     if (target < this._minPx) {
185       target = this._minPx;
186       nextSteps = Math.ceil((target - this._originWidthPx) / this._stepPx);
187     }
188
189     this._stepsFromOrigin = nextSteps;
190     el.style.width = `${Math.round(target)}px`;
191   }
192
      ....
401 }
402
403 customElements.define('code-viewer', CodeViewer);            

  1 class CodeViewer extends HTMLElement {
  2   static get observedAttributes() {
  3     return [
  4       // visuals
  5       'bg-color', 'title-bg-color', 'background-color', 'color',
  6       // code sizing/typo
  7       'width', 'height', 'overflow-x', 'font-family', 'font-size', 'code-padding',
  8       // prism
  9       'highlight', 'language',
 10       // conveniences
 11       'trim', 'normalize-indent'
 12     ];
 13   }
 14
       ....
 99
100     // Refs
101     this.titleEl     = this.shadowRoot.querySelector('.title');
102     this.preInternal = this.shadowRoot.querySelector('#pre-internal');
103     this.slotEl      = this.shadowRoot.querySelector('#code-slot');
104
105     // Width stepping (single-click only)
106     this._originWidthPx   = null;
107     this._stepsFromOrigin = 0;
108     this._stepPx          = 40;   // ≈ 5ch typical
109     this._minPx           = 240;
110
111     // Active display element (internal <pre> or slotted <pre>)
112     this._displayEl = this.preInternal;
113
114     // Handlers
115     this._onBodyClick  = this._onBodyClick.bind(this);
116     this._onTitleClick = this._onTitleClick.bind(this);
117   }
118
      ....
162
163   _resetWidthStepping() {
164     this._originWidthPx = null;
165     this._stepsFromOrigin = 0;
166   }
167
168   _onBodyClick()  { this._bumpWidth(+1); }
169   _onTitleClick() { this._bumpWidth(-1); }
170
171   _bumpWidth(direction) {
172     const el = this._displayEl;
173     if (!el) return;
174
175     if (this._originWidthPx == null) {
176       const rect = el.getBoundingClientRect();
177       this._originWidthPx = rect.width > 0 ? rect.width : 480;
178       this._stepsFromOrigin = 0;
179     }
180
181     let nextSteps = this._stepsFromOrigin + direction;
182     let target = this._originWidthPx + nextSteps * this._stepPx;
183
184     if (target < this._minPx) {
185       target = this._minPx;
186       nextSteps = Math.ceil((target - this._originWidthPx) / this._stepPx);
187     }
188
189     this._stepsFromOrigin = nextSteps;
190     el.style.width = `${Math.round(target)}px`;
191   }
192
193   /* rendering paths */
194
195   _renderDefaultFromSlot() {
196     if (this.getAttribute('highlight') === 'prism') return;
197
198     // Collect raw content from the slot
199     const nodes = this.slotEl.assignedNodes({ flatten: true });
200     let raw = '';
201     for (const n of nodes) {
202       if (n.nodeType === Node.ELEMENT_NODE && n.tagName === 'TEMPLATE') {
203         raw += n.innerHTML ?? '';
204       } else if (n.nodeType === Node.ELEMENT_NODE) {
205         raw += n.outerHTML ?? '';
206       } else {
207         raw += n.textContent ?? '';
208       }
209     }
210
211     // 1) Optional: trim a fully blank first/last line
212     if (this.hasAttribute('trim')) {
213       raw = raw.replace(/^\s*\n/, '').replace(/\n\s*$/, '');
214     }
215
216     // 2) Optional: normalize common indentation (spaces/tabs) across non-empty lines
217     if (this.hasAttribute('normalize-indent')) {
218       raw = this._stripCommonIndent(raw);
219     }
220
221     // Show literally (escaped) in internal <pre>
222     this.preInternal.textContent = raw;
223   }
224
225   _maybeSetupPrism() {
226     if (this.getAttribute('highlight') !== 'prism') return;
227
228     const lang = (this.getAttribute('language') || '').trim();
229     const assigned = this.slotEl.assignedElements({ flatten: true });
230     if (!assigned.length) return;
231
232     // Ensure <pre><code> structure (convenience: allow <code slot="code">.</code>)
233     let preEl = assigned.find(n => n.tagName === 'PRE');
234     let codeEl = assigned.find(n => n.tagName === 'CODE');
235
236     if (!preEl && codeEl) {
237       preEl = document.createElement('pre');
238       const hostParent = codeEl.parentNode;
239       hostParent.replaceChild(preEl, codeEl);
240       preEl.appendChild(codeEl);
241     } else if (preEl && !preEl.querySelector('code')) {
242       const wrap = document.createElement('code');
243       while (preEl.firstChild) wrap.appendChild(preEl.firstChild);
244       preEl.appendChild(wrap);
245       codeEl = wrap;
246     } else {
247       if (preEl) codeEl = preEl.querySelector('code') || codeEl;
248     }
249
250     // Optional: trim & normalize-indent for Prism too (affects codeEl text)
251     if (codeEl) {
252       let txt = codeEl.textContent ?? '';
253
254       if (this.hasAttribute('trim')) {
255         txt = txt.replace(/^\s*\n/, '').replace(/\n\s*$/, '');
256       }
257       if (this.hasAttribute('normalize-indent')) {
258         txt = this._stripCommonIndent(txt);
259       }
260
261       codeEl.textContent = txt;
262     }
263
264     // Language class on both <pre> and <code> so width in ch uses the same font
265     if (lang) {
266       const cls = `language-${lang}`;
267       if (preEl && !preEl.classList.contains(cls)) preEl.classList.add(cls);
268       if (codeEl && !codeEl.classList.contains(cls)) codeEl.classList.add(cls);
269     }
270
271     // Highlight (if Prism is loaded)
272     if (window.Prism) {
273       const codes = [];
274       assigned.forEach(el => {
275         if (el.tagName === 'CODE') codes.push(el);
276         codes.push(...el.querySelectorAll('code'));
277       });
278       if (codes.length === 0 && preEl) {
279         window.Prism.highlightElement(preEl);
280       } else {
281         codes.forEach(c => window.Prism.highlightElement(c));
282       }
283     }
284   }
285
286   /* choose and normalize the visible code element */
287
288   _resolveDisplayEl(normalize = false) {
289     let next = this.preInternal;
290     if (this.getAttribute('highlight') === 'prism') {
291       const assigned = this.slotEl.assignedElements({ flatten: true });
292       const pre = assigned.find(n => n.tagName === 'PRE');
293       next = pre || assigned[0] || this.preInternal;
294     }
295
296     const changed = next !== this._displayEl;
297     this._swapBodyListener(next);
298     if (normalize) this._harmonizeDisplayBoxMetrics(next);
299     return changed;
300   }
301
302   _harmonizeDisplayBoxMetrics(el) {
303     if (!el) return;
304     const pad = (this.getAttribute('code-padding') || '0.75rem 1rem').trim();
305
306     el.style.boxSizing  = 'border-box';
307     el.style.margin     = '0';
308     el.style.padding    = pad;
309     el.style.lineHeight = '1.4';
310     el.style.display    = 'block';
311     el.style.cursor     = 'pointer';
312     el.style.textAlign  = 'left';
313
314     // Ensure inner <code> (if any) doesn't center, and remove theme margins
315     const inner = el.querySelector && el.querySelector('code');
316     if (inner) {
317       inner.style.display   = 'block';
318       inner.style.textAlign = 'left';
319       inner.style.margin    = '0';
320     }
321   }
322
323   /* styling helpers */
324
325   _applyBoxColors() {
326     const compBg  = this.getAttribute('bg-color') || 'white';
327     const titleBg = this.getAttribute('title-bg-color') || 'transparent';
328     const codeBg  = this.getAttribute('background-color') || '#333';
329     const codeFg  = this.getAttribute('color') || '#eee';
330     this.style.setProperty('--component-bg', compBg);
331     this.style.setProperty('--title-bg', titleBg);
332     this.style.setProperty('--code-bg', codeBg);
333     this.style.setProperty('--code-fg', codeFg);
334   }
335
336   _applySizingToDisplay() {
337     const width  = this.getAttribute('width');       // e.g., "50ch", "25rem", "520px"
338     const height = this.getAttribute('height') || null;
339     const ox     = (this.getAttribute('overflow-x') || 'auto').trim();
340
341     // Keep CSS vars in sync for internal path
342     this.style.setProperty('--code-width', width || 'auto');
343     this.style.setProperty('--code-height', height || 'auto');
344     this.style.setProperty('--code-overflow-x', ox);
345
346     // Apply directly to the visible element (internal or slotted)
347     const el = this._displayEl;
348     if (!el) return;
349     el.style.width = width ? width : '';
350     el.style.height = height ? height : '';
351     el.style.overflowX = ox;
352   }
353
354   _applyTypographyToDisplay() {
355     const fam = this.getAttribute('font-family');
356     const fsz = this.getAttribute('font-size');
357
358     const el = this._displayEl;
359     if (!el) return;
360
361     // In Prism mode, width is on <pre>, highlighting is on <code> - set both.
362     const pre  = el.tagName === 'PRE' ? el : (el.closest && el.closest('pre')) || null;
363     const code = (el.querySelector && el.querySelector('code')) || (el.tagName === 'CODE' ? el : null);
364
365     const targets = new Set([el]);
366     if (pre)  targets.add(pre);
367     if (code) targets.add(code);
368
369     targets.forEach(t => {
370       if (fam && fam.trim()) t.style.fontFamily = fam; else t.style.removeProperty('font-family');
371       if (fsz && fsz.trim()) t.style.fontSize   = fsz; else t.style.removeProperty('font-size');
372     });
373   }
374
375   /* utilities */
376
377   _stripCommonIndent(text) {
378     // Split, but do NOT add or remove any extra newline beyond explicit trim step.
379     const lines = text.split('\n');
380
381     // Measure leading whitespace (spaces or tabs) on non-empty lines
382     const indentLengths = [];
383     for (const l of lines) {
384       if (l.trim().length === 0) continue;
385       const m = l.match(/^[ \t]*/);
386       indentLengths.push(m ? m[0].length : 0);
387     }
388     if (indentLengths.length === 0) return text;
389
390     // Find the smallest non-zero indent; if all are zero, nothing to strip
391     const nonZero = indentLengths.filter(n => n > 0);
392     if (nonZero.length === 0) return text;
393     const minIndent = Math.min(...nonZero);
394
395     // Remove up to minIndent leading whitespace from every line
396     const re = new RegExp(`^[ \\t]{0,${minIndent}}`);
397     const out = lines.map(l => l.replace(re, '')).join('\n');
398
399     return out;
400   }
401 }
402
403 customElements.define('code-viewer', CodeViewer);            

  1 class CodeViewer extends HTMLElement {
  2   static get observedAttributes() {
  3     return [
  4       // visuals
  5       'bg-color', 'title-bg-color', 'background-color', 'color',
  6       // code sizing/typo
  7       'width', 'height', 'overflow-x', 'font-family', 'font-size', 'code-padding',
  8       // prism
  9       'highlight', 'language',
 10       // conveniences
 11       'trim', 'normalize-indent'
 12     ];
 13   }
 14
 15   constructor() {
 16     super();
 17     this.attachShadow({ mode: 'open' });
 18
 19     this.shadowRoot.innerHTML = `
 20       <style>
 21         :host { display: inline-block; }
 22         .wrapper {
 23           padding: 1rem;
 24           box-sizing: border-box;
 25           background-color: var(--wrapper-bg, var(--light, white));
 26         }
 27         .component {
 28           border: 2px solid var(--dark, #333);
 29           padding: 0.5rem;
 30           display: flex;
 31           flex-direction: column;
 32           user-select: none; /* per your app */
 33           width: min-content;
 34           box-shadow: 5px 5px 5px #999;
 35           box-sizing: border-box;
 36           background-color: var(--component-bg, white);
 37         }
 38         .title {
 39           display: flex;
 40           font-family: "Comic Sans MS", cursive, sans-serif;
 41           font-size: 1rem;              /* stable title size */
 42           font-weight: bold;
 43           cursor: pointer;
 44           max-width: 100%;
 45           margin-bottom: 8px;
 46           line-height: 1.0rem;
 47           flex-wrap: wrap;
 48           overflow-wrap: break-word;
 49           white-space: wrap;
 50           color: var(--dark, #333);
 51           background-color: var(--title-bg, transparent);
 52           padding: 0.125rem 0.5rem;      /* compact */
 53         }
 54         .code { display: block; flex: 0 0 auto; }
 55
 56         /* Default (non-Prism): show internal pre; hide slotted content */
 57         #pre-internal { display: block; cursor: pointer; }
 58         slot[name="code"]::slotted(*) { display: none !important; }
 59
 60         /* Prism: hide internal pre; show slotted pre/code */
 61         :host([highlight="prism"]) #pre-internal { display: none; }
 62         :host([highlight="prism"]) slot[name="code"]::slotted(pre),
 63         :host([highlight="prism"]) slot[name="code"]::slotted(code) {
 64           display: block !important;
 65           cursor: pointer;
 66         }
 67
 68         /* Internal pre defaults (non-Prism path) */
 69         #pre-internal {
 70           margin: 0;
 71           /* padding set dynamically to match Prism box */
 72           background-color: var(--code-bg, #333);
 73           color: var(--code-fg, #eee);
 74           border-radius: 4px;
 75           font-family: inherit;  /* overridden by attribute if provided */
 76           font-size: inherit;    /* overridden by attribute if provided */
 77           line-height: 1.4;
 78           white-space: pre;
 79           overflow-y: auto;
 80           overflow-x: var(--code-overflow-x, auto);
 81           width: var(--code-width, auto);
 82           height: var(--code-height, auto);
 83           box-sizing: border-box;
 84           transition: width 0.2s ease;
 85           text-align: left;
 86         }
 87       </style>
 88
 89       <div class="wrapper">
 90         <div class="component" part="component">
 91           <div class="title" part="title"><slot></slot></div>
 92           <div class="code">
 93             <pre id="pre-internal"></pre>
 94           </div>
 95           <slot name="code" id="code-slot"></slot>
 96         </div>
 97       </div>
 98     `;
 99
100     // Refs
101     this.titleEl     = this.shadowRoot.querySelector('.title');
102     this.preInternal = this.shadowRoot.querySelector('#pre-internal');
103     this.slotEl      = this.shadowRoot.querySelector('#code-slot');
104
105     // Width stepping (single-click only)
106     this._originWidthPx   = null;
107     this._stepsFromOrigin = 0;
108     this._stepPx          = 40;   // ≈ 5ch typical
109     this._minPx           = 240;
110
111     // Active display element (internal <pre> or slotted <pre>)
112     this._displayEl = this.preInternal;
113
114     // Handlers
115     this._onBodyClick  = this._onBodyClick.bind(this);
116     this._onTitleClick = this._onTitleClick.bind(this);
117   }
118
119   /* lifecycle */
120
121   connectedCallback() {
122     this._applyBoxColors();
123     this._renderDefaultFromSlot();     // for non-Prism
124     this._maybeSetupPrism();           // if Prism mode, ensure <pre><code> + highlight
125     this._resolveDisplayEl(true);      // sets _displayEl and normalizes its box
126     this._applySizingToDisplay();
127     this._applyTypographyToDisplay();
128     this._bindEvents();
129
130     this.slotEl.addEventListener('slotchange', () => {
131       this._renderDefaultFromSlot();
132       this._maybeSetupPrism();
133       const changed = this._resolveDisplayEl(true);
134       if (changed) this._resetWidthStepping();
135       this._applySizingToDisplay();
136       this._applyTypographyToDisplay();
137     });
138   }
139
140   attributeChangedCallback() {
141     this._applyBoxColors();
142     this._maybeSetupPrism();
143     const changed = this._resolveDisplayEl(true);
144     if (changed) this._resetWidthStepping();
145     this._applySizingToDisplay();
146     this._applyTypographyToDisplay();
147   }
148
149   /* events (single click only) */
150
151   _bindEvents() {
152     this._displayEl.addEventListener('click', this._onBodyClick);
153     this.titleEl.addEventListener('click', this._onTitleClick);
154   }
155
156   _swapBodyListener(nextEl) {
157     if (nextEl === this._displayEl) return;
158     this._displayEl.removeEventListener('click', this._onBodyClick);
159     this._displayEl = nextEl;
160     this._displayEl.addEventListener('click', this._onBodyClick);
161   }
162
163   _resetWidthStepping() {
164     this._originWidthPx = null;
165     this._stepsFromOrigin = 0;
166   }
167
168   _onBodyClick()  { this._bumpWidth(+1); }
169   _onTitleClick() { this._bumpWidth(-1); }
170
171   _bumpWidth(direction) {
172     const el = this._displayEl;
173     if (!el) return;
174
175     if (this._originWidthPx == null) {
176       const rect = el.getBoundingClientRect();
177       this._originWidthPx = rect.width > 0 ? rect.width : 480;
178       this._stepsFromOrigin = 0;
179     }
180
181     let nextSteps = this._stepsFromOrigin + direction;
182     let target = this._originWidthPx + nextSteps * this._stepPx;
183
184     if (target < this._minPx) {
185       target = this._minPx;
186       nextSteps = Math.ceil((target - this._originWidthPx) / this._stepPx);
187     }
188
189     this._stepsFromOrigin = nextSteps;
190     el.style.width = `${Math.round(target)}px`;
191   }
192
193   /* rendering paths */
194
195   _renderDefaultFromSlot() {
196     if (this.getAttribute('highlight') === 'prism') return;
197
198     // Collect raw content from the slot
199     const nodes = this.slotEl.assignedNodes({ flatten: true });
200     let raw = '';
201     for (const n of nodes) {
202       if (n.nodeType === Node.ELEMENT_NODE && n.tagName === 'TEMPLATE') {
203         raw += n.innerHTML ?? '';
204       } else if (n.nodeType === Node.ELEMENT_NODE) {
205         raw += n.outerHTML ?? '';
206       } else {
207         raw += n.textContent ?? '';
208       }
209     }
210
211     // 1) Optional: trim a fully blank first/last line
212     if (this.hasAttribute('trim')) {
213       raw = raw.replace(/^\s*\n/, '').replace(/\n\s*$/, '');
214     }
215
216     // 2) Optional: normalize common indentation (spaces/tabs) across non-empty lines
217     if (this.hasAttribute('normalize-indent')) {
218       raw = this._stripCommonIndent(raw);
219     }
220
221     // Show literally (escaped) in internal <pre>
222     this.preInternal.textContent = raw;
223   }
224
225   _maybeSetupPrism() {
226     if (this.getAttribute('highlight') !== 'prism') return;
227
228     const lang = (this.getAttribute('language') || '').trim();
229     const assigned = this.slotEl.assignedElements({ flatten: true });
230     if (!assigned.length) return;
231
232     // Ensure <pre><code> structure (convenience: allow <code slot="code">.</code>)
233     let preEl = assigned.find(n => n.tagName === 'PRE');
234     let codeEl = assigned.find(n => n.tagName === 'CODE');
235
236     if (!preEl && codeEl) {
237       preEl = document.createElement('pre');
238       const hostParent = codeEl.parentNode;
239       hostParent.replaceChild(preEl, codeEl);
240       preEl.appendChild(codeEl);
241     } else if (preEl && !preEl.querySelector('code')) {
242       const wrap = document.createElement('code');
243       while (preEl.firstChild) wrap.appendChild(preEl.firstChild);
244       preEl.appendChild(wrap);
245       codeEl = wrap;
246     } else {
247       if (preEl) codeEl = preEl.querySelector('code') || codeEl;
248     }
249
250     // Optional: trim & normalize-indent for Prism too (affects codeEl text)
251     if (codeEl) {
252       let txt = codeEl.textContent ?? '';
253
254       if (this.hasAttribute('trim')) {
255         txt = txt.replace(/^\s*\n/, '').replace(/\n\s*$/, '');
256       }
257       if (this.hasAttribute('normalize-indent')) {
258         txt = this._stripCommonIndent(txt);
259       }
260
261       codeEl.textContent = txt;
262     }
263
264     // Language class on both <pre> and <code> so width in ch uses the same font
265     if (lang) {
266       const cls = `language-${lang}`;
267       if (preEl && !preEl.classList.contains(cls)) preEl.classList.add(cls);
268       if (codeEl && !codeEl.classList.contains(cls)) codeEl.classList.add(cls);
269     }
270
271     // Highlight (if Prism is loaded)
272     if (window.Prism) {
273       const codes = [];
274       assigned.forEach(el => {
275         if (el.tagName === 'CODE') codes.push(el);
276         codes.push(...el.querySelectorAll('code'));
277       });
278       if (codes.length === 0 && preEl) {
279         window.Prism.highlightElement(preEl);
280       } else {
281         codes.forEach(c => window.Prism.highlightElement(c));
282       }
283     }
284   }
285
286   /* choose and normalize the visible code element */
287
288   _resolveDisplayEl(normalize = false) {
289     let next = this.preInternal;
290     if (this.getAttribute('highlight') === 'prism') {
291       const assigned = this.slotEl.assignedElements({ flatten: true });
292       const pre = assigned.find(n => n.tagName === 'PRE');
293       next = pre || assigned[0] || this.preInternal;
294     }
295
296     const changed = next !== this._displayEl;
297     this._swapBodyListener(next);
298     if (normalize) this._harmonizeDisplayBoxMetrics(next);
299     return changed;
300   }
301
302   _harmonizeDisplayBoxMetrics(el) {
303     if (!el) return;
304     const pad = (this.getAttribute('code-padding') || '0.75rem 1rem').trim();
305
306     el.style.boxSizing  = 'border-box';
307     el.style.margin     = '0';
308     el.style.padding    = pad;
309     el.style.lineHeight = '1.4';
310     el.style.display    = 'block';
311     el.style.cursor     = 'pointer';
312     el.style.textAlign  = 'left';
313
314     // Ensure inner <code> (if any) doesn't center, and remove theme margins
315     const inner = el.querySelector && el.querySelector('code');
316     if (inner) {
317       inner.style.display   = 'block';
318       inner.style.textAlign = 'left';
319       inner.style.margin    = '0';
320     }
321   }
322
323   /* styling helpers */
324
325   _applyBoxColors() {
326     const compBg  = this.getAttribute('bg-color') || 'white';
327     const titleBg = this.getAttribute('title-bg-color') || 'transparent';
328     const codeBg  = this.getAttribute('background-color') || '#333';
329     const codeFg  = this.getAttribute('color') || '#eee';
330     this.style.setProperty('--component-bg', compBg);
331     this.style.setProperty('--title-bg', titleBg);
332     this.style.setProperty('--code-bg', codeBg);
333     this.style.setProperty('--code-fg', codeFg);
334   }
335
336   _applySizingToDisplay() {
337     const width  = this.getAttribute('width');       // e.g., "50ch", "25rem", "520px"
338     const height = this.getAttribute('height') || null;
339     const ox     = (this.getAttribute('overflow-x') || 'auto').trim();
340
341     // Keep CSS vars in sync for internal path
342     this.style.setProperty('--code-width', width || 'auto');
343     this.style.setProperty('--code-height', height || 'auto');
344     this.style.setProperty('--code-overflow-x', ox);
345
346     // Apply directly to the visible element (internal or slotted)
347     const el = this._displayEl;
348     if (!el) return;
349     el.style.width = width ? width : '';
350     el.style.height = height ? height : '';
351     el.style.overflowX = ox;
352   }
353
354   _applyTypographyToDisplay() {
355     const fam = this.getAttribute('font-family');
356     const fsz = this.getAttribute('font-size');
357
358     const el = this._displayEl;
359     if (!el) return;
360
361     // In Prism mode, width is on <pre>, highlighting is on <code> - set both.
362     const pre  = el.tagName === 'PRE' ? el : (el.closest && el.closest('pre')) || null;
363     const code = (el.querySelector && el.querySelector('code')) || (el.tagName === 'CODE' ? el : null);
364
365     const targets = new Set([el]);
366     if (pre)  targets.add(pre);
367     if (code) targets.add(code);
368
369     targets.forEach(t => {
370       if (fam && fam.trim()) t.style.fontFamily = fam; else t.style.removeProperty('font-family');
371       if (fsz && fsz.trim()) t.style.fontSize   = fsz; else t.style.removeProperty('font-size');
372     });
373   }
374
375   /* utilities */
376
377   _stripCommonIndent(text) {
378     // Split, but do NOT add or remove any extra newline beyond explicit trim step.
379     const lines = text.split('\n');
380
381     // Measure leading whitespace (spaces or tabs) on non-empty lines
382     const indentLengths = [];
383     for (const l of lines) {
384       if (l.trim().length === 0) continue;
385       const m = l.match(/^[ \t]*/);
386       indentLengths.push(m ? m[0].length : 0);
387     }
388     if (indentLengths.length === 0) return text;
389
390     // Find the smallest non-zero indent; if all are zero, nothing to strip
391     const nonZero = indentLengths.filter(n => n > 0);
392     if (nonZero.length === 0) return text;
393     const minIndent = Math.min(...nonZero);
394
395     // Remove up to minIndent leading whitespace from every line
396     const re = new RegExp(`^[ \\t]{0,${minIndent}}`);
397     const out = lines.map(l => l.replace(re, '')).join('\n');
398
399     return out;
400   }
401 }
402
403 customElements.define('code-viewer', CodeViewer);