Skip to content

Commit 3c5e14a

Browse files
committed
webviewer: allow loading notebooks via postMessage()
1 parent 165e8cd commit 3c5e14a

1 file changed

Lines changed: 198 additions & 44 deletions

File tree

demo/index.js

Lines changed: 198 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,108 @@ const state = {
77
isDirty: false,
88
isEditing: false,
99
originalHtmlContent: null,
10+
pendingNotebookMessage: null,
11+
initialized: false,
12+
externalNotebookRequested: false,
13+
activeLoadSeq: 0,
1014
}
1115

16+
function beginNotebookLoad() {
17+
state.activeLoadSeq += 1
18+
return state.activeLoadSeq
19+
}
20+
21+
function isCurrentLoad(seq) {
22+
return seq === state.activeLoadSeq
23+
}
24+
25+
let squeakUnloadWarningInstalled = false
26+
27+
function installSqueakUnloadWarning() {
28+
if (squeakUnloadWarningInstalled) return
29+
squeakUnloadWarningInstalled = true
30+
31+
window.addEventListener('beforeunload', (e) => {
32+
if (state.squeakRunning || state.isDirty) {
33+
e.preventDefault()
34+
e.returnValue = ''
35+
}
36+
})
37+
}
38+
39+
function installSqueakUnloadWarningOnFirstInteraction() {
40+
const canvas = document.getElementById('sqCanvas')
41+
if (!canvas) return
42+
if (canvas.dataset.unloadHookAttached) return
43+
canvas.dataset.unloadHookAttached = 'true'
44+
45+
const onFirstInteraction = () => {
46+
installSqueakUnloadWarning()
47+
try { canvas.focus() } catch {}
48+
}
49+
50+
canvas.addEventListener('pointerdown', onFirstInteraction, { capture: true, once: true })
51+
canvas.addEventListener('keydown', onFirstInteraction, { capture: true, once: true })
52+
}
53+
54+
// Allow embedding contexts to provide notebooks via postMessage
55+
window.addEventListener('message', async (event) => {
56+
console.log('Received postMessage:', event.data)
57+
let data = event.data
58+
if (typeof data === 'string') {
59+
try {
60+
data = JSON.parse(data)
61+
} catch {
62+
return
63+
}
64+
}
65+
66+
if (!data || data.type !== 'notebook' || typeof data.html !== 'string') return
67+
68+
state.externalNotebookRequested = true
69+
70+
if (!state.initialized) {
71+
console.log('Deferring notebook loading until initialization is complete')
72+
state.pendingNotebookMessage = data
73+
return
74+
}
75+
76+
await openNotebookHtml(data.html, data.name, false)
77+
if (data.dynamic) {
78+
switchToDynamicMode()
79+
}
80+
})
81+
82+
window.opener?.postMessage('ready', '*')
83+
1284
// Initialization
1385
window.onload = async function () {
1486
setupEventListeners()
87+
88+
// From here on, we can safely react to postMessage immediately.
89+
state.initialized = true
90+
1591
const params = new URLSearchParams(window.location.search)
1692
const notebookUri = params.get('nb') || './simple.xnb.html'
17-
await loadNotebook(notebookUri)
18-
setMode('static')
93+
94+
let shouldSwitchToDynamic = false
95+
96+
if (state.pendingNotebookMessage?.type === 'notebook' && typeof state.pendingNotebookMessage.html === 'string') {
97+
const { html, name, dynamic } = state.pendingNotebookMessage
98+
state.pendingNotebookMessage = null
99+
await loadNotebookFromHtml(html, name, false)
100+
if (dynamic) {
101+
shouldSwitchToDynamic = true
102+
}
103+
} else if (!state.externalNotebookRequested && notebookUri !== '0') {
104+
await loadNotebook(notebookUri)
105+
}
106+
107+
if (shouldSwitchToDynamic) {
108+
switchToDynamicMode()
109+
} else {
110+
setMode('static')
111+
}
19112
}
20113

