@@ -22,8 +22,194 @@ let { data }: { data: LayoutData } = $props();
2222
2323const debug = false ;
2424let syncing = $state (false );
25+ let authorPlayerEl = $state <HTMLElement | null >(null );
2526const playerType = $derived <PlayerType >(parsePlayerType ($page .url .searchParams .get (' player' )));
2627
28+ type AssetUploadHandler = {
29+ isPasted? : boolean ;
30+ cancel? : () => void ;
31+ done? : (err ? : Error , src ? : string ) => void ;
32+ fileChosen? : (file : File ) => void ;
33+ getChosenFile? : () => File | undefined ;
34+ progress? : (percent : number , bytes : number , total : number ) => void ;
35+ };
36+
37+ type DeleteAssetDetail = {
38+ src: string ;
39+ done? : (err ? : Error ) => void ;
40+ };
41+
42+ function toError(value : unknown , fallbackMessage = ' Unknown upload error' ): Error {
43+ if (value instanceof Error ) {
44+ return value ;
45+ }
46+ return new Error (String (value ?? fallbackMessage ));
47+ }
48+
49+ function readFileAsDataUrl(
50+ file : File ,
51+ handler : AssetUploadHandler ,
52+ fallbackMessage : string
53+ ): Promise <string > {
54+ return new Promise ((resolve , reject ) => {
55+ const reader = new FileReader ();
56+ reader .onload = () => {
57+ const result = reader .result ;
58+ if (typeof result === ' string' ) {
59+ resolve (result );
60+ return ;
61+ }
62+ reject (new Error (fallbackMessage ));
63+ };
64+ reader .onerror = () => {
65+ reject (new Error (fallbackMessage ));
66+ };
67+ reader .onprogress = (event ) => {
68+ if (! event .lengthComputable || ! handler .progress ) {
69+ return ;
70+ }
71+ const percent = (event .loaded / event .total ) * 100 ;
72+ handler .progress (percent , event .loaded , event .total );
73+ };
74+ reader .readAsDataURL (file );
75+ });
76+ }
77+
78+ function pickFileWithDialogLifecycle(accept : string , onCancel : () => void ): Promise <File | null > {
79+ return new Promise ((resolve ) => {
80+ const input = document .createElement (' input' );
81+ input .type = ' file' ;
82+ input .accept = accept ;
83+
84+ let resolved = false ;
85+ let changeHandled = false ;
86+ let dialogOpened = false ;
87+
88+ const cleanup = () => {
89+ input .onchange = null ;
90+ input .removeEventListener (' cancel' , onCancelEvent as EventListener );
91+ window .removeEventListener (' focus' , onFocus );
92+ };
93+
94+ const finalize = (file : File | null ) => {
95+ if (resolved ) {
96+ return ;
97+ }
98+ resolved = true ;
99+ cleanup ();
100+ resolve (file );
101+ };
102+
103+ const onFocus = () => {
104+ if (! dialogOpened || changeHandled ) {
105+ return ;
106+ }
107+ dialogOpened = false ;
108+ window .setTimeout (() => {
109+ if (resolved || changeHandled ) {
110+ return ;
111+ }
112+ const file = input .files ?.[0 ] ?? null ;
113+ if (! file ) {
114+ onCancel ();
115+ }
116+ finalize (file );
117+ }, 300 );
118+ };
119+
120+ const onCancelEvent = () => {
121+ onCancel ();
122+ finalize (null );
123+ };
124+
125+ input .onchange = () => {
126+ changeHandled = true ;
127+ dialogOpened = false ;
128+ finalize (input .files ?.[0 ] ?? null );
129+ };
130+
131+ input .addEventListener (' cancel' , onCancelEvent as EventListener );
132+ window .addEventListener (' focus' , onFocus );
133+ dialogOpened = true ;
134+ input .click ();
135+ });
136+ }
137+
138+ async function resolveUpload(
139+ handler : AssetUploadHandler ,
140+ options : {
141+ accept: string ;
142+ cancelledMessage: string ;
143+ failedReadMessage: string ;
144+ }
145+ ) {
146+ if (! handler || typeof handler !== ' object' ) {
147+ return ;
148+ }
149+
150+ let resolved = false ;
151+ const finish = (err ? : Error , src ? : string ) => {
152+ if (resolved ) {
153+ return ;
154+ }
155+ resolved = true ;
156+ handler .done ?.(err , src );
157+ };
158+
159+ try {
160+ let file: File | null = null ;
161+ if (handler .isPasted && handler .getChosenFile ) {
162+ file = handler .getChosenFile () ?? null ;
163+ } else {
164+ file = await pickFileWithDialogLifecycle (options .accept , () => {
165+ handler .cancel ?.();
166+ });
167+ if (file && handler .fileChosen ) {
168+ handler .fileChosen (file );
169+ }
170+ }
171+
172+ if (! file ) {
173+ handler .cancel ?.();
174+ finish (new Error (options .cancelledMessage ));
175+ return ;
176+ }
177+
178+ const src = await readFileAsDataUrl (file , handler , options .failedReadMessage );
179+ finish (undefined , src );
180+ } catch (error ) {
181+ finish (toError (error , options .failedReadMessage ));
182+ }
183+ }
184+
185+ async function handleInsertImage(event : Event ) {
186+ const customEvent = event as CustomEvent <AssetUploadHandler >;
187+ await resolveUpload (customEvent .detail , {
188+ accept: ' image/*' ,
189+ cancelledMessage: ' Image selection cancelled' ,
190+ failedReadMessage: ' Unable to read selected image file' ,
191+ });
192+ }
193+
194+ async function handleInsertSound(event : Event ) {
195+ const customEvent = event as CustomEvent <AssetUploadHandler >;
196+ await resolveUpload (customEvent .detail , {
197+ accept: ' audio/*' ,
198+ cancelledMessage: ' Sound selection cancelled' ,
199+ failedReadMessage: ' Unable to read selected sound file' ,
200+ });
201+ }
202+
203+ function handleDeleteAsset(event : Event ) {
204+ const customEvent = event as CustomEvent <DeleteAssetDetail >;
205+ const detail = customEvent .detail ;
206+ try {
207+ detail ?.done ?.();
208+ } catch (error ) {
209+ detail ?.done ?.(toError (error , ' Unable to delete uploaded asset' ));
210+ }
211+ }
212+
27213// Handle model changes from configure component
28214function handleModelChanged(event : CustomEvent ) {
29215 console .log (' [author] Model changed event received:' , event .detail );
@@ -69,6 +255,34 @@ function handleBuildState(event: CustomEvent) {
69255 }));
70256 }
71257}
258+
259+ $effect (() => {
260+ if (! authorPlayerEl ) {
261+ return ;
262+ }
263+ const onInsertImage = (event : Event ) => {
264+ void handleInsertImage (event );
265+ };
266+ const onInsertSound = (event : Event ) => {
267+ void handleInsertSound (event );
268+ };
269+ const onDeleteImage = (event : Event ) => {
270+ handleDeleteAsset (event );
271+ };
272+ const onDeleteSound = (event : Event ) => {
273+ handleDeleteAsset (event );
274+ };
275+ authorPlayerEl .addEventListener (' insert.image' , onInsertImage );
276+ authorPlayerEl .addEventListener (' insert.sound' , onInsertSound );
277+ authorPlayerEl .addEventListener (' delete.image' , onDeleteImage );
278+ authorPlayerEl .addEventListener (' delete.sound' , onDeleteSound );
279+ return () => {
280+ authorPlayerEl ?.removeEventListener (' insert.image' , onInsertImage );
281+ authorPlayerEl ?.removeEventListener (' insert.sound' , onInsertSound );
282+ authorPlayerEl ?.removeEventListener (' delete.image' , onDeleteImage );
283+ authorPlayerEl ?.removeEventListener (' delete.sound' , onDeleteSound );
284+ };
285+ });
72286 </script >
73287
74288<PlayerLayout
@@ -92,6 +306,7 @@ function handleBuildState(event: CustomEvent) {
92306 <div class ="author-view" class:iife-author-player ={playerType === ' iife' }>
93307 <div class =" configure-container" >
94308 <pie-element-player
309+ bind:this ={authorPlayerEl }
95310 strategy ={playerType }
96311 view =" author"
97312 element-name ={data .elementName }
0 commit comments