diff --git a/README.md b/README.md index f4131bf..b31ed7b 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,12 @@ Until `loaded` arrives the viewer shows its spinner, so don't forget it. > itself, so `loaded` or `errored` comes once per element, and `onMounted` is > enough here. The other props (`shown`, `maxWidth`, `maxHeight`, `editing`, > `turns`, ...) do change while your element is mounted. +> +> An update of the file shown (`files:node:updated`) is handed to the element +> already showing it as its new `file` when it brings nothing new to show: a +> new name, a favourite, the version it already shows. A new version mounts a +> new element, unless your element wrote it: emit `update:file` with the node +> as written before announcing it, and the viewer keeps your element. #### 3. Register it @@ -260,6 +266,7 @@ const src = computed(() => props.file.encodedSource) | `update:canSwipe` | `[boolean]` | Enable/disable the swipe gesture (e.g. for custom controls) | | `update:editing` | `[boolean]` | Notify the viewer the editing mode changed | | `update:playing` | `[boolean]` | Notify the viewer media plays, so the slideshow waits for it | +| `update:file` | `[File]` | You wrote the file you show: the node as written, before `files:node:updated`, so the viewer keeps your element | Before it downloads the file shown, from its own Download, Ctrl+S or the Files download action, the viewer dispatches `before-download` on your element. A view diff --git a/__tests__/component/Viewer.spec.ts b/__tests__/component/Viewer.spec.ts index 0679bc0..c641f2f 100644 --- a/__tests__/component/Viewer.spec.ts +++ b/__tests__/component/Viewer.spec.ts @@ -1527,29 +1527,54 @@ describe('the file shown, updated', () => { /** * Open a file, then announce an update of it. * - * @param etag - The version the update carries + * @param changes - What the update changes of the file */ - async function update(etag: string) { - const { vm, wrapper } = mountViewer([imageHandler()]) + async function update(changes: { etag?: string, basename?: string }) { + const { vm, wrapper, modalName } = mountViewer([imageHandler()]) const file = makeFile({ basename: 'notes.jpg', mime: 'image/jpeg', attributes: { etag: 'v1' } }) await vm.open([file], file) await wrapper.vm.$nextTick() const before = wrapper.find('oca-viewer-image').element const updated = vi.mocked(subscribe).mock.calls.findLast(([event]) => event === 'files:node:updated')![1] as (node: unknown) => void - updated(makeFile({ id: file.fileid, basename: 'notes.jpg', mime: 'image/jpeg', attributes: { etag } })) + updated(makeFile({ id: file.fileid, basename: changes.basename ?? 'notes.jpg', mime: 'image/jpeg', attributes: { etag: changes.etag ?? 'v1' } })) await wrapper.vm.$nextTick() - return wrapper.find('oca-viewer-image').element === before + return { kept: wrapper.find('oca-viewer-image').element === before, name: modalName() } } // Text announces each of its own saves: rebuilding the editor for one // lost the user's place, mid-sentence it('keeps its element for the version already shown', async () => { - expect(await update('v1')).toBe(true) + expect((await update({ etag: 'v1' })).kept).toBe(true) }) it('shows another version afresh', async () => { - expect(await update('v2')).toBe(false) + expect((await update({ etag: 'v2' })).kept).toBe(false) + }) + + it('is shown under its new name, in the element already showing it', async () => { + const { kept, name } = await update({ basename: 'holiday.jpg' }) + + expect(kept).toBe(true) + expect(name).toBe('holiday.jpg') + }) + + // Collabora saves the file it edits and announces it: rebuilding the + // editor for its own save reloaded the whole document + it('keeps its element for a new version its handler says it wrote', async () => { + const { vm, wrapper } = mountViewer([imageHandler()]) + const file = makeFile({ basename: 'notes.jpg', mime: 'image/jpeg', attributes: { etag: 'v1' } }) + await vm.open([file], file) + await wrapper.vm.$nextTick() + const element = wrapper.find('oca-viewer-image').element + const written = makeFile({ id: file.fileid, basename: 'notes.jpg', mime: 'image/jpeg', attributes: { etag: 'v2' } }) + + element.dispatchEvent(new CustomEvent('update:file', { detail: [written] })) + const updated = vi.mocked(subscribe).mock.calls.findLast(([event]) => event === 'files:node:updated')![1] as (node: unknown) => void + updated(written) + await wrapper.vm.$nextTick() + + expect(wrapper.find('oca-viewer-image').element).toBe(element) }) }) diff --git a/__tests__/component/handlerContract.spec.ts b/__tests__/component/handlerContract.spec.ts index fbf5c17..b4f6a88 100644 --- a/__tests__/component/handlerContract.spec.ts +++ b/__tests__/component/handlerContract.spec.ts @@ -236,7 +236,7 @@ describe('what a handler emits', () => { it('is given the edited image as localSource once the editor saves, instead of a refetch', async () => { renders.length = 0 - const f1 = makeFile({ mime: 'image/jpeg' }) + const f1 = makeFile({ mime: 'image/jpeg', attributes: { etag: 'opened' } }) const { vm, wrapper } = mountViewer([probeHandler({ canEdit: true })]) await vm.open([f1], f1, { editing: true }) diff --git a/__tests__/component/viewerApi.spec.ts b/__tests__/component/viewerApi.spec.ts index 9077b4b..c66926c 100644 --- a/__tests__/component/viewerApi.spec.ts +++ b/__tests__/component/viewerApi.spec.ts @@ -470,7 +470,9 @@ describe('the editing option', () => { */ async function saveAnEdit() { const ctx = mountViewer([imageHandler({ canEdit: true })]) - const file = makeFile() + // Saved at the version it was opened at, the editor's response having + // no etag to say otherwise + const file = makeFile({ attributes: { etag: 'opened' } }) await ctx.vm.open([file], file, { editing: true }) // The editor is loaded on demand, so it is not there on the first tick diff --git a/lib/composables/useRotation.ts b/lib/composables/useRotation.ts index 7f4d02d..0e0271b 100644 --- a/lib/composables/useRotation.ts +++ b/lib/composables/useRotation.ts @@ -41,7 +41,7 @@ const ROTATABLE_MIME = 'image/jpeg' * * @param file the file on screen * @param onWritten called with a file just before its update is announced, - * so the viewer can tell its own write from a change made elsewhere + * so the viewer can take it as the one it shows */ export function useRotation(file: Ref, onWritten?: (node: IFile) => void) { /** Quarter turns anticlockwise the viewer is showing, beyond the file's own */ diff --git a/lib/viewer.ts b/lib/viewer.ts index a27c84b..69810ac 100644 --- a/lib/viewer.ts +++ b/lib/viewer.ts @@ -160,6 +160,16 @@ export interface ViewerEmits { * @param playing Whether media is playing */ 'update:playing': [boolean] + + /** + * Emit this event once you wrote the file you show, with the node as + * written (its new etag, its new mtime), before announcing it with + * `files:node:updated`. The viewer then keeps your element for that + * update, rather than mounting a new one to show the new version. + * + * @param file The file shown, as written + */ + 'update:file': [IFile] } /** diff --git a/lib/views/Viewer.vue b/lib/views/Viewer.vue index f81da52..256f5c3 100644 --- a/lib/views/Viewer.vue +++ b/lib/views/Viewer.vue @@ -255,7 +255,7 @@ :is="currentHandler?.tagName" v-else-if="currentFile && elementReady(currentHandler)" v-show="!loading && !errorString" - :key="`${currentFile.source}-${reloadKey}`" + :key="`${mountedSource}-${reloadKey}`" ref="handlerElement" :can-swipe="canSwipe" :file="currentFile" @@ -425,8 +425,6 @@ onUnmounted(() => clearTimeout(slowLoadingTimer)) // Not by file id: every version of a file shares it, and an older version // would otherwise show the edit made to the current one. const editedSources = ref>({}) -// Files this viewer has just saved, whose next update event is its own. -const ownSaves = new Set() const canSwipe = ref(true) const isFullscreen = ref(false) @@ -458,9 +456,18 @@ const COMPARISON_GAP = 8 // Current context const currentFile = ref() -watch(() => currentFile.value?.source, () => { +// The source the element shown was mounted for. The update of a renamed file +// is swapped in without changing it, so the element is not rebuilt for a name +const mountedSource = ref() +let swapping = false +// Sync, for the swap to be told apart from showing another file +watch(() => currentFile.value?.source, (source) => { + if (swapping) { + return + } shownVersion = currentFile.value?.attributes?.etag -}) + mountedSource.value = source +}, { flush: 'sync' }) const currentFileList = ref([]) const currentHandler = ref() @@ -592,11 +599,10 @@ const canEdit = computed(() => { }) // Turning the picture on screen, written back to the file shortly after. -// The update a write announces is ours, and the turn is already on screen, -// unless the user has moved on: then nothing reloads for it anyway +// The version written is the one on screen, as the turn already is, unless +// the user has moved on: then nothing reloads for it anyway const { canRotate, rotateLeft, turns } = useRotation(currentFile, (node) => { - if (node.fileid !== undefined && node.fileid === currentFile.value?.fileid) { - ownSaves.add(node.fileid) + if (syncShownFile(node)) { showRotationSaved() } }) @@ -1016,36 +1022,67 @@ function onNodeDeleted(node: INode) { } /** - * When the shown file is updated (e.g. saved from the image editor), remount the - * handler so it re-reads the node and shows the new content. + * Take a node as the file shown, at the version on screen. + * + * What the handler, or the viewer itself, says once it has written the file + * it shows: the update announced for that write then brings nothing new. + * + * @param node - The file shown, as written + * @return Whether it is the file shown + */ +function syncShownFile(node: INode): boolean { + if (node.type !== FileType.File || node.fileid === undefined || node.fileid !== currentFile.value?.fileid) { + return false + } + swapping = true + currentFile.value = node as IFile + swapping = false + shownVersion = node.attributes?.etag + currentFileList.value = currentFileList.value.map((file) => file.fileid === node.fileid ? node as IFile : file) + return true +} + +/** + * When a file is updated, show the updated node. + * + * The element showing it is kept for an update that brings nothing new to + * show: a name, a favourite, the version already on screen, which is what a + * handler that wrote the file said with `update:file`. Another version, from + * anywhere else, is shown afresh by mounting the element again. * * @param node - The updated node */ function onNodeUpdated(node: INode) { - // A neighbour renamed or changed elsewhere is swapped in, or stepping to - // it would show it from the node it had when the viewer opened - if (node.type === FileType.File && node.fileid !== undefined && node.fileid !== currentFile.value?.fileid) { + if (node.type !== FileType.File || node.fileid === undefined) { + return + } + const shown = currentFile.value + const version = node.attributes?.etag + // Text announces each of its own saves at the version it opened: nothing + // new to show, and remounting would rebuild the editor under the user + const sameVersion = version !== undefined && version === shownVersion + if (!syncShownFile(node)) { + // A neighbour renamed or changed elsewhere is swapped in, or stepping + // to it would show it from the node it had when the viewer opened currentFileList.value = currentFileList.value.map((file) => file.fileid === node.fileid ? node as IFile : file) + return } - - // The save the viewer did itself is already on screen, from the blob the - // editor handed over, so that one update is not worth a refetch. Only - // that one: a change from anywhere else supersedes what is shown, and - // waiting for it here is how an edited file stayed frozen until close. - if (node.fileid !== undefined && ownSaves.delete(node.fileid)) { + if (sameVersion) { return } - if (currentFile.value !== undefined && node.fileid === currentFile.value.fileid) { - // The version already shown, as Text announces each of its own saves: - // nothing new to show, and remounting would rebuild the editor under - // the user, mid-sentence - const version = node.attributes?.etag - if (version !== undefined && version === shownVersion) { - return - } - shownVersion = version - releaseEditedSource(currentFile.value.source) - reloadKey.value++ + releaseEditedSource(shown!.source) + reloadKey.value++ +} + +/** + * The handler wrote the file it shows, and hands over the node it wrote. + * + * @param reported - What the handler emitted + */ +function onHandlerFile(reported: unknown) { + const file = emittedValue(reported) + if (file) { + syncShownFile(file) } } @@ -1061,7 +1098,8 @@ function onEditSaved(source: string) { } // Release a previous edit of the same file before replacing it. releaseEditedSource(file.source) - ownSaves.add(file.fileid) + // The editor wrote the node it was given, at its new version + syncShownFile(file) editedSources.value = { ...editedSources.value, [file.source]: source } } @@ -1088,7 +1126,6 @@ function clearEditedSources() { URL.revokeObjectURL(url) } editedSources.value = {} - ownSaves.clear() } /** @@ -1570,7 +1607,7 @@ watch(() => !!currentFile.value || !!errorString.value, async (shown) => { } }, { flush: 'sync' }) -// `update:canSwipe`, `update:editing` and `update:playing` are bound by hand rather than with +// `update:canSwipe`, `update:editing`, `update:playing` and `update:file` are bound by hand rather than with // v-on. A handler is a custom element, so its emits leave as DOM events under // the name it declared, while v-on hyphenates the listener it is given // (`update:canSwipe` becomes `update:can-swipe`) and then matches nothing. @@ -1580,11 +1617,13 @@ watch(handlerElement, (element, previous) => { previous.removeEventListener('update:canSwipe', onCanSwipe) previous.removeEventListener('update:editing', onHandlerEditing) previous.removeEventListener('update:playing', onPlaying) + previous.removeEventListener('update:file', onHandlerFile) } if (element) { element.addEventListener('update:canSwipe', onCanSwipe) element.addEventListener('update:editing', onHandlerEditing) element.addEventListener('update:playing', onPlaying) + element.addEventListener('update:file', onHandlerFile) } // Whatever the previous handler was playing left with it playing.value = false