Skip to content

Commit 227a996

Browse files
authored
fix(files): use clipboard + data URI for built-in doc actions (#500)
The Open and Download buttons did nothing for built-in docs because window.open() and blob URLs silently fail in the Office WebView. For built-in docs (always text files): - Replace 'Open ↗' with 'Copy content' (navigator.clipboard.writeText) - Download via data URI instead of blob URL Also add a failure toast to the general openBlobInNewTab() fallback chain so non-built-in file failures are surfaced instead of silent. Fixes #492
1 parent 35fc1cb commit 227a996

File tree

2 files changed

+108
-27
lines changed

2 files changed

+108
-27
lines changed

src/ui/files-dialog-actions.ts

Lines changed: 106 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -78,13 +78,38 @@ function openBlobInNewTab(blob: Blob, pendingWindow: Window | null): void {
7878
document.body.appendChild(anchor);
7979
anchor.click();
8080
anchor.remove();
81+
// Best-effort — anchor click may also silently fail in WebView.
82+
// We can't verify whether it worked, so no error toast here.
8183
}
8284

8385
setTimeout(() => {
8486
URL.revokeObjectURL(url);
8587
}, 60_000);
8688
}
8789

90+
/**
91+
* Copy text content to clipboard — reliable in Office WebView where
92+
* window.open / blob URLs silently fail.
93+
*/
94+
async function copyTextToClipboard(text: string, fileName: string): Promise<void> {
95+
await navigator.clipboard.writeText(text);
96+
showToast(`Copied ${fileName} to clipboard.`);
97+
}
98+
99+
/**
100+
* Download via data URI — works in WebView where blob URLs may not.
101+
*/
102+
function downloadViaDataUri(text: string, fileName: string, mimeType: string): void {
103+
const dataUri = `data:${mimeType};charset=utf-8,${encodeURIComponent(text)}`;
104+
const anchor = document.createElement("a");
105+
anchor.href = dataUri;
106+
anchor.download = fileName;
107+
anchor.style.display = "none";
108+
document.body.appendChild(anchor);
109+
anchor.click();
110+
anchor.remove();
111+
}
112+
88113
async function openFileInBrowser(options: {
89114
file: WorkspaceFileEntry;
90115
fileRef: FilesDialogDetailActionFileRef;
@@ -141,36 +166,91 @@ export function createFilesDialogDetailActions(options: CreateFilesDialogDetailA
141166
const actions = document.createElement("div");
142167
actions.className = "pi-files-detail-actions";
143168

144-
const openButton = document.createElement("button");
145-
openButton.type = "button";
146-
openButton.className = options.file.kind === "text"
147-
? "pi-overlay-btn pi-overlay-btn--ghost pi-overlay-btn--compact"
148-
: "pi-overlay-btn pi-overlay-btn--primary pi-overlay-btn--compact";
149-
openButton.textContent = "Open ↗";
150-
openButton.addEventListener("click", () => {
151-
void openFileInBrowser({
152-
file: options.file,
153-
fileRef: options.fileRef,
154-
workspace: options.workspace,
155-
auditContext: options.auditContext,
156-
}).catch((error: unknown) => {
157-
showToast(`Open failed: ${getErrorMessage(error)}`);
169+
const isBuiltIn = isFilesDialogBuiltInDoc(options.file);
170+
171+
if (isBuiltIn && options.file.kind === "text") {
172+
// Built-in docs: use clipboard + data-URI download instead of
173+
// window.open / blob URLs which silently fail in the Office WebView.
174+
// This add-in always runs inside the Office WebView (loaded via
175+
// manifest.xml into Excel's sidebar), so this path covers all
176+
// production usage. Dev-server testing in a browser is unaffected
177+
// because built-in docs are only available via the workspace.
178+
const copyButton = document.createElement("button");
179+
copyButton.type = "button";
180+
copyButton.className = "pi-overlay-btn pi-overlay-btn--ghost pi-overlay-btn--compact";
181+
copyButton.textContent = "Copy content";
182+
copyButton.addEventListener("click", () => {
183+
void (async () => {
184+
const result = await options.workspace.readFile(options.file.path, {
185+
mode: "text",
186+
maxChars: 16_000_000,
187+
audit: options.auditContext,
188+
locationKind: options.fileRef.locationKind,
189+
});
190+
if (result.text === undefined) throw new Error("Could not read file.");
191+
await copyTextToClipboard(result.text, options.file.name);
192+
})().catch((error: unknown) => {
193+
showToast(`Copy failed: ${getErrorMessage(error)}`);
194+
});
158195
});
159-
});
160196

161-
const downloadButton = document.createElement("button");
162-
downloadButton.type = "button";
163-
downloadButton.className = "pi-overlay-btn pi-overlay-btn--ghost pi-overlay-btn--compact";
164-
downloadButton.textContent = "Download";
165-
downloadButton.addEventListener("click", () => {
166-
void options.workspace.downloadFile(options.file.path, {
167-
locationKind: options.fileRef.locationKind,
168-
}).catch((error: unknown) => {
169-
showToast(`Download failed: ${getErrorMessage(error)}`);
197+
const downloadButton = document.createElement("button");
198+
downloadButton.type = "button";
199+
downloadButton.className = "pi-overlay-btn pi-overlay-btn--ghost pi-overlay-btn--compact";
200+
downloadButton.textContent = "Download";
201+
downloadButton.addEventListener("click", () => {
202+
void (async () => {
203+
const result = await options.workspace.readFile(options.file.path, {
204+
mode: "text",
205+
maxChars: 16_000_000,
206+
audit: options.auditContext,
207+
locationKind: options.fileRef.locationKind,
208+
});
209+
if (result.text === undefined) throw new Error("Could not read file.");
210+
downloadViaDataUri(
211+
result.text,
212+
options.file.name,
213+
resolveSafeBlobUrlMimeType(options.file.mimeType || "text/plain"),
214+
);
215+
})().catch((error: unknown) => {
216+
showToast(`Download failed: ${getErrorMessage(error)}`);
217+
});
170218
});
171-
});
172219

173-
actions.append(openButton, downloadButton);
220+
actions.append(copyButton, downloadButton);
221+
} else {
222+
// Non-built-in files: use the standard blob URL approach.
223+
const openButton = document.createElement("button");
224+
openButton.type = "button";
225+
openButton.className = options.file.kind === "text"
226+
? "pi-overlay-btn pi-overlay-btn--ghost pi-overlay-btn--compact"
227+
: "pi-overlay-btn pi-overlay-btn--primary pi-overlay-btn--compact";
228+
openButton.textContent = "Open ↗";
229+
openButton.addEventListener("click", () => {
230+
void openFileInBrowser({
231+
file: options.file,
232+
fileRef: options.fileRef,
233+
workspace: options.workspace,
234+
auditContext: options.auditContext,
235+
}).catch((error: unknown) => {
236+
showToast(`Open failed: ${getErrorMessage(error)}`);
237+
});
238+
});
239+
240+
const downloadButton = document.createElement("button");
241+
downloadButton.type = "button";
242+
downloadButton.className = "pi-overlay-btn pi-overlay-btn--ghost pi-overlay-btn--compact";
243+
downloadButton.textContent = "Download";
244+
downloadButton.addEventListener("click", () => {
245+
void options.workspace.downloadFile(options.file.path, {
246+
locationKind: options.fileRef.locationKind,
247+
}).catch((error: unknown) => {
248+
showToast(`Download failed: ${getErrorMessage(error)}`);
249+
});
250+
});
251+
252+
actions.append(openButton, downloadButton);
253+
}
174254

175255
const isReadOnly = options.file.readOnly || isFilesDialogBuiltInDoc(options.file);
176256
if (isReadOnly) {

tests/files-dialog-actions.test.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ test("detail actions show open/download only for read-only files", () => {
6363
onAfterDelete: () => Promise.resolve(),
6464
});
6565

66-
assert.deepEqual(getButtonLabels(actions), ["Open ↗", "Download"]);
66+
// Built-in docs use clipboard copy instead of Open (blob URLs fail in Office WebView).
67+
assert.deepEqual(getButtonLabels(actions), ["Copy content", "Download"]);
6768
} finally {
6869
restore();
6970
}

0 commit comments

Comments
 (0)