Implement node movement and copying

This commit is contained in:
Joscha 2025-02-12 12:54:45 +01:00
parent 75791c4e71
commit c7452166e0
6 changed files with 260 additions and 9 deletions

View file

@ -137,6 +137,29 @@ function onInsertEditorFinish(text: string): void {
onInsertEditorClose(); onInsertEditorClose();
} }
function onInsertEditorMove(): void {
if (!ui.pinned) return;
if (insertIndex.value === undefined) return;
if (ui.pinned.parentId) {
notes.removeChild(ui.pinned.parentId, ui.pinned.segment);
}
notes.addChild(segment.id, ui.pinned.segment.id, insertIndex.value);
onInsertEditorClose();
ui.unsetPinned();
}
function onInsertEditorCopy(): void {
if (!ui.pinned) return;
if (insertIndex.value === undefined) return;
notes.addChild(segment.id, ui.pinned.segment.id, insertIndex.value);
onInsertEditorClose();
}
</script> </script>
<template> <template>
@ -236,8 +259,11 @@ function onInsertEditorFinish(text: string): void {
<div v-if="open || insertIndex !== undefined" class="flex flex-col pl-2"> <div v-if="open || insertIndex !== undefined" class="flex flex-col pl-2">
<CNoteChildEditor <CNoteChildEditor
v-if="insertIndex === 0" v-if="insertIndex === 0"
:move-and-copy="ui.pinned !== undefined"
@close="onInsertEditorClose" @close="onInsertEditorClose"
@finish="onInsertEditorFinish" @finish="onInsertEditorFinish"
@move="onInsertEditorMove"
@copy="onInsertEditorCopy"
/> />
<template v-for="(child, index) of children" :key="child.fmt()"> <template v-for="(child, index) of children" :key="child.fmt()">
@ -245,8 +271,11 @@ function onInsertEditorFinish(text: string): void {
<CNoteChildEditor <CNoteChildEditor
v-if="insertIndex === index + 1" v-if="insertIndex === index + 1"
:move-and-copy="ui.pinned !== undefined"
@close="onInsertEditorClose" @close="onInsertEditorClose"
@finish="onInsertEditorFinish" @finish="onInsertEditorFinish"
@move="onInsertEditorMove"
@copy="onInsertEditorCopy"
/> />
</template> </template>
</div> </div>

View file

@ -2,8 +2,12 @@
import { RiAddLine } from "@remixicon/vue"; import { RiAddLine } from "@remixicon/vue";
import CNoteEditor from "./CNoteEditor.vue"; import CNoteEditor from "./CNoteEditor.vue";
const { moveAndCopy = false } = defineProps<{
moveAndCopy?: boolean;
}>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: "close"): void; (e: "close" | "move" | "copy"): void;
(e: "finish", text: string): void; (e: "finish", text: string): void;
}>(); }>();
</script> </script>
@ -16,8 +20,11 @@ const emit = defineEmits<{
<CNoteEditor <CNoteEditor
class="flex-1" class="flex-1"
:move-and-copy
@close="() => emit('close')" @close="() => emit('close')"
@finish="(text) => emit('finish', text)" @finish="(text) => emit('finish', text)"
@move="() => emit('move')"
@copy="() => emit('copy')"
/> />
</div> </div>
</template> </template>

View file

@ -1,14 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { RiCheckLine, RiCloseLine } from "@remixicon/vue"; import { RiCheckLine, RiCloseLine, RiFileCopyLine, RiFileTransferLine } from "@remixicon/vue";
import { onMounted, ref, useTemplateRef } from "vue"; import { onMounted, ref, useTemplateRef } from "vue";
import CNoteButton from "./CNoteButton.vue"; import CNoteButton from "./CNoteButton.vue";
const { initialText = "" } = defineProps<{ const { initialText = "", moveAndCopy = false } = defineProps<{
initialText?: string; initialText?: string;
moveAndCopy?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: "close"): void; (e: "close" | "move" | "copy"): void;
(e: "finish", text: string): void; (e: "finish", text: string): void;
}>(); }>();
@ -56,13 +57,17 @@ function onKeyPress(ev: KeyboardEvent): void {
@click.stop @click.stop
></textarea> ></textarea>
<div class="flex h-6 items-center"> <div class="flex h-6 items-center gap-0.5">
<CNoteButton v-if="moveAndCopy">
<RiFileTransferLine size="16px" @click.stop="emit('move')" />
</CNoteButton>
<CNoteButton v-if="moveAndCopy">
<RiFileCopyLine size="16px" @click.stop="emit('copy')" />
</CNoteButton>
<div v-if="moveAndCopy" class="w-0.5"></div>
<CNoteButton @click.stop="emit('finish', text)"> <CNoteButton @click.stop="emit('finish', text)">
<RiCheckLine size="16px" /> <RiCheckLine size="16px" />
</CNoteButton> </CNoteButton>
</div>
<div class="flex h-6 items-center">
<CNoteButton @click.stop="emit('close')"> <CNoteButton @click.stop="emit('close')">
<RiCloseLine size="16px" /> <RiCloseLine size="16px" />
</CNoteButton> </CNoteButton>

View file

@ -1,3 +1,4 @@
import { Segment } from "@/lib/path";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { computed, ref } from "vue"; import { computed, ref } from "vue";
@ -41,10 +42,32 @@ export const useNotesStore = defineStore("notes", () => {
notes.value.clear(); notes.value.clear();
} }
function addChild(id: string, childId: string, index: number): void {
const note = getNote(id);
if (!note) return;
note.children.splice(index, 0, childId);
}
function removeChild(id: string, segment: Segment): void {
const note = getNote(id);
if (!note) return;
let index = note.children.indexOf(segment.id);
for (let i = 0; i < segment.iteration; i++) {
index = note.children.indexOf(segment.id, index + 1);
}
if (index < 0) return;
note.children.splice(index, 1);
}
return { return {
getNote, getNote,
getParents, getParents,
createNote, createNote,
clearNotes, clearNotes,
addChild,
removeChild,
}; };
}); });

