PageSourceSearch

https://littlebirdelectronics.com.au/assets/controllers/ecommerce/fritzing_controller-5d0a9d11.js

js littlebirdelectronics.com.au collected 2026-09-25 14:10:29 UTC 10,557 bytes, 268 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2import { attach3D } from "src/circuit3d/attach"
3
4// Boots the Fritzing breadboard editor/viewer for whichever <fritzing-editor>
5// or <fritzing-embed> element this controller wraps, and — when a save URL is
6// present — saves the sketch back to the server.
7//
8// The ~180KB bundle is dynamic-import()ed here rather than pinned into
9// application.js, so a page without a circuit on it never downloads a byte of
10// it (nor the stylesheet, which the bundle injects on import). Eager controller
11// loading registers this module, but the import only fires when an element
12// actually connects. Module-scoped promise so two circuits on one page share
13// a single download.
14let fritzing
15
16function loadFritzing() {
17  fritzing ||= import("fritzing-web")
18  return fritzing
19}
20
21export default class extends Controller {
22  static targets = ["editor", "save", "status", "title"]
23  static values = { saveUrl: String, createUrl: String }
24
25  async connect() {
26    this.dirty = false
27    this.unsubscribes = []
28    this.onBeforeUnload = this.onBeforeUnload.bind(this)
29    this.onKeydown = this.onKeydown.bind(this)
30
31    await loadFritzing()
32    // Turbo may have torn us down while the bundle was in flight.
33    if (!this.element.isConnected) return
34
35    // Importing the bundle calls customElements.define, which upgrades the
36    // element already in the DOM synchronously — so `.editor` exists by now.
37    this.wireDirtyTracking()
38    this.centrePlaygroundStarter()
39    this.setupQuickActions()
40
41    // Every circuit on the page gets the 2D/3D switch. three.js itself is not
42    // fetched until someone presses it (see src/circuit3d/attach).
43    this.circuitElements.forEach((el) => attach3D(el))
44
45    if (this.savable) {
46      window.addEventListener("beforeunload", this.onBeforeUnload)
47      // Capture phase on window beats the editor's own keydown listener (bound
48      // on its root, bubble phase), so ⌘S saves to the server instead of
49      // downloading a .fzz.
50      window.addEventListener("keydown", this.onKeydown, true)
51    }
52  }
53
54  disconnect() {
55    this.stopInitialCentring?.()
56    this.removeQuickActions?.()
57    this.unsubscribes.forEach((off) => off())
58    this.unsubscribes = []
59    window.removeEventListener("beforeunload", this.onBeforeUnload)
60    window.removeEventListener("keydown", this.onKeydown, true)
61  }
62
63  // Frame the initial breadboard after both its SVG and the surrounding
64  // panels have layout. Stop immediately once the user starts interacting.
65  centrePlaygroundStarter() {
66    if (!this.hasEditorTarget || this.editorTarget.hasAttribute("src")) return
67    const editor = this.editor, stage = this.editorTarget.querySelector(".fz-canvas-host")
68    if (!editor || !stage) return
69    const abort = new AbortController()
70    let stopped = false, frame
71    const centre = () => {
72      cancelAnimationFrame(frame)
73      frame = requestAnimationFrame(async () => {
74        await editor.view.whenRendered()
75        if (!stopped && this.element.isConnected && editor.sketch.size === 1) editor.view.centerContent(1)
76      })
77    }
78    const observer = new ResizeObserver(centre)
79    observer.observe(stage)
80    const off = editor.sketch.events.on("item:add", centre)
81    this.stopInitialCentring = () => { stopped = true;cancelAnimationFrame(frame);observer.disconnect();off();abort.abort() }
82    for (const event of ["pointerdown", "wheel", "keydown"]) stage.addEventListener(event, this.stopInitialCentring, { once: true, signal: abort.signal })
83    centre()
84  }
85
86  setupQuickActions() {
87    const editor = this.editor
88    if (!editor?.view) return
89    const bar = document.createElement("div")
90    bar.className = "lb-selection-actions"
91    bar.hidden = true
92    bar.style.cssText = "position:absolute;bottom:18px;left:50%;transform:translateX(-50%);z-index:8;align-items:center;gap:10px;padding:10px 14px;border:1px solid #52758b;border-radius:12px;background:#102736f5;color:white;box-shadow:0 8px 30px #15344b44;font:13px system-ui;max-width:calc(100% - 32px)"
93    const label = document.createElement("span")
94    label.style.cssText = "overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:280px"
95    const reverse = document.createElement("button"), remove = document.createElement("button"), und
95o = document.createElement("button")
96    for (const b of [reverse, remove, undo]) { b.type = "button";b.style.cssText = "white-space:nowrap;padding:7px 10px;border:1px solid #7895a8;border-radius:7px;background:#25465d;color:white;cursor:pointer;font:600 12px system-ui" }
97    remove.style.background = "#ab3544";reverse.textContent = "Reverse wire";undo.textContent = "Undo"
98    bar.append(label, reverse, remove, undo);editor.canvasHost.append(bar)
99    let message = ""
100    const refresh = () => {
101      const items = editor.view.selectedIds().map(id=>editor.sketch.get(id)).filter(Boolean)
102      const wire = items.length === 1 && items[0].kind === "wire"
103      if (items.length) message = ""
104      label.textContent = items.length === 1 ? (wire ? "Jumper wire" : items[0].title || editor.view.defOf(items[0].id)?.title || "Component") : items.length ? `${items.length} selected` : message
105      remove.textContent = wire ? "Delete wire" : items.length > 1 ? "Delete selection" : "Delete component"
106      remove.hidden = !items.length;reverse.hidden = !wire
107      bar.hidden = !items.length && !message;bar.style.display = bar.hidden ? "none" : "flex"
108    }
109    const prior = editor.view.onSelectionChange
110    const selection = ids => { prior?.(ids);refresh() }
111    editor.view.onSelectionChange = selection
112    remove.addEventListener("click", () => { message = "Selection deleted";editor.view.deleteSelection();editor.view.selectOnly(null);refresh() })
113    undo.addEventListener("click", () => { editor.sketch.undo();message = "Deletion or edit undone";refresh() })
114    reverse.addEventListener("click", () => {
115      const item = editor.sketch.get(editor.view.selectedIds()[0])
116      if (item?.kind !== "wire") return
117      const a = structuredClone(item.end0), b = structuredClone(item.end1)
118      editor.sketch.transact("reverse wire", () => { editor.sketch.setWireEnd(item.id, 0, b);editor.sketch.setWireEnd(item.id, 1, a) })
119      refresh()
120    })
121    this.removeQuickActions = () => { if(editor.view.onSelectionChange === selection) editor.view.onSelectionChange = prior;bar.remove() }
122  }
123
124  get circuitElements() {
125    return this.element.querySelectorAll("fritzing-embed, fritzing-editor")
126  }
127
128  // A page-level "View in 3D" button, for the circuit page's header. The
129  // switch on the canvas does the same thing.
130  toggle3d(event) {
131    event?.preventDefault()
132    const first = this.circuitElements[0]
133    first?.circuit3d?.toggle()
134  }
135
136  get savable() {
137    return this.hasEditorTarget && (this.updates || this.creates)
138  }
139
140  // Editing an existing circuit — PATCH the sketch back over itself.
141  get updates() {
142    return this.hasSaveUrlValue && this.saveUrlValue.length > 0
143  }
144
145  // Playground with a signed-in user — POST a brand new circuit.
146  get creates() {
147    return this.hasCreateUrlValue && this.createUrlValue.length > 0
148  }
149
150  get editor() {
151    return this.hasEditorTarget ? this.editorTarget.editor : null
152  }
153
154  // Every undoable change commits; loading a sketch resets us to clean so
155  // opening a saved circuit doesn't immediately look unsaved.
156  wireDirtyTracking() {
157    const editor = this.editor
158    if (!editor || !this.savable) return
159
160    const events = editor.sketch.events
161    this.unsubscribes.push(
162      events.on("committed", () => this.markDirty()),
163      events.on("batch", (payload) => {
164        if (payload.reason === "load") this.markClean()
165        else this.markDirty()
166      })
167    )
168  }
169
170  async save(event) {
171    event?.preventDefault()
172    const editor = this.editor
173    if (!editor || !this.savable || this.saving) return
174
175    this.saving = true
176    this.setStatus("Saving…")
177    if (this.hasSaveTarget) this.saveTarget.disabled = true
178
179    try {
180      const response = this.updates
181        ? await this.patchSketch(editor)
182        : await this.postNewCircuit(editor)
183
184      if (!response.ok) {
185        const detail = await response.json().catch(() => ({}))
186        throw new Error(detail.error || `HTTP ${response.status}`)
187      }
188
189      const saved = await response.json().catch(() => ({}))
190      this.markClean()
191
192      // A brand new circuit now has a home — go to it, so the next save
193      // updates that circuit instead of creating a second one.
194      if (saved.edit_url) {
195        this.setStatus("Saved — opening your circuit…")
196        window.location.href = saved.edit_url
197        return
198      }
199
200      this.setStatus(saved.saved_at ? `Saved ${saved.saved_at}` : "Saved")
201    } catch (error) {
202      this.setStatus(`Couldn't save — ${error.message}`, true)
203    } finally {
204      this.saving = false
205      if (this.hasSaveTarget) this.saveTarget.disabled = false
206    }
207  }
208
209  // Existing circuit: the sketch itself is the whole request body.
210  patchSketch(editor) {
211    return fetch(this.saveUrlValue, {
212      method: "PATCH",
213      credentials: "same-origin",
214      headers: {...this.headers, "Content-Type": "application/xml"},
215      body: editor.toFz()
216    })
217  }
218
219  // New circuit: needs a name alongside the sketch, so send JSON.
220  postNewCircuit(editor) {
221    return fetch(this.createUrlValue, {
222      method: "POST",
223      credentials: "same-origin",
224      headers: {...this.headers, "Content-Type": "application/json"},
225      body: JSON.stringify({
226        title: (this.hasTitleTarget ? this.titleTarget.value : "").trim() || "Untitled circuit",
227        content: editor.toFz()
228      })
229    })
230  }
231
232  get headers() {
233    return {
234      Accept: "application/json",
235      "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content ?? ""
236    }
237  }
238
239  markDirty() {
240    if (this.dirty) return
241    this.dirty = true
242    this.setStatus("Unsaved changes")
243  }
244
245  markClean() {
246    this.dirty = false
247  }
248
249  setStatus(text, isError = false) {
250    if (!this.hasStatusTarget) return
251    this.statusTarget.textContent = text
252    this.statusTarget.classList.toggle("text-red-600", isError)
253  }
254
255  onKeydown(event) {
256    if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "s") return
257    event.preventDefault()
258    event.stopPropagation()
259    this.save()
260  }
261
262  onBeforeUnload(event) {
263    if (!this.dirty) return
264    event.preventDefault()
265    // Chrome requires returnValue to be set for the prompt to show.
266    event.returnValue = ""
267  }
268}

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.