|
1 | | -import { createSignal, Show, onMount, onCleanup } from "solid-js"; |
| 1 | +import { onMount, onCleanup } from "solid-js"; |
2 | 2 | import { appStore } from "../../stores/app-store"; |
| 3 | +import * as ipc from "../../ipc"; |
| 4 | + |
| 5 | +// Track created webviews |
| 6 | +const created = new Set<string>(); |
3 | 7 |
|
4 | 8 | interface Props { |
5 | 9 | threadId: string; |
6 | 10 | } |
7 | 11 |
|
| 12 | +/** |
| 13 | + * This component is JUST a placeholder div. The native webview |
| 14 | + * is positioned over it by Rust. All controls (URL bar, buttons) |
| 15 | + * live in the TabBar component above — NOT here — because the |
| 16 | + * native webview renders on top of all DOM content. |
| 17 | + */ |
8 | 18 | export function BrowserPanel(props: Props) { |
9 | | - const { store, setStore } = appStore; |
10 | | - const [url, setUrl] = createSignal(store.threadBrowserUrls[props.threadId] || "https://en.wikipedia.org"); |
11 | | - const [loadedUrl, setLoadedUrl] = createSignal(url()); |
12 | | - const [inspecting, setInspecting] = createSignal(false); |
13 | | - const [pasteMode, setPasteMode] = createSignal(false); |
14 | | - const [pasteHtml, setPasteHtml] = createSignal(""); |
15 | | - let iframeRef: HTMLIFrameElement | undefined; |
16 | | - |
17 | | - function navigate() { |
18 | | - let u = url().trim(); |
19 | | - if (!u) return; |
20 | | - if (!u.match(/^https?:\/\//)) u = "https://" + u; |
21 | | - setUrl(u); |
22 | | - setLoadedUrl(u); |
23 | | - setStore("threadBrowserUrls", props.threadId, u); |
24 | | - } |
25 | | - |
26 | | - function close() { |
27 | | - setStore("threadBrowserOpen", props.threadId, false); |
28 | | - } |
29 | | - |
30 | | - // Try to inject inspector into iframe (works for same-origin only) |
31 | | - function toggleInspect() { |
32 | | - const next = !inspecting(); |
33 | | - setInspecting(next); |
34 | | - if (next) { |
35 | | - injectInspector(); |
36 | | - } else { |
37 | | - removeInspector(); |
38 | | - } |
39 | | - } |
40 | | - |
41 | | - function injectInspector() { |
42 | | - try { |
43 | | - const doc = iframeRef?.contentDocument; |
44 | | - if (!doc) { |
45 | | - // Cross-origin — fall back to paste mode |
46 | | - setPasteMode(true); |
47 | | - setInspecting(false); |
48 | | - return; |
49 | | - } |
50 | | - if (doc.getElementById("cf-inspector")) return; |
51 | | - |
52 | | - const script = doc.createElement("script"); |
53 | | - script.id = "cf-inspector"; |
54 | | - script.textContent = ` |
55 | | - (function() { |
56 | | - var ov = document.createElement('div'); |
57 | | - ov.id = 'cf-ov'; |
58 | | - ov.style.cssText = 'position:fixed;pointer-events:none;z-index:999999;border:2px solid #6b7cff;background:rgba(107,124,255,0.06);display:none;transition:all .08s;'; |
59 | | - document.body.appendChild(ov); |
60 | | - var last = null; |
61 | | - function onM(e) { |
62 | | - var el = document.elementFromPoint(e.clientX, e.clientY); |
63 | | - if (!el || el === ov) return; |
64 | | - last = el; |
65 | | - var r = el.getBoundingClientRect(); |
66 | | - ov.style.display = 'block'; |
67 | | - ov.style.left = r.left + 'px'; |
68 | | - ov.style.top = r.top + 'px'; |
69 | | - ov.style.width = r.width + 'px'; |
70 | | - ov.style.height = r.height + 'px'; |
71 | | - } |
72 | | - function getStyles(el) { |
73 | | - var cs = getComputedStyle(el); |
74 | | - var d = document.createElement(el.tagName); |
75 | | - document.body.appendChild(d); |
76 | | - var ds = getComputedStyle(d); |
77 | | - var out = {}; |
78 | | - var keep = ['color','background','background-color','font-size','font-weight','font-family', |
79 | | - 'padding','margin','border','border-radius','display','flex-direction','align-items', |
80 | | - 'justify-content','gap','width','height','max-width','position','box-shadow','text-align', |
81 | | - 'line-height','letter-spacing','overflow']; |
82 | | - for (var i = 0; i < keep.length; i++) { |
83 | | - var v = cs.getPropertyValue(keep[i]); |
84 | | - var dv = ds.getPropertyValue(keep[i]); |
85 | | - if (v && v !== dv && v !== 'none' && v !== 'normal' && v !== 'auto' && v !== '0px') |
86 | | - out[keep[i]] = v; |
87 | | - } |
88 | | - d.remove(); |
89 | | - return out; |
90 | | - } |
91 | | - function onC(e) { |
92 | | - e.preventDefault(); e.stopPropagation(); |
93 | | - if (!last) return; |
94 | | - var html = last.outerHTML; |
95 | | - if (html.length > 3000) html = html.substring(0, 3000) + '...'; |
96 | | - var css = JSON.stringify(getStyles(last), null, 2); |
97 | | - window.parent.postMessage({ type: 'cf-extract', html: html, css: css }, '*'); |
98 | | - } |
99 | | - document.addEventListener('mousemove', onM, true); |
100 | | - document.addEventListener('click', onC, true); |
101 | | - window.__cfClean = function() { |
102 | | - document.removeEventListener('mousemove', onM, true); |
103 | | - document.removeEventListener('click', onC, true); |
104 | | - ov.remove(); |
105 | | - }; |
106 | | - })(); |
107 | | - `; |
108 | | - doc.head.appendChild(script); |
109 | | - } catch { |
110 | | - // Cross-origin — show paste fallback |
111 | | - setPasteMode(true); |
112 | | - setInspecting(false); |
113 | | - } |
114 | | - } |
115 | | - |
116 | | - function removeInspector() { |
117 | | - try { |
118 | | - const doc = iframeRef?.contentDocument; |
119 | | - if (!doc) return; |
120 | | - if ((doc.defaultView as any)?.__cfClean) (doc.defaultView as any).__cfClean(); |
121 | | - doc.getElementById("cf-inspector")?.remove(); |
122 | | - } catch {} |
123 | | - } |
124 | | - |
125 | | - // Listen for extraction messages from the iframe |
126 | | - function onMessage(e: MessageEvent) { |
127 | | - if (e.data?.type === "cf-extract") { |
128 | | - addExtraction(e.data.html, e.data.css); |
129 | | - setInspecting(false); |
130 | | - removeInspector(); |
131 | | - } |
| 19 | + const { store } = appStore; |
| 20 | + let ref: HTMLDivElement | undefined; |
| 21 | + let lastKey = ""; |
| 22 | + |
| 23 | + function sync() { |
| 24 | + if (!ref) return; |
| 25 | + const r = ref.getBoundingClientRect(); |
| 26 | + if (r.width < 10 || r.height < 10) return; |
| 27 | + const key = `${Math.round(r.left)},${Math.round(r.top)},${Math.round(r.width)},${Math.round(r.height)}`; |
| 28 | + if (key === lastKey) return; |
| 29 | + lastKey = key; |
| 30 | + ipc.browserSetBounds(props.threadId, Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height)).catch(() => {}); |
132 | 31 | } |
133 | 32 |
|
134 | 33 | onMount(() => { |
135 | | - window.addEventListener("message", onMessage); |
136 | | - onCleanup(() => window.removeEventListener("message", onMessage)); |
| 34 | + const t = setTimeout(() => { |
| 35 | + if (!ref) return; |
| 36 | + const r = ref.getBoundingClientRect(); |
| 37 | + if (r.width < 10 || r.height < 10) return; |
| 38 | + const url = store.threadBrowserUrls[props.threadId] || "https://google.com"; |
| 39 | + |
| 40 | + if (created.has(props.threadId)) { |
| 41 | + // Already exists — just reposition |
| 42 | + ipc.browserSetBounds(props.threadId, Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height)).catch(() => {}); |
| 43 | + } else { |
| 44 | + ipc.browserOpen(props.threadId, url, Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height)) |
| 45 | + .then(() => created.add(props.threadId)) |
| 46 | + .catch((e) => console.error("browser_open:", e)); |
| 47 | + } |
| 48 | + }, 300); |
| 49 | + |
| 50 | + const ro = new ResizeObserver(() => sync()); |
| 51 | + if (ref) ro.observe(ref); |
| 52 | + window.addEventListener("resize", sync); |
| 53 | + const poll = setInterval(sync, 150); |
| 54 | + |
| 55 | + onCleanup(() => { |
| 56 | + clearTimeout(t); |
| 57 | + ro.disconnect(); |
| 58 | + window.removeEventListener("resize", sync); |
| 59 | + clearInterval(poll); |
| 60 | + ipc.browserHide(props.threadId).catch(() => {}); |
| 61 | + }); |
137 | 62 | }); |
138 | 63 |
|
139 | | - function addExtraction(html: string, css: string) { |
140 | | - const formatted = `Extracted from ${loadedUrl()}:\n\n\`\`\`html\n${html}\n\`\`\`\n\n\`\`\`css\n${css}\n\`\`\``; |
141 | | - // Add as attachment-style context (prepend to composer) |
142 | | - const current = store.composerText; |
143 | | - setStore("composerText", current ? formatted + "\n\n" + current : formatted); |
144 | | - } |
145 | | - |
146 | | - function extractFromPaste() { |
147 | | - const html = pasteHtml().trim(); |
148 | | - if (!html) return; |
149 | | - addExtraction(html, "/* Paste mode — styles not available */"); |
150 | | - setPasteHtml(""); |
151 | | - setPasteMode(false); |
152 | | - } |
153 | | - |
154 | | - return ( |
155 | | - <div class="bp"> |
156 | | - <div class="bp-bar"> |
157 | | - <input |
158 | | - class="bp-url" |
159 | | - value={url()} |
160 | | - onInput={(e) => setUrl(e.currentTarget.value)} |
161 | | - onKeyDown={(e) => { if (e.key === "Enter") navigate(); }} |
162 | | - placeholder="URL..." |
163 | | - /> |
164 | | - <button class="bp-go" onClick={navigate}>Go</button> |
165 | | - <button |
166 | | - class="bp-btn" |
167 | | - classList={{ active: inspecting() }} |
168 | | - onClick={toggleInspect} |
169 | | - title="Inspect element — click to extract HTML/CSS" |
170 | | - > |
171 | | - <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> |
172 | | - <path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4z"/> |
173 | | - </svg> |
174 | | - </button> |
175 | | - <button |
176 | | - class="bp-btn" |
177 | | - classList={{ active: pasteMode() }} |
178 | | - onClick={() => setPasteMode(!pasteMode())} |
179 | | - title="Paste HTML manually" |
180 | | - > |
181 | | - <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> |
182 | | - <path d="M16 4h2a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6a2 2 0 012-2h2"/> |
183 | | - <rect x="8" y="2" width="8" height="4" rx="1"/> |
184 | | - </svg> |
185 | | - </button> |
186 | | - <button class="bp-btn" onClick={close} title="Close"> |
187 | | - <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> |
188 | | - </button> |
189 | | - </div> |
190 | | - |
191 | | - <Show when={inspecting()}> |
192 | | - <div class="bp-hint">Click an element in the page to extract its HTML and CSS</div> |
193 | | - </Show> |
194 | | - |
195 | | - <Show when={pasteMode()}> |
196 | | - <div class="bp-paste"> |
197 | | - <textarea |
198 | | - class="bp-paste-area" |
199 | | - value={pasteHtml()} |
200 | | - onInput={(e) => setPasteHtml(e.currentTarget.value)} |
201 | | - placeholder="Paste outerHTML from DevTools here..." |
202 | | - /> |
203 | | - <button class="bp-paste-btn" onClick={extractFromPaste} disabled={!pasteHtml().trim()}> |
204 | | - Extract to composer |
205 | | - </button> |
206 | | - </div> |
207 | | - </Show> |
208 | | - |
209 | | - <Show when={!pasteMode()}> |
210 | | - <iframe |
211 | | - ref={iframeRef} |
212 | | - class="bp-frame" |
213 | | - src={loadedUrl()} |
214 | | - sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox" |
215 | | - /> |
216 | | - </Show> |
217 | | - </div> |
218 | | - ); |
219 | | -} |
220 | | - |
221 | | -if (!document.getElementById("bp-styles")) { |
222 | | - const s = document.createElement("style"); |
223 | | - s.id = "bp-styles"; |
224 | | - s.textContent = ` |
225 | | - .bp { flex:1; display:flex; flex-direction:column; min-height:0; overflow:hidden; } |
226 | | - .bp-bar { |
227 | | - display:flex; align-items:center; gap:3px; padding:5px 6px; |
228 | | - border-bottom:1px solid var(--border); background:var(--bg-surface); flex-shrink:0; |
229 | | - } |
230 | | - .bp-btn { |
231 | | - width:24px;height:24px;border-radius:var(--radius-sm); |
232 | | - display:flex;align-items:center;justify-content:center; |
233 | | - color:var(--text-tertiary);transition:background .1s,color .1s;flex-shrink:0; |
234 | | - } |
235 | | - .bp-btn:hover { background:var(--bg-accent);color:var(--text-secondary); } |
236 | | - .bp-btn.active { color:var(--primary);background:rgba(107,124,255,0.15); } |
237 | | - .bp-url { |
238 | | - flex:1;min-width:0;height:24px;padding:0 8px;font-size:12px; |
239 | | - font-family:var(--font-mono);background:var(--bg-muted); |
240 | | - border:1px solid var(--border);border-radius:var(--radius-sm); |
241 | | - color:var(--text);outline:none; |
242 | | - } |
243 | | - .bp-url:focus { border-color:var(--primary); } |
244 | | - .bp-go { |
245 | | - height:24px;padding:0 10px;font-size:11px;font-weight:600; |
246 | | - background:var(--primary);color:#fff;border-radius:var(--radius-sm);flex-shrink:0; |
247 | | - } |
248 | | - .bp-go:hover { filter:brightness(1.15); } |
249 | | - .bp-frame { flex:1; min-height:0; border:none; background:#fff; } |
250 | | - .bp-hint { |
251 | | - padding:4px 10px;font-size:10px;font-weight:500; |
252 | | - color:var(--primary);background:var(--bg-muted); |
253 | | - border-bottom:1px solid var(--border);text-align:center;flex-shrink:0; |
254 | | - } |
255 | | - .bp-paste { |
256 | | - flex:1;display:flex;flex-direction:column;padding:10px;gap:8px; |
257 | | - background:var(--bg-base);min-height:0; |
258 | | - } |
259 | | - .bp-paste-area { |
260 | | - flex:1;min-height:60px;padding:8px;font-size:12px; |
261 | | - font-family:var(--font-mono);background:var(--bg-surface); |
262 | | - border:1px solid var(--border);border-radius:var(--radius-sm); |
263 | | - color:var(--text);resize:none;outline:none; |
264 | | - } |
265 | | - .bp-paste-area:focus { border-color:var(--primary); } |
266 | | - .bp-paste-area::placeholder { color:var(--text-tertiary); } |
267 | | - .bp-paste-btn { |
268 | | - align-self:flex-end;height:28px;padding:0 14px;font-size:11px;font-weight:600; |
269 | | - background:var(--primary);color:#fff;border-radius:var(--radius-sm); |
270 | | - } |
271 | | - .bp-paste-btn:hover { filter:brightness(1.15); } |
272 | | - .bp-paste-btn:disabled { opacity:0.4;cursor:not-allowed; } |
273 | | - `; |
274 | | - document.head.appendChild(s); |
| 64 | + return <div ref={ref} style="flex:1;min-height:0;min-width:0;" />; |
275 | 65 | } |
0 commit comments