View file

@ -0,0 +1,188 @@
import { Segment, Path as UiPath } from "@/lib/path";
import { defineStore } from "pinia";
import { ref, watchEffect } from "vue";
interface HistoryEntry {
anchorId: string;
focusPath: UiPath;
openPaths: Set<string>;
}
type Mode =
| { type: "focus" }
| { type: "edit"; path: UiPath }
| { type: "insert"; path: UiPath; index: number };
export const usePinnedStore = defineStore("pinned", () => {
const history = ref<HistoryEntry[]>([]);
// Managed by history
const anchorId = ref<string>();
const focusPath = ref<UiPath>(new UiPath());
const openPaths = ref<Set<string>>(new Set());
const mode = ref<Mode>({ type: "focus" });
const pinned = ref<{ segment: Segment; parentId?: string }>();
// Ensure the currently focused note is visible.
watchEffect(() => {
if (mode.value.type === "insert") {
for (const ancestor of mode.value.path.ancestors()) {
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 //
///////////////////////////////////
function pushAnchorId(id: string): void {
if (anchorId.value) {
history.value.push({
anchorId: anchorId.value,
focusPath: focusPath.value,
openPaths: openPaths.value,
});
}
anchorId.value = id;
focusPath.value = new UiPath();
openPaths.value = new Set();
mode.value = { type: "focus" };
}
function popAnchorId(): void {
// Temporary solution until I implement some UI for anchorId===undefined
if (history.value.length === 0) return;
const entry = history.value.pop();
if (entry) {
anchorId.value = entry.anchorId;
focusPath.value = entry.focusPath;
openPaths.value = entry.openPaths;
} else {
anchorId.value = undefined;
focusPath.value = new UiPath();
openPaths.value = new Set();
}
mode.value = { type: "focus" };
}
///////////////////////////////
// Mode and focus management //
///////////////////////////////
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 {
return openPaths.value.has(path.fmt());
}
function setOpen(path: UiPath, value: boolean): void {
// Don't update openPaths unnecessarily.
// Just in case vue itself doesn't debounce Set operations.
if (value && !isOpen(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: UiPath): void {
setOpen(path, !isOpen(path));
}
/////////////
// Pinning //
/////////////
function isPinned(segment: Segment, parentId?: string): boolean {
if (!pinned.value) return false;
return pinned.value.segment.eq(segment) && pinned.value.parentId === parentId;
}
function setPinned(segment: Segment, parentId?: string): void {
pinned.value = { segment, parentId };
}
function unsetPinned(): void {
pinned.value = undefined;
}
return {
history,
anchorId,
focusPath,
openPaths,
mode,
pinned,
// History and anchor management
pushAnchorId,
popAnchorId,
// Mode and focus management
isFocused,
isEditing,
getInsertIndex,
focus,
focusOn,
edit,
insertAt,
// Node folding
isOpen,
setOpen,
toggleOpen,
// Pinning
isPinned,
setPinned,
unsetPinned,
};
});

View file

@ -91,7 +91,6 @@ export const useUiStore = defineStore("ui", () => {
function getInsertIndex(path: UiPath): number | undefined { function getInsertIndex(path: UiPath): number | undefined {
if (mode.value.type !== "insert") return; if (mode.value.type !== "insert") return;
if (!mode.value.path.eq(path)) return; if (!mode.value.path.eq(path)) return;
if (mode.value.index < 0) return;
return mode.value.index; return mode.value.index;
} }