Skip to content

Commit 56f713a

Browse files
committed
implement asset handling (authoring)
1 parent 6911af0 commit 56f713a

2 files changed

Lines changed: 383 additions & 0 deletions

File tree

apps/element-demo/src/routes/[element]/author/+page.svelte

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,194 @@ let { data }: { data: LayoutData } = $props();
2222
2323
const debug = false;
2424
let syncing = $state(false);
25+
let authorPlayerEl = $state<HTMLElement | null>(null);
2526
const 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
28214
function 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

Comments
 (0)