Add double-click to rename layers, extract useInlineRename composable

- New useInlineRename composable: shared editing state, commit logic,
  and keyboard handling for inline rename across panels
- LayerTree: double-click a layer name to rename inline
- PagesPanel: refactored to use useInlineRename, removing duplicate code

Closes #57
This commit is contained in:
Danila Poyarkov 2026-03-06 09:50:35 +03:00
parent 6f31af44e9
commit bc52095138
3 changed files with 67 additions and 29 deletions

View file

@ -3,6 +3,8 @@ import { nextTick, onUnmounted, ref, watch } from 'vue'
import { useEventListener } from '@vueuse/core'
import { TreeRoot, TreeItem, ContextMenuRoot, ContextMenuTrigger, ContextMenuPortal } from 'reka-ui'
import { useInlineRename } from '@/composables/use-inline-rename'
import IconCircle from '~icons/lucide/circle'
import IconComponent from '~icons/lucide/diamond'
import IconComponentSet from '~icons/lucide/component'
@ -19,6 +21,7 @@ import { useEditorStore } from '@/stores/editor'
import NodeContextMenuContent from './NodeContextMenuContent.vue'
const store = useEditorStore()
const rename = useInlineRename((id, name) => store.renameNode(id, name))
interface LayerNode {
id: string
@ -277,7 +280,27 @@ function updateDropTarget(ev: PointerEvent) {
:data-level="item.level"
>
<TreeItem v-slot="{ isExpanded }" v-bind="item.bind" as-child @select="onSelect">
<div
v-if="rename.editingId.value === item.value.id"
class="flex w-full items-center gap-1 py-1"
:style="{ paddingLeft: `${8 + (item.level - 1) * 16}px` }"
>
<span class="w-4 shrink-0" />
<component
:is="nodeIcons[item.value.type] ?? IconSquare"
class="size-3 shrink-0 opacity-70"
/>
<input
data-layer-edit
data-test-id="layers-item-input"
class="min-w-0 flex-1 rounded border border-accent bg-input px-1 py-0 text-xs text-surface outline-none"
:value="item.value.name"
@blur="rename.commit(item.value.id, $event.target as HTMLInputElement)"
@keydown="rename.onKeydown"
/>
</div>
<button
v-else
data-test-id="layers-item"
class="group/row flex w-full cursor-pointer items-center gap-1 rounded border-none py-1 text-left text-xs"
:class="[
@ -290,6 +313,7 @@ function updateDropTarget(ev: PointerEvent) {
]"
:style="{ paddingLeft: `${8 + (item.level - 1) * 16}px` }"
@pointerdown.prevent="onPointerDown($event, item.value.id)"
@dblclick="rename.start(item.value.id, item.value.name, '[data-layer-edit]')"
>
<span
v-if="item.hasChildren"

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, ref, nextTick } from 'vue'
import { computed } from 'vue'
import { useInlineRename } from '@/composables/use-inline-rename'
import { useEditorStore } from '@/stores/editor'
const store = useEditorStore()
@ -16,30 +17,10 @@ const pages = computed(() => {
return store.graph.getPages()
})
const editingPageId = ref<string | null>(null)
const rename = useInlineRename((id, name) => store.renamePage(id, name))
function startRename(pageId: string) {
editingPageId.value = pageId
nextTick(() => {
const input = document.querySelector<HTMLInputElement>('[data-page-edit]')
input?.focus()
input?.select()
})
}
function commitRename(pageId: string, input: HTMLInputElement) {
if (editingPageId.value !== pageId) return
const value = input.value.trim()
if (value && value !== store.graph.getNode(pageId)?.name) {
store.renamePage(pageId, value)
}
editingPageId.value = null
}
function onKeydown(e: KeyboardEvent, pageId: string) {
if (e.key === 'Enter' || e.key === 'Escape') {
;(e.target as HTMLInputElement).blur()
}
function startRename(pg: { id: string; name: string }) {
rename.start(pg.id, pg.name, '[data-page-edit]')
}
</script>
@ -61,18 +42,18 @@ function onKeydown(e: KeyboardEvent, pageId: string) {
<div class="overflow-x-hidden overflow-y-auto scrollbar-thin px-1 pb-1">
<div v-for="pg in pages" :key="pg.id">
<input
v-if="editingPageId === pg.id"
v-if="rename.editingId.value === pg.id"
data-page-edit
data-test-id="pages-item-input"
class="w-full rounded border border-accent bg-input px-2 py-1 text-xs text-surface outline-none"
:value="pg.name"
@blur="commitRename(pg.id, $event.target as HTMLInputElement)"
@keydown="onKeydown($event, pg.id)"
@blur="rename.commit(pg.id, $event.target as HTMLInputElement)"
@keydown="rename.onKeydown"
/>
<div
v-else-if="isDivider(pg)"
class="my-1 flex items-center px-2"
@dblclick="startRename(pg.id)"
@dblclick="startRename(pg)"
>
<div class="h-px flex-1 bg-border" />
</div>
@ -86,7 +67,7 @@ function onKeydown(e: KeyboardEvent, pageId: string) {
: 'bg-transparent text-muted hover:bg-hover hover:text-surface'
"
@click="store.switchPage(pg.id)"
@dblclick="startRename(pg.id)"
@dblclick="startRename(pg)"
>
<icon-lucide-file class="size-3 shrink-0" />
<span class="truncate">{{ pg.name }}</span>

View file

@ -0,0 +1,33 @@
import { nextTick, ref } from 'vue'
export function useInlineRename(onCommit: (id: string, newName: string) => void) {
const editingId = ref<string | null>(null)
let originalName = ''
function start(id: string, currentName: string, selector: string) {
editingId.value = id
originalName = currentName
nextTick(() => {
const input = document.querySelector<HTMLInputElement>(selector)
input?.focus()
input?.select()
})
}
function commit(id: string, input: HTMLInputElement) {
if (editingId.value !== id) return
const value = input.value.trim()
if (value && value !== originalName) {
onCommit(id, value)
}
editingId.value = null
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === 'Escape') {
;(e.target as HTMLInputElement).blur()
}
}
return { editingId, start, commit, onKeydown }
}