diff --git a/app/flowix-web/features/document/components/document-container.session.test.tsx b/app/flowix-web/features/document/components/document-container.session.test.tsx index 8eca8dae..c3374fbd 100644 --- a/app/flowix-web/features/document/components/document-container.session.test.tsx +++ b/app/flowix-web/features/document/components/document-container.session.test.tsx @@ -19,6 +19,7 @@ import { notifyDocumentBufferChanged } from '../store/buffer-registry'; import { waitForDocumentCommits } from '../store/document-commit-queue'; import { EditorView } from '@codemirror/view'; import { publishAgentLocationRequest, peekAgentLocationRequest } from '../use-cases/agent-location-navigation'; +import * as attachmentIo from '@features/editor/extensions/attachment-link/upload/io'; const mocks = vi.hoisted(() => ({ recoveryRead: vi.fn().mockResolvedValue(null), read: vi.fn(), write: vi.fn(), rename: vi.fn(), publish: vi.fn() })); vi.mock('@platform/tauri/core', () => ({ invoke: vi.fn(), convertFileSrc: (path: string) => `asset://localhost/${encodeURIComponent(path)}` })); @@ -26,7 +27,8 @@ vi.mock('./lazy-document-editor', async () => ({ LazyDocumentEditor: (await import('@features/editor/markdown-editor')).MarkdownEditor, preloadDocumentEditor: () => {}, })); -vi.mock('../use-cases/local-document-operations', () => ({ +vi.mock('../use-cases/local-document-operations', async importOriginal => ({ + ...await importOriginal(), markdownFilenameForTitle: (path: string, title: string) => { const extension = path.match(/(\.markdown|\.md)$/i)?.[0] ?? ''; const trimmed = title.trim(); @@ -42,6 +44,17 @@ vi.mock('../use-cases/local-document-operations', () => ({ }, localDocumentOperations: { read: mocks.read, write: mocks.write, rename: mocks.rename }, })); +vi.mock('@platform/tauri/client/desktop', async importOriginal => { + const original = await importOriginal(); + return { + ...original, + files: { + ...original.files, + rename: async (path: string, name: string, scopePath: string) => (await mocks.rename({ path, name, scopePath })).path, + }, + }; +}); +vi.mock('@features/memo/services/note-link-rewriter', () => ({ updateNoteLinksAfterMove: vi.fn() })); vi.mock('@features/workspace/use-cases/workspace-navigation', () => ({ replaceExternalDocumentPath: (displayId: string, oldPath: string, path: string) => { rebaseFileDisplayPath(oldPath, path, displayId); mocks.publish(path); }, openExternalTarget: vi.fn().mockResolvedValue({ host: 'main-third', state: 'active' }) })); @@ -54,6 +67,20 @@ vi.mock('@platform/tauri/window', () => ({ getCurrentWindow: () => ({ label: 'ma let root: Root; let container: HTMLDivElement; let sequence = 0; const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; + +function pasteScreenshot(editor: Editor): ClipboardEvent { + const event = new Event('paste', { bubbles: true, cancelable: true }) as ClipboardEvent; + Object.defineProperty(event, 'clipboardData', { + value: { + types: ['Files'], + files: [new File(['image'], 'Screenshot.png', { type: 'image/png' })], + getData: () => '', + }, + }); + editor.view.dom.dispatchEvent(event); + return event; +} + function setup(initialBoth = true) { const identity = { kind: 'md' as const, ...ensureFileDisplayIdentity('/container-' + ++sequence + '/A.md') }; reconcileFileDisplays([identity]); @@ -174,6 +201,75 @@ describe('real document container session lifecycle', () => { container.remove(); vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers(); environment.IS_REACT_ACT_ENVIRONMENT = false; }); + it('pastes a screenshot into its owning note when another notebook is selected', async () => { + const doc = setup(false); + const notebook = { id: 'owner', name: 'Notebook', path: doc.identity.path.replace('/A.md', ''), createdAt: 1, updatedAt: 1, isDefault: false }; + const other = { ...notebook, id: 'other', path: '/another-notebook' }; + useNoteStore.setState({ notebooks: [other, notebook], selectedNotebook: other }); + vi.spyOn(attachmentIo, 'readFileBase64').mockResolvedValue('aW1hZ2U='); + const saved = `${notebook.path}/attachments/Screenshot.png`; + vi.mocked(invoke).mockImplementation(async command => { + if (command === 'begin_attachment_upload') return 'screenshot-upload'; + if (command === 'finish_attachment_upload') return { path: saved, created: true }; + }); + + await act(async () => { doc.render(); }); + await act(async () => { expect(pasteScreenshot(doc.editors.left!).defaultPrevented).toBe(true); }); + + expect(invoke).toHaveBeenCalledWith('begin_attachment_upload', { + notePath: doc.identity.path, fileName: 'Screenshot.png', size: 5, deduplicate: true, + }); + expect(invoke).toHaveBeenCalledWith('append_attachment_upload', { + uploadId: 'screenshot-upload', offset: 0, content: 'aW1hZ2U=', + }); + expect(invoke).toHaveBeenCalledWith('finish_attachment_upload', { uploadId: 'screenshot-upload' }); + expect(doc.editors.left!.getMarkdown()).toContain('Screenshot.png'); + expect(doc.editors.left!.getMarkdown()).toContain(encodeURIComponent(saved)); + }); + it.each(['paste', 'insertFiles', 'drop'] as const)('uses the renamed note path for %s without remounting the editor', async method => { + const doc = setup(false); + const notebook = { id: 'owner', name: 'Notebook', path: doc.identity.path.replace('/A.md', ''), createdAt: 1, updatedAt: 1, isDefault: false }; + useNoteStore.setState({ notebooks: [notebook], selectedNotebook: notebook }); + vi.spyOn(attachmentIo, 'readFileBase64').mockResolvedValue('aW1hZ2U='); + const saved = `${notebook.path}/attachments/Screenshot.png`; + vi.mocked(invoke).mockImplementation(async command => { + if (command === 'begin_attachment_upload') return 'screenshot-upload'; + if (command === 'finish_attachment_upload') return { path: saved, created: true }; + }); + await act(async () => { doc.render(); }); + const editor = doc.editors.left!; + await act(async () => { doc.rename('Renamed'); await waitForDocumentCommits('md:' + doc.identity.displayId); }); + const renamedPath = `${notebook.path}/Renamed.md`; + expect(findFileDisplayPath(doc.identity.displayId)).toBe(renamedPath); + + await act(async () => { + if (method === 'paste') { + pasteScreenshot(editor); + return; + } + const files = [new File(['image'], 'Screenshot.png', { type: 'image/png' })]; + if (method === 'insertFiles') { + expect(editor.commands.insertFiles({ files })).toBe(true); + return; + } + vi.spyOn(editor.view, 'posAtCoords').mockReturnValue({ pos: editor.state.selection.from, inside: -1 }); + const event = new Event('drop', { bubbles: true, cancelable: true }); + Object.defineProperties(event, { + dataTransfer: { value: { files, types: ['Files'], getData: () => '' } }, + clientX: { value: 0 }, + clientY: { value: 0 }, + }); + editor.view.dom.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + }); + + expect(invoke).toHaveBeenCalledWith('begin_attachment_upload', { + notePath: renamedPath, fileName: 'Screenshot.png', size: 5, deduplicate: true, + }); + expect(editor.getMarkdown()).toContain('Screenshot.png'); + expect(doc.editors.left).toBe(editor); + expect(doc.mounts()).toBe(1); + }); it('inserts a picked image into a path-based note even when another notebook is selected', async () => { const doc = setup(false); const notebook = { id: 'owner', name: 'Notebook', path: doc.identity.path.replace('/A.md', ''), createdAt: 1, updatedAt: 1, isDefault: false }; @@ -217,9 +313,10 @@ describe('real document container session lifecycle', () => { vi.stubGlobal('__TAURI__', {}); vi.mocked(invoke).mockResolvedValue(['/selected/fig.png']); await act(async () => { doc.render(); }); + vi.mocked(invoke).mockClear(); await act(async () => { doc.editors.left!.commands.openFileDialog(); }); - // The watch lease is unrelated lifecycle noise; assert no asset was saved. expect(invoke).not.toHaveBeenCalledWith('save_notebook_asset', expect.anything()); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual(['select_files']); expect(doc.editors.left!.getMarkdown()).toBe('Body'); }); it('opening a second view preserves scheduled autosave', async () => { @@ -272,9 +369,10 @@ describe('real document container session lifecycle', () => { await act(async () => { await vi.advanceTimersByTimeAsync(300); }); expect(findFileDisplayPath(doc.identity.displayId)).toBe(doc.identity.path); expect(mocks.write).toHaveBeenLastCalledWith(expect.objectContaining({ path: doc.identity.path, content: expect.stringContaining('retained body') })); + expect((container.querySelector('textarea.memo-title-editor') as HTMLTextAreaElement).value).toBe('A'); + expect(getDocumentSession(doc.identity).restoredTitle?.draft).toBe('Taken'); // A rejected rename restores the confirmed title and keeps the attempted // title recoverable through an explicit action. - expect((container.querySelector('textarea.memo-title-editor') as HTMLTextAreaElement).value).toBe('A'); expect(container.textContent).toContain('Recover unsaved title'); expect(doc.mounts()).toBe(2); }); diff --git a/app/flowix-web/features/document/components/document-container.tsx b/app/flowix-web/features/document/components/document-container.tsx index 91a93287..490b5c5a 100644 --- a/app/flowix-web/features/document/components/document-container.tsx +++ b/app/flowix-web/features/document/components/document-container.tsx @@ -124,6 +124,10 @@ export function DocumentContainer({ const getAttachmentNotebookId = useCallback(() => { return resolveNotebookIdForPath(documentIdentity.path, getWorkspaceMemoState().notebooks); }, [documentIdentity]); + const getAttachmentOwnerPath = useCallback(() => { + // COSEC: Uploads must belong to a note inside a registered notebook. + return getAttachmentNotebookId() ? getCurrentFilePath() : undefined; + }, [getAttachmentNotebookId, getCurrentFilePath]); const { state, setState, @@ -518,6 +522,7 @@ export function DocumentContainer({ )} {!state.isLoading && state.isLoaded && !usesCodeEditor && ( ({ const opts = normalizeOptions(this.options); return [ createFileUploadPlugin({ - ownerPath: this.options.ownerPath, + getOwnerPath: () => this.options.getOwnerPath ? this.options.getOwnerPath() : this.options.ownerPath, ingest: { drop: opts.ingest.drop, paste: false, @@ -61,7 +61,10 @@ export const AttachmentLink = Extension.create({ }, addCommands() { - return createAttachmentCommands(this.options.ownerPath, this.options.getNotebookId); + return createAttachmentCommands( + () => this.options.getOwnerPath ? this.options.getOwnerPath() : this.options.ownerPath, + this.options.getNotebookId, + ); }, }); diff --git a/app/flowix-web/features/editor/extensions/attachment-link/upload/chunked.test.ts b/app/flowix-web/features/editor/extensions/attachment-link/upload/chunked.test.ts index b41ee3f1..3aab2d78 100644 --- a/app/flowix-web/features/editor/extensions/attachment-link/upload/chunked.test.ts +++ b/app/flowix-web/features/editor/extensions/attachment-link/upload/chunked.test.ts @@ -16,6 +16,7 @@ describe('chunked attachment transport', () => { it('slices input and waits for each chunk before committing', async () => { const file = new File([new Uint8Array(UPLOAD_CHUNK_BYTES + 1)], 'file'); expect(await uploadInChunks(file, 'file', 'memo')).toEqual({ path: '/attachments/file', created: true }); + expect(invoke).toHaveBeenNthCalledWith(1, 'begin_attachment_upload', { notePath: 'memo', fileName: 'file', size: file.size }); expect(vi.mocked(readFileBase64).mock.calls.map(([blob]) => blob.size)).toEqual([UPLOAD_CHUNK_BYTES, 1]); expect(invoke).toHaveBeenNthCalledWith(2, 'append_attachment_upload', { uploadId: 'upload', offset: 0, content: 'YQ==' }); expect(invoke).toHaveBeenNthCalledWith(3, 'append_attachment_upload', { uploadId: 'upload', offset: UPLOAD_CHUNK_BYTES, content: 'YQ==' }); diff --git a/app/flowix-web/features/editor/extensions/attachment-link/upload/chunked.ts b/app/flowix-web/features/editor/extensions/attachment-link/upload/chunked.ts index a32e5a56..04a4569e 100644 --- a/app/flowix-web/features/editor/extensions/attachment-link/upload/chunked.ts +++ b/app/flowix-web/features/editor/extensions/attachment-link/upload/chunked.ts @@ -16,7 +16,7 @@ export async function uploadInChunks( }; checkCancelled(); const uploadId = await invoke('begin_attachment_upload', { - ownerPath, + notePath: ownerPath, fileName, size: file.size, ...(deduplicate ? { deduplicate: true } : {}), diff --git a/app/flowix-web/features/editor/extensions/attachment-link/upload/commands.ts b/app/flowix-web/features/editor/extensions/attachment-link/upload/commands.ts index 0f3f86eb..c24cfaf8 100644 --- a/app/flowix-web/features/editor/extensions/attachment-link/upload/commands.ts +++ b/app/flowix-web/features/editor/extensions/attachment-link/upload/commands.ts @@ -35,12 +35,13 @@ function pickBrowserFiles(params: OpenFileDialogParams | undefined, signal: Abor }); } -export function createAttachmentCommands(ownerPath?: string, getNotebookId?: () => string | undefined): Partial { +export function createAttachmentCommands(getOwnerPath?: () => string | undefined, getNotebookId?: () => string | undefined): Partial { return { openFileDialog: (params?: OpenFileDialogParams) => ({ editor }: { editor: Editor }) => { if (editor.isDestroyed || !editor.isEditable) return false; + const ownerPath = getOwnerPath?.(); const notebookId = getNotebookId?.(); void runTrackedUpload(editor.view, async (signal) => { if (isTauriApp()) { @@ -62,7 +63,7 @@ export function createAttachmentCommands(ownerPath?: string, getNotebookId?: () (params: { files: File[]; position?: number }) => ({ editor }: { editor: Editor }) => { if (editor.isDestroyed || !editor.isEditable) return false; - void handleFileUpload(editor.view, params.files, params.position, undefined, ownerPath); + void handleFileUpload(editor.view, params.files, params.position, undefined, getOwnerPath?.()); return true; }, }; diff --git a/app/flowix-web/features/editor/extensions/attachment-link/upload/file-source.ts b/app/flowix-web/features/editor/extensions/attachment-link/upload/file-source.ts index a6f11ab1..b2194cfa 100644 --- a/app/flowix-web/features/editor/extensions/attachment-link/upload/file-source.ts +++ b/app/flowix-web/features/editor/extensions/attachment-link/upload/file-source.ts @@ -22,6 +22,7 @@ export type AttachmentSaveResult = { export type AttachmentUploadOptions = { ownerPath?: string; + getOwnerPath?: () => string | undefined; getNotebookId?: () => string | undefined; storage: { mode: 'attachment' }; picker: { accept: string | undefined; multiple: boolean }; diff --git a/app/flowix-web/features/editor/extensions/attachment-link/upload/plugin.ts b/app/flowix-web/features/editor/extensions/attachment-link/upload/plugin.ts index 85293fbc..d13ac2e6 100644 --- a/app/flowix-web/features/editor/extensions/attachment-link/upload/plugin.ts +++ b/app/flowix-web/features/editor/extensions/attachment-link/upload/plugin.ts @@ -26,7 +26,7 @@ export async function handleFileUpload( } export function createFileUploadPlugin(options: { - ownerPath?: string; + getOwnerPath?: () => string | undefined; ingest: { drop: boolean; paste: boolean; allowedMimeTypes?: string[] }; }) { const { ingest } = options; @@ -48,7 +48,7 @@ export function createFileUploadPlugin(options: { event.stopPropagation(); const coords = { left: event.clientX, top: event.clientY }; const pos = view.posAtCoords(coords)?.pos; - handleFileUpload(view, filteredFiles, pos, undefined, options.ownerPath); + handleFileUpload(view, filteredFiles, pos, undefined, options.getOwnerPath?.()); return true; }, handlePaste(view, event) { @@ -64,7 +64,7 @@ export function createFileUploadPlugin(options: { event.preventDefault(); event.stopPropagation(); const pos = view.state.selection.from; - handleFileUpload(view, files, pos, undefined, options.ownerPath); + handleFileUpload(view, files, pos, undefined, options.getOwnerPath?.()); return true; }, }, diff --git a/app/flowix-web/features/editor/extensions/paste-rules/index.ts b/app/flowix-web/features/editor/extensions/paste-rules/index.ts index 8cafb222..05299a58 100644 --- a/app/flowix-web/features/editor/extensions/paste-rules/index.ts +++ b/app/flowix-web/features/editor/extensions/paste-rules/index.ts @@ -6,7 +6,7 @@ import { hasImportableHtml, isInternalEditorHtml, sanitizeExternalHtml } from '@ import type { PasteContext } from '@features/editor/extensions/paste-rules/types'; import type { Editor } from '@tiptap/core'; -export const ManagedPasteRules = Extension.create<{ ownerPath?: string }>({ +export const ManagedPasteRules = Extension.create<{ ownerPath?: string; getOwnerPath?: () => string | undefined }>({ name: 'managedPasteRules', priority: 1100, @@ -33,15 +33,16 @@ export const ManagedPasteRules = Extension.create<{ ownerPath?: string }>({ const clipboardData = event.clipboardData; if (!clipboardData) return false; const snapshot = readClipboardSnapshot(clipboardData); + const ownerPath = this.options.getOwnerPath ? this.options.getOwnerPath() : this.options.ownerPath; if (snapshot.html && !hasImportableHtml(snapshot.html) && snapshot.text) { event.preventDefault(); - return pasteClipboardSnapshot(this.editor, { ...snapshot, html: '' }, this.options.ownerPath); + return pasteClipboardSnapshot(this.editor, { ...snapshot, html: '' }, ownerPath); } const ctx: PasteContext = { editor: this.editor, view, - ownerPath: this.options.ownerPath, + ownerPath, event, types: snapshot.types, markdown: snapshot.markdown, diff --git a/app/flowix-web/features/editor/markdown-editor.tsx b/app/flowix-web/features/editor/markdown-editor.tsx index 276af002..d6576c7c 100644 --- a/app/flowix-web/features/editor/markdown-editor.tsx +++ b/app/flowix-web/features/editor/markdown-editor.tsx @@ -55,6 +55,8 @@ import { OverlayScrollbar } from '@shared/ui/overlay-scrollbar'; interface MarkdownEditorProps { /** Absolute or notebook-relative path of the note owning editor uploads. */ ownerPath?: string; + /** Read the current path when an upload starts, including after a rename. */ + getAttachmentOwnerPath?: () => string | undefined; getAttachmentNotebookId?: () => string | undefined; propertyTargetId?: string; onViewSourceMode?: () => void; @@ -558,6 +560,7 @@ function focusEmptyParagraphAfterMedia( export const MarkdownEditor = forwardRef(function MarkdownEditor({ ownerPath, + getAttachmentOwnerPath, getAttachmentNotebookId, propertyTargetId, onViewSourceMode, @@ -614,6 +617,7 @@ export const MarkdownEditor = forwardRef(linkContext); @@ -624,8 +628,15 @@ export const MarkdownEditor = forwardRef { + const current = attachmentOwnerRef.current; + return current.getAttachmentOwnerPath ? current.getAttachmentOwnerPath() : current.ownerPath; + }, []); + const getCurrentAttachmentNotebookId = useCallback(() => attachmentOwnerRef.current.getAttachmentNotebookId?.(), []); + const clearSerializeTimer = useCallback(() => { if (serializeTimerRef.current) { clearTimeout(serializeTimerRef.current); @@ -854,8 +865,8 @@ export const MarkdownEditor = forwardRef { const editor = editorRef.current; @@ -1012,7 +1023,7 @@ export const MarkdownEditor = forwardRef