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);