Write custom Path and Segment classes
Also fixes how pinning works in some edge cases.
This commit is contained in:
parent
0b485e6cfe
commit
b29b3c1e4e
6 changed files with 149 additions and 77 deletions
|
|
@ -1,8 +1,8 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import CNavbar from "./components/CNavbar.vue";
|
import CNavbar from "./components/CNavbar.vue";
|
||||||
import CNote from "./components/CNote.vue";
|
import CNote from "./components/CNote.vue";
|
||||||
|
import { Path, Segment } from "./lib/path";
|
||||||
import { useUiStore } from "./stores/ui";
|
import { useUiStore } from "./stores/ui";
|
||||||
import { pathAncestor } from "./util";
|
|
||||||
|
|
||||||
const ui = useUiStore();
|
const ui = useUiStore();
|
||||||
|
|
||||||
|
|
@ -10,7 +10,8 @@ window.addEventListener("keypress", (ev) => {
|
||||||
if (document.activeElement !== document.body) return;
|
if (document.activeElement !== document.body) return;
|
||||||
|
|
||||||
if (ev.key === "Escape") {
|
if (ev.key === "Escape") {
|
||||||
ui.focusPath = pathAncestor(ui.focusPath);
|
const parent = ui.focusPath.parent();
|
||||||
|
if (parent) ui.focusPath = parent;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -20,7 +21,12 @@ window.addEventListener("keypress", (ev) => {
|
||||||
<div class="flex h-screen touch-pan-x touch-pan-y flex-col">
|
<div class="flex h-screen touch-pan-x touch-pan-y flex-col">
|
||||||
<CNavbar />
|
<CNavbar />
|
||||||
<div class="h-full overflow-auto p-1 pr-5">
|
<div class="h-full overflow-auto p-1 pr-5">
|
||||||
<CNote v-if="ui.anchorId" :noteId="ui.anchorId" :path="''" :forceOpen="true" />
|
<CNote
|
||||||
|
v-if="ui.anchorId"
|
||||||
|
:path="new Path()"
|
||||||
|
:segment="new Segment(ui.anchorId, 0)"
|
||||||
|
:forceOpen="true"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Path, Segment } from "@/lib/path";
|
||||||
import { useNotesStore } from "@/stores/notes";
|
import { useNotesStore } from "@/stores/notes";
|
||||||
import { useUiStore } from "@/stores/ui";
|
import { useUiStore } from "@/stores/ui";
|
||||||
import { pathAppend, pathSlice } from "@/util";
|
|
||||||
import {
|
import {
|
||||||
RiAddLine,
|
RiAddLine,
|
||||||
RiArrowDownSLine,
|
RiArrowDownSLine,
|
||||||
|
|
@ -20,30 +20,30 @@ const notes = useNotesStore();
|
||||||
const ui = useUiStore();
|
const ui = useUiStore();
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
noteId: string;
|
path: Path; // From root to here
|
||||||
|
segment: Segment;
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
path: string; // From root to here
|
|
||||||
forceOpen?: boolean;
|
forceOpen?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const note = computed(() => notes.getNote(props.noteId));
|
const id = computed(() => props.segment.id);
|
||||||
|
const note = computed(() => notes.getNote(id.value));
|
||||||
|
|
||||||
const parents = computed(() => {
|
const parents = computed(() => {
|
||||||
let parents = notes.getParents(props.noteId);
|
let parents = notes.getParents(id.value);
|
||||||
if (props.parentId) parents = parents.difference(new Set([props.parentId]));
|
if (props.parentId) parents = parents.difference(new Set([props.parentId]));
|
||||||
return [...parents].sort().map((id) => ({ id, text: notes.getNote(id)?.text }));
|
return [...parents].sort().map((id) => ({ id, text: notes.getNote(id)?.text }));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Our children and their locally unique keys.
|
// Our children and the
|
||||||
const children = computed(() => {
|
const children = computed(() => {
|
||||||
if (!note.value) return [];
|
if (!note.value) return [];
|
||||||
const seen = new Map<string, number>();
|
const seen = new Map<string, number>();
|
||||||
const children: { id: string; key: string }[] = [];
|
const children = [];
|
||||||
for (const id of note.value.children) {
|
for (const id of note.value.children) {
|
||||||
const n = seen.get(id) || 0;
|
const iteration = seen.get(id) || 0;
|
||||||
seen.set(id, n + 1);
|
seen.set(id, iteration + 1);
|
||||||
const key = `${id}-${n}`;
|
children.push(new Segment(id, iteration));
|
||||||
children.push({ id, key });
|
|
||||||
}
|
}
|
||||||
return children;
|
return children;
|
||||||
});
|
});
|
||||||
|
|
@ -54,8 +54,8 @@ const mode = ref<"editing" | "creating">();
|
||||||
const mayOpen = computed(() => children.value.length > 0);
|
const mayOpen = computed(() => children.value.length > 0);
|
||||||
const open = computed(() => mayOpen.value && ui.isOpen(props.path));
|
const open = computed(() => mayOpen.value && ui.isOpen(props.path));
|
||||||
|
|
||||||
const focused = computed(() => ui.focusPath === props.path);
|
const focused = computed(() => ui.focusPath.eq(props.path));
|
||||||
const pinned = computed(() => ui.isPinned(props.path));
|
const pinned = computed(() => ui.isPinned(props.segment, props.parentId));
|
||||||
const hover = computed(() => hovering.value && mode.value !== "editing");
|
const hover = computed(() => hovering.value && mode.value !== "editing");
|
||||||
|
|
||||||
const creating = computed(() => mode.value === "creating");
|
const creating = computed(() => mode.value === "creating");
|
||||||
|
|
@ -91,7 +91,7 @@ function onClick() {
|
||||||
|
|
||||||
function onPinButtonClick() {
|
function onPinButtonClick() {
|
||||||
if (pinned.value) ui.unsetPinned();
|
if (pinned.value) ui.unsetPinned();
|
||||||
else ui.setPinned(props.path);
|
else ui.setPinned(props.segment, props.parentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onEditButtonClick() {
|
function onEditButtonClick() {
|
||||||
|
|
@ -125,7 +125,7 @@ function onCreateEditorFinish(text: string) {
|
||||||
note.value.children.push(newNote.id);
|
note.value.children.push(newNote.id);
|
||||||
|
|
||||||
const lastChild = children.value.at(-1);
|
const lastChild = children.value.at(-1);
|
||||||
if (lastChild) ui.focusPath = pathAppend(props.path, lastChild.key);
|
if (lastChild) ui.focusPath = props.path.concat(lastChild);
|
||||||
|
|
||||||
onCreateEditorClose();
|
onCreateEditorClose();
|
||||||
}
|
}
|
||||||
|
|
@ -152,9 +152,9 @@ function onCreateEditorFinish(text: string) {
|
||||||
<div
|
<div
|
||||||
class="rounded"
|
class="rounded"
|
||||||
:class="focused ? 'hover:bg-neutral-300' : 'hover:bg-neutral-200'"
|
:class="focused ? 'hover:bg-neutral-300' : 'hover:bg-neutral-200'"
|
||||||
@click.stop="ui.toggleOpen(props.path)"
|
@click.stop="ui.toggleOpen(path)"
|
||||||
>
|
>
|
||||||
<RiArrowDownSLine v-if="open && props.forceOpen" size="16px" class="text-neutral-400" />
|
<RiArrowDownSLine v-if="open && forceOpen" size="16px" class="text-neutral-400" />
|
||||||
<RiArrowDownSLine v-else-if="open" size="16px" />
|
<RiArrowDownSLine v-else-if="open" size="16px" />
|
||||||
<RiArrowRightSLine v-else-if="mayOpen" size="16px" />
|
<RiArrowRightSLine v-else-if="mayOpen" size="16px" />
|
||||||
<RiArrowRightSLine v-else size="16px" class="text-neutral-400" />
|
<RiArrowRightSLine v-else size="16px" class="text-neutral-400" />
|
||||||
|
|
@ -194,10 +194,10 @@ function onCreateEditorFinish(text: string) {
|
||||||
<div v-if="open && children.length > 0" class="flex flex-col pl-2">
|
<div v-if="open && children.length > 0" class="flex flex-col pl-2">
|
||||||
<CNote
|
<CNote
|
||||||
v-for="child of children"
|
v-for="child of children"
|
||||||
:key="child.key"
|
:key="child.fmt()"
|
||||||
:noteId="child.id"
|
:path="path.concat(child)"
|
||||||
:parentId="props.noteId"
|
:segment="child"
|
||||||
:path="pathAppend(path, child.key)"
|
:parentId="id"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
16
gdn-app/src/lib/assert.ts
Normal file
16
gdn-app/src/lib/assert.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
export class AssertionError extends Error {}
|
||||||
|
|
||||||
|
export function assert(condition: boolean, description?: string): asserts condition {
|
||||||
|
if (condition) return;
|
||||||
|
if (description === undefined) {
|
||||||
|
description = "assertion failed";
|
||||||
|
console.error("assertion failed");
|
||||||
|
} else {
|
||||||
|
console.error("assertion failed:", description);
|
||||||
|
}
|
||||||
|
throw new AssertionError(description);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertUnreachable(): never {
|
||||||
|
assert(false, "unreachable code reached");
|
||||||
|
}
|
||||||
81
gdn-app/src/lib/path.ts
Normal file
81
gdn-app/src/lib/path.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { assert } from "./assert";
|
||||||
|
|
||||||
|
export class Segment {
|
||||||
|
constructor(
|
||||||
|
readonly id: string,
|
||||||
|
readonly iteration: number,
|
||||||
|
) {
|
||||||
|
assert(Number.isInteger(iteration), "n must be an integer");
|
||||||
|
assert(iteration >= 0), "n must not be negative";
|
||||||
|
}
|
||||||
|
|
||||||
|
static parse(text: string): Segment {
|
||||||
|
const match = text.match(/^([^/]+):([0-9]{1,10})$/);
|
||||||
|
assert(match !== null, "invalid segment string");
|
||||||
|
return new Segment(match[1]!, Number.parseInt(match[2]!));
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt(): string {
|
||||||
|
return `${this.id}:${this.iteration}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: Segment): boolean {
|
||||||
|
return this.fmt() === other.fmt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Path {
|
||||||
|
readonly segments: readonly Segment[];
|
||||||
|
|
||||||
|
constructor(segments: Segment[] = []) {
|
||||||
|
this.segments = segments.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
static parse(text: string): Path {
|
||||||
|
if (text === "") return new Path();
|
||||||
|
return new Path(text.split("/").map((it) => Segment.parse(it)));
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt(): string {
|
||||||
|
return this.segments.map((it) => it.fmt()).join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: Path): boolean {
|
||||||
|
return this.fmt() === other.fmt();
|
||||||
|
}
|
||||||
|
|
||||||
|
slice(start?: number, end?: number): Path {
|
||||||
|
return new Path(this.segments.slice(start, end));
|
||||||
|
}
|
||||||
|
|
||||||
|
concat(...other: (Path | Segment)[]): Path {
|
||||||
|
const result = this.segments.slice();
|
||||||
|
for (const part of other) {
|
||||||
|
if (part instanceof Segment) result.push(part);
|
||||||
|
else result.push(...part.segments);
|
||||||
|
}
|
||||||
|
return new Path(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
parent(): Path | undefined {
|
||||||
|
if (this.segments.length === 0) return undefined;
|
||||||
|
return this.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All ancestors of this path (including the path itself), ordered by
|
||||||
|
* decreasing length.
|
||||||
|
*/
|
||||||
|
ancestors(): Path[] {
|
||||||
|
const result = [];
|
||||||
|
for (let i = this.segments.length; i >= 0; i--) {
|
||||||
|
result.push(this.slice(0, i));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
isPrefixOf(path: Path): boolean {
|
||||||
|
if (path.segments.length < this.segments.length) return false;
|
||||||
|
return path.slice(0, this.segments.length).eq(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,47 +1,49 @@
|
||||||
import { pathAncestors, pathLiesOn, pathSlice } from "@/util";
|
import { Segment, Path as UiPath } from "@/lib/path";
|
||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { ref, watchEffect } from "vue";
|
import { ref, watchEffect } from "vue";
|
||||||
|
|
||||||
export const useUiStore = defineStore("ui", () => {
|
export const useUiStore = defineStore("ui", () => {
|
||||||
const anchorId = ref<string>();
|
const anchorId = ref<string>();
|
||||||
const focusPath = ref<string>("");
|
const focusPath = ref<UiPath>(new UiPath());
|
||||||
const openPaths = ref<Set<string>>(new Set());
|
const openPaths = ref<Set<string>>(new Set());
|
||||||
const pinned = ref<string>(); // The last two segments of the path
|
const pinned = ref<{ segment: Segment; parentId?: string }>();
|
||||||
|
|
||||||
// Ensure all nodes on the focusPath are unfolded.
|
// Ensure all nodes on the focusPath are unfolded.
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
// The node pointed to by the path itself doesn't need to be unfolded.
|
// The node pointed to by the path itself doesn't need to be unfolded.
|
||||||
for (const ancestor of pathAncestors(focusPath.value).slice(1)) {
|
for (const ancestor of focusPath.value.ancestors().slice(1)) {
|
||||||
openPaths.value.add(ancestor);
|
setOpen(ancestor, true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function isOpen(path: string): boolean {
|
function isOpen(path: UiPath): boolean {
|
||||||
return openPaths.value.has(path);
|
return openPaths.value.has(path.fmt());
|
||||||
}
|
|
||||||
|
|
||||||
function setOpen(path: string, value: boolean) {
|
|
||||||
// Move the focusPath if necessary
|
|
||||||
if (!value && isOpen(path) && pathLiesOn(path, focusPath.value)) {
|
|
||||||
focusPath.value = path;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setOpen(path: UiPath, value: boolean) {
|
||||||
// Don't update openPaths unnecessarily.
|
// Don't update openPaths unnecessarily.
|
||||||
// Just in case vue itself doesn't debounce Set operations.
|
// Just in case vue itself doesn't debounce Set operations.
|
||||||
if (value && !isOpen(path)) openPaths.value.add(path);
|
if (value && !isOpen(path)) {
|
||||||
else if (!value && isOpen(path)) openPaths.value.delete(path);
|
openPaths.value.add(path.fmt());
|
||||||
|
} else if (!value && isOpen(path)) {
|
||||||
|
// Move the focusPath if necessary
|
||||||
|
if (path.isPrefixOf(focusPath.value)) focusPath.value = path;
|
||||||
|
|
||||||
|
openPaths.value.delete(path.fmt());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleOpen(path: string) {
|
function toggleOpen(path: UiPath) {
|
||||||
setOpen(path, !isOpen(path));
|
setOpen(path, !isOpen(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPinned(path: string): boolean {
|
function isPinned(segment: Segment, parentId?: string): boolean {
|
||||||
return pathSlice(path, -2) === pinned.value;
|
if (!pinned.value) return false;
|
||||||
|
return pinned.value.segment.eq(segment) && pinned.value.parentId === parentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setPinned(path: string) {
|
function setPinned(segment: Segment, parentId?: string) {
|
||||||
pinned.value = pathSlice(path, -2);
|
pinned.value = { segment, parentId };
|
||||||
}
|
}
|
||||||
|
|
||||||
function unsetPinned() {
|
function unsetPinned() {
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
function pathString(keys: string[]): string {
|
|
||||||
return keys.join("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
function pathParse(path: string): string[] {
|
|
||||||
if (path === "") return [];
|
|
||||||
return path.split("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pathSlice(path: string, start: number, end?: number): string {
|
|
||||||
return pathString(pathParse(path).slice(start, end));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pathAppend(path: string, key: string): string {
|
|
||||||
return pathString(pathParse(path).concat(key));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pathAncestor(path: string): string {
|
|
||||||
return pathSlice(path, 0, -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pathAncestors(path: string): string[] {
|
|
||||||
const parsedPath = pathParse(path);
|
|
||||||
const result = [];
|
|
||||||
for (let i = parsedPath.length; i >= 0; i--) {
|
|
||||||
result.push(pathString(parsedPath.slice(0, i)));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pathLiesOn(p1: string, p2: string): boolean {
|
|
||||||
return pathAncestors(p2).indexOf(p1) >= 0;
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue