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;