diff --git a/package-lock.json b/package-lock.json index 4637b055..adc82140 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@nextcloud/l10n": "^1.6.0", "@nextcloud/paths": "^2.1.0", "@nextcloud/vue": "^7.0.0", + "justified-layout": "^4.1.0", "moment": "^2.29.4", "path-posix": "^1.0.0", "reflect-metadata": "^0.1.13", @@ -7430,6 +7431,11 @@ "node": ">=6" } }, + "node_modules/justified-layout": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/justified-layout/-/justified-layout-4.1.0.tgz", + "integrity": "sha512-M5FimNMXgiOYerVRGsXZ2YK9YNCaTtwtYp7Hb2308U1Q9TXXHx5G0p08mcVR5O53qf8bWY4NJcPBxE6zuayXSg==" + }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -17701,6 +17707,11 @@ "dev": true, "peer": true }, + "justified-layout": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/justified-layout/-/justified-layout-4.1.0.tgz", + "integrity": "sha512-M5FimNMXgiOYerVRGsXZ2YK9YNCaTtwtYp7Hb2308U1Q9TXXHx5G0p08mcVR5O53qf8bWY4NJcPBxE6zuayXSg==" + }, "kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", diff --git a/package.json b/package.json index 9e16bd30..06398739 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@nextcloud/l10n": "^1.6.0", "@nextcloud/paths": "^2.1.0", "@nextcloud/vue": "^7.0.0", + "justified-layout": "^4.1.0", "moment": "^2.29.4", "path-posix": "^1.0.0", "reflect-metadata": "^0.1.13", diff --git a/src/App.vue b/src/App.vue index 847d9c98..6efe8655 100644 --- a/src/App.vue +++ b/src/App.vue @@ -119,6 +119,7 @@ export default class App extends Mixins(GlobalMixin, UserConfig) { padding: 0px; // Get rid of padding on img-outer (1px on mobile) + // Also need to make sure we don't end up with a scrollbar -- see below margin-left: -1px; width: calc(100% + 3px); // 1px extra here because ... reasons } @@ -139,6 +140,12 @@ body { width: calc(100% - var(--body-container-margin)*1); // was *2 } +// Hide horizontal scrollbar on mobile +// For the padding removal above +#app-content-vue { + overflow-x: hidden; +} + // Fill all available space .fill-block { width: 100%; diff --git a/src/components/ScrollerManager.vue b/src/components/ScrollerManager.vue index 36b39522..68dc49f5 100644 --- a/src/components/ScrollerManager.vue +++ b/src/components/ScrollerManager.vue @@ -72,6 +72,8 @@ export default class ScrollerManager extends Mixins(GlobalMixin) { private scrollingRecyclerTimer = null as number | null; /** View size reflow timer */ private reflowRequest = false; + /** Tick adjust timer */ + private adjustTimer = null as number | null; /** Get the visible ticks */ get visibleTicks() { @@ -91,7 +93,7 @@ export default class ScrollerManager extends Mixins(GlobalMixin) { /** Recycler scroll event, must be called by timeline */ public recyclerScrolled(event?: any) { - this.cursorY = event ? event.target.scrollTop * this.height / this.recyclerHeight : 0; + this.cursorY = utils.roundHalf(event ? event.target.scrollTop * this.height / this.recyclerHeight : 0); this.moveHoverCursor(this.cursorY); if (this.scrollingRecyclerTimer) window.clearTimeout(this.scrollingRecyclerTimer); @@ -114,22 +116,77 @@ export default class ScrollerManager extends Mixins(GlobalMixin) { this.reflowRequest = false; } + private setTickTop(tick: ITick) { + const extraY = this.recyclerBefore?.clientHeight || 0; + tick.topF = (extraY + tick.y) * (this.height / this.recyclerHeight); + tick.top = utils.roundHalf(tick.topF); + } + /** Re-create tick data */ private reflowNow() { + // Refresh height of recycler + this.recyclerHeight = this.recycler.$refs.wrapper.clientHeight; + // Recreate ticks data this.recreate(); - // Get height of recycler - this.recyclerHeight = this.recycler.$refs.wrapper.clientHeight; + // Recompute which ticks are visible + this.computeVisibleTicks(); + } - // Static extra height at top (before slot) - const extraY = this.recyclerBefore?.clientHeight || 0; + /** Recreate from scratch */ + private recreate() { + // Clear + this.ticks = []; - // Compute tick positions - for (const tick of this.ticks) { - tick.top = (extraY + tick.y) * (this.height / this.recyclerHeight); + // Ticks + let y = 0; + let prevYear = 9999; + let prevMonth = 0; + const thisYear = new Date().getFullYear(); + + // Get a new tick + const getTick = (dayId: number, text?: string | number): ITick => { + const tick = { + dayId, + y: y, + text, + topF: 0, + top: 0, + s: false, + }; + this.setTickTop(tick); + return tick; } + // Itearte over rows + for (const row of this.rows) { + if (row.type === IRowType.HEAD) { + if (this.TagDayIDValueSet.has(row.dayId)) { + // Blank tick + this.ticks.push(getTick(row.dayId)); + } else { + // Make date string + const dateTaken = utils.dayIdToDate(row.dayId); + + // Create tick if month changed + const dtYear = dateTaken.getUTCFullYear(); + const dtMonth = dateTaken.getUTCMonth() + if (Number.isInteger(row.dayId) && (dtMonth !== prevMonth || dtYear !== prevYear)) { + const text = (dtYear === prevYear || dtYear === thisYear) ? undefined : dtYear; + this.ticks.push(getTick(row.dayId, text)); + } + prevMonth = dtMonth; + prevYear = dtYear; + } + } + + y += row.size; + } + } + + /** Mark ticks as visible or invisible */ + private computeVisibleTicks() { // Do another pass to figure out which points are visible // This is not as bad as it looks, it's actually 12*O(n) // because there are only 12 months in a year @@ -181,47 +238,45 @@ export default class ScrollerManager extends Mixins(GlobalMixin) { } } - /** Recreate from scratch */ - private recreate() { - // Clear - this.ticks = []; + /** + * Update tick positions without truncating the list + * This is much cheaper than reflowing the whole thing + */ + public adjust() { + if (this.adjustTimer) return; + this.adjustTimer = window.setTimeout(() => { + this.adjustTimer = null; + this.adjustNow(); + }, 300); + } - // Ticks + /** Do adjustment synchrnously */ + private adjustNow() { + // Refresh height of recycler + this.recyclerHeight = this.recycler.$refs.wrapper.clientHeight; + + // Start with the first tick. Walk over all rows counting the + // y position. When you hit a row with the tick, update y and + // top values and move to the next visible tick. + let tickId = 0; // regardless of whether it's visible or not let y = 0; - let prevYear = 9999; - let prevMonth = 0; - const thisYear = new Date().getFullYear(); - // Get a new tick - const getTick = (dayId: number, text?: string | number): ITick => { - return { - dayId, - y: y, - text, - top: 0, - s: false, - }; - } - - // Itearte over rows for (const row of this.rows) { - if (row.type === IRowType.HEAD) { - if (this.TagDayIDValueSet.has(row.dayId)) { - // Blank tick - this.ticks.push(getTick(row.dayId)); - } else { - // Make date string - const dateTaken = utils.dayIdToDate(row.dayId); + // Check if tick is valid + if (tickId >= this.ticks.length) { + return; + } - // Create tick if month changed - const dtYear = dateTaken.getUTCFullYear(); - const dtMonth = dateTaken.getUTCMonth() - if (Number.isInteger(row.dayId) && (dtMonth !== prevMonth || dtYear !== prevYear)) { - const text = (dtYear === prevYear || dtYear === thisYear) ? undefined : dtYear; - this.ticks.push(getTick(row.dayId, text)); - } - prevMonth = dtMonth; - prevYear = dtYear; + // Check if we hit the next tick + const tick = this.ticks[tickId]; + if (tick.dayId === row.dayId) { + tick.y = y; + this.setTickTop(tick); + + // Get the next visible tick + tickId++; + while (tickId < this.ticks.length && !this.ticks[tickId].s) { + tickId++; } } @@ -231,10 +286,10 @@ export default class ScrollerManager extends Mixins(GlobalMixin) { /** Change actual position of the hover cursor */ private moveHoverCursor(y: number) { - this.hoverCursorY = y; + this.hoverCursorY = utils.roundHalf(y); // Get index of previous tick - let idx = utils.binarySearch(this.ticks, y, 'top'); + let idx = utils.binarySearch(this.ticks, y, 'topF'); if (idx === 0) { // use this tick } else if (idx >= 1 && idx <= this.ticks.length) { diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index 63bfc495..780371ec 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -51,10 +51,10 @@
+ :style="{ height: item.size + 'px', width: rowWidth + 'px' }"> -
+
768) { - width -= 40; + if (window.innerWidth <= 768) { + // Mobile + this.numCols = MOBILE_NUM_COLS; + this.rowHeight = this.rowWidth / this.numCols; + this.squareMode = true; + } else { + // Desktop + this.rowWidth -= 40; + this.rowHeight = DESKTOP_ROW_HEIGHT; + this.squareMode = false; + + // As a heuristic, assume all images are 4:3 landscape + this.numCols = Math.floor(this.rowWidth / (this.rowHeight * 4 / 3)); } - if (this.days.length === 0) { - // Don't change cols if already initialized - this.numCols = Math.max(MIN_COLS, Math.floor(width / MAX_PHOTO_WIDTH)); - } - - this.rowHeight = Math.floor(width / this.numCols); - - // Set heights of rows - this.list.filter(r => r.type !== IRowType.HEAD).forEach(row => { - row.size = this.rowHeight; - }); this.scrollerManager.reflow(); } @@ -322,9 +326,12 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) { if (row.pct && !row.photos.length) { row.photos = new Array(row.pct); for (let j = 0; j < row.pct; j++) { + // Any row that has placeholders has ONLY placeholders + // so we can calculate the display width row.photos[j] = { flag: this.c.FLAG_PLACEHOLDER, fileid: Math.random(), + dispWp: 1 / this.numCols, }; } delete row.pct; @@ -374,6 +381,18 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) { } } + /** Store the current scroll position to restore later */ + private getScrollY() { + const recycler = this.$refs.recycler as any; + return recycler.$el.scrollTop + } + + /** Restore the stored scroll position */ + private setScrollY(y: number) { + const recycler = this.$refs.recycler as any; + recycler.scrollToPosition(y); + } + /** Get query string for API calls */ appendQuery(url: string) { const query = new URLSearchParams(); @@ -614,6 +633,19 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) { const dayId = day.dayid; const data = day.detail; + // Create justified layout with correct params + const justify = justifiedLayout(day.detail.map(p => { + return { + width: (this.squareMode ? null : p.w) || this.rowHeight, + height: (this.squareMode ? null : p.h) || this.rowHeight, + }; + }), { + containerWidth: this.rowWidth, + containerPadding: 0, + boxSpacing: 0, + targetRowHeight: this.rowHeight, + }); + const head = this.heads[dayId]; this.loadedDays.add(dayId); @@ -625,30 +657,46 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) { } head.day.rows.clear(); - // Check if some row was added - let addedRow = false; + // Check if some rows were added + let addedRows: IRow[] = []; + + // Check if row height changed + let rowSizeDelta = 0; // Get index of header O(n) const headIdx = this.list.findIndex(item => item.id === head.id); let rowIdx = headIdx + 1; + // Store the scroll position in case we change any rows + const scrollY = this.getScrollY(); + + // Previous justified row + let prevJustifyTop = justify.boxes[0]?.top || 0; + // Add all rows let dataIdx = 0; while (dataIdx < data.length) { // Check if we ran out of rows if (rowIdx >= this.list.length || this.list[rowIdx].type === IRowType.HEAD) { - addedRow = true; - this.list.splice(rowIdx, 0, this.getBlankRow(day)); + const newRow = this.getBlankRow(day); + addedRows.push(newRow); + rowSizeDelta += newRow.size; + this.list.splice(rowIdx, 0, newRow); } - const row = this.list[rowIdx]; - // Go to the next row - if (row.photos.length >= this.numCols) { + const jbox = justify.boxes[dataIdx]; + if (jbox.top !== prevJustifyTop) { + prevJustifyTop = jbox.top; rowIdx++; continue; } + // Set row height + const row = this.list[rowIdx]; + rowSizeDelta += jbox.height - row.size; + row.size = jbox.height; + // Add the photo to the row const photo = data[dataIdx]; if (typeof photo.flag === "undefined") { @@ -678,6 +726,9 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) { delete photo.istag; } + // Get aspect ratio + photo.dispWp = jbox.width / this.rowWidth; + // Move to next index of photo dataIdx++; @@ -695,10 +746,15 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) { head.day.rows.add(row); } + // Rows that were removed + const removedRows: IRow[] = []; + let headRemoved = false; + // No rows, splice everything including the header if (head.day.rows.size === 0) { - this.list.splice(headIdx, 1); + removedRows.push(...this.list.splice(headIdx, 1)); rowIdx = headIdx - 1; + headRemoved = true; delete this.heads[dayId]; } @@ -708,14 +764,34 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) { spliceCount++; } if (spliceCount > 0) { - this.list.splice(rowIdx + 1, spliceCount); + removedRows.push(...this.list.splice(rowIdx + 1, spliceCount)); + } + + // Update size delta for removed rows + for (const row of removedRows) { + rowSizeDelta -= row.size; } // This will be true even if the head is being spliced // because one row is always removed in that case // So just reflow the timeline here - if (addedRow || spliceCount > 0) { - this.scrollerManager.reflow(); + if (rowSizeDelta !== 0) { + if (headRemoved) { + // If the head was removed, that warrants a reflow + // since months or years might disappear! + this.scrollerManager.reflow(); + } else { + // Otherwise just adjust the visible ticks + this.scrollerManager.adjust(); + } + + // Scroll to the same actual position if the added rows + // were above the current scroll position + const recycler: any = this.$refs.recycler; + const midIndex = (recycler.$_startIndex + recycler.$_endIndex) / 2; + if (midIndex > headIdx) { + this.setScrollY(scrollY + rowSizeDelta); + } } } @@ -815,9 +891,6 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) { exitedLeft.forEach((photo: any) => { photo.flag &= ~this.c.FLAG_ENTER_RIGHT; }); - - // Reflow timeline - this.scrollerManager.reflow(); } } diff --git a/src/components/frame/Folder.vue b/src/components/frame/Folder.vue index 83054027..838facec 100644 --- a/src/components/frame/Folder.vue +++ b/src/components/frame/Folder.vue @@ -19,7 +19,7 @@ 'p-load-fail': info.flag & c.FLAG_LOAD_FAIL, }" :key="'fpreview-' + info.fileid" - :src="getPreviewUrl(info.fileid, info.etag)" + :src="getPreviewUrl(info.fileid, info.etag, true, 256)" @load="info.flag |= c.FLAG_LOADED" @error="info.flag |= c.FLAG_LOAD_FAIL" />
diff --git a/src/components/frame/Photo.vue b/src/components/frame/Photo.vue index cdfc341d..ddb31319 100644 --- a/src/components/frame/Photo.vue +++ b/src/components/frame/Photo.vue @@ -6,6 +6,7 @@ 'leaving': (data.flag & c.FLAG_LEAVING), 'exit-left': (data.flag & c.FLAG_EXIT_LEFT), 'enter-right': (data.flag & c.FLAG_ENTER_RIGHT), + 'error': (data.flag & c.FLAG_LOAD_FAIL), }"> & { box-shadow: 0 0 3px 2px var(--color-primary); } - .p-loading > & { display: none; } + .p-outer.p-loading > & { display: none; } + .p-outer.error & { object-fit: contain; } } } \ No newline at end of file diff --git a/src/components/frame/Tag.vue b/src/components/frame/Tag.vue index c4ee9ebe..8d23179c 100644 --- a/src/components/frame/Tag.vue +++ b/src/components/frame/Tag.vue @@ -18,7 +18,7 @@ 'p-load-fail': info.flag & c.FLAG_LOAD_FAIL, }" :key="'fpreview-' + info.fileid" - :src="getPreviewUrl(info.fileid, info.etag)" + :src="getPreviewUrl(info.fileid, info.etag, true, 256)" :style="getCoverStyle(info)" @load="info.flag |= c.FLAG_LOADED" @error="info.flag |= c.FLAG_LOAD_FAIL" /> @@ -71,9 +71,9 @@ export default class Tag extends Mixins(GlobalMixin) { getPreviewUrl(fileid: number, etag: string) { if (this.isFace) { - return generateUrl(`/core/preview?fileId=${fileid}&c=${etag}&x=2048&y=2048&forceIcon=0&a=1`); + return getPreviewUrl(fileid, etag, false, 2048); } - return getPreviewUrl(fileid, etag); + return getPreviewUrl(fileid, etag, true, 256); } get isFace() { diff --git a/src/services/FileUtils.ts b/src/services/FileUtils.ts index e47e1da7..16572a24 100644 --- a/src/services/FileUtils.ts +++ b/src/services/FileUtils.ts @@ -123,8 +123,9 @@ return fileInfo } - const getPreviewUrl = function(fileid: number, etag: string): string { - return generateUrl(`/core/preview?fileId=${fileid}&c=${etag}&x=250&y=250&forceIcon=0&a=0`); + const getPreviewUrl = function(fileid: number, etag: string, square: boolean, size: number): string { + const a = square ? '0' : '1' + return generateUrl(`/core/preview?fileId=${fileid}&c=${etag}&x=${size}&y=${size}&forceIcon=0&a=${a}`); } export { encodeFilePath, extractFilePaths, sortCompare, genFileInfo, getPreviewUrl } \ No newline at end of file diff --git a/src/services/Utils.ts b/src/services/Utils.ts index 2484706a..1c241f9b 100644 --- a/src/services/Utils.ts +++ b/src/services/Utils.ts @@ -90,6 +90,24 @@ export function binarySearch(arr: any, elem: any, key?: string) { return minIndex; } +/** + * Round a number to N decimal places + * @param num Number to round + * @param places Number of decimal places + */ +export function round(num: number, places: number) { + const pow = Math.pow(10, places); + return Math.round(num * pow) / pow; +} + +/** + * Round to nearest 0.5. Useful for pixels. + * @param num Number to round + */ +export function roundHalf(num: number) { + return Math.round(num * 2) / 2; +} + /** Global constants */ export const constants = { c: { diff --git a/src/types.ts b/src/types.ts index 2cd9054b..4db0f2c6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -43,6 +43,8 @@ export type IPhoto = { w?: number; /** Height of full image */ h?: number; + /** Grid display width percentage */ + dispWp?: number; /** Reference to day object */ d?: IDay; /** Video flag from server */ @@ -114,6 +116,8 @@ export type ITick = { /** Day ID */ dayId: number; /** Display top position */ + topF: number; + /** Display top position (truncated to 1 decimal pt) */ top: number; /** Y coordinate on recycler */ y: number;