Add drag-and-drop reordering for note tabs, and a user-facing guide
Tabs can now be dragged to reorder within a group and to move between sections in one gesture: dropping on a tab's left/right half inserts before/after it and adopts that tab's section, while a dedicated zone past the last group ungroups and appends at the end. A blue indicator line shows the drop position while dragging. core/notes.reorder() takes the full new page-id order computed client-side (NotePanel.vue has the reactive list already) rather than recomputing positions server-side, validates it against the current page-id set, sets the moved page's section, then renumbers position. POST /api/notes/reorder exposes it. Also adds docs/notes-guide.md, an end-user walkthrough of the shared notes panel (pages, sections, drag-and-drop, AI interaction) — the existing docs were all developer-facing specs with no operating instructions for the feature itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -306,3 +306,21 @@ export async function renameNoteSection(oldName: string, newName: string | null)
|
||||
})
|
||||
return parseJson<{ renamed: number }>(res, 'セクション名変更')
|
||||
}
|
||||
|
||||
/**
|
||||
* タブのドラッグ&ドロップによる並べ替えをサーバーへ反映する。
|
||||
* order には全ページIDをドラッグ後の新しい順序で渡す。pageId のページは section へ移動する
|
||||
* (ドロップ先のタブと同じセクションを渡せば、その場でセクション間の移動を兼ねる)。
|
||||
*/
|
||||
export async function reorderNotePages(
|
||||
pageId: number,
|
||||
section: string | null,
|
||||
order: number[],
|
||||
): Promise<{ reordered: number }> {
|
||||
const res = await fetch(`${BASE}/api/notes/reorder`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ page_id: pageId, section, order }),
|
||||
})
|
||||
return parseJson<{ reordered: number }>(res, 'ノート並べ替え')
|
||||
}
|
||||
|
||||
@@ -13,6 +13,13 @@ const renamingId = ref<number | null>(null)
|
||||
// ref="renameInput" は入れ子の v-for の中にあるため、Vue 3 では単一要素ではなく配列になる
|
||||
const renameInput = ref<HTMLInputElement[]>([])
|
||||
|
||||
/** ドラッグ中のページID */
|
||||
const draggingId = ref<number | null>(null)
|
||||
/** ドラッグ中に今どのタブの前/後にドロップされるかの表示用インジケータ */
|
||||
const dropTarget = ref<{ id: number; place: 'before' | 'after' } | null>(null)
|
||||
/** タブ列の末尾(+ボタンの手前)にドロップしようとしているか */
|
||||
const dropAtEnd = ref(false)
|
||||
|
||||
/** 隣接して同じsectionが続く区間を1グループとして表示する(並び順はサーバー側が保証) */
|
||||
const groups = computed<TabGroup[]>(() => {
|
||||
const result: TabGroup[] = []
|
||||
@@ -64,6 +71,61 @@ async function renameSectionGroup(currentName: string) {
|
||||
await store.renameSection(currentName, input.trim())
|
||||
}
|
||||
|
||||
function onDragStart(id: number, e: DragEvent) {
|
||||
draggingId.value = id
|
||||
e.dataTransfer?.setData('text/plain', String(id)) // Firefoxはdataが無いとドラッグを許可しない
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
draggingId.value = null
|
||||
dropTarget.value = null
|
||||
dropAtEnd.value = false
|
||||
}
|
||||
|
||||
function onDragOverTab(page: NotePage, e: DragEvent) {
|
||||
if (draggingId.value === null || draggingId.value === page.id) return
|
||||
dropAtEnd.value = false
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const place: 'before' | 'after' = e.clientX - rect.left < rect.width / 2 ? 'before' : 'after'
|
||||
dropTarget.value = { id: page.id, place }
|
||||
}
|
||||
|
||||
function onDragOverEnd() {
|
||||
if (draggingId.value === null) return
|
||||
dropTarget.value = null
|
||||
dropAtEnd.value = true
|
||||
}
|
||||
|
||||
/** ドラッグ後の新しい並びを計算してサーバーへ送る。section にドロップ先タブの所属セクションを渡すと、並べ替えと同時にセクション間の移動も反映される。 */
|
||||
async function moveTab(id: number, targetId: number | null, place: 'before' | 'after', section: string | null) {
|
||||
const order = store.pages.map((p) => p.id)
|
||||
const fromIdx = order.indexOf(id)
|
||||
if (fromIdx === -1) return
|
||||
order.splice(fromIdx, 1)
|
||||
const insertAt = targetId === null
|
||||
? order.length
|
||||
: order.indexOf(targetId) + (place === 'after' ? 1 : 0)
|
||||
order.splice(insertAt, 0, id)
|
||||
await store.reorder(id, section, order)
|
||||
}
|
||||
|
||||
async function onDropTab(page: NotePage, e: DragEvent) {
|
||||
const id = draggingId.value
|
||||
onDragEnd()
|
||||
if (id === null || id === page.id) return
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const place: 'before' | 'after' = e.clientX - rect.left < rect.width / 2 ? 'before' : 'after'
|
||||
await moveTab(id, page.id, place, page.section)
|
||||
}
|
||||
|
||||
async function onDropEnd() {
|
||||
const id = draggingId.value
|
||||
onDragEnd()
|
||||
if (id === null) return
|
||||
await moveTab(id, null, 'after', null)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.load()
|
||||
})
|
||||
@@ -91,9 +153,19 @@ onUnmounted(() => {
|
||||
v-for="page in group.pages"
|
||||
:key="page.id"
|
||||
class="tab"
|
||||
:class="{ active: page.id === store.activeId }"
|
||||
:class="{
|
||||
active: page.id === store.activeId,
|
||||
dragging: draggingId === page.id,
|
||||
'drop-before': dropTarget?.id === page.id && dropTarget.place === 'before',
|
||||
'drop-after': dropTarget?.id === page.id && dropTarget.place === 'after',
|
||||
}"
|
||||
:draggable="renamingId !== page.id"
|
||||
@click="store.selectPage(page.id)"
|
||||
@dblclick="startRename(page.id)"
|
||||
@dragstart="onDragStart(page.id, $event)"
|
||||
@dragend="onDragEnd"
|
||||
@dragover.prevent="onDragOverTab(page, $event)"
|
||||
@drop.prevent="onDropTab(page, $event)"
|
||||
>
|
||||
<input
|
||||
v-if="renamingId === page.id"
|
||||
@@ -114,6 +186,13 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="draggingId !== null"
|
||||
class="drop-end"
|
||||
:class="{ active: dropAtEnd }"
|
||||
@dragover.prevent="onDragOverEnd"
|
||||
@drop.prevent="onDropEnd"
|
||||
/>
|
||||
<button class="tab-add" title="ページを追加" @click="store.addPage()">+</button>
|
||||
</div>
|
||||
|
||||
@@ -136,6 +215,7 @@ onUnmounted(() => {
|
||||
<div class="hint">
|
||||
ページのタイトル一覧はAIのシステムプロンプトに常に含まれます。本文はAIが必要に応じて読み出します。
|
||||
🏷 でページをセクションにまとめられます(セクション名をクリックすると変更・解除)。
|
||||
タブはドラッグで並べ替え・他のセクションへの移動ができます。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -199,6 +279,17 @@ onUnmounted(() => {
|
||||
max-width: 160px;
|
||||
}
|
||||
.tab.active { background: #2a2a2a; color: #fff; border-color: #444; }
|
||||
.tab.dragging { opacity: 0.4; }
|
||||
.tab.drop-before { box-shadow: inset 2px 0 0 #93c5fd; }
|
||||
.tab.drop-after { box-shadow: inset -2px 0 0 #93c5fd; }
|
||||
.drop-end {
|
||||
align-self: stretch;
|
||||
width: 14px;
|
||||
flex-shrink: 0;
|
||||
margin: 0 2px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.drop-end.active { background: #93c5fd; opacity: 0.5; }
|
||||
.tab-title { overflow: hidden; text-overflow: ellipsis; }
|
||||
.dot { color: #eab308; font-size: 8px; }
|
||||
.tab-tag, .tab-del, .tab-add {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
updateNotePage,
|
||||
deleteNotePage,
|
||||
renameNoteSection,
|
||||
reorderNotePages,
|
||||
type NotePage,
|
||||
} from '../api/client'
|
||||
|
||||
@@ -81,6 +82,21 @@ export const useNotesStore = defineStore('notes', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* タブのドラッグ&ドロップ用。order には全ページIDをドラッグ後の新しい順序で渡す。
|
||||
* section にドロップ先タブの所属セクションを渡せば、並べ替えと同時にセクション間の移動も反映される。
|
||||
*/
|
||||
async function reorder(pageId: number, section: string | null, order: number[]): Promise<void> {
|
||||
error.value = null
|
||||
flush()
|
||||
try {
|
||||
await reorderNotePages(pageId, section, order)
|
||||
await load()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '並べ替えに失敗しました'
|
||||
}
|
||||
}
|
||||
|
||||
async function removePage(id: number): Promise<void> {
|
||||
error.value = null
|
||||
try {
|
||||
@@ -146,6 +162,7 @@ export const useNotesStore = defineStore('notes', () => {
|
||||
edit,
|
||||
setSection,
|
||||
renameSection,
|
||||
reorder,
|
||||
flush,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user