big: switch to justified layout

cache
Varun Patil 2022-10-15 19:55:53 -07:00
parent 4e98e93d6e
commit e298ef97fa
11 changed files with 261 additions and 89 deletions

11
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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%;

View File

@ -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;
// Static extra height at top (before slot)
const extraY = this.recyclerBefore?.clientHeight || 0;
// Compute tick positions
for (const tick of this.ticks) {
tick.top = (extraY + tick.y) * (this.height / this.recyclerHeight);
// Recompute which ticks are visible
this.computeVisibleTicks();
}
/** Recreate from scratch */
private recreate() {
// Clear
this.ticks = [];
// 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);
// 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));
// Check if tick is valid
if (tickId >= this.ticks.length) {
return;
}
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) {

View File

@ -51,10 +51,10 @@
<div v-else
class="photo-row"
:style="{ height: item.size + 'px' }">
:style="{ height: item.size + 'px', width: rowWidth + 'px' }">
<div class="photo" v-for="(photo, index) in item.photos" :key="index"
:style="{ width: rowHeight + 'px' }">
<div class="photo" v-for="(photo, index) in item.photos" :key="photo.fileid"
:style="{ width: (photo.dispWp * 100) + '%' }">
<Folder v-if="photo.flag & c.FLAG_IS_FOLDER"
:data="photo"
@ -102,6 +102,7 @@ import moment from 'moment';
import * as dav from "../services/DavRequests";
import * as utils from "../services/Utils";
import justifiedLayout from "justified-layout";
import axios from '@nextcloud/axios'
import Folder from "./frame/Folder.vue";
import Tag from "./frame/Tag.vue";
@ -117,8 +118,8 @@ import PeopleIcon from 'vue-material-design-icons/AccountMultiple.vue';
import ImageMultipleIcon from 'vue-material-design-icons/ImageMultiple.vue';
const SCROLL_LOAD_DELAY = 100; // Delay in loading data when scrolling
const MAX_PHOTO_WIDTH = 175; // Max width of a photo
const MIN_COLS = 3; // Min number of columns (on phone, e.g.)
const DESKTOP_ROW_HEIGHT = 200; // Height of row on desktop
const MOBILE_NUM_COLS = 3; // Number of columns on phone
@Component({
components: {
@ -144,7 +145,9 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
/** Counter of rows */
private numRows = 0;
/** Computed number of columns */
private numCols = 5;
private numCols = 0;
/** Keep all images square */
private squareMode = false;
/** Header rows for dayId key */
private heads: { [dayid: number]: IHeadRow } = {};
/** Original days response */
@ -152,6 +155,8 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
/** Computed row height */
private rowHeight = 100;
/** Computed row width */
private rowWidth = 100;
/** Current start index */
private currentStart = 0;
@ -264,7 +269,7 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
// Size of outer container
const e = this.$refs.container as Element;
let height = e.clientHeight;
let width = e.clientWidth;
this.rowWidth = e.clientWidth;
// Scroller spans the container height
this.scrollerHeight = height;
@ -277,22 +282,21 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
const recycler = this.$refs.recycler as any;
recycler.$el.style.height = (height - tmHeight - 4) + 'px';
// Desktop scroller width
if (window.innerWidth > 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) {
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();
}
}
</script>

View File

@ -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" />
</div>

View File

@ -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),
}">
<Check :size="15" class="select"
@ -81,7 +82,7 @@ export default class Photo extends Mixins(GlobalMixin) {
/** Get url of the photo */
get url() {
return getPreviewUrl(this.data.fileid, this.data.etag)
return getPreviewUrl(this.data.fileid, this.data.etag, false, 512)
}
/** Image loaded successfully */
@ -276,7 +277,8 @@ div.img-outer {
user-select: none;
.selected > & { 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; }
}
}
</style>

View File

@ -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() {

View File

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

View File

@ -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: {

View File

@ -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;