PageSourceSearch

https://filament.com/assets/contents/image_toolbar_component/image_toolbar_component_controller-0afaff63.js

js filament.com collected 2026-09-25 14:01:30 UTC 10,669 bytes, 243 lines download raw bytes

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.