feat(share): route annotation exports through the system share sheet (#4107)
Adds a `share` flag and `sharePosition` to `saveFile` across the app
services. On iOS/Android/macOS/Windows the annotation export now calls
the sharekit `shareFile` (writing the markdown/txt to `$TEMP` first when
no `filePath` is provided), so users get the system "Share via…" sheet
that drops the export into Mail, Notes, Messages, etc. Linux desktop
keeps the existing save dialog, since sharekit has no Linux backend.
On the web, `saveFile` now prefers `navigator.share({ files })` when the
browser advertises support via `canShare`. AbortError (user dismissed)
is treated as a deliberate "don't share" choice; any other rejection
(e.g., Chrome desktop's `NotAllowedError` despite a positive `canShare`)
falls through to the `<a download>` fallback so a save still happens.
Also fixes the macOS share popover anchoring: `preferredEdge: 'top'`
maps to `NSMaxYEdge`, which is the rect's bottom edge in WKWebView's
flipped coords, so the picker rendered below the trigger button. The
annotations export only got away with it because its dialog has no room
below — macOS auto-flipped above. Switching to `preferredEdge: 'bottom'`
(`NSMinYEdge` → top edge in flipped coords) anchors the popover above
the button consistently. Adds `$TEMP/**/*` to the Tauri fs capabilities
so the writable temp share file is permitted.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -85,6 +85,9 @@
|
||||
},
|
||||
{
|
||||
"path": "**/last-book-cover.png"
|
||||
},
|
||||
{
|
||||
"path": "$TEMP/**/*"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -242,6 +242,213 @@ describe('WebAppService', () => {
|
||||
const result = await service.saveFile('book.epub', 'content');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test('uses navigator.share when share option is set and supported', async () => {
|
||||
const shareSpy = vi.fn().mockResolvedValue(undefined);
|
||||
const canShareSpy = vi.fn().mockReturnValue(true);
|
||||
const originalShare = (navigator as unknown as { share?: unknown }).share;
|
||||
const originalCanShare = (navigator as unknown as { canShare?: unknown }).canShare;
|
||||
Object.defineProperty(navigator, 'share', {
|
||||
value: shareSpy,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(navigator, 'canShare', {
|
||||
value: canShareSpy,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
const createObjectURLSpy = vi.spyOn(URL, 'createObjectURL');
|
||||
|
||||
const result = await service.saveFile('annotations.md', '# notes', {
|
||||
mimeType: 'text/markdown',
|
||||
share: true,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(shareSpy).toHaveBeenCalledTimes(1);
|
||||
const shareData = shareSpy.mock.calls[0]![0] as { files: File[]; title?: string };
|
||||
expect(shareData.files).toHaveLength(1);
|
||||
expect(shareData.files[0]!.name).toBe('annotations.md');
|
||||
expect(shareData.files[0]!.type).toBe('text/markdown');
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled();
|
||||
|
||||
if (originalShare === undefined) {
|
||||
delete (navigator as unknown as { share?: unknown }).share;
|
||||
} else {
|
||||
Object.defineProperty(navigator, 'share', {
|
||||
value: originalShare,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
if (originalCanShare === undefined) {
|
||||
delete (navigator as unknown as { canShare?: unknown }).canShare;
|
||||
} else {
|
||||
Object.defineProperty(navigator, 'canShare', {
|
||||
value: originalCanShare,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('falls through to download when share=true but canShare rejects', async () => {
|
||||
const shareSpy = vi.fn();
|
||||
const canShareSpy = vi.fn().mockReturnValue(false);
|
||||
const originalShare = (navigator as unknown as { share?: unknown }).share;
|
||||
const originalCanShare = (navigator as unknown as { canShare?: unknown }).canShare;
|
||||
Object.defineProperty(navigator, 'share', {
|
||||
value: shareSpy,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(navigator, 'canShare', {
|
||||
value: canShareSpy,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const clickSpy = vi.fn();
|
||||
vi.spyOn(document.body, 'appendChild').mockImplementation((node) => {
|
||||
(node as HTMLAnchorElement).click = clickSpy;
|
||||
return node;
|
||||
});
|
||||
vi.spyOn(document.body, 'removeChild').mockImplementation((node) => node);
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
||||
|
||||
const result = await service.saveFile('annotations.md', '# notes', {
|
||||
mimeType: 'text/markdown',
|
||||
share: true,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(shareSpy).not.toHaveBeenCalled();
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
if (originalShare === undefined) {
|
||||
delete (navigator as unknown as { share?: unknown }).share;
|
||||
} else {
|
||||
Object.defineProperty(navigator, 'share', {
|
||||
value: originalShare,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
if (originalCanShare === undefined) {
|
||||
delete (navigator as unknown as { canShare?: unknown }).canShare;
|
||||
} else {
|
||||
Object.defineProperty(navigator, 'canShare', {
|
||||
value: originalCanShare,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('returns true without falling back when user dismisses share sheet', async () => {
|
||||
const dismissError = new DOMException('share canceled', 'AbortError');
|
||||
const shareSpy = vi.fn().mockRejectedValue(dismissError);
|
||||
const canShareSpy = vi.fn().mockReturnValue(true);
|
||||
const originalShare = (navigator as unknown as { share?: unknown }).share;
|
||||
const originalCanShare = (navigator as unknown as { canShare?: unknown }).canShare;
|
||||
Object.defineProperty(navigator, 'share', {
|
||||
value: shareSpy,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(navigator, 'canShare', {
|
||||
value: canShareSpy,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
const createObjectURLSpy = vi.spyOn(URL, 'createObjectURL');
|
||||
|
||||
const result = await service.saveFile('annotations.md', '# notes', {
|
||||
mimeType: 'text/markdown',
|
||||
share: true,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(shareSpy).toHaveBeenCalledTimes(1);
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled();
|
||||
|
||||
if (originalShare === undefined) {
|
||||
delete (navigator as unknown as { share?: unknown }).share;
|
||||
} else {
|
||||
Object.defineProperty(navigator, 'share', {
|
||||
value: originalShare,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
if (originalCanShare === undefined) {
|
||||
delete (navigator as unknown as { canShare?: unknown }).canShare;
|
||||
} else {
|
||||
Object.defineProperty(navigator, 'canShare', {
|
||||
value: originalCanShare,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('falls back to download when navigator.share rejects with non-AbortError', async () => {
|
||||
// Chrome desktop sometimes reports canShare={files}=true but then rejects
|
||||
// navigator.share with NotAllowedError; without a fallback nothing happens.
|
||||
const shareSpy = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new DOMException('Permission denied', 'NotAllowedError'));
|
||||
const canShareSpy = vi.fn().mockReturnValue(true);
|
||||
const originalShare = (navigator as unknown as { share?: unknown }).share;
|
||||
const originalCanShare = (navigator as unknown as { canShare?: unknown }).canShare;
|
||||
Object.defineProperty(navigator, 'share', {
|
||||
value: shareSpy,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(navigator, 'canShare', {
|
||||
value: canShareSpy,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const clickSpy = vi.fn();
|
||||
vi.spyOn(document.body, 'appendChild').mockImplementation((node) => {
|
||||
(node as HTMLAnchorElement).click = clickSpy;
|
||||
return node;
|
||||
});
|
||||
vi.spyOn(document.body, 'removeChild').mockImplementation((node) => node);
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
||||
|
||||
const result = await service.saveFile('annotations.md', '# notes', {
|
||||
mimeType: 'text/markdown',
|
||||
share: true,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(shareSpy).toHaveBeenCalledTimes(1);
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
if (originalShare === undefined) {
|
||||
delete (navigator as unknown as { share?: unknown }).share;
|
||||
} else {
|
||||
Object.defineProperty(navigator, 'share', {
|
||||
value: originalShare,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
if (originalCanShare === undefined) {
|
||||
delete (navigator as unknown as { canShare?: unknown }).canShare;
|
||||
} else {
|
||||
Object.defineProperty(navigator, 'canShare', {
|
||||
value: originalCanShare,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('fs.resolvePath basePrefix', () => {
|
||||
|
||||
@@ -154,10 +154,21 @@ const ShareBookDialog: React.FC<ShareBookDialogProps> = ({ isOpen, book, cfi, on
|
||||
}
|
||||
};
|
||||
|
||||
const handleNativeShare = async () => {
|
||||
const handleNativeShare = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (!created) return;
|
||||
const title = book.title;
|
||||
const url = created.url;
|
||||
// Anchor the macOS / iPad share sheet to the trigger button rect so
|
||||
// NSSharingServicePicker doesn't fall back to the WebView's top-left.
|
||||
// `preferredEdge: 'bottom'` maps to NSMinYEdge — in the flipped WKWebView
|
||||
// coord space that's the rect's top edge, which makes the popover appear
|
||||
// above the button (matching the annotations export popover).
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const sharePosition = {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top,
|
||||
preferredEdge: 'bottom' as const,
|
||||
};
|
||||
|
||||
// Tauri (mobile + desktop windowed): try sharekit. If the import or
|
||||
// call throws, the plugin isn't usable on this platform — fall through
|
||||
@@ -167,7 +178,7 @@ const ShareBookDialog: React.FC<ShareBookDialogProps> = ({ isOpen, book, cfi, on
|
||||
let sharekitWorked = false;
|
||||
try {
|
||||
const { shareText } = await import('@choochmeque/tauri-plugin-sharekit-api');
|
||||
await shareText(`${title}\n${url}`);
|
||||
await shareText(`${title}\n${url}`, { position: sharePosition });
|
||||
sharekitWorked = true;
|
||||
} catch (err) {
|
||||
console.error('shareText failed; falling back:', err);
|
||||
@@ -357,7 +368,7 @@ const ShareBookDialog: React.FC<ShareBookDialogProps> = ({ isOpen, book, cfi, on
|
||||
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleNativeShare}
|
||||
onClick={(e) => handleNativeShare(e)}
|
||||
className='btn btn-block gap-2 rounded-2xl'
|
||||
>
|
||||
<IoShareSocialOutline className='h-5 w-5' aria-hidden='true' />
|
||||
|
||||
@@ -892,27 +892,34 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
setShowExportDialog(true);
|
||||
};
|
||||
|
||||
const handleConfirmExport = async (markdownContent: string) => {
|
||||
const handleConfirmExport = async (
|
||||
content: string,
|
||||
isPlainText: boolean,
|
||||
sharePosition?: { x: number; y: number; preferredEdge?: 'top' | 'bottom' | 'left' | 'right' },
|
||||
) => {
|
||||
const { book } = bookData;
|
||||
if (!book) return;
|
||||
|
||||
setTimeout(() => {
|
||||
// Delay to ensure it won't be overridden by system clipboard actions
|
||||
navigator.clipboard?.writeText(markdownContent);
|
||||
navigator.clipboard?.writeText(content);
|
||||
}, 100);
|
||||
|
||||
const filename = `${makeSafeFilename(book.title)}.md`;
|
||||
const saved = await appService?.saveFile(filename, markdownContent, {
|
||||
mimeType: 'text/markdown',
|
||||
const ext = isPlainText ? 'txt' : 'md';
|
||||
const mimeType = isPlainText ? 'text/plain' : 'text/markdown';
|
||||
const filename = `${makeSafeFilename(book.title)}.${ext}`;
|
||||
const saved = await appService?.saveFile(filename, content, {
|
||||
mimeType,
|
||||
share: true,
|
||||
sharePosition,
|
||||
});
|
||||
|
||||
if (appService?.isMacOSApp) return;
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'info',
|
||||
message: saved ? _('Exported successfully') : _('Copied to clipboard'),
|
||||
timeout: 2000,
|
||||
});
|
||||
|
||||
setShowExportDialog(false);
|
||||
setExportData(null);
|
||||
};
|
||||
|
||||
const handleCancelExport = () => {
|
||||
|
||||
@@ -20,7 +20,11 @@ interface ExportMarkdownDialogProps {
|
||||
booknotes: BookNote[];
|
||||
booknoteGroups: { [href: string]: BooknoteGroup };
|
||||
onCancel: () => void;
|
||||
onExport: (markdown: string) => void;
|
||||
onExport: (
|
||||
markdown: string,
|
||||
isPlainText: boolean,
|
||||
sharePosition?: { x: number; y: number; preferredEdge?: 'top' | 'bottom' | 'left' | 'right' },
|
||||
) => void;
|
||||
}
|
||||
|
||||
const ExportMarkdownDialog: React.FC<ExportMarkdownDialogProps> = ({
|
||||
@@ -252,8 +256,19 @@ const ExportMarkdownDialog: React.FC<ExportMarkdownDialogProps> = ({
|
||||
}));
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
onExport(markdownPreview);
|
||||
const handleExport = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
// Anchor the macOS / iPad share sheet to the Export button rect so
|
||||
// NSSharingServicePicker doesn't fall back to the WebView's top-left.
|
||||
// `preferredEdge: 'bottom'` maps to NSMinYEdge — in the flipped WKWebView
|
||||
// coord space that's the rect's top edge, so the popover appears above
|
||||
// the button regardless of whether there is room below it.
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const sharePosition = {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top,
|
||||
preferredEdge: 'bottom' as const,
|
||||
};
|
||||
onExport(markdownPreview, !!exportConfig.exportAsPlainText, sharePosition);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -129,10 +129,21 @@ const SharedLinksSection: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleNativeShare = async (row: ShareRow) => {
|
||||
const handleNativeShare = async (row: ShareRow, e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const url = buildUrl(row);
|
||||
if (!url) return;
|
||||
const title = row.title;
|
||||
// Anchor the macOS / iPad share sheet to the row's share button so
|
||||
// NSSharingServicePicker doesn't fall back to the WebView's top-left.
|
||||
// `preferredEdge: 'bottom'` maps to NSMinYEdge — in the flipped WKWebView
|
||||
// coord space that's the rect's top edge, so the popover appears above
|
||||
// the button (and only auto-flips below when there's no room above).
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const sharePosition = {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top,
|
||||
preferredEdge: 'bottom' as const,
|
||||
};
|
||||
|
||||
// See ShareBookDialog.handleNativeShare for the rationale: only fall
|
||||
// through to copy when no native share method is available at all.
|
||||
@@ -141,7 +152,7 @@ const SharedLinksSection: React.FC = () => {
|
||||
let sharekitWorked = false;
|
||||
try {
|
||||
const { shareText } = await import('@choochmeque/tauri-plugin-sharekit-api');
|
||||
await shareText(`${title}\n${url}`);
|
||||
await shareText(`${title}\n${url}`, { position: sharePosition });
|
||||
sharekitWorked = true;
|
||||
} catch (err) {
|
||||
console.error('shareText failed; falling back:', err);
|
||||
@@ -299,7 +310,7 @@ const SharedLinksSection: React.FC = () => {
|
||||
type='button'
|
||||
title={_('Share via…')}
|
||||
aria-label={_('Share via…')}
|
||||
onClick={() => handleNativeShare(row)}
|
||||
onClick={(e) => handleNativeShare(row, e)}
|
||||
className='btn btn-ghost btn-sm'
|
||||
>
|
||||
<IoShareSocialOutline className='h-4 w-4' aria-hidden='true' />
|
||||
|
||||
@@ -77,7 +77,12 @@ export abstract class BaseAppService implements AppService {
|
||||
abstract saveFile(
|
||||
filename: string,
|
||||
content: string | ArrayBuffer,
|
||||
options?: { filePath?: string; mimeType?: string },
|
||||
options?: {
|
||||
filePath?: string;
|
||||
mimeType?: string;
|
||||
share?: boolean;
|
||||
sharePosition?: { x: number; y: number; preferredEdge?: 'top' | 'bottom' | 'left' | 'right' };
|
||||
},
|
||||
): Promise<boolean>;
|
||||
abstract ask(message: string): Promise<boolean>;
|
||||
abstract openDatabase(
|
||||
|
||||
@@ -561,26 +561,51 @@ export class NativeAppService extends BaseAppService {
|
||||
async saveFile(
|
||||
filename: string,
|
||||
content: string | ArrayBuffer,
|
||||
options?: { filePath?: string; mimeType?: string },
|
||||
options?: {
|
||||
filePath?: string;
|
||||
mimeType?: string;
|
||||
share?: boolean;
|
||||
sharePosition?: { x: number; y: number; preferredEdge?: 'top' | 'bottom' | 'left' | 'right' };
|
||||
},
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const ext = filename.split('.').pop() || '';
|
||||
if (this.isIOSApp && options?.filePath) {
|
||||
await shareFile(options.filePath, {
|
||||
mimeType: options?.mimeType || 'application/octet-stream',
|
||||
});
|
||||
} else {
|
||||
const filePath = await saveDialog({
|
||||
defaultPath: filename,
|
||||
filters: [{ name: ext.toUpperCase(), extensions: [ext] }],
|
||||
});
|
||||
if (!filePath) return false;
|
||||
|
||||
if (typeof content === 'string') {
|
||||
await writeTextFile(filePath, content);
|
||||
} else {
|
||||
await writeFile(filePath, new Uint8Array(content));
|
||||
// Linux desktop has no system share sheet; always fall through to saveDialog.
|
||||
const wantShare = !this.isLinuxApp && (this.isIOSApp || options?.share);
|
||||
if (wantShare) {
|
||||
let shareablePath = options?.filePath;
|
||||
if (!shareablePath) {
|
||||
shareablePath = await this.resolveFilePath(filename, 'Temp');
|
||||
if (typeof content === 'string') {
|
||||
await writeTextFile(shareablePath, content);
|
||||
} else {
|
||||
await writeFile(shareablePath, new Uint8Array(content));
|
||||
}
|
||||
}
|
||||
try {
|
||||
await shareFile(shareablePath, {
|
||||
mimeType: options?.mimeType || 'application/octet-stream',
|
||||
// Anchor the macOS NSSharingServicePicker / iPad popover to
|
||||
// the trigger button. Without this, the picker pops at the
|
||||
// WebView's top-left corner.
|
||||
...(options?.sharePosition ? { position: options.sharePosition } : {}),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('shareFile failed; falling back to saveDialog:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const filePath = await saveDialog({
|
||||
defaultPath: filename,
|
||||
filters: [{ name: ext.toUpperCase(), extensions: [ext] }],
|
||||
});
|
||||
if (!filePath) return false;
|
||||
|
||||
if (typeof content === 'string') {
|
||||
await writeTextFile(filePath, content);
|
||||
} else {
|
||||
await writeFile(filePath, new Uint8Array(content));
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
||||
@@ -387,7 +387,12 @@ export class NodeAppService extends BaseAppService {
|
||||
async saveFile(
|
||||
_filename: string,
|
||||
content: string | ArrayBuffer,
|
||||
options?: { filePath?: string; mimeType?: string },
|
||||
options?: {
|
||||
filePath?: string;
|
||||
mimeType?: string;
|
||||
share?: boolean;
|
||||
sharePosition?: { x: number; y: number; preferredEdge?: 'top' | 'bottom' | 'left' | 'right' };
|
||||
},
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const filepath = options?.filePath ?? '';
|
||||
|
||||
@@ -334,10 +334,50 @@ export class WebAppService extends BaseAppService {
|
||||
async saveFile(
|
||||
filename: string,
|
||||
content: string | ArrayBuffer,
|
||||
options?: { filePath?: string; mimeType?: string },
|
||||
options?: {
|
||||
filePath?: string;
|
||||
mimeType?: string;
|
||||
share?: boolean;
|
||||
// Web ignores `sharePosition` — `navigator.share()` anchors itself
|
||||
// natively to the calling element on Safari / iOS.
|
||||
sharePosition?: { x: number; y: number; preferredEdge?: 'top' | 'bottom' | 'left' | 'right' };
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const mimeType = options?.mimeType || 'application/octet-stream';
|
||||
if (
|
||||
options?.share &&
|
||||
typeof navigator !== 'undefined' &&
|
||||
typeof navigator.share === 'function'
|
||||
) {
|
||||
let shareData: ShareData | null = null;
|
||||
try {
|
||||
const file = new File([content], filename, { type: mimeType });
|
||||
const candidate: ShareData = { files: [file], title: filename };
|
||||
if (typeof navigator.canShare !== 'function' || navigator.canShare(candidate)) {
|
||||
shareData = candidate;
|
||||
}
|
||||
} catch (error) {
|
||||
// File constructor unavailable or rejected the input; fall through to download.
|
||||
console.warn('Failed to build share file; falling back to download:', error);
|
||||
}
|
||||
if (shareData) {
|
||||
try {
|
||||
await navigator.share(shareData);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// AbortError = user dismissed the sheet; respect that as an explicit
|
||||
// "don't share" choice. Any other error (e.g., NotAllowedError on
|
||||
// desktop Chrome where canShare lies about file support) means the
|
||||
// share never happened — fall through to the download fallback.
|
||||
if ((error as DOMException)?.name === 'AbortError') {
|
||||
return true;
|
||||
}
|
||||
console.warn('navigator.share failed; falling back to download:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
const blob = new Blob([content], { type: options?.mimeType || 'application/octet-stream' });
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
|
||||
@@ -121,7 +121,16 @@ export interface AppService {
|
||||
saveFile(
|
||||
filename: string,
|
||||
content: string | ArrayBuffer,
|
||||
options?: { filePath?: string; mimeType?: string },
|
||||
options?: {
|
||||
filePath?: string;
|
||||
mimeType?: string;
|
||||
share?: boolean;
|
||||
// Anchor point for the macOS / iPad share sheet. Coordinates are in
|
||||
// CSS pixels of the WebView; the sharekit plugin maps them onto the
|
||||
// native NSView. Without this, NSSharingServicePicker defaults to
|
||||
// (0,0) of the WebView and pops at the top-left of the window.
|
||||
sharePosition?: { x: number; y: number; preferredEdge?: 'top' | 'bottom' | 'left' | 'right' };
|
||||
},
|
||||
): Promise<boolean>;
|
||||
|
||||
getDefaultViewSettings(): ViewSettings;
|
||||
|
||||
Reference in New Issue
Block a user