21114
window.addEventListener('beforeunload', (e) => {
@@ -72,18 +165,25 @@ async function handleModeChange(newMode) {
72165
state.currentMode = newMode
73166
}
74167

75-
// Load a bundled example notebook by URI with the same guards as file selection
76-
async function openNotebookUri(uri) {
168+
async function okToChangeNotebook() {
77169
if (state.isDirty) {
78170
const confirmed = confirm('You have unsaved changes. Opening a new notebook will lose them. Continue?')
79-
if (!confirmed) return
171+
if (!confirmed) return false
80172
}
81173

82174
if (state.squeakRunning) {
83175
const stopped = await stopSqueak()
84-
if (!stopped) return
176+
if (!stopped) return false
85177
}
86178

179+
return true
180+
}
181+
182+
// Load a bundled example notebook by URI with the same guards as file selection
183+
async function openNotebookUri(uri) {
184+
const ok = await okToChangeNotebook()
185+
if (!ok) return
186+
87187
await loadNotebook(uri)
88188

89189
// Update URL parameter
@@ -102,26 +202,93 @@ function setMode(mode) {
102202
}
103203

104204
// Notebook loading
105-
async function loadNotebook(uri) {
106-
const response = await fetch(uri)
107-
if (!response.ok) throw new Error('Failed to fetch notebook')
205+
function applyLoadedNotebook({ displayName, notebookUri, blob, htmlContent }, loadSeq) {
206+
if (!isCurrentLoad(loadSeq)) return
108207

109-
const displayName = uri.startsWith('data:') ? uri : (uri.split('/').pop() || uri)
110208
document.getElementById('fileInputDisplay').textContent = displayName
111-
112-
state.currentNotebookBlob = await response.blob()
113-
state.currentNotebookUri = uri
209+
210+
state.currentNotebookBlob = blob
211+
state.currentNotebookUri = notebookUri
114212
state.isDirty = false
115213
state.isEditing = false
116-
117-
const htmlContent = await state.currentNotebookBlob.text()
118214
state.originalHtmlContent = htmlContent
119-
const wrappedContent = wrapContentWithStyles(htmlContent)
215+
216+
const staticHtmlContent = stripIgnoredElements(htmlContent)
217+
const wrappedContent = wrapContentWithStyles(staticHtmlContent)
120218
const staticFrame = document.getElementById('staticContent')
121219
staticFrame.srcdoc = wrappedContent
122220
staticFrame.onload = () => setupNotebookClickHandlers()
123221
}
124222

223+
async function loadNotebook(uri) {
224+
const loadSeq = beginNotebookLoad()
225+
const response = await fetch(uri)
226+
if (!response.ok) throw new Error('Failed to fetch notebook')
227+
228+
const displayName = uri.startsWith('data:') ? uri : (uri.split('/').pop() || uri)
229+
230+
const blob = await response.blob()
231+
const htmlContent = await blob.text()
232+
233+
if (!isCurrentLoad(loadSeq)) return
234+
235+
applyLoadedNotebook({
236+
displayName,
237+
notebookUri: uri,
238+
blob,
239+
htmlContent,
240+
}, loadSeq)
241+
}
242+
243+
async function openNotebookHtml(htmlContent, suggestedName, navigate = true) {
244+
const ok = await okToChangeNotebook()
245+
if (!ok) return
246+
247+
await loadNotebookFromHtml(htmlContent, suggestedName, navigate)
248+
if (state.currentMode === 'dynamic') {
249+
await startSqueak()
250+
}
251+
}
252+
253+
async function loadNotebookFromHtml(htmlContent, suggestedName, navigate = true) {
254+
const loadSeq = beginNotebookLoad()
255+
const filename = (typeof suggestedName === 'string' && suggestedName.trim())
256+
? suggestedName.trim()
257+
: 'notebook.xnb.html'
258+
259+
const blob = new Blob([htmlContent], { type: 'text/html' })
260+
261+
if (!isCurrentLoad(loadSeq)) return
262+
263+
applyLoadedNotebook({
264+
displayName: filename,
265+
notebookUri: filename,
266+
blob,
267+
htmlContent,
268+
}, loadSeq)
269+
270+
// Remove URL parameter when loading notebook from an external HTML string
271+
const url = new URL(window.location)
272+
url.searchParams.delete('nb')
273+
window.history.pushState({}, '', url)
274+
}
275+
276+
function stripIgnoredElements(htmlContent) {
277+
try {
278+
const parser = new DOMParser()
279+
const isFullDoc = /<html[\s>]/i.test(htmlContent)
280+
const doc = isFullDoc
281+
? parser.parseFromString(htmlContent, 'text/html')
282+
: parser.parseFromString(`<!doctype html><html><head></head><body>${htmlContent}</body></html>`, 'text/html')
283+
284+
doc.querySelectorAll('[xnb-ignore]').forEach(el => el.remove())
285+
286+
return isFullDoc ? doc.documentElement.outerHTML : doc.body.innerHTML
287+
} catch {
288+
return htmlContent
289+
}
290+
}
291+
125292
function wrapContentWithStyles(htmlContent) {
126293
const styles = '<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"><style>body { margin: 0; padding: 1rem; font-size: 0.9rem; } body *, font { font-size: inherit !important; }</style>'
127294

@@ -163,7 +330,7 @@ function setupNotebookClickHandlers() {
163330
if (!confirmBtn.dataset.handlerAttached) {
164331
confirmBtn.dataset.handlerAttached = 'true'
165332
confirmBtn.addEventListener('click', () => {
166-
document.querySelector('input[name="mode"][value="dynamic"]').click()
333+
switchToDynamicMode()
167334
bootstrap.Modal.getInstance(document.getElementById('viewNotebookModal')).hide()
168335
if (confetti) setTimeout(() => {
169336
confetti({
@@ -206,41 +373,22 @@ function setupNotebookClickHandlers() {
206373
}
207374
}
208375

376+
function switchToDynamicMode() {
377+
document.querySelector('input[name="mode"][value="dynamic"]').click()
378+
}
379+
209380
// File operations
210381
async function openNotebookFile(file) {
211382
if (!file.name.endsWith('.xnb.html')) {
212383
alert('Please select a .xnb.html file')
213384
return
214385
}
215386

216-
if (state.isDirty) {
217-
const confirmed = confirm('You have unsaved changes. Opening a new notebook will lose them. Continue?')
218-
if (!confirmed) return
219-
}
220-
221-
if (state.squeakRunning) {
222-
const stopped = await stopSqueak()
223-
if (!stopped) return
224-
}
387+
const ok = await okToChangeNotebook()
388+
if (!ok) return
225389

226-
document.getElementById('fileInputDisplay').textContent = file.name
227-
228-
const blob = new Blob([await file.arrayBuffer()], { type: 'text/html' })
229-
state.currentNotebookBlob = blob
230-
state.currentNotebookUri = file.name
231-
state.isDirty = false
232-
state.isEditing = false
233-
234-
const htmlContent = await blob.text()
235-
state.originalHtmlContent = htmlContent
236-
const staticFrame = document.getElementById('staticContent')
237-
staticFrame.srcdoc = wrapContentWithStyles(htmlContent)
238-
staticFrame.onload = () => setupNotebookClickHandlers()
239-
240-
// Remove URL parameter when uploading a file
241-
const url = new URL(window.location)
242-
url.searchParams.delete('nb')
243-
window.history.pushState({}, '', url)
390+
const htmlContent = await file.text()
391+
await loadNotebookFromHtml(htmlContent, file.name)
244392

245393
if (state.currentMode === 'dynamic') {
246394
await startSqueak()
@@ -310,6 +458,12 @@ async function startSqueak() {
310458
if (state.squeakRunning) return
311459
state.squeakRunning = true
312460

461+
// Chrome gates beforeunload dialogs behind user activation.
462+
// If Squeak starts automatically, install a one-time hook so that once
463+
// the user interacts with the Squeak canvas, we (re)register an unload
464+
// warning handler that will actually be allowed to prompt.
465+
installSqueakUnloadWarningOnFirstInteraction()
466+
313467
let blobToUse = state.currentNotebookBlob
314468

315469
if (state.isDirty && state.originalHtmlContent) {

0 commit comments

Comments
 (0)