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.