Refactor ui state

Now with more mode!
This commit is contained in:
Joscha 2025-02-12 00:08:45 +01:00
parent 4710f19b1e
commit 2f9b1925dc
3 changed files with 119 additions and 85 deletions

View file

@ -9,9 +9,9 @@ const ui = useUiStore();
window.addEventListener("keypress", (ev) => { 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.mode === "focus") {
const parent = ui.focusPath.parent(); const parent = ui.focusPath.parent();
if (parent) ui.focusPath = parent; if (parent) ui.focusOn(parent);
return; return;
} }
}); });

View file

@ -11,7 +11,6 @@ import {
RiEditLine, RiEditLine,
RiPushpinFill, RiPushpinFill,
RiPushpinLine, RiPushpinLine,
RiStickyNoteAddLine,
} from "@remixicon/vue"; } from "@remixicon/vue";
import { computed, ref, watchEffect } from "vue"; import { computed, ref, watchEffect } from "vue";
import CNoteButton from "./CNoteButton.vue"; import CNoteButton from "./CNoteButton.vue";
@ -55,40 +54,24 @@ const children = computed(() => {
}); });
const hovering = ref(false); const hovering = ref(false);
const mode = ref<"editing" | "creating">(); const hover = computed(() => hovering.value && !editing.value);
const mayOpen = computed(() => children.value.length > 0); const mayOpen = computed(() => children.value.length > 0);
const open = computed(() => mayOpen.value && ui.isOpen(path)); const open = computed(() => mayOpen.value && ui.isOpen(path));
const focused = computed(() => ui.focusPath.eq(path));
const pinned = computed(() => ui.isPinned(segment, parentId)); const pinned = computed(() => ui.isPinned(segment, parentId));
const hover = computed(() => hovering.value && mode.value !== "editing"); const focused = computed(() => ui.isFocused(path));
const editing = computed(() => ui.isEditing(path));
const creating = computed(() => mode.value === "creating"); const insertIndex = computed(() => ui.getInsertIndex(path));
const editing = computed(() => mode.value === "editing");
// Ensure we're open if we need to be. // Ensure we're open if we need to be.
watchEffect(() => { watchEffect(() => {
if (forceOpen || creating.value) ui.setOpen(path, true); if (forceOpen || editing.value) ui.setOpen(path, true);
}); });
// Ensure only one editor is ever open.
watchEffect(() => {
if (!focused.value) mode.value = undefined;
});
// Ensure we're focused when an editor is open.
watchEffect(() => {
if (mode.value) focusOnThis();
});
function focusOnThis(): void {
ui.focusPath = path;
}
function onClick(): void { function onClick(): void {
if (!focused.value) { if (!focused.value) {
focusOnThis(); ui.focusOn(path);
return; return;
} }
@ -102,11 +85,11 @@ function onPinButtonClick(): void {
function onEditButtonClick(): void { function onEditButtonClick(): void {
if (!note.value) return; if (!note.value) return;
mode.value = "editing"; ui.edit(path);
} }
function onEditEditorClose(): void { function onEditEditorClose(): void {
mode.value = undefined; ui.focus();
} }
function onEditEditorFinish(text: string): void { function onEditEditorFinish(text: string): void {
@ -115,27 +98,6 @@ function onEditEditorFinish(text: string): void {
onEditEditorClose(); onEditEditorClose();
} }
function onCreateButtonClick(): void {
if (!note.value) return;
mode.value = "creating";
}
function onCreateEditorClose(): void {
mode.value = undefined;
}
function onCreateEditorFinish(text: string): void {
if (!note.value) return;
const newNote = notes.createNote(text);
note.value.children.push(newNote.id);
const lastChild = children.value.at(-1);
if (lastChild) ui.focusPath = path.concat(lastChild);
onCreateEditorClose();
}
function onMoveButtonClick(): void { function onMoveButtonClick(): void {
ui.pushAnchorId(segment.id); ui.pushAnchorId(segment.id);
} }
@ -190,9 +152,6 @@ function onMoveButtonClick(): void {
<CNoteButton :visible="hover" @click.stop="onEditButtonClick"> <CNoteButton :visible="hover" @click.stop="onEditButtonClick">
<RiEditLine size="16px" /> <RiEditLine size="16px" />
</CNoteButton> </CNoteButton>
<CNoteButton :visible="hover" @click.stop="onCreateButtonClick">
<RiStickyNoteAddLine size="16px" />
</CNoteButton>
<CNoteButton :visible="hover || pinned" :inverted="pinned" @click.stop="onPinButtonClick"> <CNoteButton :visible="hover || pinned" :inverted="pinned" @click.stop="onPinButtonClick">
<RiPushpinFill v-if="pinned" size="16px" /> <RiPushpinFill v-if="pinned" size="16px" />
<RiPushpinLine v-else size="16px" /> <RiPushpinLine v-else size="16px" />
@ -209,21 +168,19 @@ function onMoveButtonClick(): void {
<!-- Children --> <!-- Children -->
<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 <div v-if="insertIndex === 0" class="flex items-start pl-3">
v-for="child of children" <div class="flex h-6 items-center"><RiAddLine size="16px" /></div>
:key="child.fmt()" <CNoteEditor class="flex-1" />
:path="path.concat(child)"
:segment="child"
:parent-id="id"
/>
</div>
<!-- Editor for creating new children -->
<div v-if="creating" class="flex items-start pl-3">
<div class="flex h-6 items-center">
<RiAddLine size="16px" />
</div> </div>
<CNoteEditor class="flex-1" @close="onCreateEditorClose" @finish="onCreateEditorFinish" />
<template v-for="(child, index) of children" :key="child.fmt()">
<CNote :path="path.concat(child)" :segment="child" :parent-id="id" />
<div v-if="insertIndex === index + 1" class="flex items-start pl-3">
<div class="flex h-6 items-center"><RiAddLine size="16px" /></div>
<CNoteEditor class="flex-1" />
</div>
</template>
</div> </div>
</div> </div>
</template> </template>

View file

@ -2,42 +2,62 @@ import { Segment, Path as UiPath } from "@/lib/path";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { computed, ref, watchEffect } from "vue"; import { computed, ref, watchEffect } from "vue";
export const useUiStore = defineStore("ui", () => { interface HistoryEntry {
const history = ref< anchorId: string;
{ focusPath: UiPath;
anchorId: string; openPaths: Set<string>;
focusPath: UiPath; }
openPaths: Set<string>;
}[]
>([]);
type Mode =
| { type: "focus" }
| { type: "edit"; path: UiPath }
| { type: "insert"; path: UiPath; index: number };
export const useUiStore = defineStore("ui", () => {
const history = ref<HistoryEntry[]>([]);
// Managed by history
const _anchorId = ref<string>(); const _anchorId = ref<string>();
const anchorId = computed(() => _anchorId.value); const _focusPath = ref<UiPath>(new UiPath());
const focusPath = ref<UiPath>(new UiPath());
const openPaths = ref<Set<string>>(new Set()); const openPaths = ref<Set<string>>(new Set());
const _mode = ref<Mode>({ type: "focus" });
const pinned = ref<{ segment: Segment; parentId?: string }>(); const pinned = ref<{ segment: Segment; parentId?: string }>();
// Ensure all nodes on the focusPath are unfolded. // Ensure the currently focused note is visible.
watchEffect(() => { watchEffect(() => {
// The node pointed to by the path itself doesn't need to be unfolded. if (_mode.value.type === "insert") {
for (const ancestor of focusPath.value.ancestors().slice(1)) { for (const ancestor of _mode.value.path.ancestors()) {
setOpen(ancestor, true); setOpen(ancestor, true);
}
} else {
// The node pointed to by the path itself doesn't need to be unfolded.
for (const ancestor of _focusPath.value.ancestors().slice(1)) {
setOpen(ancestor, true);
}
} }
}); });
///////////////////////////////////
// History and anchor management //
///////////////////////////////////
const anchorId = computed(() => _anchorId.value); // Getter
function pushAnchorId(id: string): void { function pushAnchorId(id: string): void {
if (_anchorId.value) { if (_anchorId.value) {
history.value.push({ history.value.push({
anchorId: _anchorId.value, anchorId: _anchorId.value,
focusPath: focusPath.value, focusPath: _focusPath.value,
openPaths: openPaths.value, openPaths: openPaths.value,
}); });
} }
_anchorId.value = id; _anchorId.value = id;
focusPath.value = new UiPath(); _focusPath.value = new UiPath();
openPaths.value = new Set(); openPaths.value = new Set();
_mode.value = { type: "focus" };
} }
function popAnchorId(): void { function popAnchorId(): void {
@ -47,15 +67,60 @@ export const useUiStore = defineStore("ui", () => {
const entry = history.value.pop(); const entry = history.value.pop();
if (entry) { if (entry) {
_anchorId.value = entry.anchorId; _anchorId.value = entry.anchorId;
focusPath.value = entry.focusPath; _focusPath.value = entry.focusPath;
openPaths.value = entry.openPaths; openPaths.value = entry.openPaths;
} else { } else {
_anchorId.value = undefined; _anchorId.value = undefined;
focusPath.value = new UiPath(); _focusPath.value = new UiPath();
openPaths.value = new Set(); openPaths.value = new Set();
} }
_mode.value = { type: "focus" };
} }
///////////////////////////////
// Mode and focus management //
///////////////////////////////
const mode = computed(() => _mode.value.type);
const focusPath = computed(() => _focusPath.value);
function isFocused(path: UiPath): boolean {
return _mode.value.type === "focus" && _focusPath.value.eq(path);
}
function isEditing(path: UiPath): boolean {
return _mode.value.type === "edit" && _mode.value.path.eq(path);
}
function getInsertIndex(path: UiPath): number | undefined {
if (_mode.value.type !== "insert") return;
if (!_mode.value.path.eq(path)) return;
if (_mode.value.index < 0) return;
return _mode.value.index;
}
function focus(): void {
_mode.value = { type: "focus" };
}
function focusOn(path: UiPath): void {
focus();
_focusPath.value = path;
}
function edit(path: UiPath): void {
_mode.value = { type: "edit", path };
}
function insertAt(path: UiPath, index: number): void {
_mode.value = { type: "insert", path, index };
}
//////////////////
// Node folding //
//////////////////
function isOpen(path: UiPath): boolean { function isOpen(path: UiPath): boolean {
return openPaths.value.has(path.fmt()); return openPaths.value.has(path.fmt());
} }
@ -67,7 +132,7 @@ export const useUiStore = defineStore("ui", () => {
openPaths.value.add(path.fmt()); openPaths.value.add(path.fmt());
} else if (!value && isOpen(path)) { } else if (!value && isOpen(path)) {
// Move the focusPath if necessary // Move the focusPath if necessary
if (path.isPrefixOf(focusPath.value)) focusPath.value = path; if (path.isPrefixOf(_focusPath.value)) _focusPath.value = path;
openPaths.value.delete(path.fmt()); openPaths.value.delete(path.fmt());
} }
@ -77,6 +142,10 @@ export const useUiStore = defineStore("ui", () => {
setOpen(path, !isOpen(path)); setOpen(path, !isOpen(path));
} }
/////////////
// Pinning //
/////////////
function isPinned(segment: Segment, parentId?: string): boolean { function isPinned(segment: Segment, parentId?: string): boolean {
if (!pinned.value) return false; if (!pinned.value) return false;
return pinned.value.segment.eq(segment) && pinned.value.parentId === parentId; return pinned.value.segment.eq(segment) && pinned.value.parentId === parentId;
@ -92,9 +161,17 @@ export const useUiStore = defineStore("ui", () => {
return { return {
anchorId, anchorId,
focusPath,
pushAnchorId, pushAnchorId,
popAnchorId, popAnchorId,
isFocused,
isEditing,
getInsertIndex,
mode,
focusPath,
focus,
focusOn,
edit,
insertAt,
isOpen, isOpen,
setOpen, setOpen,
toggleOpen, toggleOpen,