@@ -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
1385window . 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
21114window . 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 = / < h t m l [ \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+
125292function 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
210381async 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