From 8b2231ea977a2aba062045807c6457871110f7a4 Mon Sep 17 00:00:00 2001 From: zukki259 Date: Thu, 20 Aug 2026 07:27:38 +0900 Subject: [PATCH] Add drag-and-drop reordering for note tabs, and a user-facing guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/api/notes.py | 17 ++++- backend/app/core/notes.py | 21 ++++++ docs/api.md | 20 ++++++ docs/architecture.md | 4 +- docs/notes-guide.md | 58 +++++++++++++++++ frontend/src/api/client.ts | 18 ++++++ frontend/src/components/NotePanel.vue | 93 ++++++++++++++++++++++++++- frontend/src/stores/notes.ts | 17 +++++ 8 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 docs/notes-guide.md diff --git a/backend/app/api/notes.py b/backend/app/api/notes.py index d5c2fac..67d4638 100644 --- a/backend/app/api/notes.py +++ b/backend/app/api/notes.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.orm import Session as DBSession -from app.core.notes import next_position, rename_section, set_section +from app.core.notes import next_position, reorder, rename_section, set_section from app.db.database import get_db from app.db.models import NotePage @@ -40,6 +40,12 @@ class SectionRenameRequest(BaseModel): new_name: str | None = None +class ReorderRequest(BaseModel): + page_id: int + section: str | None = None + order: list[int] + + @router.get("", response_model=list[NotePageResponse]) def list_pages(db: DBSession = Depends(get_db)): return db.query(NotePage).order_by(NotePage.position, NotePage.id).all() @@ -64,6 +70,15 @@ def rename_section_endpoint(req: SectionRenameRequest, db: DBSession = Depends(g return {"renamed": count} +@router.post("/reorder") +def reorder_pages(req: ReorderRequest, db: DBSession = Depends(get_db)): + """タブのドラッグ&ドロップによる並べ替え。order は全ページIDを新しい順序で並べたもの。""" + ok = reorder(db, req.page_id, req.section, req.order) + if not ok: + raise HTTPException(status_code=400, detail="order must contain exactly the current page ids") + return {"reordered": len(req.order)} + + @router.patch("/{page_id}", response_model=NotePageResponse) def update_page(page_id: int, req: NotePageUpdate, db: DBSession = Depends(get_db)): page = db.get(NotePage, page_id) diff --git a/backend/app/core/notes.py b/backend/app/core/notes.py index ee7dd69..2604801 100644 --- a/backend/app/core/notes.py +++ b/backend/app/core/notes.py @@ -104,6 +104,27 @@ def set_section(db: DBSession, page_id: int, section: str | None) -> NotePage | return page +def reorder(db: DBSession, page_id: int, section: str | None, order: list[int]) -> bool: + """タブのドラッグ&ドロップによる並べ替えを反映する。 + + order にはドラッグ後の全ページIDを新しい順序で渡す(フロント側で計算する)。 + page_id のページには先に section を設定し、その後 order の順に position を振り直す。 + こうすることで、別セクションのタブの前後にドロップした場合はそのセクションへの + 移動を兼ね、同じセクション内でのドロップなら単純な並べ替えになる。 + + order が現在の全ページIDの集合と一致しない場合は何もせず False を返す。 + """ + pages = {p.id: p for p in db.query(NotePage).all()} + if page_id not in pages or set(order) != set(pages.keys()): + return False + + pages[page_id].section = (section or "").strip() or None + for i, pid in enumerate(order): + pages[pid].position = i + db.commit() + return True + + def rename_section(db: DBSession, old_name: str, new_name: str | None) -> int: """あるセクションに属する全ページのセクション名を一括で変更する。new_name が空ならグループ解除になる。 diff --git a/docs/api.md b/docs/api.md index 75b77cd..386df5b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -158,6 +158,26 @@ SSE でストリーミング応答を返す。 --- +### POST /api/notes/reorder +タブのドラッグ&ドロップによる並べ替えを反映する。`order` には**全ページID**をドラッグ後の +新しい順序で渡す(サーバー側では計算しない。フロントが `store.pages` から算出する)。 +`page_id` のページには先に `section` を設定してから、`order` の順に `position` を振り直す。 +別セクションのタブの前後にドロップした場合は、そのセクションへの移動も兼ねる。 + +`order` が現在の全ページIDの集合と一致しない場合は `400` を返す。 + +**リクエスト** +```json +{ "page_id": 3, "section": "生活", "order": [1, 3, 2, 4] } +``` + +**レスポンス** +```json +{ "reordered": 4 } +``` + +--- + ### DELETE /api/notes/{page_id} ページを削除。`204 No Content` を返す。 diff --git a/docs/architecture.md b/docs/architecture.md index f49e890..7cc6551 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -129,12 +129,14 @@ LLM に渡すコンテキストは `memory_kb/memory.py` の `get_context_messag ### 共有ノート(`core/notes.py` / `api/notes.py`) ユーザーと AI が共同編集するノート。`note_pages` の 1 行が UI の 1 タブに対応する。 +利用者向けの操作方法は `docs/notes-guide.md` を参照。 - `get_notes_context()` が**タイトル一覧のみ**をシステムメッセージとして生成し、`get_context_messages()` が会話コンテキストに差し込む(本文は含めない)。セクションがあれば `## セクション名` の見出しでグループ化して提示する - 本文の読み書きはツール経由: `read_note`(本文取得)/ `write_note`(追記・置換・新規作成) - `llm.resolve_tool_calls()` が `web_search` と同じループでこれらを解決する。ノート系ツールは `db` を渡したときのみモデルに提示される -- REST(`/api/notes`)はフロントのノートパネル用。GET 一覧 / POST 作成 / PATCH 更新 / DELETE 削除 / `POST /api/notes/sections/rename`(セクション名の一括変更) +- REST(`/api/notes`)はフロントのノートパネル用。GET 一覧 / POST 作成 / PATCH 更新 / DELETE 削除 / `POST /api/notes/sections/rename`(セクション名の一括変更)/ `POST /api/notes/reorder`(ドラッグ&ドロップの並べ替え) - **セクションによるグルーピング**: `note_pages.section` に名前を持たせ、同じセクションのページは常に `position` 上で連続させる。`notes.set_section()` がページのセクションを変更するたびに全ページを再採番し、同じセクションの末尾に挿入し直す(隣接=同一グループという前提をタブ描画側・システムプロンプト側の両方が使っている)。`notes.rename_section()` は既に連続しているページ群のラベルだけを一括で書き換える +- **ドラッグ&ドロップの並べ替え**: `notes.reorder()` は `set_section()` と違い、位置の計算をサーバー側ではなくフロント(`NotePanel.vue`)に任せる。フロントがドラッグ後の全ページIDの順序を計算し、`{ page_id, section, order }` として一括送信、サーバーは検証(`order` が現在の全ページID集合と一致するか)した上でそのまま `position` に反映する。ドロップ先タブの `section` を渡すことで、並べ替えと同時にセクション間移動も1リクエストで完結する - 既存 DB への追加カラムは Alembic 等の仕組みを使っておらず、`db/database.py` の `_add_missing_columns()` が起動時に `PRAGMA table_info` を見て `ALTER TABLE ... ADD COLUMN` を素朴に実行する(SQLite 専用の簡易マイグレーション) ### SSE ストリーミング(`api/chat.py`) diff --git a/docs/notes-guide.md b/docs/notes-guide.md new file mode 100644 index 0000000..9ce22e3 --- /dev/null +++ b/docs/notes-guide.md @@ -0,0 +1,58 @@ +# 共有ノートの使い方 + +ユーザーとAIが同じノートを一緒に編集できる機能。ページをタブで切り替え、関連するページ同士は +セクションでグループ化できる。実装の詳細は `docs/architecture.md` の「共有ノート」節、 +API 仕様は `docs/api.md` の「共有ノート」節を参照。 + +## 開き方 + +画面右上ヘッダーの **「ノート」** ボタンをクリックすると、右側にノートパネルが開く。 +もう一度クリックすると閉じる。 + +## ページの操作 + +| 操作 | 方法 | +|---|---| +| ページを開く | タブをクリック | +| ページを新規作成 | タブ列右端の **+** をクリック(末尾に「新しいページ」が追加される) | +| タイトルを変更 | タブを**ダブルクリック**すると入力欄になるので書き換えて Enter(または欄外をクリック) | +| ページを削除 | タブ内の **×** をクリック(確認ダイアログあり) | +| 本文を編集 | ページを開いた状態で下の編集エリアに直接入力 | + +本文は入力を止めてから少し待つと自動保存される。保存待ちの間はタブに **●**(未保存マーク)が付く。 +ページが1つも無い場合は「+ 最初のページを作成」から始められる。 + +## セクションでまとめる + +複数のページを1つのグループとしてタブ上にまとめられる。 + +| 操作 | 方法 | +|---|---| +| ページをセクションに入れる | タブにマウスを乗せると出てくる **🏷** をクリックし、セクション名を入力 | +| セクション名を変更する | セクションのラベル(タブ群の左上に表示される名前)をクリックし、新しい名前を入力 | +| セクションを解除する | 🏷 またはセクションラベルをクリックした際、名前を**空欄**にして確定 | + +同じセクションに入れたページは、タブ列上で必ず隣り合って表示される +(新しくセクションに追加したページは、そのセクションの末尾に自動的に移動する)。 +セクション名を変更すると、そのグループに属する全ページのラベルが一括で変わる。 + +## タブをドラッグで動かす + +タブは**ドラッグ&ドロップ**でも操作できる(🏷 やセクションラベルのクリックと同等の結果になる)。 + +| 操作 | 方法 | +|---|---| +| 同じグループ内で順番を変える | タブを掴んで、移動先のタブの左半分/右半分にドロップ(左=そのタブの前、右=後ろに入る) | +| 別のセクションへ移動する | 移動先のセクション内のタブにドロップ(そのタブと同じセクションに入る) | +| セクションから外す | 未分類のタブにドロップ、またはタブ列の一番右(+ボタンの手前の細い枠)にドロップ | + +ドラッグ中はドロップ位置に青い線が表示される。タイトル編集中(ダブルクリックで入力欄になっている間)のタブはドラッグできない。 + +## AIとの連携 + +- 全ページの**タイトル一覧**は、会話のたびに自動でAIに渡される(セクションがあれば見出し付きで)。 + ただし本文はここには含まれない。 +- AIは `read_note` ツールでページ本文を読み、`write_note` ツールで追記・置き換え・新規ページ作成ができる。 + 「ノートに書いておいて」「〇〇について記録して」のように頼むと、AIが該当ページを判断して書き込む。 +- AIがノートを書き換えると、開いているノートパネルは自動的に最新の内容へ更新される。 +- 自分が編集中(未保存)のページは、AIの更新で上書きされない。 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 923cec4..3daf3e6 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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, 'ノート並べ替え') +} diff --git a/frontend/src/components/NotePanel.vue b/frontend/src/components/NotePanel.vue index efb5642..35ff910 100644 --- a/frontend/src/components/NotePanel.vue +++ b/frontend/src/components/NotePanel.vue @@ -13,6 +13,13 @@ const renamingId = ref(null) // ref="renameInput" は入れ子の v-for の中にあるため、Vue 3 では単一要素ではなく配列になる const renameInput = ref([]) +/** ドラッグ中のページID */ +const draggingId = ref(null) +/** ドラッグ中に今どのタブの前/後にドロップされるかの表示用インジケータ */ +const dropTarget = ref<{ id: number; place: 'before' | 'after' } | null>(null) +/** タブ列の末尾(+ボタンの手前)にドロップしようとしているか */ +const dropAtEnd = ref(false) + /** 隣接して同じsectionが続く区間を1グループとして表示する(並び順はサーバー側が保証) */ const groups = computed(() => { 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)" > { +
@@ -136,6 +215,7 @@ onUnmounted(() => {
ページのタイトル一覧はAIのシステムプロンプトに常に含まれます。本文はAIが必要に応じて読み出します。 🏷 でページをセクションにまとめられます(セクション名をクリックすると変更・解除)。 + タブはドラッグで並べ替え・他のセクションへの移動ができます。
@@ -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 { diff --git a/frontend/src/stores/notes.ts b/frontend/src/stores/notes.ts index 8f97a86..892f8f8 100644 --- a/frontend/src/stores/notes.ts +++ b/frontend/src/stores/notes.ts @@ -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 { + 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 { error.value = null try { @@ -146,6 +162,7 @@ export const useNotesStore = defineStore('notes', () => { edit, setSection, renameSection, + reorder, flush, } })