From 295a588988af21a2db0759fb923bedb1e6c37923 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sat, 9 May 2026 19:50:08 +0800 Subject: [PATCH] feat(share): route annotation exports through the system share sheet (#4107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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) --- .../src-tauri/capabilities/default.json | 3 + .../services/web-app-service.test.ts | 207 ++++++++++++++++++ .../library/components/ShareBookDialog.tsx | 17 +- .../reader/components/annotator/Annotator.tsx | 23 +- .../annotator/ExportMarkdownDialog.tsx | 21 +- .../user/components/SharedLinksSection.tsx | 17 +- apps/readest-app/src/services/appService.ts | 7 +- .../src/services/nativeAppService.ts | 57 +++-- .../src/services/nodeAppService.ts | 7 +- .../readest-app/src/services/webAppService.ts | 44 +++- apps/readest-app/src/types/system.ts | 11 +- 11 files changed, 376 insertions(+), 38 deletions(-) diff --git a/apps/readest-app/src-tauri/capabilities/default.json b/apps/readest-app/src-tauri/capabilities/default.json index 46ab50da..97ade28d 100644 --- a/apps/readest-app/src-tauri/capabilities/default.json +++ b/apps/readest-app/src-tauri/capabilities/default.json @@ -85,6 +85,9 @@ }, { "path": "**/last-book-cover.png" + }, + { + "path": "$TEMP/**/*" } ] }, diff --git a/apps/readest-app/src/__tests__/services/web-app-service.test.ts b/apps/readest-app/src/__tests__/services/web-app-service.test.ts index f2e6de6d..7b4c6947 100644 --- a/apps/readest-app/src/__tests__/services/web-app-service.test.ts +++ b/apps/readest-app/src/__tests__/services/web-app-service.test.ts @@ -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', () => { diff --git a/apps/readest-app/src/app/library/components/ShareBookDialog.tsx b/apps/readest-app/src/app/library/components/ShareBookDialog.tsx index af28df8b..8812ff7b 100644 --- a/apps/readest-app/src/app/library/components/ShareBookDialog.tsx +++ b/apps/readest-app/src/app/library/components/ShareBookDialog.tsx @@ -154,10 +154,21 @@ const ShareBookDialog: React.FC = ({ isOpen, book, cfi, on } }; - const handleNativeShare = async () => { + const handleNativeShare = async (e: React.MouseEvent) => { 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 = ({ 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 = ({ isOpen, book, cfi, on