Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
39 changes: 32 additions & 7 deletions __tests__/component/Viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})

Expand Down
2 changes: 1 addition & 1 deletion __tests__/component/handlerContract.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
4 changes: 3 additions & 1 deletion __tests__/component/viewerApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/composables/useRotation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IFile | undefined>, onWritten?: (node: IFile) => void) {
/** Quarter turns anticlockwise the viewer is showing, beyond the file's own */
Expand Down
10 changes: 10 additions & 0 deletions lib/viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}

/**
Expand Down
107 changes: 73 additions & 34 deletions lib/views/Viewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<Record<string, string>>({})
// Files this viewer has just saved, whose next update event is its own.
const ownSaves = new Set<number>()

const canSwipe = ref(true)
const isFullscreen = ref(false)
Expand Down Expand Up @@ -458,9 +456,18 @@ const COMPARISON_GAP = 8

// Current context
const currentFile = ref<IFile>()
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<string>()
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<IFile[]>([])
const currentHandler = ref<IHandler>()

Expand Down Expand Up @@ -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()
}
})
Expand Down Expand Up @@ -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<IFile>(reported)
if (file) {
syncShownFile(file)
}
}

Expand All @@ -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 }
}

Expand All @@ -1088,7 +1126,6 @@ function clearEditedSources() {
URL.revokeObjectURL(url)
}
editedSources.value = {}
ownSaves.clear()
}

/**
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
Loading