Template Strings
ViewComparator defines two module-level constants before the class:
VC_STYLE holds the shadow DOM CSS and VC_TEMPLATE
holds the HTML structure.
VC_STYLE uses CSS custom properties (--panel-bg,
--panel-fg, --panel-padding,
--panel-overflow-x, --bar-width, --bar-color)
so _applyStyles() can update appearance without touching the
shadow stylesheet directly.
The .offset-controls block in VC_STYLE positions the
three offset buttons as an absolutely positioned overlay in the top-right
corner of the right panel. Opacity transitions from 0.3 to 1 on hover or
when the right panel has focus, keeping the buttons unobtrusive.
Shadow DOM Template
VC_TEMPLATE embeds VC_STYLE then defines two top-level
elements: a .container flex row and a .resizer strip.
Inside .container: .panel-left (fixed width via
flex: none) holds <slot name="left">,
.splitter provides the drag handle, and .panel-right
(flex: 1, position: relative) holds both the
.offset-controls overlay and <slot name="right">.
The .resizer strip sits below the container and handles vertical
drag to change component height. Hover brightening on both .splitter
and .resizer provides visual affordance.
Class and Constructor
observedAttributes declares 14 attributes covering dimensions,
split ratio, bar appearance, panel colors, overflow, Prism, resize steps,
font size, and offset step.
The constructor caches 10 shadow DOM references in this._els:
the 7 structural elements shared with ViewSplitterBar plus
btnUp, btnReset, and btnDown.
Drag state mirrors ViewSplitterBar. The extra field _rightOffsetPx
tracks the current vertical shift applied to the right panel's slotted content.
Three additional handler closures cover the offset buttons
(_onOffsetUp, _onOffsetDown, _onOffsetReset)
and a fourth (_onKeydownRight) handles keyboard shortcuts on the
right panel. All call e.stopPropagation() to prevent the click from
reaching the panel-click bump handler.
Lifecycle Callbacks
connectedCallback applies styles and layout, runs the initial
Prism pass and slot harmonization, attaches all event listeners including
the offset buttons and right-panel keydown, starts the
ResizeObserver, and resets panel scroll positions.
disconnectedCallback removes every listener added in
connectedCallback, including the document-level mousemove/mouseup
pairs for both drag operations, and disconnects the ResizeObserver.
attributeChangedCallback clears _leftPx and
_heightPx so the next layout pass recomputes both from the
current attributes, then re-applies styles and layout.
Drag Mechanics
Horizontal drag: _startDrag records the start X and left-panel
width, attaches document mousemove/mouseup. _onDrag calls
_setLeftPx with the start width plus the cursor delta.
_endDrag clears the _dragging flag and removes
the document listeners.
Vertical drag mirrors the pattern: _startHeightDrag,
_onHeightDrag, and _endHeightDrag operate on
container height instead of panel width.
_bump(dir) handles panel clicks: adds or subtracts one
step-px increment via _setLeftPx.
Offset Controls
_adjustOffset(dir) adds or subtracts one offset-step-px
increment (default 40) to _rightOffsetPx, then calls
_setRightOffset().
_setRightOffset(px) clamps the value to zero or above and applies
_applyRightPaddingTop() to every slotted element in the right slot.
_applyRightPaddingTop(el) extracts the top component of the
code-padding attribute and sets the element's
paddingTop to calc(basePadTop + offsetPx), shifting
the content down without changing left/right padding.
_handleOffsetKey(e) intercepts Alt+ArrowDown,
Alt+ArrowUp, and Escape on the focused right panel,
calling _adjustOffset() or _setRightOffset(0)
respectively.
Layout & Prism Helpers
_getLeftPx() computes the initial left width from
left-ratio times available width and caches the result.
_setLeftPx() clamps the value to [min-panel-px,
available - min-panel-px] and applies it directly.
_totalWidthPx() reads the actual rendered width first;
if the element is not yet laid out, it falls back to parsing the
width attribute (handling rem and plain px).
_applyStyles() sets six CSS custom properties on the host.
_applyLayout() applies the width attribute to the
host, the computed height to the container, and defers the initial panel
width via requestAnimationFrame.
_maybePrism() highlights all <code> elements
in both slots when highlight="prism" is set.
_harmonizeSlotted() applies flex and padding to every slotted
<pre>, optionally sets font-size, and calls
_applyRightPaddingTop() for elements in the right slot.
1 const VC_STYLE = /* css */ `
2 :host { display: flex; flex-direction: column; }
3
4 .container {
5 display: flex; flex-direction: row;
6 border: 2px solid var(--dark, #333);
7 box-shadow: 5px 5px 5px #999;
8 box-sizing: border-box; overflow: hidden;
9 }
10
11 .panel {
12 display: flex; flex-direction: column;
13 overflow-x: var(--panel-overflow-x, auto);
14 overflow-y: auto; box-sizing: border-box;
15 background-color: var(--panel-bg, #f8f8f8);
16 color: var(--panel-fg, #333);
17 cursor: pointer; user-select: none;
18 }
19
20 .panel-left { flex: none; }
21 .panel-right { flex: 1; min-width: 0; position: relative; }
22
23 .splitter {
24 flex: none; width: var(--bar-width, 6px);
25 background-color: var(--bar-color, #888);
26 cursor: col-resize; user-select: none;
27 }
28 .splitter:hover { filter: brightness(0.75); }
29
30 .resizer {
31 flex: none; height: var(--bar-width, 6px);
32 background-color: var(--bar-color, #888);
33 cursor: ns-resize; user-select: none;
34 }
35 .resizer:hover { filter: brightness(0.75); }
36
37 ::slotted(pre) {
38 flex: 1; min-height: 0; margin: 0 !important;
39 padding: var(--panel-padding, 0.75rem 1rem);
40 line-height: 1.4; white-space: pre;
41 box-sizing: border-box; background: transparent;
42 color: inherit;
43 font-family: Consolas, 'Courier New', monospace;
44 font-size: 0.9rem;
45 }
46
47 .offset-controls {
48 position: absolute; top: 4px; right: 6px;
49 display: flex; flex-direction: column; gap: 2px;
50 z-index: 1; opacity: 0.3; transition: opacity 0.15s;
51 }
52 .panel-right:focus-within .offset-controls,
53 .offset-controls:hover { opacity: 1; }
54
55 .offset-btn {
56 width: 22px; height: 22px;
57 border: 1px solid var(--bar-color, #888);
58 background: var(--panel-bg, #f8f8f8);
59 color: var(--panel-fg, #333);
60 cursor: pointer; font-size: 11px; line-height: 1;
61 padding: 0; border-radius: 2px; user-select: none;
62 }
63 .offset-btn:hover { filter: brightness(0.85); }
64 `;
66 const VC_TEMPLATE = /* html */ `
67 <style>${VC_STYLE}</style>
68 <div class="container" part="container">
69 <div class="panel panel-left" part="panel-left">
70 <slot name="left"></slot>
71 </div>
72 <div class="splitter" part="splitter"></div>
73 <div class="panel panel-right" part="panel-right" tabindex="-1">
74 <div class="offset-controls" part="offset-controls">
75 <button class="offset-btn" part="offset-up"
76 title="Shift content up (Alt+↑)">▲</button>
77 <button class="offset-btn" part="offset-reset"
78 title="Reset offset (Esc)">0</button>
79 <button class="offset-btn" part="offset-down"
80 title="Shift content down (Alt+↓)">▼</button>
81 </div>
82 <slot name="right"></slot>
83 </div>
84 </div>
85 <div class="resizer" part="resizer"></div>
86 `;
88 class ViewComparator extends HTMLElement {
89 static get observedAttributes() {
90 return [
91 'width', 'height', 'left-ratio', 'bar-width', 'bar-color',
92 'bg-color', 'color', 'overflow-x', 'code-padding',
93 'highlight', 'step-px', 'min-panel-px', 'min-height-px', 'font-size'
94 ];
95 }
96
97 constructor() {
98 super();
99 this.attachShadow({ mode: 'open' });
100 this.shadowRoot.innerHTML = VC_TEMPLATE;
101
102 this._els = {
103 container: this.shadowRoot.querySelector('.container'),
104 panelLeft: this.shadowRoot.querySelector('.panel-left'),
105 splitter: this.shadowRoot.querySelector('.splitter'),
106 panelRight: this.shadowRoot.querySelector('.panel-right'),
107 resizer: this.shadowRoot.querySelector('.resizer'),
108 slotLeft: this.shadowRoot.querySelector('slot[name="left"]'),
109 slotRight: this.shadowRoot.querySelector('slot[name="right"]'),
110 btnUp: this.shadowRoot.querySelector('[part="offset-up"]'),
111 btnReset: this.shadowRoot.querySelector('[part="offset-reset"]'),
112 btnDown: this.shadowRoot.querySelector('[part="offset-down"]'),
113 };
114
115 this._leftPx = null; this._heightPx = null;
116 this._rightOffsetPx = 0; this._dragging = false;
117 this._dragStartX = 0; this._dragStartW = 0;
118 this._dragStartY = 0; this._dragStartH = 0;
119
120 this._onMousedown = e => this._startDrag(e);
121 this._onMousemove = e => this._onDrag(e);
122 this._onMouseup = () => this._endDrag();
123 this._onResizerMousedown = e => this._startHeightDrag(e);
124 this._onResizerMousemove = e => this._onHeightDrag(e);
125 this._onResizerMouseup = () => this._endHeightDrag();
126 this._onClickLeft = () => this._bump(+1);
127 this._onClickRight = () => this._bump(-1);
128 this._onOffsetUp = e => { e.stopPropagation(); this._adjustOffset(-1); };
129 this._onOffsetDown = e => { e.stopPropagation(); this._adjustOffset(+1); };
130 this._onOffsetReset = e => { e.stopPropagation(); this._setRightOffset(0); };
131 this._onKeydownRight = e => this._handleOffsetKey(e);
132 this._onSlotChange = () => {
133 this._maybePrism();
134 this._harmonizeSlotted();
135 requestAnimationFrame(() => {
136 this._els.panelLeft.scrollTop = 0;
137 this._els.panelRight.scrollTop = 0;
138 [this._els.slotLeft, this._els.slotRight].forEach(slot => {
139 slot.assignedElements({ flatten: true })
140 .forEach(el => { el.scrollTop = 0; });
141 });
142 });
143 };
144
145 this._resizeObserver = new ResizeObserver(() => {
146 if (this._dragging) return;
147 this._leftPx = null;
148 requestAnimationFrame(() => {
149 this._els.panelLeft.style.width = `${this._getLeftPx()}px`;
150 });
151 });
152 }
154 connectedCallback() {
155 this._applyStyles(); this._applyLayout();
156 this._maybePrism(); this._harmonizeSlotted();
157 this._els.splitter.addEventListener('mousedown', this._onMousedown);
158 this._els.resizer.addEventListener('mousedown', this._onResizerMousedown);
159 this._els.panelLeft.addEventListener('click', this._onClickLeft);
160 this._els.panelRight.addEventListener('click', this._onClickRight);
161 this._els.slotLeft.addEventListener('slotchange', this._onSlotChange);
162 this._els.slotRight.addEventListener('slotchange', this._onSlotChange);
163 this._els.btnUp.addEventListener('click', this._onOffsetUp);
164 this._els.btnDown.addEventListener('click', this._onOffsetDown);
165 this._els.btnReset.addEventListener('click', this._onOffsetReset);
166 this._els.panelRight.addEventListener('keydown', this._onKeydownRight);
167 this._resizeObserver.observe(this);
168 requestAnimationFrame(() => {
169 this._els.panelLeft.scrollTop = 0;
170 this._els.panelRight.scrollTop = 0;
171 });
172 }
173
174 disconnectedCallback() {
175 this._els.splitter.removeEventListener('mousedown', this._onMousedown);
176 this._els.resizer.removeEventListener('mousedown', this._onResizerMousedown);
177 this._els.panelLeft.removeEventListener('click', this._onClickLeft);
178 this._els.panelRight.removeEventListener('click', this._onClickRight);
179 this._els.slotLeft.removeEventListener('slotchange', this._onSlotChange);
180 this._els.slotRight.removeEventListener('slotchange', this._onSlotChange);
181 this._els.btnUp.removeEventListener('click', this._onOffsetUp);
182 this._els.btnDown.removeEventListener('click', this._onOffsetDown);
183 this._els.btnReset.removeEventListener('click', this._onOffsetReset);
184 this._els.panelRight.removeEventListener('keydown', this._onKeydownRight);
185 document.removeEventListener('mousemove', this._onMousemove);
186 document.removeEventListener('mouseup', this._onMouseup);
187 document.removeEventListener('mousemove', this._onResizerMousemove);
188 document.removeEventListener('mouseup', this._onResizerMouseup);
189 this._resizeObserver.disconnect();
190 }
191
192 attributeChangedCallback() {
193 this._leftPx = null;
194 this._heightPx = null;
195 this._applyStyles();
196 this._applyLayout();
197 }
199 _adjustOffset(dir) {
200 const step = parseFloat(this.getAttribute('offset-step-px')) || 40;
201 this._setRightOffset(this._rightOffsetPx + dir * step);
202 }
203
204 _setRightOffset(px) {
205 this._rightOffsetPx = Math.max(0, Math.round(px));
206 this._els.slotRight.assignedElements({ flatten: true }).forEach(el => {
207 this._applyRightPaddingTop(el);
208 });
209 }
210
211 _applyRightPaddingTop(el) {
212 if (el.tagName !== 'PRE') return;
213 const pad = this.getAttribute('code-padding') || '0.75rem 1rem';
214 const basePadTop = pad.trim().split(/\s+/)[0];
215 el.style.paddingTop = `calc(${basePadTop} + ${this._rightOffsetPx}px)`;
216 }
217
218 _handleOffsetKey(e) {
219 if (e.altKey && e.key === 'ArrowDown') {
220 e.preventDefault(); this._adjustOffset(+1);
221 } else if (e.altKey && e.key === 'ArrowUp') {
222 e.preventDefault(); this._adjustOffset(-1);
223 } else if (e.key === 'Escape') {
224 this._setRightOffset(0);
225 }
226 }
228 _harmonizeSlotted() {
229 const pad = this.getAttribute('code-padding') || '0.75rem 1rem';
230 const fs = this.getAttribute('font-size');
231 const apply = (el) => {
232 if (el.tagName !== 'PRE') return;
233 el.style.margin = '0';
234 el.style.padding = pad;
235 el.style.flex = '1';
236 el.style.minHeight = '0';
237 el.style.boxSizing = 'border-box';
238 if (fs) {
239 el.style.fontSize = fs;
240 el.querySelectorAll('code').forEach(c => { c.style.fontSize = fs; });
241 }
242 };
243 this._els.slotLeft.assignedElements({ flatten: true }).forEach(apply);
244 this._els.slotRight.assignedElements({ flatten: true }).forEach(el => {
245 apply(el);
246 this._applyRightPaddingTop(el);
247 });
248 }
249 }
250
251 customElements.define('view-comparator', ViewComparator);