remove class vue dep (1)
parent
8520d0dc1e
commit
07379d836c
326
src/App.vue
326
src/App.vue
|
@ -38,7 +38,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Mixins, Watch } from "vue-property-decorator";
|
import Vue, { defineComponent } from "vue";
|
||||||
|
|
||||||
import NcContent from "@nextcloud/vue/dist/Components/NcContent";
|
import NcContent from "@nextcloud/vue/dist/Components/NcContent";
|
||||||
import NcAppContent from "@nextcloud/vue/dist/Components/NcAppContent";
|
import NcAppContent from "@nextcloud/vue/dist/Components/NcAppContent";
|
||||||
|
@ -49,15 +49,12 @@ const NcAppNavigationSettings = () =>
|
||||||
import("@nextcloud/vue/dist/Components/NcAppNavigationSettings");
|
import("@nextcloud/vue/dist/Components/NcAppNavigationSettings");
|
||||||
|
|
||||||
import { generateUrl } from "@nextcloud/router";
|
import { generateUrl } from "@nextcloud/router";
|
||||||
import { getCurrentUser } from "@nextcloud/auth";
|
|
||||||
import { translate as t } from "@nextcloud/l10n";
|
import { translate as t } from "@nextcloud/l10n";
|
||||||
|
|
||||||
import Timeline from "./components/Timeline.vue";
|
import Timeline from "./components/Timeline.vue";
|
||||||
import Settings from "./components/Settings.vue";
|
import Settings from "./components/Settings.vue";
|
||||||
import FirstStart from "./components/FirstStart.vue";
|
import FirstStart from "./components/FirstStart.vue";
|
||||||
import Metadata from "./components/Metadata.vue";
|
import Metadata from "./components/Metadata.vue";
|
||||||
import GlobalMixin from "./mixins/GlobalMixin";
|
|
||||||
import UserConfig from "./mixins/UserConfig";
|
|
||||||
|
|
||||||
import ImageMultiple from "vue-material-design-icons/ImageMultiple.vue";
|
import ImageMultiple from "vue-material-design-icons/ImageMultiple.vue";
|
||||||
import FolderIcon from "vue-material-design-icons/Folder.vue";
|
import FolderIcon from "vue-material-design-icons/Folder.vue";
|
||||||
|
@ -70,7 +67,8 @@ import PeopleIcon from "vue-material-design-icons/AccountBoxMultiple.vue";
|
||||||
import TagsIcon from "vue-material-design-icons/Tag.vue";
|
import TagsIcon from "vue-material-design-icons/Tag.vue";
|
||||||
import MapIcon from "vue-material-design-icons/Map.vue";
|
import MapIcon from "vue-material-design-icons/Map.vue";
|
||||||
|
|
||||||
@Component({
|
export default defineComponent({
|
||||||
|
name: "App",
|
||||||
components: {
|
components: {
|
||||||
NcContent,
|
NcContent,
|
||||||
NcAppContent,
|
NcAppContent,
|
||||||
|
@ -93,132 +91,66 @@ import MapIcon from "vue-material-design-icons/Map.vue";
|
||||||
TagsIcon,
|
TagsIcon,
|
||||||
MapIcon,
|
MapIcon,
|
||||||
},
|
},
|
||||||
})
|
|
||||||
export default class App extends Mixins(GlobalMixin, UserConfig) {
|
|
||||||
// Outer element
|
|
||||||
|
|
||||||
private metadataComponent!: Metadata;
|
data() {
|
||||||
|
return {
|
||||||
|
navItems: [],
|
||||||
|
metadataComponent: null as Metadata,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
private readonly navItemsAll = (self: typeof this) => [
|
computed: {
|
||||||
{
|
ncVersion() {
|
||||||
name: "timeline",
|
const version = (<any>window.OC).config.version.split(".");
|
||||||
icon: ImageMultiple,
|
return Number(version[0]);
|
||||||
title: t("memories", "Timeline"),
|
|
||||||
},
|
},
|
||||||
{
|
recognize() {
|
||||||
name: "folders",
|
if (!this.config_recognizeEnabled) {
|
||||||
icon: FolderIcon,
|
return false;
|
||||||
title: t("memories", "Folders"),
|
}
|
||||||
|
|
||||||
|
if (this.config_facerecognitionInstalled) {
|
||||||
|
return t("memories", "People (Recognize)");
|
||||||
|
}
|
||||||
|
|
||||||
|
return t("memories", "People");
|
||||||
},
|
},
|
||||||
{
|
facerecognition() {
|
||||||
name: "favorites",
|
if (!this.config_facerecognitionInstalled) {
|
||||||
icon: Star,
|
return false;
|
||||||
title: t("memories", "Favorites"),
|
}
|
||||||
|
|
||||||
|
if (this.config_recognizeEnabled) {
|
||||||
|
return t("memories", "People (Face Recognition)");
|
||||||
|
}
|
||||||
|
|
||||||
|
return t("memories", "People");
|
||||||
},
|
},
|
||||||
{
|
isFirstStart() {
|
||||||
name: "videos",
|
return this.config_timelinePath === "EMPTY";
|
||||||
icon: Video,
|
|
||||||
title: t("memories", "Videos"),
|
|
||||||
},
|
},
|
||||||
{
|
showAlbums() {
|
||||||
name: "albums",
|
return this.config_albumsEnabled;
|
||||||
icon: AlbumIcon,
|
|
||||||
title: t("memories", "Albums"),
|
|
||||||
if: self.showAlbums,
|
|
||||||
},
|
},
|
||||||
{
|
removeOuterGap() {
|
||||||
name: "recognize",
|
return this.ncVersion >= 25;
|
||||||
icon: PeopleIcon,
|
|
||||||
title: self.recognize,
|
|
||||||
if: self.recognize,
|
|
||||||
},
|
},
|
||||||
{
|
showNavigation() {
|
||||||
name: "facerecognition",
|
return this.$route.name !== "folder-share";
|
||||||
icon: PeopleIcon,
|
|
||||||
title: self.facerecognition,
|
|
||||||
if: self.facerecognition,
|
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
name: "archive",
|
|
||||||
icon: ArchiveIcon,
|
watch: {
|
||||||
title: t("memories", "Archive"),
|
route() {
|
||||||
|
this.doRouteChecks();
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
name: "thisday",
|
|
||||||
icon: CalendarIcon,
|
|
||||||
title: t("memories", "On this day"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "tags",
|
|
||||||
icon: TagsIcon,
|
|
||||||
title: t("memories", "Tags"),
|
|
||||||
if: self.config_tagsEnabled,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "maps",
|
|
||||||
icon: MapIcon,
|
|
||||||
title: t("memories", "Maps"),
|
|
||||||
if: self.config_mapsEnabled,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
private navItems = [];
|
|
||||||
|
|
||||||
get ncVersion() {
|
|
||||||
const version = (<any>window.OC).config.version.split(".");
|
|
||||||
return Number(version[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
get recognize() {
|
|
||||||
if (!this.config_recognizeEnabled) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.config_facerecognitionInstalled) {
|
|
||||||
return t("memories", "People (Recognize)");
|
|
||||||
}
|
|
||||||
|
|
||||||
return t("memories", "People");
|
|
||||||
}
|
|
||||||
|
|
||||||
get facerecognition() {
|
|
||||||
if (!this.config_facerecognitionInstalled) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.config_recognizeEnabled) {
|
|
||||||
return t("memories", "People (Face Recognition)");
|
|
||||||
}
|
|
||||||
|
|
||||||
return t("memories", "People");
|
|
||||||
}
|
|
||||||
|
|
||||||
get isFirstStart() {
|
|
||||||
return this.config_timelinePath === "EMPTY";
|
|
||||||
}
|
|
||||||
|
|
||||||
get showAlbums() {
|
|
||||||
return this.config_albumsEnabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
get removeOuterGap() {
|
|
||||||
return this.ncVersion >= 25;
|
|
||||||
}
|
|
||||||
|
|
||||||
get showNavigation() {
|
|
||||||
return this.$route.name !== "folder-share";
|
|
||||||
}
|
|
||||||
|
|
||||||
@Watch("$route")
|
|
||||||
routeChanged() {
|
|
||||||
this.doRouteChecks();
|
|
||||||
}
|
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.doRouteChecks();
|
this.doRouteChecks();
|
||||||
|
|
||||||
// Populate navigation
|
// Populate navigation
|
||||||
this.navItems = this.navItemsAll(this).filter(
|
this.navItems = this.navItemsAll().filter(
|
||||||
(item) => typeof item.if === "undefined" || Boolean(item.if)
|
(item) => typeof item.if === "undefined" || Boolean(item.if)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -242,10 +174,7 @@ export default class App extends Mixins(GlobalMixin, UserConfig) {
|
||||||
if (this.metadataComponent) {
|
if (this.metadataComponent) {
|
||||||
this.metadataComponent.$destroy();
|
this.metadataComponent.$destroy();
|
||||||
}
|
}
|
||||||
this.metadataComponent = new Metadata({
|
this.metadataComponent = new Vue(Metadata);
|
||||||
// Better integration with vue parent component
|
|
||||||
parent: context,
|
|
||||||
});
|
|
||||||
// Only mount after we have all the info we need
|
// Only mount after we have all the info we need
|
||||||
await this.metadataComponent.update(fileInfo);
|
await this.metadataComponent.update(fileInfo);
|
||||||
this.metadataComponent.$mount(el);
|
this.metadataComponent.$mount(el);
|
||||||
|
@ -260,56 +189,123 @@ export default class App extends Mixins(GlobalMixin, UserConfig) {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
async beforeMount() {
|
methods: {
|
||||||
if ("serviceWorker" in navigator) {
|
navItemsAll() {
|
||||||
// Use the window load event to keep the page load performant
|
return [
|
||||||
window.addEventListener("load", async () => {
|
{
|
||||||
try {
|
name: "timeline",
|
||||||
const url = generateUrl("/apps/memories/service-worker.js");
|
icon: ImageMultiple,
|
||||||
const registration = await navigator.serviceWorker.register(url, {
|
title: t("memories", "Timeline"),
|
||||||
scope: generateUrl("/apps/memories"),
|
},
|
||||||
});
|
{
|
||||||
console.log("SW registered: ", registration);
|
name: "folders",
|
||||||
} catch (error) {
|
icon: FolderIcon,
|
||||||
console.error("SW registration failed: ", error);
|
title: t("memories", "Folders"),
|
||||||
}
|
},
|
||||||
});
|
{
|
||||||
} else {
|
name: "favorites",
|
||||||
console.debug("Service Worker is not enabled on this browser.");
|
icon: Star,
|
||||||
}
|
title: t("memories", "Favorites"),
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
name: "videos",
|
||||||
|
icon: Video,
|
||||||
|
title: t("memories", "Videos"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "albums",
|
||||||
|
icon: AlbumIcon,
|
||||||
|
title: t("memories", "Albums"),
|
||||||
|
if: this.showAlbums,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "recognize",
|
||||||
|
icon: PeopleIcon,
|
||||||
|
title: this.recognize,
|
||||||
|
if: this.recognize,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "facerecognition",
|
||||||
|
icon: PeopleIcon,
|
||||||
|
title: this.facerecognition,
|
||||||
|
if: this.facerecognition,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "archive",
|
||||||
|
icon: ArchiveIcon,
|
||||||
|
title: t("memories", "Archive"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "thisday",
|
||||||
|
icon: CalendarIcon,
|
||||||
|
title: t("memories", "On this day"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tags",
|
||||||
|
icon: TagsIcon,
|
||||||
|
title: t("memories", "Tags"),
|
||||||
|
if: this.config_tagsEnabled,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "maps",
|
||||||
|
icon: MapIcon,
|
||||||
|
title: t("memories", "Maps"),
|
||||||
|
if: this.config_mapsEnabled,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
linkClick() {
|
async beforeMount() {
|
||||||
const nav: any = this.$refs.nav;
|
if ("serviceWorker" in navigator) {
|
||||||
if (globalThis.windowInnerWidth <= 1024) nav?.toggleNavigation(false);
|
// Use the window load event to keep the page load performant
|
||||||
}
|
window.addEventListener("load", async () => {
|
||||||
|
try {
|
||||||
|
const url = generateUrl("/apps/memories/service-worker.js");
|
||||||
|
const registration = await navigator.serviceWorker.register(url, {
|
||||||
|
scope: generateUrl("/apps/memories"),
|
||||||
|
});
|
||||||
|
console.log("SW registered: ", registration);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SW registration failed: ", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.debug("Service Worker is not enabled on this browser.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
doRouteChecks() {
|
linkClick() {
|
||||||
if (this.$route.name === "folder-share") {
|
const nav: any = this.$refs.nav;
|
||||||
this.putFolderShareToken(this.$route.params.token);
|
if (globalThis.windowInnerWidth <= 1024) nav?.toggleNavigation(false);
|
||||||
}
|
},
|
||||||
}
|
|
||||||
|
|
||||||
putFolderShareToken(token: string) {
|
doRouteChecks() {
|
||||||
// Viewer looks for an input with ID sharingToken with the value as the token
|
if (this.$route.name === "folder-share") {
|
||||||
// Create this element or update it otherwise files not gonna open
|
this.putFolderShareToken(this.$route.params.token);
|
||||||
// https://github.com/nextcloud/viewer/blob/a8c46050fb687dcbb48a022a15a5d1275bf54a8e/src/utils/davUtils.js#L61
|
}
|
||||||
let tokenInput = document.getElementById(
|
},
|
||||||
"sharingToken"
|
|
||||||
) as HTMLInputElement;
|
|
||||||
if (!tokenInput) {
|
|
||||||
tokenInput = document.createElement("input");
|
|
||||||
tokenInput.id = "sharingToken";
|
|
||||||
tokenInput.type = "hidden";
|
|
||||||
tokenInput.style.display = "none";
|
|
||||||
document.body.appendChild(tokenInput);
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenInput.value = token;
|
putFolderShareToken(token: string) {
|
||||||
}
|
// Viewer looks for an input with ID sharingToken with the value as the token
|
||||||
}
|
// Create this element or update it otherwise files not gonna open
|
||||||
|
// https://github.com/nextcloud/viewer/blob/a8c46050fb687dcbb48a022a15a5d1275bf54a8e/src/utils/davUtils.js#L61
|
||||||
|
let tokenInput = document.getElementById(
|
||||||
|
"sharingToken"
|
||||||
|
) as HTMLInputElement;
|
||||||
|
if (!tokenInput) {
|
||||||
|
tokenInput = document.createElement("input");
|
||||||
|
tokenInput.id = "sharingToken";
|
||||||
|
tokenInput.type = "hidden";
|
||||||
|
tokenInput.style.display = "none";
|
||||||
|
document.body.appendChild(tokenInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenInput.value = token;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|
|
@ -47,7 +47,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Mixins } from "vue-property-decorator";
|
import { defineComponent } from "vue";
|
||||||
|
|
||||||
import NcContent from "@nextcloud/vue/dist/Components/NcContent";
|
import NcContent from "@nextcloud/vue/dist/Components/NcContent";
|
||||||
import NcAppContent from "@nextcloud/vue/dist/Components/NcAppContent";
|
import NcAppContent from "@nextcloud/vue/dist/Components/NcAppContent";
|
||||||
|
@ -57,95 +57,100 @@ import { getFilePickerBuilder } from "@nextcloud/dialogs";
|
||||||
import { getCurrentUser } from "@nextcloud/auth";
|
import { getCurrentUser } from "@nextcloud/auth";
|
||||||
import axios from "@nextcloud/axios";
|
import axios from "@nextcloud/axios";
|
||||||
|
|
||||||
import GlobalMixin from "../mixins/GlobalMixin";
|
|
||||||
import UserConfig from "../mixins/UserConfig";
|
|
||||||
|
|
||||||
import banner from "../assets/banner.svg";
|
import banner from "../assets/banner.svg";
|
||||||
import { IDay } from "../types";
|
import { IDay } from "../types";
|
||||||
import { API } from "../services/API";
|
import { API } from "../services/API";
|
||||||
|
|
||||||
@Component({
|
export default defineComponent({
|
||||||
|
name: "FirstStart",
|
||||||
components: {
|
components: {
|
||||||
NcContent,
|
NcContent,
|
||||||
NcAppContent,
|
NcAppContent,
|
||||||
NcButton,
|
NcButton,
|
||||||
},
|
},
|
||||||
})
|
|
||||||
export default class FirstStart extends Mixins(GlobalMixin, UserConfig) {
|
data() {
|
||||||
banner = banner;
|
return {
|
||||||
error = "";
|
banner,
|
||||||
info = "";
|
error: "",
|
||||||
show = false;
|
info: "",
|
||||||
chosenPath = "";
|
show: false,
|
||||||
|
chosenPath: "",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
this.show = true;
|
this.show = true;
|
||||||
}, 300);
|
}, 300);
|
||||||
}
|
},
|
||||||
|
|
||||||
get isAdmin() {
|
computed: {
|
||||||
return getCurrentUser().isAdmin;
|
isAdmin() {
|
||||||
}
|
return getCurrentUser().isAdmin;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
async begin() {
|
methods: {
|
||||||
const path = await this.chooseFolder(
|
async begin() {
|
||||||
this.t("memories", "Choose the root of your timeline"),
|
const path = await this.chooseFolder(
|
||||||
"/"
|
this.t("memories", "Choose the root of your timeline"),
|
||||||
);
|
"/"
|
||||||
|
|
||||||
// Get folder days
|
|
||||||
this.error = "";
|
|
||||||
this.info = "";
|
|
||||||
const query = new URLSearchParams();
|
|
||||||
query.set("timelinePath", path);
|
|
||||||
let url = API.Q(API.DAYS(), query);
|
|
||||||
const res = await axios.get<IDay[]>(url);
|
|
||||||
|
|
||||||
// Check response
|
|
||||||
if (res.status !== 200) {
|
|
||||||
this.error = this.t(
|
|
||||||
"memories",
|
|
||||||
"The selected folder does not seem to be valid. Try again."
|
|
||||||
);
|
);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count total photos
|
// Get folder days
|
||||||
const n = res.data.reduce((acc, day) => acc + day.count, 0);
|
this.error = "";
|
||||||
this.info = this.n(
|
this.info = "";
|
||||||
"memories",
|
const query = new URLSearchParams();
|
||||||
"Found {n} item in {path}",
|
query.set("timelinePath", path);
|
||||||
"Found {n} items in {path}",
|
let url = API.Q(API.DAYS(), query);
|
||||||
n,
|
const res = await axios.get<IDay[]>(url);
|
||||||
{
|
|
||||||
n,
|
// Check response
|
||||||
path,
|
if (res.status !== 200) {
|
||||||
|
this.error = this.t(
|
||||||
|
"memories",
|
||||||
|
"The selected folder does not seem to be valid. Try again."
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
);
|
|
||||||
this.chosenPath = path;
|
|
||||||
}
|
|
||||||
|
|
||||||
async finish() {
|
// Count total photos
|
||||||
this.show = false;
|
const n = res.data.reduce((acc, day) => acc + day.count, 0);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
this.info = this.n(
|
||||||
this.config_timelinePath = this.chosenPath;
|
"memories",
|
||||||
await this.updateSetting("timelinePath");
|
"Found {n} item in {path}",
|
||||||
}
|
"Found {n} items in {path}",
|
||||||
|
n,
|
||||||
|
{
|
||||||
|
n,
|
||||||
|
path,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.chosenPath = path;
|
||||||
|
},
|
||||||
|
|
||||||
async chooseFolder(title: string, initial: string) {
|
async finish() {
|
||||||
const picker = getFilePickerBuilder(title)
|
this.show = false;
|
||||||
.setMultiSelect(false)
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
.setModal(true)
|
this.config_timelinePath = this.chosenPath;
|
||||||
.setType(1)
|
await this.updateSetting("timelinePath");
|
||||||
.addMimeTypeFilter("httpd/unix-directory")
|
},
|
||||||
.allowDirectories()
|
|
||||||
.startAt(initial)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
return await picker.pick();
|
async chooseFolder(title: string, initial: string) {
|
||||||
}
|
const picker = getFilePickerBuilder(title)
|
||||||
}
|
.setMultiSelect(false)
|
||||||
|
.setModal(true)
|
||||||
|
.setType(1)
|
||||||
|
.addMimeTypeFilter("httpd/unix-directory")
|
||||||
|
.allowDirectories()
|
||||||
|
.startAt(initial)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return await picker.pick();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|
|
@ -45,8 +45,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Mixins } from "vue-property-decorator";
|
import { defineComponent } from "vue";
|
||||||
import GlobalMixin from "../mixins/GlobalMixin";
|
|
||||||
|
|
||||||
import NcActions from "@nextcloud/vue/dist/Components/NcActions";
|
import NcActions from "@nextcloud/vue/dist/Components/NcActions";
|
||||||
import NcActionButton from "@nextcloud/vue/dist/Components/NcActionButton";
|
import NcActionButton from "@nextcloud/vue/dist/Components/NcActionButton";
|
||||||
|
@ -68,258 +67,266 @@ import InfoIcon from "vue-material-design-icons/InformationOutline.vue";
|
||||||
import LocationIcon from "vue-material-design-icons/MapMarker.vue";
|
import LocationIcon from "vue-material-design-icons/MapMarker.vue";
|
||||||
import { API } from "../services/API";
|
import { API } from "../services/API";
|
||||||
|
|
||||||
@Component({
|
export default defineComponent({
|
||||||
|
name: "Metadata",
|
||||||
components: {
|
components: {
|
||||||
NcActions,
|
NcActions,
|
||||||
NcActionButton,
|
NcActionButton,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
},
|
},
|
||||||
})
|
|
||||||
export default class Metadata extends Mixins(GlobalMixin) {
|
|
||||||
private fileInfo: IFileInfo = null;
|
|
||||||
private exif: { [prop: string]: any } = {};
|
|
||||||
private baseInfo: any = {};
|
|
||||||
private nominatim: any = null;
|
|
||||||
private state = 0;
|
|
||||||
|
|
||||||
public async update(fileInfo: IFileInfo) {
|
data() {
|
||||||
this.state = Math.random();
|
return {
|
||||||
this.fileInfo = fileInfo;
|
fileInfo: null as IFileInfo,
|
||||||
this.exif = {};
|
exif: {} as { [prop: string]: any },
|
||||||
this.nominatim = null;
|
baseInfo: {} as any,
|
||||||
|
nominatim: null as any,
|
||||||
const state = this.state;
|
state: 0,
|
||||||
const url = API.IMAGE_INFO(fileInfo.id);
|
};
|
||||||
const res = await axios.get<any>(url);
|
},
|
||||||
if (state !== this.state) return;
|
|
||||||
|
|
||||||
this.baseInfo = res.data;
|
|
||||||
this.exif = res.data.exif || {};
|
|
||||||
|
|
||||||
// Lazy loading
|
|
||||||
this.getNominatim().catch();
|
|
||||||
}
|
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
subscribe("files:file:updated", this.handleFileUpdated);
|
subscribe("files:file:updated", this.handleFileUpdated);
|
||||||
}
|
},
|
||||||
|
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
unsubscribe("files:file:updated", this.handleFileUpdated);
|
unsubscribe("files:file:updated", this.handleFileUpdated);
|
||||||
}
|
},
|
||||||
|
|
||||||
private handleFileUpdated({ fileid }) {
|
computed: {
|
||||||
if (fileid && this.fileInfo?.id === fileid) {
|
topFields() {
|
||||||
this.update(this.fileInfo);
|
let list: {
|
||||||
}
|
title: string;
|
||||||
}
|
subtitle: string[];
|
||||||
|
icon: any;
|
||||||
|
href?: string;
|
||||||
|
edit?: () => void;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
get topFields() {
|
if (this.dateOriginal) {
|
||||||
let list: {
|
list.push({
|
||||||
title: string;
|
title: this.dateOriginalStr,
|
||||||
subtitle: string[];
|
subtitle: this.dateOriginalTime,
|
||||||
icon: any;
|
icon: CalendarIcon,
|
||||||
href?: string;
|
edit: () => globalThis.editDate(globalThis.currentViewerPhoto),
|
||||||
edit?: () => void;
|
});
|
||||||
}[] = [];
|
|
||||||
|
|
||||||
if (this.dateOriginal) {
|
|
||||||
list.push({
|
|
||||||
title: this.dateOriginalStr,
|
|
||||||
subtitle: this.dateOriginalTime,
|
|
||||||
icon: CalendarIcon,
|
|
||||||
edit: () => globalThis.editDate(globalThis.currentViewerPhoto),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.camera) {
|
|
||||||
list.push({
|
|
||||||
title: this.camera,
|
|
||||||
subtitle: this.cameraSub,
|
|
||||||
icon: CameraIrisIcon,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.imageInfo) {
|
|
||||||
list.push({
|
|
||||||
title: this.imageInfo,
|
|
||||||
subtitle: this.imageInfoSub,
|
|
||||||
icon: ImageIcon,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = this.exif?.["Title"];
|
|
||||||
const desc = this.exif?.["Description"];
|
|
||||||
if (title || desc) {
|
|
||||||
list.push({
|
|
||||||
title: title || this.t("memories", "No title"),
|
|
||||||
subtitle: [desc || this.t("memories", "No description")],
|
|
||||||
icon: InfoIcon,
|
|
||||||
edit: () => globalThis.editExif(globalThis.currentViewerPhoto),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.address) {
|
|
||||||
list.push({
|
|
||||||
title: this.address,
|
|
||||||
subtitle: [],
|
|
||||||
icon: LocationIcon,
|
|
||||||
href: this.mapFullUrl,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Date taken info */
|
|
||||||
get dateOriginal() {
|
|
||||||
const dt = this.exif["DateTimeOriginal"] || this.exif["CreateDate"];
|
|
||||||
if (!dt) return null;
|
|
||||||
|
|
||||||
const m = moment.utc(dt, "YYYY:MM:DD HH:mm:ss");
|
|
||||||
if (!m.isValid()) return null;
|
|
||||||
m.locale(getCanonicalLocale());
|
|
||||||
return m;
|
|
||||||
}
|
|
||||||
|
|
||||||
get dateOriginalStr() {
|
|
||||||
if (!this.dateOriginal) return null;
|
|
||||||
return utils.getLongDateStr(this.dateOriginal.toDate(), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
get dateOriginalTime() {
|
|
||||||
if (!this.dateOriginal) return null;
|
|
||||||
|
|
||||||
// Try to get timezone
|
|
||||||
let tz = this.exif["OffsetTimeOriginal"] || this.exif["OffsetTime"];
|
|
||||||
tz = tz ? "GMT" + tz : "";
|
|
||||||
|
|
||||||
let parts = [];
|
|
||||||
parts.push(this.dateOriginal.format("h:mm A"));
|
|
||||||
if (tz) parts.push(tz);
|
|
||||||
|
|
||||||
return parts;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Camera make and model info */
|
|
||||||
get camera() {
|
|
||||||
const make = this.exif["Make"];
|
|
||||||
const model = this.exif["Model"];
|
|
||||||
if (!make || !model) return null;
|
|
||||||
if (model.startsWith(make)) return model;
|
|
||||||
return `${make} ${model}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
get cameraSub() {
|
|
||||||
const f = this.exif["FNumber"] || this.exif["Aperture"];
|
|
||||||
const s = this.shutterSpeed;
|
|
||||||
const len = this.exif["FocalLength"];
|
|
||||||
const iso = this.exif["ISO"];
|
|
||||||
|
|
||||||
const parts = [];
|
|
||||||
if (f) parts.push(`f/${f}`);
|
|
||||||
if (s) parts.push(`${s}`);
|
|
||||||
if (len) parts.push(`${len}mm`);
|
|
||||||
if (iso) parts.push(`ISO${iso}`);
|
|
||||||
return parts;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Convert shutter speed decimal to 1/x format */
|
|
||||||
get shutterSpeed() {
|
|
||||||
const speed = Number(
|
|
||||||
this.exif["ShutterSpeedValue"] ||
|
|
||||||
this.exif["ShutterSpeed"] ||
|
|
||||||
this.exif["ExposureTime"]
|
|
||||||
);
|
|
||||||
if (!speed) return null;
|
|
||||||
|
|
||||||
if (speed < 1) {
|
|
||||||
return `1/${Math.round(1 / speed)}`;
|
|
||||||
} else {
|
|
||||||
return `${Math.round(speed * 10) / 10}s`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Image info */
|
|
||||||
get imageInfo() {
|
|
||||||
return this.fileInfo.basename || (<any>this.fileInfo).name;
|
|
||||||
}
|
|
||||||
|
|
||||||
get imageInfoSub() {
|
|
||||||
let parts = [];
|
|
||||||
let mp = Number(this.exif["Megapixels"]);
|
|
||||||
|
|
||||||
if (this.baseInfo.w && this.baseInfo.h) {
|
|
||||||
parts.push(`${this.baseInfo.w}x${this.baseInfo.h}`);
|
|
||||||
|
|
||||||
if (!mp) {
|
|
||||||
mp = (this.baseInfo.w * this.baseInfo.h) / 1000000;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (mp) {
|
if (this.camera) {
|
||||||
parts.unshift(`${mp.toFixed(1)}MP`);
|
list.push({
|
||||||
}
|
title: this.camera,
|
||||||
|
subtitle: this.cameraSub,
|
||||||
|
icon: CameraIrisIcon,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return parts;
|
if (this.imageInfo) {
|
||||||
}
|
list.push({
|
||||||
|
title: this.imageInfo,
|
||||||
|
subtitle: this.imageInfoSub,
|
||||||
|
icon: ImageIcon,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
get address() {
|
const title = this.exif?.["Title"];
|
||||||
if (!this.lat || !this.lon) return null;
|
const desc = this.exif?.["Description"];
|
||||||
|
if (title || desc) {
|
||||||
|
list.push({
|
||||||
|
title: title || this.t("memories", "No title"),
|
||||||
|
subtitle: [desc || this.t("memories", "No description")],
|
||||||
|
icon: InfoIcon,
|
||||||
|
edit: () => globalThis.editExif(globalThis.currentViewerPhoto),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.nominatim) return this.t("memories", "Loading …");
|
if (this.address) {
|
||||||
|
list.push({
|
||||||
|
title: this.address,
|
||||||
|
subtitle: [],
|
||||||
|
icon: LocationIcon,
|
||||||
|
href: this.mapFullUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const n = this.nominatim;
|
return list;
|
||||||
const country = n.address.country_code?.toUpperCase();
|
},
|
||||||
|
|
||||||
if (n.address?.city && n.address.state) {
|
/** Date taken info */
|
||||||
return `${n.address.city}, ${n.address.state}, ${country}`;
|
dateOriginal() {
|
||||||
} else if (n.address?.state) {
|
const dt = this.exif["DateTimeOriginal"] || this.exif["CreateDate"];
|
||||||
return `${n.address.state}, ${country}`;
|
if (!dt) return null;
|
||||||
} else if (n.address?.country) {
|
|
||||||
return n.address.country;
|
|
||||||
} else {
|
|
||||||
return n.display_name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get lat() {
|
const m = moment.utc(dt, "YYYY:MM:DD HH:mm:ss");
|
||||||
return this.exif["GPSLatitude"];
|
if (!m.isValid()) return null;
|
||||||
}
|
m.locale(getCanonicalLocale());
|
||||||
|
return m;
|
||||||
|
},
|
||||||
|
|
||||||
get lon() {
|
dateOriginalStr() {
|
||||||
return this.exif["GPSLongitude"];
|
if (!this.dateOriginal) return null;
|
||||||
}
|
return utils.getLongDateStr(this.dateOriginal.toDate(), true);
|
||||||
|
},
|
||||||
|
|
||||||
get mapUrl() {
|
dateOriginalTime() {
|
||||||
const boxSize = 0.0075;
|
if (!this.dateOriginal) return null;
|
||||||
const bbox = [
|
|
||||||
this.lon - boxSize,
|
|
||||||
this.lat - boxSize,
|
|
||||||
this.lon + boxSize,
|
|
||||||
this.lat + boxSize,
|
|
||||||
];
|
|
||||||
const m = `${this.lat},${this.lon}`;
|
|
||||||
return `https://www.openstreetmap.org/export/embed.html?bbox=${bbox.join()}&marker=${m}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
get mapFullUrl() {
|
// Try to get timezone
|
||||||
return `https://www.openstreetmap.org/?mlat=${this.lat}&mlon=${this.lon}#map=18/${this.lat}/${this.lon}`;
|
let tz = this.exif["OffsetTimeOriginal"] || this.exif["OffsetTime"];
|
||||||
}
|
tz = tz ? "GMT" + tz : "";
|
||||||
|
|
||||||
async getNominatim() {
|
let parts = [];
|
||||||
const lat = this.lat;
|
parts.push(this.dateOriginal.format("h:mm A"));
|
||||||
const lon = this.lon;
|
if (tz) parts.push(tz);
|
||||||
if (!lat || !lon) return null;
|
|
||||||
|
|
||||||
const state = this.state;
|
return parts;
|
||||||
const n = await axios.get(
|
},
|
||||||
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=18`
|
|
||||||
);
|
/** Camera make and model info */
|
||||||
if (state !== this.state) return;
|
camera() {
|
||||||
this.nominatim = n.data;
|
const make = this.exif["Make"];
|
||||||
}
|
const model = this.exif["Model"];
|
||||||
}
|
if (!make || !model) return null;
|
||||||
|
if (model.startsWith(make)) return model;
|
||||||
|
return `${make} ${model}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
cameraSub() {
|
||||||
|
const f = this.exif["FNumber"] || this.exif["Aperture"];
|
||||||
|
const s = this.shutterSpeed;
|
||||||
|
const len = this.exif["FocalLength"];
|
||||||
|
const iso = this.exif["ISO"];
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
if (f) parts.push(`f/${f}`);
|
||||||
|
if (s) parts.push(`${s}`);
|
||||||
|
if (len) parts.push(`${len}mm`);
|
||||||
|
if (iso) parts.push(`ISO${iso}`);
|
||||||
|
return parts;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Convert shutter speed decimal to 1/x format */
|
||||||
|
shutterSpeed() {
|
||||||
|
const speed = Number(
|
||||||
|
this.exif["ShutterSpeedValue"] ||
|
||||||
|
this.exif["ShutterSpeed"] ||
|
||||||
|
this.exif["ExposureTime"]
|
||||||
|
);
|
||||||
|
if (!speed) return null;
|
||||||
|
|
||||||
|
if (speed < 1) {
|
||||||
|
return `1/${Math.round(1 / speed)}`;
|
||||||
|
} else {
|
||||||
|
return `${Math.round(speed * 10) / 10}s`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Image info */
|
||||||
|
imageInfo() {
|
||||||
|
return this.fileInfo.basename || (<any>this.fileInfo).name;
|
||||||
|
},
|
||||||
|
|
||||||
|
imageInfoSub() {
|
||||||
|
let parts = [];
|
||||||
|
let mp = Number(this.exif["Megapixels"]);
|
||||||
|
|
||||||
|
if (this.baseInfo.w && this.baseInfo.h) {
|
||||||
|
parts.push(`${this.baseInfo.w}x${this.baseInfo.h}`);
|
||||||
|
|
||||||
|
if (!mp) {
|
||||||
|
mp = (this.baseInfo.w * this.baseInfo.h) / 1000000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mp) {
|
||||||
|
parts.unshift(`${mp.toFixed(1)}MP`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
},
|
||||||
|
|
||||||
|
address() {
|
||||||
|
if (!this.lat || !this.lon) return null;
|
||||||
|
|
||||||
|
if (!this.nominatim) return this.t("memories", "Loading …");
|
||||||
|
|
||||||
|
const n = this.nominatim;
|
||||||
|
const country = n.address.country_code?.toUpperCase();
|
||||||
|
|
||||||
|
if (n.address?.city && n.address.state) {
|
||||||
|
return `${n.address.city}, ${n.address.state}, ${country}`;
|
||||||
|
} else if (n.address?.state) {
|
||||||
|
return `${n.address.state}, ${country}`;
|
||||||
|
} else if (n.address?.country) {
|
||||||
|
return n.address.country;
|
||||||
|
} else {
|
||||||
|
return n.display_name;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
lat() {
|
||||||
|
return this.exif["GPSLatitude"];
|
||||||
|
},
|
||||||
|
|
||||||
|
lon() {
|
||||||
|
return this.exif["GPSLongitude"];
|
||||||
|
},
|
||||||
|
|
||||||
|
mapUrl() {
|
||||||
|
const boxSize = 0.0075;
|
||||||
|
const bbox = [
|
||||||
|
this.lon - boxSize,
|
||||||
|
this.lat - boxSize,
|
||||||
|
this.lon + boxSize,
|
||||||
|
this.lat + boxSize,
|
||||||
|
];
|
||||||
|
const m = `${this.lat},${this.lon}`;
|
||||||
|
return `https://www.openstreetmap.org/export/embed.html?bbox=${bbox.join()}&marker=${m}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
mapFullUrl() {
|
||||||
|
return `https://www.openstreetmap.org/?mlat=${this.lat}&mlon=${this.lon}#map=18/${this.lat}/${this.lon}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
async update(fileInfo: IFileInfo) {
|
||||||
|
this.state = Math.random();
|
||||||
|
this.fileInfo = fileInfo;
|
||||||
|
this.exif = {};
|
||||||
|
this.nominatim = null;
|
||||||
|
|
||||||
|
const state = this.state;
|
||||||
|
const url = API.IMAGE_INFO(fileInfo.id);
|
||||||
|
const res = await axios.get<any>(url);
|
||||||
|
if (state !== this.state) return;
|
||||||
|
|
||||||
|
this.baseInfo = res.data;
|
||||||
|
this.exif = res.data.exif || {};
|
||||||
|
|
||||||
|
// Lazy loading
|
||||||
|
this.getNominatim().catch();
|
||||||
|
},
|
||||||
|
|
||||||
|
handleFileUpdated({ fileid }) {
|
||||||
|
if (fileid && this.fileInfo?.id === fileid) {
|
||||||
|
this.update(this.fileInfo);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getNominatim() {
|
||||||
|
const lat = this.lat;
|
||||||
|
const lon = this.lon;
|
||||||
|
if (!lat || !lon) return null;
|
||||||
|
|
||||||
|
const state = this.state;
|
||||||
|
const n = await axios.get(
|
||||||
|
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=18`
|
||||||
|
);
|
||||||
|
if (state !== this.state) return;
|
||||||
|
this.nominatim = n.data;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|
|
@ -49,9 +49,8 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Mixins, Prop } from "vue-property-decorator";
|
import { defineComponent, PropType } from "vue";
|
||||||
import { IRow, IRowType, ITick } from "../types";
|
import { IRow, IRowType, ITick } from "../types";
|
||||||
import GlobalMixin from "../mixins/GlobalMixin";
|
|
||||||
import ScrollIcon from "vue-material-design-icons/UnfoldMoreHorizontal.vue";
|
import ScrollIcon from "vue-material-design-icons/UnfoldMoreHorizontal.vue";
|
||||||
|
|
||||||
import * as utils from "../services/Utils";
|
import * as utils from "../services/Utils";
|
||||||
|
@ -59,458 +58,468 @@ import * as utils from "../services/Utils";
|
||||||
// Pixels to snap at
|
// Pixels to snap at
|
||||||
const SNAP_OFFSET = -35;
|
const SNAP_OFFSET = -35;
|
||||||
|
|
||||||
@Component({
|
export default defineComponent({
|
||||||
|
name: "ScrollerManager",
|
||||||
components: {
|
components: {
|
||||||
ScrollIcon,
|
ScrollIcon,
|
||||||
},
|
},
|
||||||
})
|
|
||||||
export default class ScrollerManager extends Mixins(GlobalMixin) {
|
|
||||||
/** Rows from Timeline */
|
|
||||||
@Prop() rows!: IRow[];
|
|
||||||
/** Total height */
|
|
||||||
@Prop() height!: number;
|
|
||||||
/** Actual recycler component */
|
|
||||||
@Prop() recycler!: any;
|
|
||||||
/** Recycler before slot component */
|
|
||||||
@Prop() recyclerBefore!: any;
|
|
||||||
|
|
||||||
/** Last known height at adjustment */
|
props: {
|
||||||
private lastAdjustHeight = 0;
|
/** Rows from Timeline */
|
||||||
/** Height of the entire photo view */
|
rows: Array as PropType<IRow[]>,
|
||||||
private recyclerHeight: number = 100;
|
/** Total height */
|
||||||
/** Rect of scroller */
|
height: Number,
|
||||||
private scrollerRect: DOMRect = null;
|
/** Actual recycler component */
|
||||||
/** Computed ticks */
|
recycler: Object,
|
||||||
private ticks: ITick[] = [];
|
/** Recycler before slot component */
|
||||||
/** Computed cursor top */
|
recyclerBefore: HTMLDivElement,
|
||||||
private cursorY = 0;
|
},
|
||||||
/** Hover cursor top */
|
|
||||||
private hoverCursorY = -5;
|
|
||||||
/** Hover cursor text */
|
|
||||||
private hoverCursorText = "";
|
|
||||||
/** Scrolling using the scroller */
|
|
||||||
private scrollingTimer = 0;
|
|
||||||
/** Scrolling now using the scroller */
|
|
||||||
private scrollingNowTimer = 0;
|
|
||||||
/** Scrolling recycler */
|
|
||||||
private scrollingRecyclerTimer = 0;
|
|
||||||
/** Scrolling recycler now */
|
|
||||||
private scrollingRecyclerNowTimer = 0;
|
|
||||||
/** Recycler scrolling throttle */
|
|
||||||
private scrollingRecyclerUpdateTimer = 0;
|
|
||||||
/** View size reflow timer */
|
|
||||||
private reflowRequest = false;
|
|
||||||
/** Tick adjust timer */
|
|
||||||
private adjustRequest = false;
|
|
||||||
/** Scroller is being moved with interaction */
|
|
||||||
private interacting = false;
|
|
||||||
/** Track the last requested y position when interacting */
|
|
||||||
private lastRequestedRecyclerY = 0;
|
|
||||||
|
|
||||||
/** Get the visible ticks */
|
data() {
|
||||||
get visibleTicks() {
|
return {
|
||||||
let key = 9999999900;
|
/** Last known height at adjustment */
|
||||||
return this.ticks
|
lastAdjustHeight: 0,
|
||||||
.filter((tick) => tick.s)
|
/** Height of the entire photo view */
|
||||||
.map((tick) => {
|
recyclerHeight: 100,
|
||||||
if (tick.text) {
|
/** Rect of scroller */
|
||||||
tick.key = key = tick.dayId * 100;
|
scrollerRect: null as DOMRect,
|
||||||
} else {
|
/** Computed ticks */
|
||||||
tick.key = ++key; // days are sorted descending
|
ticks: [] as ITick[],
|
||||||
}
|
/** Computed cursor top */
|
||||||
return tick;
|
cursorY: 0,
|
||||||
});
|
/** Hover cursor top */
|
||||||
}
|
hoverCursorY: -5,
|
||||||
|
/** Hover cursor text */
|
||||||
/** Reset state */
|
hoverCursorText: "",
|
||||||
public reset() {
|
/** Scrolling using the scroller */
|
||||||
this.ticks = [];
|
scrollingTimer: 0,
|
||||||
this.cursorY = 0;
|
/** Scrolling now using the scroller */
|
||||||
this.hoverCursorY = -5;
|
scrollingNowTimer: 0,
|
||||||
this.hoverCursorText = "";
|
/** Scrolling recycler */
|
||||||
this.reflowRequest = false;
|
scrollingRecyclerTimer: 0,
|
||||||
|
/** Scrolling recycler now */
|
||||||
// Clear all timers
|
scrollingRecyclerNowTimer: 0,
|
||||||
clearTimeout(this.scrollingTimer);
|
/** Recycler scrolling throttle */
|
||||||
clearTimeout(this.scrollingNowTimer);
|
scrollingRecyclerUpdateTimer: 0,
|
||||||
clearTimeout(this.scrollingRecyclerTimer);
|
/** View size reflow timer */
|
||||||
clearTimeout(this.scrollingRecyclerNowTimer);
|
reflowRequest: false,
|
||||||
clearTimeout(this.scrollingRecyclerUpdateTimer);
|
/** Tick adjust timer */
|
||||||
this.scrollingTimer = 0;
|
adjustRequest: false,
|
||||||
this.scrollingNowTimer = 0;
|
/** Scroller is being moved with interaction */
|
||||||
this.scrollingRecyclerTimer = 0;
|
interacting: false,
|
||||||
this.scrollingRecyclerNowTimer = 0;
|
/** Track the last requested y position when interacting */
|
||||||
this.scrollingRecyclerUpdateTimer = 0;
|
lastRequestedRecyclerY: 0,
|
||||||
}
|
|
||||||
|
|
||||||
/** Recycler scroll event, must be called by timeline */
|
|
||||||
public recyclerScrolled() {
|
|
||||||
// This isn't a renewing timer, it's a scheduled task
|
|
||||||
if (this.scrollingRecyclerUpdateTimer) return;
|
|
||||||
this.scrollingRecyclerUpdateTimer = window.setTimeout(() => {
|
|
||||||
this.scrollingRecyclerUpdateTimer = 0;
|
|
||||||
this.updateFromRecyclerScroll();
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
// Update that we're scrolling with the recycler
|
|
||||||
utils.setRenewingTimeout(this, "scrollingRecyclerNowTimer", null, 200);
|
|
||||||
utils.setRenewingTimeout(this, "scrollingRecyclerTimer", null, 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Update cursor position from recycler scroll position */
|
|
||||||
public updateFromRecyclerScroll() {
|
|
||||||
// Ignore if not initialized or moving
|
|
||||||
if (!this.ticks.length || this.interacting) return;
|
|
||||||
|
|
||||||
// Get the scroll position
|
|
||||||
const scroll = this.recycler?.$el?.scrollTop || 0;
|
|
||||||
|
|
||||||
// Get cursor px position
|
|
||||||
const { top1, top2, y1, y2 } = this.getCoords(scroll, "y");
|
|
||||||
const topfrac = (scroll - y1) / (y2 - y1);
|
|
||||||
const rtop = top1 + (top2 - top1) * (topfrac || 0);
|
|
||||||
|
|
||||||
// Always move static cursor to right position
|
|
||||||
this.cursorY = rtop;
|
|
||||||
|
|
||||||
// Move hover cursor to same position unless hovering
|
|
||||||
// Regardless, we need this call because the internal mapping might have changed
|
|
||||||
if ((<HTMLElement>this.$refs.scroller).matches(":hover")) {
|
|
||||||
this.moveHoverCursor(this.hoverCursorY);
|
|
||||||
} else {
|
|
||||||
this.moveHoverCursor(rtop);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Re-create tick data in the next frame */
|
|
||||||
public async reflow() {
|
|
||||||
if (this.reflowRequest) return;
|
|
||||||
this.reflowRequest = true;
|
|
||||||
await this.$nextTick();
|
|
||||||
this.reflowNow();
|
|
||||||
this.reflowRequest = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Re-create tick data */
|
|
||||||
private reflowNow() {
|
|
||||||
// Ignore if not initialized
|
|
||||||
if (!this.recycler?.$refs.wrapper) return;
|
|
||||||
|
|
||||||
// Refresh height of recycler
|
|
||||||
this.recyclerHeight = this.recycler.$refs.wrapper.clientHeight;
|
|
||||||
|
|
||||||
// Recreate ticks data
|
|
||||||
this.recreate();
|
|
||||||
|
|
||||||
// Adjust top
|
|
||||||
this.adjustNow();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Recreate from scratch */
|
|
||||||
private recreate() {
|
|
||||||
// Clear and override any adjust timer
|
|
||||||
this.ticks = [];
|
|
||||||
|
|
||||||
// Ticks
|
|
||||||
let prevYear = 9999;
|
|
||||||
let prevMonth = 0;
|
|
||||||
|
|
||||||
// Get a new tick
|
|
||||||
const getTick = (
|
|
||||||
dayId: number,
|
|
||||||
isMonth = false,
|
|
||||||
text?: string | number
|
|
||||||
): ITick => {
|
|
||||||
return {
|
|
||||||
dayId,
|
|
||||||
isMonth,
|
|
||||||
text,
|
|
||||||
y: 0,
|
|
||||||
count: 0,
|
|
||||||
topF: 0,
|
|
||||||
top: 0,
|
|
||||||
s: false,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
},
|
||||||
|
|
||||||
// Iterate over rows
|
computed: {
|
||||||
for (const row of this.rows) {
|
/** Get the visible ticks */
|
||||||
if (row.type === IRowType.HEAD) {
|
visibleTicks() {
|
||||||
// Create tick
|
let key = 9999999900;
|
||||||
if (this.TagDayIDValueSet.has(row.dayId)) {
|
return this.ticks
|
||||||
// Blank tick
|
.filter((tick) => tick.s)
|
||||||
this.ticks.push(getTick(row.dayId));
|
.map((tick) => {
|
||||||
} else {
|
if (tick.text) {
|
||||||
// Make date string
|
tick.key = key = tick.dayId * 100;
|
||||||
const dateTaken = utils.dayIdToDate(row.dayId);
|
} else {
|
||||||
|
tick.key = ++key; // days are sorted descending
|
||||||
|
}
|
||||||
|
return tick;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
/** Reset state */
|
||||||
|
reset() {
|
||||||
|
this.ticks = [];
|
||||||
|
this.cursorY = 0;
|
||||||
|
this.hoverCursorY = -5;
|
||||||
|
this.hoverCursorText = "";
|
||||||
|
this.reflowRequest = false;
|
||||||
|
|
||||||
|
// Clear all timers
|
||||||
|
clearTimeout(this.scrollingTimer);
|
||||||
|
clearTimeout(this.scrollingNowTimer);
|
||||||
|
clearTimeout(this.scrollingRecyclerTimer);
|
||||||
|
clearTimeout(this.scrollingRecyclerNowTimer);
|
||||||
|
clearTimeout(this.scrollingRecyclerUpdateTimer);
|
||||||
|
this.scrollingTimer = 0;
|
||||||
|
this.scrollingNowTimer = 0;
|
||||||
|
this.scrollingRecyclerTimer = 0;
|
||||||
|
this.scrollingRecyclerNowTimer = 0;
|
||||||
|
this.scrollingRecyclerUpdateTimer = 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Recycler scroll event, must be called by timeline */
|
||||||
|
recyclerScrolled() {
|
||||||
|
// This isn't a renewing timer, it's a scheduled task
|
||||||
|
if (this.scrollingRecyclerUpdateTimer) return;
|
||||||
|
this.scrollingRecyclerUpdateTimer = window.setTimeout(() => {
|
||||||
|
this.scrollingRecyclerUpdateTimer = 0;
|
||||||
|
this.updateFromRecyclerScroll();
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// Update that we're scrolling with the recycler
|
||||||
|
utils.setRenewingTimeout(this, "scrollingRecyclerNowTimer", null, 200);
|
||||||
|
utils.setRenewingTimeout(this, "scrollingRecyclerTimer", null, 1500);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Update cursor position from recycler scroll position */
|
||||||
|
updateFromRecyclerScroll() {
|
||||||
|
// Ignore if not initialized or moving
|
||||||
|
if (!this.ticks.length || this.interacting) return;
|
||||||
|
|
||||||
|
// Get the scroll position
|
||||||
|
const scroll = this.recycler?.$el?.scrollTop || 0;
|
||||||
|
|
||||||
|
// Get cursor px position
|
||||||
|
const { top1, top2, y1, y2 } = this.getCoords(scroll, "y");
|
||||||
|
const topfrac = (scroll - y1) / (y2 - y1);
|
||||||
|
const rtop = top1 + (top2 - top1) * (topfrac || 0);
|
||||||
|
|
||||||
|
// Always move static cursor to right position
|
||||||
|
this.cursorY = rtop;
|
||||||
|
|
||||||
|
// Move hover cursor to same position unless hovering
|
||||||
|
// Regardless, we need this call because the internal mapping might have changed
|
||||||
|
if ((<HTMLElement>this.$refs.scroller).matches(":hover")) {
|
||||||
|
this.moveHoverCursor(this.hoverCursorY);
|
||||||
|
} else {
|
||||||
|
this.moveHoverCursor(rtop);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Re-create tick data in the next frame */
|
||||||
|
async reflow() {
|
||||||
|
if (this.reflowRequest) return;
|
||||||
|
this.reflowRequest = true;
|
||||||
|
await this.$nextTick();
|
||||||
|
this.reflowNow();
|
||||||
|
this.reflowRequest = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Re-create tick data */
|
||||||
|
reflowNow() {
|
||||||
|
// Ignore if not initialized
|
||||||
|
if (!this.recycler?.$refs.wrapper) return;
|
||||||
|
|
||||||
|
// Refresh height of recycler
|
||||||
|
this.recyclerHeight = this.recycler.$refs.wrapper.clientHeight;
|
||||||
|
|
||||||
|
// Recreate ticks data
|
||||||
|
this.recreate();
|
||||||
|
|
||||||
|
// Adjust top
|
||||||
|
this.adjustNow();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Recreate from scratch */
|
||||||
|
recreate() {
|
||||||
|
// Clear and override any adjust timer
|
||||||
|
this.ticks = [];
|
||||||
|
|
||||||
|
// Ticks
|
||||||
|
let prevYear = 9999;
|
||||||
|
let prevMonth = 0;
|
||||||
|
|
||||||
|
// Get a new tick
|
||||||
|
const getTick = (
|
||||||
|
dayId: number,
|
||||||
|
isMonth = false,
|
||||||
|
text?: string | number
|
||||||
|
): ITick => {
|
||||||
|
return {
|
||||||
|
dayId,
|
||||||
|
isMonth,
|
||||||
|
text,
|
||||||
|
y: 0,
|
||||||
|
count: 0,
|
||||||
|
topF: 0,
|
||||||
|
top: 0,
|
||||||
|
s: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Iterate over rows
|
||||||
|
for (const row of this.rows) {
|
||||||
|
if (row.type === IRowType.HEAD) {
|
||||||
// Create tick
|
// Create tick
|
||||||
const dtYear = dateTaken.getUTCFullYear();
|
if (this.TagDayIDValueSet.has(row.dayId)) {
|
||||||
const dtMonth = dateTaken.getUTCMonth();
|
// Blank tick
|
||||||
const isMonth = dtMonth !== prevMonth || dtYear !== prevYear;
|
this.ticks.push(getTick(row.dayId));
|
||||||
const text = dtYear === prevYear ? undefined : dtYear;
|
} else {
|
||||||
this.ticks.push(getTick(row.dayId, isMonth, text));
|
// Make date string
|
||||||
|
const dateTaken = utils.dayIdToDate(row.dayId);
|
||||||
|
|
||||||
prevMonth = dtMonth;
|
// Create tick
|
||||||
prevYear = dtYear;
|
const dtYear = dateTaken.getUTCFullYear();
|
||||||
|
const dtMonth = dateTaken.getUTCMonth();
|
||||||
|
const isMonth = dtMonth !== prevMonth || dtYear !== prevYear;
|
||||||
|
const text = dtYear === prevYear ? undefined : dtYear;
|
||||||
|
this.ticks.push(getTick(row.dayId, isMonth, text));
|
||||||
|
|
||||||
|
prevMonth = dtMonth;
|
||||||
|
prevYear = dtYear;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update tick positions without truncating the list
|
* Update tick positions without truncating the list
|
||||||
* This is much cheaper than reflowing the whole thing
|
* This is much cheaper than reflowing the whole thing
|
||||||
*/
|
*/
|
||||||
public async adjust() {
|
async adjust() {
|
||||||
if (this.adjustRequest) return;
|
if (this.adjustRequest) return;
|
||||||
this.adjustRequest = true;
|
this.adjustRequest = true;
|
||||||
await this.$nextTick();
|
await this.$nextTick();
|
||||||
this.adjustNow();
|
this.adjustNow();
|
||||||
this.adjustRequest = false;
|
this.adjustRequest = false;
|
||||||
}
|
},
|
||||||
|
|
||||||
/** Do adjustment synchronously */
|
/** Do adjustment synchronously */
|
||||||
private adjustNow() {
|
adjustNow() {
|
||||||
// Refresh height of recycler
|
// Refresh height of recycler
|
||||||
this.recyclerHeight = this.recycler.$refs.wrapper.clientHeight;
|
this.recyclerHeight = this.recycler.$refs.wrapper.clientHeight;
|
||||||
const extraY = this.recyclerBefore?.clientHeight || 0;
|
const extraY = this.recyclerBefore?.clientHeight || 0;
|
||||||
|
|
||||||
// Start with the first tick. Walk over all rows counting the
|
// Start with the first tick. Walk over all rows counting the
|
||||||
// y position. When you hit a row with the tick, update y and
|
// y position. When you hit a row with the tick, update y and
|
||||||
// top values and move to the next tick.
|
// top values and move to the next tick.
|
||||||
let tickId = 0;
|
let tickId = 0;
|
||||||
let y = extraY;
|
let y = extraY;
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
|
||||||
// We only need to recompute top and visible ticks if count
|
// We only need to recompute top and visible ticks if count
|
||||||
// of some tick has changed.
|
// of some tick has changed.
|
||||||
let needRecomputeTop = false;
|
let needRecomputeTop = false;
|
||||||
|
|
||||||
// Check if height changed
|
// Check if height changed
|
||||||
if (this.lastAdjustHeight !== this.height) {
|
if (this.lastAdjustHeight !== this.height) {
|
||||||
needRecomputeTop = true;
|
needRecomputeTop = true;
|
||||||
this.lastAdjustHeight = this.height;
|
this.lastAdjustHeight = this.height;
|
||||||
}
|
|
||||||
|
|
||||||
for (const row of this.rows) {
|
|
||||||
// Check if tick is valid
|
|
||||||
if (tickId >= this.ticks.length) break;
|
|
||||||
|
|
||||||
// Check if we hit the next tick
|
|
||||||
const tick = this.ticks[tickId];
|
|
||||||
if (tick.dayId === row.dayId) {
|
|
||||||
tick.y = y;
|
|
||||||
|
|
||||||
// Check if count has changed
|
|
||||||
needRecomputeTop ||= tick.count !== count;
|
|
||||||
tick.count = count;
|
|
||||||
|
|
||||||
// Move to next tick
|
|
||||||
count += row.day.count;
|
|
||||||
tickId++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
y += row.size;
|
for (const row of this.rows) {
|
||||||
}
|
// Check if tick is valid
|
||||||
|
if (tickId >= this.ticks.length) break;
|
||||||
|
|
||||||
// Compute visible ticks
|
// Check if we hit the next tick
|
||||||
if (needRecomputeTop) {
|
const tick = this.ticks[tickId];
|
||||||
this.setTicksTop(count);
|
if (tick.dayId === row.dayId) {
|
||||||
this.computeVisibleTicks();
|
tick.y = y;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mark ticks as visible or invisible */
|
// Check if count has changed
|
||||||
private computeVisibleTicks() {
|
needRecomputeTop ||= tick.count !== count;
|
||||||
// Kind of unrelated here, but refresh rect
|
tick.count = count;
|
||||||
this.scrollerRect = (
|
|
||||||
this.$refs.scroller as HTMLElement
|
|
||||||
).getBoundingClientRect();
|
|
||||||
|
|
||||||
// Do another pass to figure out which points are visible
|
// Move to next tick
|
||||||
// This is not as bad as it looks, it's actually 12*O(n)
|
count += row.day.count;
|
||||||
// because there are only 12 months in a year
|
tickId++;
|
||||||
const fontSizePx = parseFloat(
|
|
||||||
getComputedStyle(this.$refs.cursorSt as any).fontSize
|
|
||||||
);
|
|
||||||
const minGap = fontSizePx + (globalThis.windowInnerWidth <= 768 ? 5 : 2);
|
|
||||||
let prevShow = -9999;
|
|
||||||
for (const [idx, tick] of this.ticks.entries()) {
|
|
||||||
// Conservative
|
|
||||||
tick.s = false;
|
|
||||||
|
|
||||||
// These aren't for showing
|
|
||||||
if (!tick.isMonth) continue;
|
|
||||||
|
|
||||||
// You can't see these anyway, why bother?
|
|
||||||
if (tick.top < minGap || tick.top > this.height - minGap) continue;
|
|
||||||
|
|
||||||
// Will overlap with the previous tick. Skip anyway.
|
|
||||||
if (tick.top - prevShow < minGap) continue;
|
|
||||||
|
|
||||||
// This is a labelled tick then show it anyway for the sake of best effort
|
|
||||||
if (tick.text) {
|
|
||||||
prevShow = tick.top;
|
|
||||||
tick.s = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lookahead for next labelled tick
|
|
||||||
// If showing this tick would overlap the next one, don't show this one
|
|
||||||
let i = idx + 1;
|
|
||||||
while (i < this.ticks.length) {
|
|
||||||
if (this.ticks[i].text) {
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
i++;
|
|
||||||
|
y += row.size;
|
||||||
}
|
}
|
||||||
if (i < this.ticks.length) {
|
|
||||||
// A labelled tick was found
|
// Compute visible ticks
|
||||||
const nextLabelledTick = this.ticks[i];
|
if (needRecomputeTop) {
|
||||||
if (
|
this.setTicksTop(count);
|
||||||
tick.top + minGap > nextLabelledTick.top &&
|
this.computeVisibleTicks();
|
||||||
nextLabelledTick.top < this.height - minGap
|
}
|
||||||
) {
|
},
|
||||||
// make sure this will be shown
|
|
||||||
|
/** Mark ticks as visible or invisible */
|
||||||
|
computeVisibleTicks() {
|
||||||
|
// Kind of unrelated here, but refresh rect
|
||||||
|
this.scrollerRect = (
|
||||||
|
this.$refs.scroller as HTMLElement
|
||||||
|
).getBoundingClientRect();
|
||||||
|
|
||||||
|
// 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
|
||||||
|
const fontSizePx = parseFloat(
|
||||||
|
getComputedStyle(this.$refs.cursorSt as any).fontSize
|
||||||
|
);
|
||||||
|
const minGap = fontSizePx + (globalThis.windowInnerWidth <= 768 ? 5 : 2);
|
||||||
|
let prevShow = -9999;
|
||||||
|
for (const [idx, tick] of this.ticks.entries()) {
|
||||||
|
// Conservative
|
||||||
|
tick.s = false;
|
||||||
|
|
||||||
|
// These aren't for showing
|
||||||
|
if (!tick.isMonth) continue;
|
||||||
|
|
||||||
|
// You can't see these anyway, why bother?
|
||||||
|
if (tick.top < minGap || tick.top > this.height - minGap) continue;
|
||||||
|
|
||||||
|
// Will overlap with the previous tick. Skip anyway.
|
||||||
|
if (tick.top - prevShow < minGap) continue;
|
||||||
|
|
||||||
|
// This is a labelled tick then show it anyway for the sake of best effort
|
||||||
|
if (tick.text) {
|
||||||
|
prevShow = tick.top;
|
||||||
|
tick.s = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lookahead for next labelled tick
|
||||||
|
// If showing this tick would overlap the next one, don't show this one
|
||||||
|
let i = idx + 1;
|
||||||
|
while (i < this.ticks.length) {
|
||||||
|
if (this.ticks[i].text) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (i < this.ticks.length) {
|
||||||
|
// A labelled tick was found
|
||||||
|
const nextLabelledTick = this.ticks[i];
|
||||||
|
if (
|
||||||
|
tick.top + minGap > nextLabelledTick.top &&
|
||||||
|
nextLabelledTick.top < this.height - minGap
|
||||||
|
) {
|
||||||
|
// make sure this will be shown
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show this tick
|
||||||
|
tick.s = true;
|
||||||
|
prevShow = tick.top;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setTicksTop(total: number) {
|
||||||
|
for (const tick of this.ticks) {
|
||||||
|
tick.topF = this.height * (tick.count / total);
|
||||||
|
tick.top = utils.roundHalf(tick.topF);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Change actual position of the hover cursor */
|
||||||
|
moveHoverCursor(y: number) {
|
||||||
|
this.hoverCursorY = y;
|
||||||
|
|
||||||
|
// Get index of previous tick
|
||||||
|
let idx = utils.binarySearch(this.ticks, y, "topF");
|
||||||
|
if (idx === 0) {
|
||||||
|
// use this tick
|
||||||
|
} else if (idx >= 1 && idx <= this.ticks.length) {
|
||||||
|
idx = idx - 1;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show this tick
|
// DayId of current hover
|
||||||
tick.s = true;
|
const dayId = this.ticks[idx]?.dayId;
|
||||||
prevShow = tick.top;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private setTicksTop(total: number) {
|
// Special days
|
||||||
for (const tick of this.ticks) {
|
if (dayId === undefined || this.TagDayIDValueSet.has(dayId)) {
|
||||||
tick.topF = this.height * (tick.count / total);
|
this.hoverCursorText = "";
|
||||||
tick.top = utils.roundHalf(tick.topF);
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/** Change actual position of the hover cursor */
|
const date = utils.dayIdToDate(dayId);
|
||||||
private moveHoverCursor(y: number) {
|
this.hoverCursorText = utils.getShortDateStr(date);
|
||||||
this.hoverCursorY = y;
|
},
|
||||||
|
|
||||||
// Get index of previous tick
|
/** Handle mouse hover */
|
||||||
let idx = utils.binarySearch(this.ticks, y, "topF");
|
mousemove(event: MouseEvent) {
|
||||||
if (idx === 0) {
|
if (event.buttons) {
|
||||||
// use this tick
|
this.mousedown(event);
|
||||||
} else if (idx >= 1 && idx <= this.ticks.length) {
|
}
|
||||||
idx = idx - 1;
|
this.moveHoverCursor(event.offsetY);
|
||||||
} else {
|
},
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// DayId of current hover
|
/** Handle mouse leave */
|
||||||
const dayId = this.ticks[idx]?.dayId;
|
mouseleave() {
|
||||||
|
this.interactend();
|
||||||
|
this.moveHoverCursor(this.cursorY);
|
||||||
|
},
|
||||||
|
|
||||||
// Special days
|
/** Binary search and get coords surrounding position */
|
||||||
if (dayId === undefined || this.TagDayIDValueSet.has(dayId)) {
|
getCoords(y: number, field: "topF" | "y") {
|
||||||
this.hoverCursorText = "";
|
// Top of first and second ticks
|
||||||
return;
|
let top1 = 0,
|
||||||
}
|
top2 = 0,
|
||||||
|
y1 = 0,
|
||||||
|
y2 = 0;
|
||||||
|
|
||||||
const date = utils.dayIdToDate(dayId);
|
// Get index of previous tick
|
||||||
this.hoverCursorText = utils.getShortDateStr(date);
|
let idx = utils.binarySearch(this.ticks, y, field);
|
||||||
}
|
if (idx <= 0) {
|
||||||
|
top1 = 0;
|
||||||
|
top2 = this.ticks[0].topF;
|
||||||
|
y1 = 0;
|
||||||
|
y2 = this.ticks[0].y;
|
||||||
|
} else if (idx >= this.ticks.length) {
|
||||||
|
const t = this.ticks[this.ticks.length - 1];
|
||||||
|
top1 = t.topF;
|
||||||
|
top2 = this.height;
|
||||||
|
y1 = t.y;
|
||||||
|
y2 = this.recyclerHeight;
|
||||||
|
} else {
|
||||||
|
const t1 = this.ticks[idx - 1];
|
||||||
|
const t2 = this.ticks[idx];
|
||||||
|
top1 = t1.topF;
|
||||||
|
top2 = t2.topF;
|
||||||
|
y1 = t1.y;
|
||||||
|
y2 = t2.y;
|
||||||
|
}
|
||||||
|
|
||||||
/** Handle mouse hover */
|
return { top1, top2, y1, y2 };
|
||||||
private mousemove(event: MouseEvent) {
|
},
|
||||||
if (event.buttons) {
|
|
||||||
this.mousedown(event);
|
|
||||||
}
|
|
||||||
this.moveHoverCursor(event.offsetY);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Handle mouse leave */
|
/** Move to given scroller Y */
|
||||||
private mouseleave() {
|
moveto(y: number, snap: boolean) {
|
||||||
this.interactend();
|
// Move cursor immediately to prevent jank
|
||||||
this.moveHoverCursor(this.cursorY);
|
this.cursorY = y;
|
||||||
}
|
this.hoverCursorY = y;
|
||||||
|
|
||||||
/** Binary search and get coords surrounding position */
|
const { top1, top2, y1, y2 } = this.getCoords(y, "topF");
|
||||||
private getCoords(y: number, field: "topF" | "y") {
|
const yfrac = (y - top1) / (top2 - top1);
|
||||||
// Top of first and second ticks
|
const ry = y1 + (y2 - y1) * (yfrac || 0);
|
||||||
let top1 = 0,
|
const targetY = snap ? y1 + SNAP_OFFSET : ry;
|
||||||
top2 = 0,
|
|
||||||
y1 = 0,
|
|
||||||
y2 = 0;
|
|
||||||
|
|
||||||
// Get index of previous tick
|
if (this.lastRequestedRecyclerY !== targetY) {
|
||||||
let idx = utils.binarySearch(this.ticks, y, field);
|
this.lastRequestedRecyclerY = targetY;
|
||||||
if (idx <= 0) {
|
this.recycler.scrollToPosition(targetY);
|
||||||
top1 = 0;
|
}
|
||||||
top2 = this.ticks[0].topF;
|
|
||||||
y1 = 0;
|
|
||||||
y2 = this.ticks[0].y;
|
|
||||||
} else if (idx >= this.ticks.length) {
|
|
||||||
const t = this.ticks[this.ticks.length - 1];
|
|
||||||
top1 = t.topF;
|
|
||||||
top2 = this.height;
|
|
||||||
y1 = t.y;
|
|
||||||
y2 = this.recyclerHeight;
|
|
||||||
} else {
|
|
||||||
const t1 = this.ticks[idx - 1];
|
|
||||||
const t2 = this.ticks[idx];
|
|
||||||
top1 = t1.topF;
|
|
||||||
top2 = t2.topF;
|
|
||||||
y1 = t1.y;
|
|
||||||
y2 = t2.y;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { top1, top2, y1, y2 };
|
this.handleScroll();
|
||||||
}
|
},
|
||||||
|
|
||||||
/** Move to given scroller Y */
|
/** Handle mouse click */
|
||||||
private moveto(y: number, snap: boolean) {
|
mousedown(event: MouseEvent) {
|
||||||
// Move cursor immediately to prevent jank
|
this.interactstart(); // end called on mouseup
|
||||||
this.cursorY = y;
|
this.moveto(event.offsetY, false);
|
||||||
this.hoverCursorY = y;
|
},
|
||||||
|
|
||||||
const { top1, top2, y1, y2 } = this.getCoords(y, "topF");
|
/** Handle touch */
|
||||||
const yfrac = (y - top1) / (top2 - top1);
|
touchmove(event: any) {
|
||||||
const ry = y1 + (y2 - y1) * (yfrac || 0);
|
let y = event.targetTouches[0].pageY - this.scrollerRect.top;
|
||||||
const targetY = snap ? y1 + SNAP_OFFSET : ry;
|
y = Math.max(0, y - 20); // middle of touch finger
|
||||||
|
this.moveto(y, true);
|
||||||
|
},
|
||||||
|
|
||||||
if (this.lastRequestedRecyclerY !== targetY) {
|
interactstart() {
|
||||||
this.lastRequestedRecyclerY = targetY;
|
this.interacting = true;
|
||||||
this.recycler.scrollToPosition(targetY);
|
},
|
||||||
}
|
|
||||||
|
|
||||||
this.handleScroll();
|
interactend() {
|
||||||
}
|
this.interacting = false;
|
||||||
|
this.recyclerScrolled(); // make sure final position is correct
|
||||||
|
},
|
||||||
|
|
||||||
/** Handle mouse click */
|
/** Update scroller is being used to scroll recycler */
|
||||||
private mousedown(event: MouseEvent) {
|
handleScroll() {
|
||||||
this.interactstart(); // end called on mouseup
|
utils.setRenewingTimeout(this, "scrollingNowTimer", null, 200);
|
||||||
this.moveto(event.offsetY, false);
|
utils.setRenewingTimeout(this, "scrollingTimer", null, 1500);
|
||||||
}
|
},
|
||||||
|
},
|
||||||
/** Handle touch */
|
});
|
||||||
private touchmove(event: any) {
|
|
||||||
let y = event.targetTouches[0].pageY - this.scrollerRect.top;
|
|
||||||
y = Math.max(0, y - 20); // middle of touch finger
|
|
||||||
this.moveto(y, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private interactstart() {
|
|
||||||
this.interacting = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private interactend() {
|
|
||||||
this.interacting = false;
|
|
||||||
this.recyclerScrolled(); // make sure final position is correct
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Update scroller is being used to scroll recycler */
|
|
||||||
private handleScroll() {
|
|
||||||
utils.setRenewingTimeout(this, "scrollingNowTimer", null, 200);
|
|
||||||
utils.setRenewingTimeout(this, "scrollingTimer", null, 1500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -69,9 +69,7 @@ input[type="text"] {
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Mixins } from "vue-property-decorator";
|
import { defineComponent } from "vue";
|
||||||
import GlobalMixin from "../mixins/GlobalMixin";
|
|
||||||
import UserConfig from "../mixins/UserConfig";
|
|
||||||
|
|
||||||
import { getFilePickerBuilder } from "@nextcloud/dialogs";
|
import { getFilePickerBuilder } from "@nextcloud/dialogs";
|
||||||
const NcCheckboxRadioSwitch = () =>
|
const NcCheckboxRadioSwitch = () =>
|
||||||
|
@ -79,62 +77,69 @@ const NcCheckboxRadioSwitch = () =>
|
||||||
|
|
||||||
import MultiPathSelectionModal from "./modal/MultiPathSelectionModal.vue";
|
import MultiPathSelectionModal from "./modal/MultiPathSelectionModal.vue";
|
||||||
|
|
||||||
@Component({
|
export default defineComponent({
|
||||||
|
name: "Settings",
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
NcCheckboxRadioSwitch,
|
NcCheckboxRadioSwitch,
|
||||||
MultiPathSelectionModal,
|
MultiPathSelectionModal,
|
||||||
},
|
},
|
||||||
})
|
|
||||||
export default class Settings extends Mixins(UserConfig, GlobalMixin) {
|
|
||||||
get pathSelTitle() {
|
|
||||||
return this.t("memories", "Choose Timeline Paths");
|
|
||||||
}
|
|
||||||
|
|
||||||
async chooseFolder(title: string, initial: string) {
|
computed: {
|
||||||
const picker = getFilePickerBuilder(title)
|
pathSelTitle() {
|
||||||
.setMultiSelect(false)
|
return this.t("memories", "Choose Timeline Paths");
|
||||||
.setModal(true)
|
},
|
||||||
.setType(1)
|
},
|
||||||
.addMimeTypeFilter("httpd/unix-directory")
|
|
||||||
.allowDirectories()
|
|
||||||
.startAt(initial)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
return await picker.pick();
|
methods: {
|
||||||
}
|
async chooseFolder(title: string, initial: string) {
|
||||||
|
const picker = getFilePickerBuilder(title)
|
||||||
|
.setMultiSelect(false)
|
||||||
|
.setModal(true)
|
||||||
|
.setType(1)
|
||||||
|
.addMimeTypeFilter("httpd/unix-directory")
|
||||||
|
.allowDirectories()
|
||||||
|
.startAt(initial)
|
||||||
|
.build();
|
||||||
|
|
||||||
async chooseTimelinePath() {
|
return await picker.pick();
|
||||||
(<any>this.$refs.multiPathModal).open(this.config_timelinePath.split(";"));
|
},
|
||||||
}
|
|
||||||
|
|
||||||
async saveTimelinePath(paths: string[]) {
|
async chooseTimelinePath() {
|
||||||
if (!paths || !paths.length) return;
|
(<any>this.$refs.multiPathModal).open(
|
||||||
|
this.config_timelinePath.split(";")
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
const newPath = paths.join(";");
|
async saveTimelinePath(paths: string[]) {
|
||||||
if (newPath !== this.config_timelinePath) {
|
if (!paths || !paths.length) return;
|
||||||
this.config_timelinePath = newPath;
|
|
||||||
await this.updateSetting("timelinePath");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async chooseFoldersPath() {
|
const newPath = paths.join(";");
|
||||||
let newPath = await this.chooseFolder(
|
if (newPath !== this.config_timelinePath) {
|
||||||
this.t("memories", "Choose the root for the folders view"),
|
this.config_timelinePath = newPath;
|
||||||
this.config_foldersPath
|
await this.updateSetting("timelinePath");
|
||||||
);
|
}
|
||||||
if (newPath === "") newPath = "/";
|
},
|
||||||
if (newPath !== this.config_foldersPath) {
|
|
||||||
this.config_foldersPath = newPath;
|
|
||||||
await this.updateSetting("foldersPath");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateSquareThumbs() {
|
async chooseFoldersPath() {
|
||||||
await this.updateSetting("squareThumbs");
|
let newPath = await this.chooseFolder(
|
||||||
}
|
this.t("memories", "Choose the root for the folders view"),
|
||||||
|
this.config_foldersPath
|
||||||
|
);
|
||||||
|
if (newPath === "") newPath = "/";
|
||||||
|
if (newPath !== this.config_foldersPath) {
|
||||||
|
this.config_foldersPath = newPath;
|
||||||
|
await this.updateSetting("foldersPath");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async updateShowHidden() {
|
async updateSquareThumbs() {
|
||||||
await this.updateSetting("showHidden");
|
await this.updateSetting("squareThumbs");
|
||||||
}
|
},
|
||||||
}
|
|
||||||
|
async updateShowHidden() {
|
||||||
|
await this.updateSetting("showHidden");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
</script>
|
</script>
|
File diff suppressed because it is too large
Load Diff
|
@ -29,82 +29,89 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Prop, Watch, Mixins } from "vue-property-decorator";
|
import { defineComponent, PropType } from "vue";
|
||||||
import { IFolder, IPhoto } from "../../types";
|
import { IFolder, IPhoto } from "../../types";
|
||||||
import GlobalMixin from "../../mixins/GlobalMixin";
|
|
||||||
import UserConfig from "../../mixins/UserConfig";
|
|
||||||
|
|
||||||
import { getPreviewUrl } from "../../services/FileUtils";
|
import { getPreviewUrl } from "../../services/FileUtils";
|
||||||
|
|
||||||
import FolderIcon from "vue-material-design-icons/Folder.vue";
|
import FolderIcon from "vue-material-design-icons/Folder.vue";
|
||||||
|
|
||||||
@Component({
|
export default defineComponent({
|
||||||
|
name: "Folder",
|
||||||
components: {
|
components: {
|
||||||
FolderIcon,
|
FolderIcon,
|
||||||
},
|
},
|
||||||
})
|
|
||||||
export default class Folder extends Mixins(GlobalMixin, UserConfig) {
|
|
||||||
@Prop() data: IFolder;
|
|
||||||
|
|
||||||
// Separate property because the one on data isn't reactive
|
props: {
|
||||||
private previews: IPhoto[] = [];
|
data: Object as PropType<IFolder>,
|
||||||
|
},
|
||||||
|
|
||||||
// Error occured fetching thumbs
|
data() {
|
||||||
private error = false;
|
return {
|
||||||
|
// Separate property because the one on data isn't reactive
|
||||||
|
previews: [] as IPhoto[],
|
||||||
|
// Error occured fetching thumbs
|
||||||
|
error: false,
|
||||||
|
// Passthrough
|
||||||
|
getPreviewUrl,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
/** Passthrough */
|
computed: {
|
||||||
private getPreviewUrl = getPreviewUrl;
|
/** Open folder */
|
||||||
|
target() {
|
||||||
|
const path = this.data.path
|
||||||
|
.split("/")
|
||||||
|
.filter((x) => x)
|
||||||
|
.slice(2) as string[];
|
||||||
|
|
||||||
|
// Remove base path if present
|
||||||
|
const basePath = this.config_foldersPath.split("/").filter((x) => x);
|
||||||
|
if (
|
||||||
|
path.length >= basePath.length &&
|
||||||
|
path.slice(0, basePath.length).every((x, i) => x === basePath[i])
|
||||||
|
) {
|
||||||
|
path.splice(0, basePath.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name: "folders", params: { path: path as any } };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.refreshPreviews();
|
this.refreshPreviews();
|
||||||
}
|
},
|
||||||
|
|
||||||
@Watch("data")
|
watch: {
|
||||||
dataChanged() {
|
data() {
|
||||||
this.refreshPreviews();
|
this.refreshPreviews();
|
||||||
}
|
},
|
||||||
|
},
|
||||||
|
|
||||||
/** Refresh previews */
|
methods: {
|
||||||
refreshPreviews() {
|
/** Refresh previews */
|
||||||
// Reset state
|
refreshPreviews() {
|
||||||
this.error = false;
|
// Reset state
|
||||||
|
this.error = false;
|
||||||
|
|
||||||
// Check if valid path present
|
// Check if valid path present
|
||||||
if (!this.data.path) {
|
if (!this.data.path) {
|
||||||
this.error = true;
|
this.error = true;
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
// Get preview infos
|
|
||||||
const previews = this.data.previews;
|
|
||||||
if (previews) {
|
|
||||||
if (previews.length > 0 && previews.length < 4) {
|
|
||||||
this.previews = [previews[0]];
|
|
||||||
} else {
|
|
||||||
this.previews = previews.slice(0, 4);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Open folder */
|
// Get preview infos
|
||||||
get target() {
|
const previews = this.data.previews;
|
||||||
const path = this.data.path
|
if (previews) {
|
||||||
.split("/")
|
if (previews.length > 0 && previews.length < 4) {
|
||||||
.filter((x) => x)
|
this.previews = [previews[0]];
|
||||||
.slice(2) as string[];
|
} else {
|
||||||
|
this.previews = previews.slice(0, 4);
|
||||||
// Remove base path if present
|
}
|
||||||
const basePath = this.config_foldersPath.split("/").filter((x) => x);
|
}
|
||||||
if (
|
},
|
||||||
path.length >= basePath.length &&
|
},
|
||||||
path.slice(0, basePath.length).every((x, i) => x === basePath[i])
|
});
|
||||||
) {
|
|
||||||
path.splice(0, basePath.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { name: "folders", params: { path: path as any } };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|
|
@ -4,6 +4,8 @@ import "reflect-metadata";
|
||||||
import Vue from "vue";
|
import Vue from "vue";
|
||||||
import VueVirtualScroller from "vue-virtual-scroller";
|
import VueVirtualScroller from "vue-virtual-scroller";
|
||||||
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
||||||
|
import GlobalMixin from "./mixins/GlobalMixin";
|
||||||
|
import UserConfig from "./mixins/UserConfig";
|
||||||
|
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
import router from "./router";
|
import router from "./router";
|
||||||
|
@ -64,6 +66,8 @@ if (!globalThis.videoClientIdPersistent) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Vue.mixin(GlobalMixin);
|
||||||
|
Vue.mixin(UserConfig);
|
||||||
Vue.use(VueVirtualScroller);
|
Vue.use(VueVirtualScroller);
|
||||||
|
|
||||||
// https://github.com/nextcloud/photos/blob/156f280c0476c483cb9ce81769ccb0c1c6500a4e/src/main.js
|
// https://github.com/nextcloud/photos/blob/156f280c0476c483cb9ce81769ccb0c1c6500a4e/src/main.js
|
||||||
|
|
Loading…
Reference in New Issue