1import ViewerToolbarController from "controllers/viewer_toolbar_controller" 2 3// Host-side toolbar for the shared image viewer. Builds on ViewerToolbarController (outlet 4// plumbing, Save-as-Default, flyout positioning) and projects the image controls onto the 5// renderer. Every readout is filled from the viewer's own events, never from what we sent â 6// the core clamps, and its pointer handling moves the view and the swipe divider on its own. 7// 8// A control that does not apply to what is open is hidden rather than disabled, so the 9// toolbar never shows a control that would fail. 10export default class extends ViewerToolbarController { 11 static outlets = ["contents--file-content-component--image-renderer"] 12 static targets = ["compareOption", "transport", "playPause", "playIcon", "pauseIcon", "playLabel", "loop", "exposure", "onion", "lane", "summary", "imageFacts", "imageEmpty"] 13 14 get renderers() { 15 return this.contentsFileContentComponentImageRendererOutlets 16 } 17 18 contentsFileContentComponentImageRendererOutletConnected(controller) { 19 controller.onViewState = (state) => this.applyViewState(state) 20 controller.onImageInfo = (image) => this.applyImageInfo(image) 21 controller.onDiffState = (diff) => this.applyDiffState(diff) 22 controller.onDiffProgress = (progress) => this.applyDiffProgress(progress) 23 controller.onCompareState = (compare) => this.applyCompareState(compare) 24 controller.onPlaybackState = (playback) => this.applyPlaybackState(playback) 25 controller.onRenderState = (render) => this.applyRenderState(render) 26 // Pull the current state in case the renderer loaded before this toolbar connected; 27 // ongoing events cover the reverse ordering. 28 controller.pushToolbarState?.() 29 } 30 31 setFitMode(event) { 32 this.forEachRenderer((c) => c.setFitMode?.(event.target.value)) 33 } 34 35 setSampling(event) { 36 this.forEachRenderer((c) => c.setSampling?.(event.target.value)) 37 this.persistOption("imageSampling") 38 } 39 40 setRenderOption(event) { 41 this.forEachRenderer((c) => c.setRenderOption?.(event.params.option, event.target.checked)) 42 this.persistOption(event.params.option) 43 } 44 45 // Live view state, not a saved default: an exposure belongs to the picture it was 46 // chosen for. 47 setExposure(event) { 48 this.forEachRenderer((c) => c.setExposure?.(parseFloat(event.target.value))) 49 } 50 51 setCompareMode(event) { 52 this.forEachRenderer((c) => c.setCompareMode?.(event.target.value)) 53 this.persistOption("imageCompareMode") 54 } 55 56 // A checkbox carries a boolean and a range a number; the protocol rejects the string an 57 // input would otherwise hand it. 58 setCompareOption(event) { 59 const value = event.target.type === "checkbox" ? event.target.checked : parseFloat(event.target.value) 60 this.forEachRenderer((c) => c.setCompareOption?.(event.params.option, value)) 61 this.persistOption(event.params.option) 62 } 63 64 setOnionOpacity(event) { 65 this.forEachRenderer((c) => c.setCompareOption?.("onionOpacity", parseFloat(event.target.value))) 66 } 67 68 playPause() { this.forEachRenderer((c) => c.playPause?.()) } 69 stepBack() { this.forEachRenderer((c) => c.stepFrame?.(-1)) } 70 stepForward() { this.forEachRenderer((c) => c.stepFrame?.(1)) } 71 toggleLoop() { this.forEachRenderer((c) => c.toggleLoop?.()) } 72 flipLane() { this.forEachRenderer((c) => c.flipLane?.()) } 73 74 applyViewState(state) { 75 if (!state) return 76 this.selectSegment(state.fitMode) 77 this.selectSegment(state.sampling) 78 this.#show(this.hasExposureTarget && this.exposureTarget, state.hasExposure) 79 } 80 81 // Dimensions, format and the decoder that produced the pixels â the last of which is the 82 // only way to tell where a picture came from when two hosts read one file differently. 83 applyImageInfo(image) { 84 if (!this.hasImageFactsTarget) return 85 if (this.hasImageEmptyTarget) this.imageEmptyTarget.classList.toggle("hidden", Boolean(image)) 86 if (!image) { this.imageFactsTarget.replaceChildren(); return } 87 88 const facts = [ 89 ["Size", `${image.width} Ã ${image.height}`], 90 ["Format", image.format || "â"], 91 ["Depth", image.bitDepth ? `${image.bitDepth}-bit` : "â"], 92 ["Colour", image.colorSpace || "sRGB"] 93 ] 94 if (image.frameCount > 1) facts.push(["Frames", String(image.frameCount)]) 95 if (image.hasAlpha) facts.push(["Alpha", "yes"]) 96 if (image.decoder) facts.push(["Decoded by", image.decoder]) 97 this.imageFactsTarget.replaceChildren(...facts.flatMap(([term, value]) => [node("dt", term), node("dd", value)])) 98 } 99
100 // "2 changed regions · 1.2% of pixels", or the progress note while the sweep runs. 101 // `identical` is exact pixel equality and never thresholded; `regions` counts clusters 102 // above the ÎE threshold. A comparison can finish, be non-identical, and still have zero 103 // regions (the same picture as sRGB and as Display P3; two JPEG re-encodes under the 104 // threshold) â so "still sweeping" is read from DiffProgress.complete, never inferred 105 // from an empty regions list. 106 applyDiffState(diff) { 107 this.diff = diff 108 this.#showOptions() 109 this.#renderDiffSummary() 110 } 111 112 applyDiffProgress(progress) { 113 this.diffProgress = progress 114 this.#renderDiffSummary() 115 } 116 117 // The onion row belongs to one compare mode and the lane toggle to another, and both 118 // follow the core's own state rather than what we last sent. 119 applyCompareState(compare) { 120 if (!compare) return 121 this.compare = compare 122 this.selectSegment(COMPARE_FRIENDLY[compare.mode] ?? "sideBySide") 123 this.#show(this.hasOnionTarget && this.onionTarget, compare.mode === "COMPARE_MODE_ONIONSKIN") 124 // Keeps its space either way â see the component, which reserves it so the row cannot 125 // reflow under an open flyout. 126 this.#reserveSpace(this.hasLaneTarget && this.laneTarget, compare.mode === "COMPARE_MODE_TOGGLE") 127 this.#showOptions() 128 const opacity = this.element.querySelector(`input[data-action*="#setOnionOpacity"]`) 129 if (opacity && document.activeElement !== opacity) opacity.value = compare.onionOpacity 130 this.#setRange("imageThreshold", compare.threshold) 131 this.#check("scaleNormalise", compare.scaleNormalise) 132 this.#check("linkViews", compare.linkViews) 133 } 134 135 applyRenderState(render) { 136 if (!render) return 137 this.#check("showCheckerboard", render.showCheckerboard) 138 } 139 140 // Hidden from an unset PlaybackState, not from the file's extension: a single-frame GIF 141 // is a still and has nothing to play. 142 applyPlaybackState(playback) { 143 this.#show(this.hasTransportTarget && this.transportTarget, Boolean(playback)) 144 145 // The button's own icon is what says which way it goes; the core owns whether it is 146 // playing, so this reads that rather than what the last click asked for. 147 const playing = Boolean(playback?.playing) 148 this.#show(this.hasPlayIconTarget && this.playIconTarget, !playing) 149 this.#show(this.hasPauseIconTarget && this.pauseIconTarget, playing) 150 const label = playing ? "Pause" : "Play" 151 if (this.hasPlayLabelTarget) this.playLabelTarget.textContent = label 152 if (this.hasPlayPauseTarget) this.playPauseTarget.title = label 153 154 // Looping is a setting the core can change on its own â an animation that ends with 155 // looping off pauses itself â so the button reflects the state, not the last click. 156 this.#setPressed(this.hasLoopTarget && this.loopTarget, Boolean(playback?.looping)) 157 } 158 159 // A darker fill, so the button reads as held down. 160 // 161 // Marked important on purpose. The strip variant's icon buttons already carry 162 // bg-color-white and the inline variant bg-transparent, and a second background utility of 163 // equal specificity leaves the winner to stylesheet order â which is why a plain 164 // bg-color-200 showed nothing at all on the single-image page. This wins over either 165 // without the controller having to know which variant it is in. 166 #setPressed(target, pressed) { 167 if (!target) return 168 target.setAttribute("aria-pressed", pressed ? "true" : "false") 169 target.classList.toggle("bg-color-200!", pressed) 170 } 171 172 #renderDiffSummary() { 173 if (!this.hasSummaryTarget) return 174 const diff = this.diff 175 if (!diff?.active) { 176 this.summaryTarget.textContent = "" 177 return 178 } 179 if (!this.diffProgress?.complete) { this.summaryTarget.textContent = "Comparingâ¦"; return } 180 if (diff.identical) { this.summaryTarget.textContent = "Identical"; return } 181 182 const regions = diff.regions?.length ?? 0 183 if (!regions) { 184 this.summaryTarget.textContent = "No changes above threshold" 185 return 186 } 187 const percent = (diff.changedFraction * 100).toFixed(diff.changedFraction < 0.01 ? 2 : 1) 188 this.summaryTarget.textContent = `${regions}
188 changed region${regions === 1 ? "" : "s"} · ${percent}% of pixels` 189 } 190 191 // Which comparison options can act on what is open, judged by what a reader can see change. 192 // `link_views` is "only meaningful in COMPARE_MODE_SIDE_BY_SIDE" (images.proto). The 193 // threshold moves nothing outside the difference view now that the outlines are gone. And 194 // normalising the scale compares at the larger size instead of the overlap, which is "a 195 // no-op when the sizes already match" â so it depends on the files, not on the mode. 196 #showOptions() { 197 const mode = this.compare?.mode 198 const applies = { 199 linkViews: mode === "COMPARE_MODE_SIDE_BY_SIDE", 200 imageThreshold: mode === "COMPARE_MODE_DIFFERENCE", 201 scaleNormalise: Boolean(this.diff?.dimensionsDiffer) 202 } 203 const attribute = `data-${this.identifier}-option-param` 204 this.compareOptionTargets.forEach((row) => { 205 const key = row.querySelector(`[${attribute}]`)?.getAttribute(attribute) 206 this.#show(row, applies[key] ?? true) 207 }) 208 } 209 210 211 #reserveSpace(el, shown) { 212 if (el) el.classList.toggle("invisible", !shown) 213 } 214 215 #show(el, shown) { 216 if (el) el.classList.toggle("hidden", !shown) 217 } 218 219 #check(key, value) { 220 const el = this.element.querySelector(`[data-${this.identifier}-option-param="${key}"]`) 221 if (el && el.type === "checkbox") el.checked = !!value 222 } 223 224 #setRange(key, value) { 225 const el = this.element.querySelector(`[data-${this.identifier}-option-param="${key}"]`) 226 if (el && el.type === "range" && document.activeElement !== el && value !== undefined) el.value = value 227 } 228 229} 230 231const COMPARE_FRIENDLY = { 232 COMPARE_MODE_SIDE_BY_SIDE: "sideBySide", 233 COMPARE_MODE_SLIDER: "slider", 234 COMPARE_MODE_ONIONSKIN: "onionskin", 235 COMPARE_MODE_DIFFERENCE: "difference", 236 COMPARE_MODE_TOGGLE: "toggle" 237} 238 239function node(tag, text) { 240 const el = document.createElement(tag) 241 el.textContent = text 242 return el 243}
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.