-
+
+
+
{errorMessage && (
{errorMessage}
diff --git a/src/components/editor/PageEditor/useEditorAutoSave.test.ts b/src/components/editor/PageEditor/useEditorAutoSave.test.ts
index add45658..738b5579 100644
--- a/src/components/editor/PageEditor/useEditorAutoSave.test.ts
+++ b/src/components/editor/PageEditor/useEditorAutoSave.test.ts
@@ -336,6 +336,150 @@ describe("useEditorAutoSave", () => {
});
});
+ describe("cancelPendingSave (issue #768)", () => {
+ it("デバウンス中に cancelPendingSave を呼ぶと onSave / syncWikiLinks がもう走らない / pending debounce is cleared so onSave never fires", async () => {
+ const syncWikiLinks = vi.fn().mockResolvedValue(undefined);
+ const onSave = vi.fn().mockResolvedValue(true);
+ const onSaveContentOnly = vi.fn().mockResolvedValue(true);
+
+ const { result } = renderHook(() =>
+ useEditorAutoSave({
+ pageId,
+ debounceMs: 500,
+ onSave,
+ onSaveContentOnly,
+ syncWikiLinks,
+ }),
+ );
+
+ act(() => {
+ result.current.saveChanges("My Title", "{}");
+ });
+
+ act(() => {
+ result.current.cancelPendingSave();
+ });
+
+ await act(async () => {
+ await vi.runAllTimersAsync();
+ });
+
+ expect(onSave).not.toHaveBeenCalled();
+ expect(onSaveContentOnly).not.toHaveBeenCalled();
+ expect(syncWikiLinks).not.toHaveBeenCalled();
+ });
+
+ it("cancelPendingSave 後にアンマウントしても unmount flush で onSave / syncWikiLinks が呼ばれない / unmount flush is suppressed after cancelPendingSave", async () => {
+ const syncWikiLinks = vi.fn().mockResolvedValue(undefined);
+ const onSave = vi.fn().mockResolvedValue(true);
+ const onSaveContentOnly = vi.fn().mockResolvedValue(true);
+
+ const { result, unmount } = renderHook(() =>
+ useEditorAutoSave({
+ pageId,
+ debounceMs: 500,
+ onSave,
+ onSaveContentOnly,
+ syncWikiLinks,
+ }),
+ );
+
+ act(() => {
+ result.current.saveChanges("My Title", "{}");
+ });
+
+ act(() => {
+ result.current.cancelPendingSave();
+ });
+
+ unmount();
+
+ await act(async () => {
+ await vi.runAllTimersAsync();
+ });
+
+ expect(onSave).not.toHaveBeenCalled();
+ expect(onSaveContentOnly).not.toHaveBeenCalled();
+ expect(syncWikiLinks).not.toHaveBeenCalled();
+ });
+
+ it("shouldBlockSave で content-only 経路が保留中でも cancelPendingSave で抑止される / content-only debounce branch is also cleared (CodeRabbit feedback)", async () => {
+ // CodeRabbit のレビュー指摘: 既存のキャンセル系テストは shouldBlockSave=false の
+ // フル保存経路しか走らせていなかったため、`onSaveContentOnly` を expect しても
+ // vacuous(そもそも呼ばれない)。`shouldBlockSave=true` を立てて content-only
+ // 経路の `pendingRef` も同じ `cancelPendingSave` で確実に消えることを検証する。
+ //
+ // CodeRabbit: the prior cancellation tests only exercised the full-save
+ // branch, so asserting `onSaveContentOnly` was vacuous. This case enables
+ // `shouldBlockSave` to schedule the content-only debounce path and asserts
+ // `cancelPendingSave` clears that pendingRef too.
+ const syncWikiLinks = vi.fn().mockResolvedValue(undefined);
+ const onSave = vi.fn().mockResolvedValue(true);
+ const onSaveContentOnly = vi.fn().mockResolvedValue(true);
+
+ const { result, unmount } = renderHook(() =>
+ useEditorAutoSave({
+ pageId,
+ debounceMs: 500,
+ shouldBlockSave: true,
+ onSave,
+ onSaveContentOnly,
+ syncWikiLinks,
+ }),
+ );
+
+ act(() => {
+ result.current.saveChanges("My Title", "{}");
+ });
+
+ act(() => {
+ result.current.cancelPendingSave();
+ });
+
+ // 単に時間を進めても content-only 経路の onSaveContentOnly は呼ばれない。
+ // Advancing timers must not flush the content-only branch.
+ await act(async () => {
+ await vi.runAllTimersAsync();
+ });
+
+ expect(onSaveContentOnly).not.toHaveBeenCalled();
+ expect(onSave).not.toHaveBeenCalled();
+ expect(syncWikiLinks).not.toHaveBeenCalled();
+
+ // unmount flush も走らない(pendingRef が null のため)。
+ // The unmount flush must also stay silent (pendingRef is null).
+ unmount();
+
+ await act(async () => {
+ await vi.runAllTimersAsync();
+ });
+
+ expect(onSaveContentOnly).not.toHaveBeenCalled();
+ expect(onSave).not.toHaveBeenCalled();
+ expect(syncWikiLinks).not.toHaveBeenCalled();
+ });
+
+ it("保留中の保存が無いときに cancelPendingSave を呼んでも安全(no-op) / cancelPendingSave is safe to call when nothing is pending", () => {
+ const onSave = vi.fn().mockResolvedValue(true);
+ const { result } = renderHook(() =>
+ useEditorAutoSave({
+ pageId,
+ debounceMs: 500,
+ onSave,
+ onSaveContentOnly: vi.fn().mockResolvedValue(true),
+ syncWikiLinks: vi.fn().mockResolvedValue(undefined),
+ }),
+ );
+
+ expect(() => {
+ act(() => {
+ result.current.cancelPendingSave();
+ });
+ }).not.toThrow();
+ expect(onSave).not.toHaveBeenCalled();
+ });
+ });
+
describe("保存成功時の linkedPages 無効化(3.5)", () => {
it("onSaveSuccess で queryClient.invalidateQueries が linkedPages の queryKey で1回呼ばれる", async () => {
const queryClient = new QueryClient();
diff --git a/src/components/editor/PageEditor/useEditorAutoSave.ts b/src/components/editor/PageEditor/useEditorAutoSave.ts
index 4935ab51..b463af60 100644
--- a/src/components/editor/PageEditor/useEditorAutoSave.ts
+++ b/src/components/editor/PageEditor/useEditorAutoSave.ts
@@ -24,6 +24,18 @@ interface UseEditorAutoSaveOptions {
interface UseEditorAutoSaveReturn {
saveChanges: (title: string, content: string, forceBlockTitle?: boolean) => void;
+ /**
+ * 保留中の debounce 保存とアンマウント時 flush をキャンセルする。
+ * `usePageDeletion` がページ削除を発火する直前に呼ぶことで、
+ * unmount flush の `updatePage` が論理削除を上書きして「無題のページ」が
+ * 復活するレース (issue #768) を防ぐ。
+ *
+ * Cancel any pending debounced save and suppress the unmount flush.
+ * Called by `usePageDeletion` immediately before firing
+ * `deletePageMutation.mutate`, so the unmount flush's `updatePage` cannot
+ * race the soft delete and resurrect the row (issue #768).
+ */
+ cancelPendingSave: () => void;
lastSaved: number | null;
isSaving: boolean;
isSyncingLinks: boolean;
@@ -192,8 +204,17 @@ export function useEditorAutoSave({
],
);
+ const cancelPendingSave = useCallback(() => {
+ if (saveTimeoutRef.current) {
+ clearTimeout(saveTimeoutRef.current);
+ saveTimeoutRef.current = null;
+ }
+ pendingRef.current = null;
+ }, []);
+
return {
saveChanges,
+ cancelPendingSave,
lastSaved,
isSaving,
isSyncingLinks,
diff --git a/src/components/editor/PageEditor/usePageDeletion.test.ts b/src/components/editor/PageEditor/usePageDeletion.test.ts
index ce8c5e70..10b9601c 100644
--- a/src/components/editor/PageEditor/usePageDeletion.test.ts
+++ b/src/components/editor/PageEditor/usePageDeletion.test.ts
@@ -10,10 +10,11 @@ import { usePageDeletion } from "./usePageDeletion";
* (`handleOpenDuplicatePage`) used by the duplicate-title warning.
*/
-const { mockNavigate, mockMutate, mockToast } = vi.hoisted(() => ({
+const { mockNavigate, mockMutate, mockToast, mockCancelPendingSave } = vi.hoisted(() => ({
mockNavigate: vi.fn(),
mockMutate: vi.fn(),
mockToast: vi.fn(),
+ mockCancelPendingSave: vi.fn(),
}));
vi.mock("react-router-dom", () => ({
@@ -52,6 +53,7 @@ describe("usePageDeletion.handleOpenDuplicatePage", () => {
title: "foo",
content: NON_EMPTY_CONTENT,
shouldBlockSave: true,
+ cancelPendingSave: mockCancelPendingSave,
}),
);
@@ -69,6 +71,7 @@ describe("usePageDeletion.handleOpenDuplicatePage", () => {
title: "foo",
content: EMPTY_CONTENT,
shouldBlockSave: true,
+ cancelPendingSave: mockCancelPendingSave,
}),
);
@@ -89,6 +92,7 @@ describe("usePageDeletion.handleOpenDuplicatePage", () => {
title: "foo",
content: NON_EMPTY_CONTENT,
shouldBlockSave: true,
+ cancelPendingSave: mockCancelPendingSave,
}),
);
@@ -107,6 +111,7 @@ describe("usePageDeletion.handleOpenDuplicatePage", () => {
title: "foo",
content: NON_EMPTY_CONTENT,
shouldBlockSave: true,
+ cancelPendingSave: mockCancelPendingSave,
}),
);
@@ -128,6 +133,7 @@ describe("usePageDeletion.handleOpenDuplicatePage", () => {
title: "foo",
content: NON_EMPTY_CONTENT,
shouldBlockSave: true,
+ cancelPendingSave: mockCancelPendingSave,
}),
);
@@ -146,6 +152,7 @@ describe("usePageDeletion.handleOpenDuplicatePage", () => {
title: "foo",
content: NON_EMPTY_CONTENT,
shouldBlockSave: true,
+ cancelPendingSave: mockCancelPendingSave,
}),
);
@@ -159,3 +166,178 @@ describe("usePageDeletion.handleOpenDuplicatePage", () => {
expect(mockNavigate).toHaveBeenLastCalledWith("/home");
});
});
+
+/**
+ * Issue #768: 削除前に保留中の autosave をキャンセルすることで、
+ * unmount flush の `updatePage` が論理削除を上書きして「無題のページ」が
+ * 復活するレースを防ぐ。各削除パスで `cancelPendingSave` が
+ * `deletePageMutation.mutate` よりも先に呼ばれることを順序検証する。
+ *
+ * Issue #768: cancelling the pending autosave before deletion prevents the
+ * unmount flush's `updatePage` from racing the soft delete and resurrecting
+ * an "untitled page" row. These tests assert that for each deletion path
+ * `cancelPendingSave` runs *before* `deletePageMutation.mutate`.
+ */
+describe("usePageDeletion - cancelPendingSave 順序 (issue #768)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("handleOpenDuplicatePage (空コンテンツ) は mutate より先に cancelPendingSave を呼ぶ", () => {
+ const callOrder: string[] = [];
+ mockCancelPendingSave.mockImplementation(() => {
+ callOrder.push("cancel");
+ });
+ mockMutate.mockImplementation(() => {
+ callOrder.push("mutate");
+ });
+
+ const { result } = renderHook(() =>
+ usePageDeletion({
+ currentPageId: "dup-id",
+ title: "foo",
+ content: EMPTY_CONTENT,
+ shouldBlockSave: true,
+ cancelPendingSave: mockCancelPendingSave,
+ }),
+ );
+
+ act(() => result.current.handleOpenDuplicatePage("target-id"));
+
+ expect(callOrder).toEqual(["cancel", "mutate"]);
+ });
+
+ it("handleBack (タイトル空・コンテンツ無し) は mutate より先に cancelPendingSave を呼ぶ", () => {
+ const callOrder: string[] = [];
+ mockCancelPendingSave.mockImplementation(() => {
+ callOrder.push("cancel");
+ });
+ mockMutate.mockImplementation(() => {
+ callOrder.push("mutate");
+ });
+
+ const { result } = renderHook(() =>
+ usePageDeletion({
+ currentPageId: "page-1",
+ title: "",
+ content: EMPTY_CONTENT,
+ shouldBlockSave: false,
+ cancelPendingSave: mockCancelPendingSave,
+ }),
+ );
+
+ act(() => result.current.handleBack());
+
+ expect(callOrder).toEqual(["cancel", "mutate"]);
+ expect(mockToast).toHaveBeenCalledWith({
+ title: "タイトルが未入力のため、ページを削除しました",
+ });
+ });
+
+ it("handleConfirmDelete は mutate より先に cancelPendingSave を呼ぶ", () => {
+ const callOrder: string[] = [];
+ mockCancelPendingSave.mockImplementation(() => {
+ callOrder.push("cancel");
+ });
+ mockMutate.mockImplementation(() => {
+ callOrder.push("mutate");
+ });
+
+ const { result } = renderHook(() =>
+ usePageDeletion({
+ currentPageId: "page-1",
+ title: "",
+ content: NON_EMPTY_CONTENT,
+ shouldBlockSave: false,
+ cancelPendingSave: mockCancelPendingSave,
+ }),
+ );
+
+ // 確認ダイアログを開いてから confirm
+ act(() => result.current.handleBack());
+ act(() => result.current.handleConfirmDelete());
+
+ expect(callOrder).toEqual(["cancel", "mutate"]);
+ });
+
+ it("handleDelete (明示削除) は mutate → onSuccess の順で cancelPendingSave を呼ぶ (Codex P2: 失敗時の保留保存を保護)", () => {
+ // Codex P2 レビューの観点: `handleDelete` は他のハンドラと違い `onError`
+ // でエディタに残るため、mutate 前に cancelPendingSave すると失敗時に
+ // 保留中の編集が落ちる。`onSuccess` の中でだけキャンセルする実装を
+ // 順序検証する。
+ //
+ // Codex P2: unlike other deletion paths, `handleDelete`'s `onError`
+ // keeps the user on the editor, so cancelling before the mutation
+ // would silently drop their queued autosave. Verify cancellation
+ // only happens inside `onSuccess`.
+ const callOrder: string[] = [];
+ mockCancelPendingSave.mockImplementation(() => {
+ callOrder.push("cancel");
+ });
+ mockMutate.mockImplementation((_id: string, opts?: { onSuccess?: () => void }) => {
+ callOrder.push("mutate");
+ opts?.onSuccess?.();
+ });
+
+ const { result } = renderHook(() =>
+ usePageDeletion({
+ currentPageId: "page-1",
+ title: "foo",
+ content: NON_EMPTY_CONTENT,
+ shouldBlockSave: false,
+ cancelPendingSave: mockCancelPendingSave,
+ }),
+ );
+
+ act(() => result.current.handleDelete());
+
+ expect(callOrder).toEqual(["mutate", "cancel"]);
+ expect(mockNavigate).toHaveBeenCalledWith("/home");
+ });
+
+ it("handleDelete: 削除失敗時は cancelPendingSave を呼ばず保留保存を保持する (Codex P2)", () => {
+ // 失敗ブランチではエディタに残るので、保留中の autosave は触らない。
+ // On the failure branch the user stays on the editor, so the pending
+ // autosave must remain intact.
+ mockMutate.mockImplementation((_id: string, opts?: { onError?: (e: Error) => void }) => {
+ opts?.onError?.(new Error("network down"));
+ });
+
+ const { result } = renderHook(() =>
+ usePageDeletion({
+ currentPageId: "page-1",
+ title: "foo",
+ content: NON_EMPTY_CONTENT,
+ shouldBlockSave: false,
+ cancelPendingSave: mockCancelPendingSave,
+ }),
+ );
+
+ act(() => result.current.handleDelete());
+
+ expect(mockCancelPendingSave).not.toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ expect(mockToast).toHaveBeenCalledWith({
+ title: "削除に失敗しました",
+ variant: "destructive",
+ });
+ });
+
+ it("削除に至らないキャンセルパス (handleCancelDelete) では cancelPendingSave を呼ばない", () => {
+ const { result } = renderHook(() =>
+ usePageDeletion({
+ currentPageId: "page-1",
+ title: "",
+ content: NON_EMPTY_CONTENT,
+ shouldBlockSave: false,
+ cancelPendingSave: mockCancelPendingSave,
+ }),
+ );
+
+ act(() => result.current.handleBack()); // opens dialog only
+ act(() => result.current.handleCancelDelete());
+
+ expect(mockCancelPendingSave).not.toHaveBeenCalled();
+ expect(mockMutate).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/components/editor/PageEditor/usePageDeletion.ts b/src/components/editor/PageEditor/usePageDeletion.ts
index f902da6d..0b1dfe06 100644
--- a/src/components/editor/PageEditor/usePageDeletion.ts
+++ b/src/components/editor/PageEditor/usePageDeletion.ts
@@ -9,6 +9,18 @@ interface UsePageDeletionOptions {
title: string;
content: string;
shouldBlockSave: boolean;
+ /**
+ * 削除発火直前に呼ぶ、保留中 autosave のキャンセル関数。
+ * `useEditorAutoSave.cancelPendingSave` を渡す想定。issue #768 のレース
+ * (unmount flush の `updatePage` が論理削除を上書きして「無題のページ」
+ * が復活する)を防ぐために必須。
+ *
+ * Cancel any pending autosave before firing a delete. Pass
+ * `useEditorAutoSave.cancelPendingSave`. Required to prevent the issue
+ * #768 race where the unmount flush's `updatePage` overwrites the soft
+ * delete and resurrects an "untitled page" row.
+ */
+ cancelPendingSave: () => void;
}
interface UsePageDeletionReturn {
@@ -32,14 +44,36 @@ interface UsePageDeletionReturn {
}
/**
- * Hook for page deletion logic
- * Handles delete confirmation, back navigation with cleanup
+ * ページ削除フロー(明示削除・戻る・タイトル空・タイトル重複)の状態と
+ * ハンドラを束ねるフック。確認ダイアログの開閉、削除理由の保持、削除後の
+ * 遷移先決定、トースト表示までを管理する。
+ *
+ * issue #768: 削除を発火する前に呼び出し側 (`useEditorAutoSave`) の
+ * `cancelPendingSave` を必ず実行し、保留中の autosave debounce と unmount
+ * flush を抑止する。これにより `updatePage` が論理削除を上書きして
+ * 「無題のページ」が `/home` に復活するレースを防ぐ。`handleDelete` だけは
+ * `onError` でユーザーがエディタに残るため、保留中の編集を落とさないよう
+ * `onSuccess` 内(navigate 直前)でのみキャンセルする。
+ *
+ * Hook bundling page deletion flows (explicit delete, back navigation,
+ * empty-title cleanup, duplicate-title cleanup): manages confirmation
+ * dialog state, the deletion reason, post-delete navigation target, and
+ * toasts.
+ *
+ * issue #768: each deletion path invokes the caller-supplied
+ * `cancelPendingSave` (from `useEditorAutoSave`) to clear pending autosave
+ * debounces and suppress the unmount flush, preventing the race where
+ * `updatePage` overwrites the soft delete and an "untitled" row reappears
+ * on `/home`. `handleDelete` is the exception: its `onError` keeps the user
+ * on the editor, so cancellation is deferred to `onSuccess` (just before
+ * `navigate`) to avoid silently dropping queued edits on a failed delete.
*/
export function usePageDeletion({
currentPageId,
title,
content,
shouldBlockSave,
+ cancelPendingSave,
}: UsePageDeletionOptions): UsePageDeletionReturn {
const navigate = useNavigate();
const { toast } = useToast();
@@ -53,8 +87,19 @@ export function usePageDeletion({
const handleDelete = useCallback(() => {
if (currentPageId) {
+ // issue #768 + Codex P2: 他のハンドラと違い、`handleDelete` の `onError`
+ // ではユーザーがエディタに残るため、削除失敗時に保留中の autosave を
+ // 落とすと最近の編集が失われる。そのため `cancelPendingSave` は削除が
+ // 成功して `/home` に遷移する直前(`onSuccess` 内)でのみ呼ぶ。
+ //
+ // issue #768 + Codex P2: unlike the other handlers, `handleDelete`'s
+ // `onError` keeps the user on the editor, so cancelling the pending
+ // autosave before the mutation would silently drop their queued edits
+ // on a failed delete. Cancel only inside `onSuccess`, just before
+ // navigating away (and the unmount flush).
deletePageMutation.mutate(currentPageId, {
onSuccess: () => {
+ cancelPendingSave();
toast({
title: "ページを削除しました",
});
@@ -68,7 +113,7 @@ export function usePageDeletion({
},
});
}
- }, [currentPageId, deletePageMutation, navigate, toast]);
+ }, [currentPageId, deletePageMutation, navigate, toast, cancelPendingSave]);
const handleBack = useCallback(() => {
const hasContent = isContentNotEmpty(content);
@@ -94,6 +139,9 @@ export function usePageDeletion({
return;
}
+ // issue #768: 削除発火前に保留中の autosave をキャンセル。
+ // issue #768: cancel any pending autosave before firing the delete.
+ cancelPendingSave();
// コンテンツがない場合はそのまま削除
deletePageMutation.mutate(currentPageId);
if (shouldDeleteForDuplicate) {
@@ -107,10 +155,22 @@ export function usePageDeletion({
}
}
navigate("/home");
- }, [navigate, currentPageId, title, content, deletePageMutation, shouldBlockSave, toast]);
+ }, [
+ navigate,
+ currentPageId,
+ title,
+ content,
+ deletePageMutation,
+ shouldBlockSave,
+ toast,
+ cancelPendingSave,
+ ]);
const handleConfirmDelete = useCallback(() => {
if (currentPageId) {
+ // issue #768: 削除発火前に保留中の autosave をキャンセル。
+ // issue #768: cancel any pending autosave before firing the delete.
+ cancelPendingSave();
deletePageMutation.mutate(currentPageId);
toast({
title: `${deleteReason}を削除しました`,
@@ -120,7 +180,15 @@ export function usePageDeletion({
navigate(pendingNavTarget);
// 次回に備えてデフォルトに戻す / reset to default for next invocation
setPendingNavTarget("/home");
- }, [currentPageId, deletePageMutation, deleteReason, navigate, pendingNavTarget, toast]);
+ }, [
+ currentPageId,
+ deletePageMutation,
+ deleteReason,
+ navigate,
+ pendingNavTarget,
+ toast,
+ cancelPendingSave,
+ ]);
const handleCancelDelete = useCallback(() => {
setDeleteConfirmOpen(false);
@@ -149,6 +217,9 @@ export function usePageDeletion({
return;
}
+ // issue #768: 削除発火前に保留中の autosave をキャンセル。
+ // issue #768: cancel any pending autosave before firing the delete.
+ cancelPendingSave();
// コンテンツがない場合はそのまま削除して遷移
// Otherwise delete immediately and navigate to the existing page.
deletePageMutation.mutate(currentPageId);
@@ -157,7 +228,7 @@ export function usePageDeletion({
});
navigate(targetPath);
},
- [currentPageId, content, deletePageMutation, navigate, toast],
+ [currentPageId, content, deletePageMutation, navigate, toast, cancelPendingSave],
);
return {
diff --git a/src/components/editor/PageEditor/usePageEditorAutoSaveWithMutation.ts b/src/components/editor/PageEditor/usePageEditorAutoSaveWithMutation.ts
index a71cc4c5..3a07bd9c 100644
--- a/src/components/editor/PageEditor/usePageEditorAutoSaveWithMutation.ts
+++ b/src/components/editor/PageEditor/usePageEditorAutoSaveWithMutation.ts
@@ -31,6 +31,7 @@ export function usePageEditorAutoSaveWithMutation({
const {
saveChanges,
+ cancelPendingSave,
lastSaved: autoSaveLastSaved,
isSyncingLinks,
} = useEditorAutoSave({
@@ -67,5 +68,5 @@ export function usePageEditorAutoSaveWithMutation({
},
});
- return { saveChanges, lastSaved: autoSaveLastSaved, isSyncingLinks };
+ return { saveChanges, cancelPendingSave, lastSaved: autoSaveLastSaved, isSyncingLinks };
}
diff --git a/src/components/editor/PageEditor/usePageEditorStateAndSync.ts b/src/components/editor/PageEditor/usePageEditorStateAndSync.ts
index 6c829b95..461bd298 100644
--- a/src/components/editor/PageEditor/usePageEditorStateAndSync.ts
+++ b/src/components/editor/PageEditor/usePageEditorStateAndSync.ts
@@ -33,12 +33,14 @@ function usePageEditorDeletionAndNav(
content: string,
sourceUrl: string,
shouldBlockSave: boolean,
+ cancelPendingSave: () => void,
) {
const deletion = usePageDeletion({
currentPageId,
title,
content,
shouldBlockSave,
+ cancelPendingSave,
});
const { handleExportMarkdown, handleCopyMarkdown } = useMarkdownExport(title, content, sourceUrl);
usePageEditorKeyboard({ onBack: deletion.handleBack });
@@ -128,6 +130,17 @@ export function usePageEditorStateAndSync() {
const { wikiContentForCollab, setWikiContentForCollab, resetWiki, onWikiContentApplied } =
usePageEditorWikiCollab(resetWikiBase, collaboration);
+ const {
+ saveChanges,
+ cancelPendingSave,
+ lastSaved: autoSaveLastSaved,
+ isSyncingLinks,
+ } = usePageEditorAutoSaveWithMutation({
+ currentPageId,
+ shouldBlockSave,
+ updateLastSaved,
+ });
+
const {
deleteConfirmOpen,
deleteReason,
@@ -139,17 +152,14 @@ export function usePageEditorStateAndSync() {
handleOpenDuplicatePage,
handleExportMarkdown,
handleCopyMarkdown,
- } = usePageEditorDeletionAndNav(currentPageId, title, content, sourceUrl, shouldBlockSave);
-
- const {
- saveChanges,
- lastSaved: autoSaveLastSaved,
- isSyncingLinks,
- } = usePageEditorAutoSaveWithMutation({
+ } = usePageEditorDeletionAndNav(
currentPageId,
+ title,
+ content,
+ sourceUrl,
shouldBlockSave,
- updateLastSaved,
- });
+ cancelPendingSave,
+ );
const { displayLastSaved, pendingInitialContent, setPendingInitialContent } =
useDisplayLastSavedAndPending(autoSaveLastSaved, lastSaved);
diff --git a/src/components/editor/PageEditor/usePendingChatPageGeneration.ts b/src/components/editor/PageEditor/usePendingChatPageGeneration.ts
index e4471574..1dde80e2 100644
--- a/src/components/editor/PageEditor/usePendingChatPageGeneration.ts
+++ b/src/components/editor/PageEditor/usePendingChatPageGeneration.ts
@@ -123,7 +123,9 @@ export function usePendingChatPageGeneration({
if (throttleId) return;
throttleId = setTimeout(() => {
throttleId = null;
- const tiptap = convertMarkdownToTiptapContent(markdown);
+ // AI 出力経路。先頭の `# Title` 行はタイトル input と重複するため落とす(issue #784)。
+ // AI path: drop a stray leading `# Title` line that duplicates the title input (issue #784).
+ const tiptap = convertMarkdownToTiptapContent(markdown, { dropLeadingH1: true });
setContent(tiptap);
setWikiContentForCollab(tiptap);
}, PENDING_CHAT_PAGE_STREAM_THROTTLE_MS);
@@ -141,7 +143,9 @@ export function usePendingChatPageGeneration({
onComplete: (result) => {
if (throttleId) clearTimeout(throttleId);
throttleId = null;
- const tiptap = convertMarkdownToTiptapContent(result.content);
+ // 同上、AI 出力なので先頭 `# Title` を落とす(issue #784)。
+ // Same as above: AI output, drop a leading `# Title` (issue #784).
+ const tiptap = convertMarkdownToTiptapContent(result.content, { dropLeadingH1: true });
setContent(tiptap);
setWikiContentForCollab(tiptap);
saveChanges(titleRef.current, tiptap);
diff --git a/src/components/editor/TiptapEditor.tsx b/src/components/editor/TiptapEditor.tsx
index d13dedc1..2e7dff46 100644
--- a/src/components/editor/TiptapEditor.tsx
+++ b/src/components/editor/TiptapEditor.tsx
@@ -9,6 +9,7 @@ import { StorageSetupDialog } from "./TiptapEditor/StorageSetupDialog";
import { DragOverlay } from "./TiptapEditor/DragOverlay";
import { WikiLinkSuggestionLayer } from "./TiptapEditor/WikiLinkSuggestionLayer";
import { WikiLinkHoverCardLayer } from "./TiptapEditor/WikiLinkHoverCardLayer";
+import { TagSuggestionLayer } from "./TiptapEditor/TagSuggestionLayer";
import { SlashSuggestionLayer } from "./TiptapEditor/SlashSuggestionLayer";
import { EditorBubbleMenu } from "./TiptapEditor/EditorBubbleMenu";
import { TableBubbleMenu } from "./TiptapEditor/TableBubbleMenu";
@@ -27,7 +28,7 @@ export type { TiptapEditorProps } from "./TiptapEditor/types";
const TiptapEditor: React.FC = ({
content,
onChange,
- placeholder = "思考を書き始める...",
+ placeholder,
className,
autoFocus = false,
pageId,
@@ -46,6 +47,7 @@ const TiptapEditor: React.FC = ({
pageNoteId = null,
}) => {
const { t } = useTranslation();
+ const resolvedPlaceholder = placeholder ?? t("editor.startWritingPlaceholder");
const {
editor,
editorFontSizePx,
@@ -67,6 +69,11 @@ const TiptapEditor: React.FC = ({
slashPos,
slashRef,
handleSlashClose,
+ tagSuggestionState,
+ tagSuggestionPos,
+ tagSuggestionRef,
+ handleTagSuggestionSelect,
+ handleTagSuggestionClose,
mermaidDialogOpen,
setMermaidDialogOpen,
handleInsertMermaid,
@@ -88,7 +95,7 @@ const TiptapEditor: React.FC = ({
} = useTiptapEditorController({
content,
onChange,
- placeholder,
+ placeholder: resolvedPlaceholder,
autoFocus,
pageId,
pageTitle,
@@ -147,6 +154,15 @@ const TiptapEditor: React.FC = ({
onClose={handleSuggestionClose}
pageNoteId={resolvedPageNoteId}
/>
+
;
+ onSelect: (item: TagSuggestionItem) => void;
+ onClose: () => void;
+ /**
+ * 編集中ページの noteId。タグサジェストの候補スコープを WikiLink と同じ
+ * 規則で個人 (`null`) / 同一ノート (`string`) に絞るために使う。
+ *
+ * Owning note id of the page being edited. Scopes tag candidates the same
+ * way `WikiLinkSuggestionLayer` scopes WikiLink candidates.
+ */
+ pageNoteId: string | null;
+}
+
+/**
+ * タグサジェスト UI の絶対配置層。`useTagCandidates` でスコープ別の候補を
+ * 取得し、`TagSuggestion` に渡す。WikiLink 用 `WikiLinkSuggestionLayer` と
+ * 同じ構造(描画・データ取得は層で吸収し、本体コンポーネントは純表示)。
+ *
+ * Floating layer for the `#name` suggestion popup. Fetches scope-aware tag
+ * candidates via `useTagCandidates` and forwards them to `TagSuggestion`,
+ * mirroring `WikiLinkSuggestionLayer`. See issue #767 (Phase 2).
+ */
+export const TagSuggestionLayer: React.FC = ({
+ editor,
+ suggestionState,
+ position,
+ suggestionRef,
+ onSelect,
+ onClose,
+ pageNoteId,
+}) => {
+ // ポップオーバーが open のときだけ候補(特にゴーストタグの fetch)を実行する。
+ // `useTagCandidates` は `enabled` でクエリを抑止するので、`#` が打鍵されるまで
+ // IndexedDB / API を叩かない(gemini-code-assist のレビュー指摘)。
+ // Only fetch candidates while the popover is active so the ghost-tag query
+ // does not hit IndexedDB / the network until the user types `#`. The hook
+ // now accepts an `enabled` flag to gate its react-query call (review
+ // feedback from gemini-code-assist).
+ const isActive = Boolean(suggestionState?.active);
+ const { candidates } = useTagCandidates(pageNoteId, { enabled: isActive });
+
+ if (!suggestionState?.active || !suggestionState.range || !position || !editor) return null;
+
+ return (
+
+
+
+ );
+};
diff --git a/src/components/editor/TiptapEditor/editorConfig.ts b/src/components/editor/TiptapEditor/editorConfig.ts
index 7d1852c2..8dc4c091 100644
--- a/src/components/editor/TiptapEditor/editorConfig.ts
+++ b/src/components/editor/TiptapEditor/editorConfig.ts
@@ -36,6 +36,8 @@ import {
SlashSuggestionPlugin,
type SlashSuggestionState,
} from "../extensions/slashSuggestionPlugin";
+import { TagSuggestionPlugin, type TagSuggestionState } from "../extensions/tagSuggestionPlugin";
+import { HeadingLevelClamp } from "./headingLevelClampExtension";
import type { Extension } from "@tiptap/core";
import type * as Y from "yjs";
import type { Awareness } from "y-protocols/awareness";
@@ -112,6 +114,15 @@ export interface EditorExtensionsOptions {
onTagClick?: (name: string) => void;
onStateChange: (state: WikiLinkSuggestionState) => void;
onSlashStateChange: (state: SlashSuggestionState) => void;
+ /**
+ * `#name` タグサジェストの状態通知。`TagSuggestionPlugin` が状態変化のたび
+ * に呼ぶ。Issue #767 (Phase 2)。
+ *
+ * Tag (`#name`) suggestion state callback. Invoked by
+ * `TagSuggestionPlugin` whenever the popover opens, closes, or the query
+ * changes. See issue #767 (Phase 2).
+ */
+ onTagSuggestionStateChange: (state: TagSuggestionState) => void;
imageUploadOptions: Partial;
imageOptions: Partial;
/** When set, enables Y.js collaboration and caret; StarterKit history is disabled */
@@ -132,6 +143,7 @@ interface CommonEditorExtensionsOptions {
onTagClick?: (name: string) => void;
onStateChange?: (state: WikiLinkSuggestionState) => void;
onSlashStateChange?: (state: SlashSuggestionState) => void;
+ onTagSuggestionStateChange?: (state: TagSuggestionState) => void;
imageUploadOptions?: Partial;
imageOptions?: Partial;
fileReference?: EditorExtensionsOptions["fileReference"];
@@ -146,7 +158,9 @@ function createCommonEditorExtensions(options: CommonEditorExtensionsOptions): E
return [
StarterKit.configure({
heading: {
- levels: [1, 2, 3],
+ // Body headings span h2–h5 (Markdown `#`/`##`/`###`/`####` map to 2/3/4/5).
+ // The page title is the only h1 and lives outside the editor document.
+ levels: [2, 3, 4, 5],
},
// Y.js が履歴を管理するためコラボ時は無効
undoRedo: useCollaboration ? false : undefined,
@@ -157,6 +171,8 @@ function createCommonEditorExtensions(options: CommonEditorExtensionsOptions): E
// 下で個別に Underline を追加するため StarterKit の underline は無効
underline: false,
}),
+ // Legacy h1 + collab: normalize to h2 after each change / 旧 h1 や Y.Doc 取り込みを h2 に揃える
+ HeadingLevelClamp,
Markdown.configure({
markedOptions: { gfm: true },
}),
@@ -245,6 +261,17 @@ function createCommonEditorExtensions(options: CommonEditorExtensionsOptions): E
SlashSuggestionPlugin.configure({
onStateChange: options.onSlashStateChange ?? (() => undefined),
}),
+ // --- Phase 2: Tag (`#name`) suggestion (issue #767) ---
+ // 入力規則 (#766) との二重マーク化を避けるため、`TagExtension` の
+ // `addInputRules` ハンドラがこのプラグインの active 状態を見て発火を
+ // 抑止する。Esc で閉じた後の終端文字は通常どおり入力規則経路で
+ // マーク化される(フォールバック契約)。
+ // Coordinated with `TagExtension.addInputRules`: while this plugin is
+ // active the input rule short-circuits to avoid double-marking; after
+ // Esc the rule fires normally (Esc-then-terminator fallback).
+ TagSuggestionPlugin.configure({
+ onStateChange: options.onTagSuggestionStateChange ?? (() => undefined),
+ }),
]
: []),
// --- Image ---
@@ -311,6 +338,7 @@ export function createEditorExtensions(options: EditorExtensionsOptions): Extens
onTagClick: options.onTagClick,
onStateChange: options.onStateChange,
onSlashStateChange: options.onSlashStateChange,
+ onTagSuggestionStateChange: options.onTagSuggestionStateChange,
imageUploadOptions: options.imageUploadOptions,
imageOptions: options.imageOptions,
fileReference: options.fileReference,
diff --git a/src/components/editor/TiptapEditor/headingLevelClampExtension.test.ts b/src/components/editor/TiptapEditor/headingLevelClampExtension.test.ts
new file mode 100644
index 00000000..0e94bfba
--- /dev/null
+++ b/src/components/editor/TiptapEditor/headingLevelClampExtension.test.ts
@@ -0,0 +1,44 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { Editor } from "@tiptap/core";
+import StarterKit from "@tiptap/starter-kit";
+import { HeadingLevelClamp } from "./headingLevelClampExtension";
+
+/**
+ * ロード直後、appendTransaction により h1 相当 (level:1) が h2 へ上書きされることを検証する
+ * / Verifies legacy Tiptap JSON (heading level:1) becomes level:2 on load
+ */
+describe("HeadingLevelClamp", () => {
+ const editors: Editor[] = [];
+ afterEach(() => {
+ for (const ed of editors) {
+ ed.destroy();
+ }
+ editors.length = 0;
+ });
+
+ it("migrates stored heading level 1 to 2 for schema levels 2-5", async () => {
+ const el = document.createElement("div");
+ const editor = new Editor({
+ element: el,
+ extensions: [
+ StarterKit.configure({
+ heading: { levels: [2, 3, 4, 5] },
+ }),
+ HeadingLevelClamp,
+ ],
+ content: {
+ type: "doc",
+ content: [
+ { type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: "Legacy" }] },
+ ],
+ },
+ });
+ editors.push(editor);
+ await new Promise((resolve) => {
+ queueMicrotask(() => resolve());
+ });
+ const first = editor.state.doc.firstChild;
+ expect(first?.type.name).toBe("heading");
+ expect(first?.attrs.level).toBe(2);
+ });
+});
diff --git a/src/components/editor/TiptapEditor/headingLevelClampExtension.ts b/src/components/editor/TiptapEditor/headingLevelClampExtension.ts
new file mode 100644
index 00000000..627afb32
--- /dev/null
+++ b/src/components/editor/TiptapEditor/headingLevelClampExtension.ts
@@ -0,0 +1,60 @@
+import { Extension } from "@tiptap/core";
+import type { EditorState, Transaction } from "@tiptap/pm/state";
+import { Plugin, PluginKey } from "@tiptap/pm/state";
+
+const headingLevelClampKey = new PluginKey("headingLevelClamp");
+
+/**
+ * 本文 h1 相当 (level:1) を h2 へ。初期 doc が appendTransaction を通らないこともあるため
+ * 初回は view マウント直後の queueMicrotask でも反映する
+ * / Body h1 (level:1) → h2. Initial Tiptap content may not run appendTransaction;
+ * also run once in queueMicrotask after the plugin view is mounted
+ */
+function buildHeadingClampTr(state: EditorState): Transaction | null {
+ let tr: Transaction | null = null;
+ state.doc.descendants((node, pos) => {
+ if (node.type.name !== "heading") return;
+ const level = typeof node.attrs.level === "number" ? node.attrs.level : 1;
+ if (level < 2) {
+ tr ??= state.tr;
+ tr.setNodeMarkup(pos, undefined, { ...node.attrs, level: 2 });
+ }
+ // headings only contain inline content; no need to scan descendants further
+ return false;
+ });
+ return tr;
+}
+
+/**
+ * 旧 h1(level:1)や Y.Doc から取り込んだ低レベル見出しを h2 にクランプする Tiptap 拡張。
+ * `appendTransaction` で各変更後に正規化し、初回のみ `view` マウント直後の `queueMicrotask` でも反映する。
+ * Tiptap extension that clamps stray heading nodes (level < 2) up to level 2.
+ * Runs on every transaction via `appendTransaction`, plus once on view mount via `queueMicrotask`
+ * because the initial document does not always pass through `appendTransaction`.
+ */
+export const HeadingLevelClamp = Extension.create({
+ name: "headingLevelClamp",
+ addProseMirrorPlugins() {
+ return [
+ new Plugin({
+ key: headingLevelClampKey,
+ view(view) {
+ queueMicrotask(() => {
+ if (view.isDestroyed) return;
+ const tr = buildHeadingClampTr(view.state);
+ if (tr) {
+ view.dispatch(tr);
+ }
+ });
+ return {};
+ },
+ appendTransaction(transactions, _oldState, newState) {
+ if (!transactions.some((tr) => tr.docChanged)) {
+ return null;
+ }
+ return buildHeadingClampTr(newState);
+ },
+ }),
+ ];
+ },
+});
diff --git a/src/components/editor/TiptapEditor/slashCommandItems.test.ts b/src/components/editor/TiptapEditor/slashCommandItems.test.ts
index 2bca678d..4bbdfdba 100644
--- a/src/components/editor/TiptapEditor/slashCommandItems.test.ts
+++ b/src/components/editor/TiptapEditor/slashCommandItems.test.ts
@@ -1,6 +1,6 @@
/**
- * slashCommandItems: filtering and platform-gated availability.
- * slashCommandItems: フィルタリングとプラットフォーム依存の可用性判定。
+ * slashCommandItems: filtering, platform-gated availability, and heading-level alignment.
+ * slashCommandItems: フィルタリング、プラットフォーム依存の可用性判定、見出しレベル整合。
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -81,8 +81,48 @@ describe("slashCommandItems platform gating", () => {
const ids = filtered.map((i) => i.id);
expect(ids).toContain("paragraph");
- expect(ids).toContain("heading1");
+ expect(ids).toContain("heading2");
expect(ids).toContain("table");
expect(ids).toContain("mermaid");
});
});
+
+describe("slashCommandItems heading entries", () => {
+ /**
+ * Slash menu の見出し ID は実 level と 1:1 で対応していなければならない
+ * (PR #777 で本文 schema が `levels: [2, 3, 4, 5]` に変わったため)。
+ * Slash menu heading IDs must map 1:1 to the real schema level
+ * (the body editor restricts headings to `levels: [2, 3, 4, 5]` since PR #777).
+ */
+ it("exposes heading2 through heading5 with matching levels and no legacy heading1", () => {
+ const ids = slashCommandItems.map((i) => i.id);
+
+ expect(ids).not.toContain("heading1");
+ expect(ids).toContain("heading2");
+ expect(ids).toContain("heading3");
+ expect(ids).toContain("heading4");
+ expect(ids).toContain("heading5");
+
+ const captured: { level?: number } = {};
+ const chain = {
+ focus: () => chain,
+ deleteRange: () => chain,
+ setHeading: (attrs: { level: number }) => {
+ captured.level = attrs.level;
+ return chain;
+ },
+ run: () => true,
+ };
+ const editorStub = {
+ chain: () => chain,
+ } as unknown as Editor;
+
+ for (const level of [2, 3, 4, 5] as const) {
+ const item = slashCommandItems.find((i) => i.id === `heading${level}`);
+ expect(item).toBeDefined();
+ captured.level = undefined;
+ item?.action(editorStub, { from: 0, to: 0 });
+ expect(captured.level).toBe(level);
+ }
+ });
+});
diff --git a/src/components/editor/TiptapEditor/slashCommandItems.ts b/src/components/editor/TiptapEditor/slashCommandItems.ts
index bd4339ed..545dd250 100644
--- a/src/components/editor/TiptapEditor/slashCommandItems.ts
+++ b/src/components/editor/TiptapEditor/slashCommandItems.ts
@@ -37,12 +37,10 @@ export const slashCommandItems: SlashCommandItem[] = [
action: (editor, range) => editor.chain().focus().deleteRange(range).setParagraph().run(),
},
{
- id: "heading1",
- icon: "Heading1",
- action: (editor, range) =>
- editor.chain().focus().deleteRange(range).setHeading({ level: 1 }).run(),
- },
- {
+ // ページタイトル input が h1 を担うため、本文 schema は `levels: [2, 3, 4, 5]`。
+ // ID とラベルは実 level に揃える。
+ // The page title input owns the only h1; the body schema allows levels 2–5,
+ // so each slash item's id matches the level it inserts.
id: "heading2",
icon: "Heading2",
action: (editor, range) =>
@@ -54,6 +52,18 @@ export const slashCommandItems: SlashCommandItem[] = [
action: (editor, range) =>
editor.chain().focus().deleteRange(range).setHeading({ level: 3 }).run(),
},
+ {
+ id: "heading4",
+ icon: "Heading4",
+ action: (editor, range) =>
+ editor.chain().focus().deleteRange(range).setHeading({ level: 4 }).run(),
+ },
+ {
+ id: "heading5",
+ icon: "Heading5",
+ action: (editor, range) =>
+ editor.chain().focus().deleteRange(range).setHeading({ level: 5 }).run(),
+ },
{
id: "bulletList",
icon: "List",
diff --git a/src/components/editor/TiptapEditor/slashSuggestionIcons.tsx b/src/components/editor/TiptapEditor/slashSuggestionIcons.tsx
index 21053092..4ef83832 100644
--- a/src/components/editor/TiptapEditor/slashSuggestionIcons.tsx
+++ b/src/components/editor/TiptapEditor/slashSuggestionIcons.tsx
@@ -6,9 +6,10 @@
import type { FC, SVGProps } from "react";
import {
Pilcrow,
- Heading1,
Heading2,
Heading3,
+ Heading4,
+ Heading5,
List,
ListOrdered,
CheckSquare,
@@ -35,9 +36,10 @@ import type { AgentSlashCommandId } from "@/lib/agentSlashCommands/types";
/** Map icon name string → Lucide component / アイコン名 → Lucide コンポーネント */
export const slashMenuIconMap: Record>> = {
Pilcrow,
- Heading1,
Heading2,
Heading3,
+ Heading4,
+ Heading5,
List,
ListOrdered,
CheckSquare,
diff --git a/src/components/editor/TiptapEditor/suggestionStateUtils.test.ts b/src/components/editor/TiptapEditor/suggestionStateUtils.test.ts
index bf3c8beb..e89d221a 100644
--- a/src/components/editor/TiptapEditor/suggestionStateUtils.test.ts
+++ b/src/components/editor/TiptapEditor/suggestionStateUtils.test.ts
@@ -3,9 +3,11 @@ import {
isSameSuggestionRange,
isSameWikiLinkSuggestionState,
isSameSlashSuggestionState,
+ isSameTagSuggestionState,
} from "./suggestionStateUtils";
import type { WikiLinkSuggestionState } from "../extensions/wikiLinkSuggestionPlugin";
import type { SlashSuggestionState } from "../extensions/slashSuggestionPlugin";
+import type { TagSuggestionState } from "../extensions/tagSuggestionPlugin";
function createWikiLinkState(overrides: Partial): WikiLinkSuggestionState {
return {
@@ -27,6 +29,16 @@ function createSlashState(overrides: Partial): SlashSugges
};
}
+function createTagState(overrides: Partial): TagSuggestionState {
+ return {
+ active: false,
+ query: "",
+ range: null,
+ decorations: {} as TagSuggestionState["decorations"],
+ ...overrides,
+ };
+}
+
describe("isSameSuggestionRange", () => {
it("returns true when both are null", () => {
expect(isSameSuggestionRange(null, null)).toBe(true);
@@ -121,3 +133,35 @@ describe("isSameSlashSuggestionState", () => {
expect(isSameSlashSuggestionState(a, b)).toBe(false);
});
});
+
+describe("isSameTagSuggestionState", () => {
+ it("returns false when first argument is null", () => {
+ const b = createTagState({ active: true, query: "tec", range: { from: 0, to: 4 } });
+ expect(isSameTagSuggestionState(null, b)).toBe(false);
+ });
+
+ it("returns true when active, query and range match", () => {
+ const state = createTagState({ active: true, query: "tec", range: { from: 1, to: 5 } });
+ expect(isSameTagSuggestionState(state, state)).toBe(true);
+ const b = createTagState({ active: true, query: "tec", range: { from: 1, to: 5 } });
+ expect(isSameTagSuggestionState(state, b)).toBe(true);
+ });
+
+ it("returns false when active differs", () => {
+ const a = createTagState({ active: true, query: "q", range: null });
+ const b = createTagState({ active: false, query: "q", range: null });
+ expect(isSameTagSuggestionState(a, b)).toBe(false);
+ });
+
+ it("returns false when query differs", () => {
+ const a = createTagState({ active: true, query: "a", range: null });
+ const b = createTagState({ active: true, query: "b", range: null });
+ expect(isSameTagSuggestionState(a, b)).toBe(false);
+ });
+
+ it("returns false when range differs", () => {
+ const a = createTagState({ active: true, query: "q", range: { from: 0, to: 1 } });
+ const b = createTagState({ active: true, query: "q", range: { from: 2, to: 3 } });
+ expect(isSameTagSuggestionState(a, b)).toBe(false);
+ });
+});
diff --git a/src/components/editor/TiptapEditor/suggestionStateUtils.ts b/src/components/editor/TiptapEditor/suggestionStateUtils.ts
index 5f5a3baa..0df2905d 100644
--- a/src/components/editor/TiptapEditor/suggestionStateUtils.ts
+++ b/src/components/editor/TiptapEditor/suggestionStateUtils.ts
@@ -1,6 +1,22 @@
import type { WikiLinkSuggestionState } from "../extensions/wikiLinkSuggestionPlugin";
import type { SlashSuggestionState } from "../extensions/slashSuggestionPlugin";
+import type { TagSuggestionState } from "../extensions/tagSuggestionPlugin";
+/**
+ * サジェスト範囲(`{ from, to }`)の値による浅比較。両方 `null` のとき同値、
+ * 片方だけ `null` のときは不一致と判定する。`isSame*SuggestionState` から
+ * 呼ばれる補助関数で、state 等価判定経由で setState のスキップに使う。
+ *
+ * Shallow value comparison for a suggestion range (`{ from, to }`). Two
+ * `null`s compare equal; a `null` against a value compares unequal. Used by
+ * the per-suggestion-kind comparators below to gate setState calls and
+ * avoid redundant re-renders.
+ *
+ * @param a - 比較対象 A / left-hand range, may be `null`
+ * @param b - 比較対象 B / right-hand range, may be `null`
+ * @returns `from` と `to` が両方一致すれば `true`、両方 `null` でも `true`。
+ * / `true` when both `from` and `to` match (or both args are `null`).
+ */
export function isSameSuggestionRange(
a: { from: number; to: number } | null,
b: { from: number; to: number } | null,
@@ -10,6 +26,24 @@ export function isSameSuggestionRange(
return a.from === b.from && a.to === b.to;
}
+/**
+ * WikiLink (`[[...]]`) サジェスト state の浅比較。`active` / `query` / `range`
+ * の 3 値だけを見る(`decorations` は `DecorationSet` の参照が毎回変わるので
+ * 比較対象から外す)。`a` が `null` のときは常に不一致扱いで `setState` の
+ * 初期化を許す。
+ *
+ * Shallow comparator for the WikiLink (`[[...]]`) suggestion state. Only
+ * inspects `active`, `query`, and `range`; `decorations` is excluded because
+ * its `DecorationSet` reference changes every transaction even when no
+ * meaningful change occurred. When `a` is `null` the comparator returns
+ * `false` so the very first state lands.
+ *
+ * @param a - 直前の state または `null` / previous state, or `null` for
+ * first-render
+ * @param b - 新しい state / new state to compare against
+ * @returns 3 つの値がすべて一致すれば `true`。/ `true` when all three
+ * tracked fields match.
+ */
export function isSameWikiLinkSuggestionState(
a: WikiLinkSuggestionState | null,
b: WikiLinkSuggestionState,
@@ -18,6 +52,20 @@ export function isSameWikiLinkSuggestionState(
return a.active === b.active && a.query === b.query && isSameSuggestionRange(a.range, b.range);
}
+/**
+ * スラッシュコマンド (`/...`) サジェスト state の浅比較。比較対象は WikiLink と
+ * 同じ 3 値(`active` / `query` / `range`)。`decorations` を除外する理由も
+ * 同じ。
+ *
+ * Shallow comparator for the slash-command (`/...`) suggestion state. Same
+ * three-field model as the WikiLink variant; `decorations` is excluded for
+ * the same reason (reference identity changes per-transaction).
+ *
+ * @param a - 直前の state または `null` / previous state, or `null`
+ * @param b - 新しい state / new state to compare against
+ * @returns 3 つの値がすべて一致すれば `true`。/ `true` when all three
+ * tracked fields match.
+ */
export function isSameSlashSuggestionState(
a: SlashSuggestionState | null,
b: SlashSuggestionState,
@@ -25,3 +73,19 @@ export function isSameSlashSuggestionState(
if (!a) return false;
return a.active === b.active && a.query === b.query && isSameSuggestionRange(a.range, b.range);
}
+
+/**
+ * `#name` タグサジェスト用の浅比較ヘルパー。WikiLink / Slash と同じく
+ * `active`/`query`/`range` だけを見て、不要な再レンダーを防ぐ。
+ *
+ * Shallow comparator for the tag suggestion state. Mirrors the WikiLink /
+ * slash variants — only `active`, `query`, and `range` matter for re-render
+ * gating. See issue #767 (Phase 2).
+ */
+export function isSameTagSuggestionState(
+ a: TagSuggestionState | null,
+ b: TagSuggestionState,
+): boolean {
+ if (!a) return false;
+ return a.active === b.active && a.query === b.query && isSameSuggestionRange(a.range, b.range);
+}
diff --git a/src/components/editor/TiptapEditor/useEditorSetup.ts b/src/components/editor/TiptapEditor/useEditorSetup.ts
index 850377e0..4ddda0d8 100644
--- a/src/components/editor/TiptapEditor/useEditorSetup.ts
+++ b/src/components/editor/TiptapEditor/useEditorSetup.ts
@@ -11,10 +11,13 @@ import { useEditor } from "@tiptap/react";
import type { Editor } from "@tiptap/core";
import type { WikiLinkSuggestionState } from "../extensions/wikiLinkSuggestionPlugin";
import type { SlashSuggestionState } from "../extensions/slashSuggestionPlugin";
+import type { TagSuggestionState } from "../extensions/tagSuggestionPlugin";
import type { WikiLinkSuggestionHandle } from "../extensions/WikiLinkSuggestion";
+import type { TagSuggestionHandle } from "../extensions/TagSuggestion";
import type { SlashSuggestionHandle } from "./SlashSuggestionLayer";
import { createEditorExtensions, defaultEditorProps } from "./editorConfig";
import type { TiptapEditorProps } from "./types";
+import i18n from "@/i18n";
/**
* Keeps latest `workspaceRoot` in a ref without re-running `useEditor` (Issue #461).
@@ -54,6 +57,7 @@ interface UseEditorSetupOptions {
handleLinkClick: (title: string) => void;
handleStateChange: (state: WikiLinkSuggestionState) => void;
handleSlashStateChange: (state: SlashSuggestionState) => void;
+ handleTagSuggestionStateChange: (state: TagSuggestionState) => void;
handleRetryUpload: (nodeId: string) => void;
handleRemoveUpload: (nodeId: string) => void;
getProviderLabel: (providerId?: string | null) => string;
@@ -62,8 +66,10 @@ interface UseEditorSetupOptions {
handleCopyImageUrl: (src: string) => void;
suggestionState: WikiLinkSuggestionState | null;
slashState: SlashSuggestionState | null;
+ tagSuggestionState: TagSuggestionState | null;
suggestionRef: RefObject;
slashRef: RefObject;
+ tagSuggestionRef: RefObject;
/** Note-linked workspace root for `@file:` (Issue #461). / `@file:` 用ワークスペースルート */
workspaceRoot: string | null;
/** Current note id for Tauri workspace registry reads (Issue #461). */
@@ -89,6 +95,7 @@ export function useEditorSetup(options: UseEditorSetupOptions) {
handleLinkClick,
handleStateChange,
handleSlashStateChange,
+ handleTagSuggestionStateChange,
handleRetryUpload,
handleRemoveUpload,
getProviderLabel,
@@ -97,8 +104,10 @@ export function useEditorSetup(options: UseEditorSetupOptions) {
handleCopyImageUrl,
suggestionState,
slashState,
+ tagSuggestionState,
suggestionRef,
slashRef,
+ tagSuggestionRef,
workspaceRoot,
noteId,
} = options;
@@ -121,7 +130,7 @@ export function useEditorSetup(options: UseEditorSetupOptions) {
if (lastReportedContentRef.current === content) return;
lastReportedContentRef.current = content;
onContentError?.({
- message: "コンテンツの解析に失敗しました。データが破損している可能性があります。",
+ message: i18n.t("errors.contentParseFailed"),
removedNodeTypes: [],
removedMarkTypes: [],
wasSanitized: false,
@@ -134,10 +143,12 @@ export function useEditorSetup(options: UseEditorSetupOptions) {
const slashStateRef = useRef(slashState);
const suggestionStateRef = useRef(suggestionState);
+ const tagSuggestionStateRef = useRef(tagSuggestionState);
useEffect(() => {
slashStateRef.current = slashState;
suggestionStateRef.current = suggestionState;
- }, [slashState, suggestionState]);
+ tagSuggestionStateRef.current = tagSuggestionState;
+ }, [slashState, suggestionState, tagSuggestionState]);
const workspaceRootRef = useWorkspaceRootRef(workspaceRoot);
const noteIdRef = useNoteIdRef(noteId);
@@ -150,6 +161,7 @@ export function useEditorSetup(options: UseEditorSetupOptions) {
onLinkClick: handleLinkClick,
onStateChange: handleStateChange,
onSlashStateChange: handleSlashStateChange,
+ onTagSuggestionStateChange: handleTagSuggestionStateChange,
imageUploadOptions: {
onRetry: handleRetryUpload,
onRemove: handleRemoveUpload,
@@ -198,6 +210,13 @@ export function useEditorSetup(options: UseEditorSetupOptions) {
return slashRef.current.onKeyDown(event);
if (suggestionStateRef.current?.active && suggestionRef.current)
return suggestionRef.current.onKeyDown(event);
+ // タグサジェストは最後にチェックする。WikiLink / Slash と排他で
+ // active になる想定(`#` と `[[` / `/` は同時にトリガしない)。
+ // Tag suggestion comes last; in practice it cannot overlap with
+ // WikiLink (`[[`) or Slash (`/`) because their triggers are
+ // mutually exclusive characters.
+ if (tagSuggestionStateRef.current?.active && tagSuggestionRef.current)
+ return tagSuggestionRef.current.onKeyDown(event);
return false;
},
},
diff --git a/src/components/editor/TiptapEditor/useSuggestionControllers.ts b/src/components/editor/TiptapEditor/useSuggestionControllers.ts
index 361661dd..883f78a6 100644
--- a/src/components/editor/TiptapEditor/useSuggestionControllers.ts
+++ b/src/components/editor/TiptapEditor/useSuggestionControllers.ts
@@ -1,22 +1,30 @@
import { useCallback, useRef, useState } from "react";
import type { WikiLinkSuggestionState } from "../extensions/wikiLinkSuggestionPlugin";
import type { SlashSuggestionState } from "../extensions/slashSuggestionPlugin";
+import type { TagSuggestionState } from "../extensions/tagSuggestionPlugin";
import type { WikiLinkSuggestionHandle } from "../extensions/WikiLinkSuggestion";
+import type { TagSuggestionHandle } from "../extensions/TagSuggestion";
import type { SlashSuggestionHandle } from "./SlashSuggestionLayer";
-import { isSameSlashSuggestionState, isSameWikiLinkSuggestionState } from "./suggestionStateUtils";
+import {
+ isSameSlashSuggestionState,
+ isSameTagSuggestionState,
+ isSameWikiLinkSuggestionState,
+} from "./suggestionStateUtils";
/**
- * WikiLink / Slash サジェストの state と ref を管理する。
+ * WikiLink / Slash / Tag サジェストの state と ref を管理する。
* useTiptapEditorController の行数削減のため切り出し。
*
- * Manages suggestion state and refs for WikiLink and Slash.
- * Extracted from useTiptapEditorController to reduce file length.
+ * Manages suggestion state and refs for WikiLink, Slash, and Tag (`#name`).
+ * Extracted from useTiptapEditorController to keep that hook compact.
*/
export function useSuggestionControllers() {
const [suggestionState, setSuggestionState] = useState(null);
const [slashState, setSlashState] = useState(null);
+ const [tagSuggestionState, setTagSuggestionState] = useState(null);
const suggestionRef = useRef(null);
const slashRef = useRef(null);
+ const tagSuggestionRef = useRef(null);
const handleStateChange = useCallback((state: WikiLinkSuggestionState) => {
setSuggestionState((prev) => (isSameWikiLinkSuggestionState(prev, state) ? prev : state));
@@ -24,13 +32,19 @@ export function useSuggestionControllers() {
const handleSlashStateChange = useCallback((state: SlashSuggestionState) => {
setSlashState((prev) => (isSameSlashSuggestionState(prev, state) ? prev : state));
}, []);
+ const handleTagSuggestionStateChange = useCallback((state: TagSuggestionState) => {
+ setTagSuggestionState((prev) => (isSameTagSuggestionState(prev, state) ? prev : state));
+ }, []);
return {
suggestionState,
slashState,
+ tagSuggestionState,
suggestionRef,
slashRef,
+ tagSuggestionRef,
handleStateChange,
handleSlashStateChange,
+ handleTagSuggestionStateChange,
};
}
diff --git a/src/components/editor/TiptapEditor/useSuggestionEffects.test.ts b/src/components/editor/TiptapEditor/useSuggestionEffects.test.ts
index a1a3ec0b..3d0f7cd2 100644
--- a/src/components/editor/TiptapEditor/useSuggestionEffects.test.ts
+++ b/src/components/editor/TiptapEditor/useSuggestionEffects.test.ts
@@ -4,6 +4,7 @@ import { useSuggestionEffects } from "./useSuggestionEffects";
import type { Editor } from "@tiptap/core";
import { wikiLinkSuggestionPluginKey } from "../extensions/wikiLinkSuggestionPlugin";
import { slashSuggestionPluginKey } from "../extensions/slashSuggestionPlugin";
+import { tagSuggestionPluginKey } from "../extensions/tagSuggestionPlugin";
const mockCheckReferenced = vi.fn();
vi.mock("@/hooks/usePageQueries", () => ({
@@ -45,6 +46,10 @@ describe("useSuggestionEffects", () => {
editor: null as Editor | null,
suggestionState: null as { active: boolean; range: { from: number; to: number } | null } | null,
slashState: null as { active: boolean; range: { from: number; to: number } | null } | null,
+ tagSuggestionState: null as {
+ active: boolean;
+ range: { from: number; to: number } | null;
+ } | null,
editorContainerRef,
pageId: "page-1",
handleInsertImageClick: vi.fn(),
@@ -182,4 +187,111 @@ describe("useSuggestionEffects", () => {
expect(handleInsertImageClick).toHaveBeenCalledTimes(1);
});
+
+ // --- Tag suggestion (issue #767, Phase 2) ---
+ // タグサジェストの確定 / クローズ動線が WikiLink と同じ契約で動くことを固定。
+ // Pin the tag suggestion confirm / close path against the same shape as
+ // WikiLink so both popovers stay behavior-equivalent.
+
+ it("handleTagSuggestionSelect inserts a tag mark via the editor chain (issue #767)", async () => {
+ const mockEditor = createMockEditor();
+ mockCheckReferenced.mockResolvedValue(false);
+
+ const { result } = renderHook(() =>
+ useSuggestionEffects({
+ ...defaultOptions,
+ editor: mockEditor,
+ tagSuggestionState: { active: true, range: { from: 1, to: 5 } },
+ }),
+ );
+
+ await act(async () => {
+ await result.current.handleTagSuggestionSelect({
+ name: "tech",
+ exists: true,
+ targetId: "page-uuid-1",
+ });
+ });
+
+ // 既存ページなので referenced の確認はスキップされる(exists=true)。
+ // exists=true → no ghost-link probe.
+ expect(mockCheckReferenced).not.toHaveBeenCalled();
+ expect(mockEditor.chain).toHaveBeenCalled();
+ expect(mockEditor.chainRun).toHaveBeenCalled();
+ // 確定後はサジェストプラグインに close メタを流して閉じる。
+ // Confirms always close the popover via the plugin's `close` meta.
+ expect(mockEditor.view.state.tr.setMeta).toHaveBeenCalledWith(tagSuggestionPluginKey, {
+ close: true,
+ });
+ expect(mockEditor.dispatchSpy).toHaveBeenCalled();
+ });
+
+ it("handleTagSuggestionSelect probes ghost references when item.exists is false", async () => {
+ // 既存ページが無いタグでは ghost_links を見て他ページでの利用有無を判定し
+ // `referenced` 属性に反映する(WikiLink と同じ規則)。
+ // Ghost tags need the referenced check so the mark renders the
+ // `referenced` style consistently with WikiLink ghosts.
+ mockCheckReferenced.mockResolvedValue(true);
+ const mockEditor = createMockEditor();
+
+ const { result } = renderHook(() =>
+ useSuggestionEffects({
+ ...defaultOptions,
+ editor: mockEditor,
+ tagSuggestionState: { active: true, range: { from: 1, to: 5 } },
+ }),
+ );
+
+ await act(async () => {
+ await result.current.handleTagSuggestionSelect({
+ name: "newtag",
+ exists: false,
+ targetId: null,
+ });
+ });
+
+ // checkReferenced は `linkType: "tag"` を明示的に渡す必要がある。さもないと
+ // `getGhostLinkSources` が既定の `"wiki"` バケットを検索してしまい、タグ用
+ // ゴーストリンクが拾えず常に false になる(PR #778 devin レビュー指摘)。
+ // The third argument MUST be `"tag"` so `getGhostLinkSources` searches the
+ // tag bucket; without it, tag ghosts go undiscovered and `referenced`
+ // would always resolve to false (PR #778 devin review).
+ expect(mockCheckReferenced).toHaveBeenCalledWith("newtag", "page-1", "tag");
+ expect(mockEditor.chainRun).toHaveBeenCalled();
+ });
+
+ it("handleTagSuggestionSelect does nothing when editor is null", async () => {
+ const { result } = renderHook(() =>
+ useSuggestionEffects({
+ ...defaultOptions,
+ tagSuggestionState: { active: true, range: { from: 0, to: 5 } },
+ }),
+ );
+
+ await act(async () => {
+ await result.current.handleTagSuggestionSelect({
+ name: "tech",
+ exists: false,
+ targetId: null,
+ });
+ });
+
+ expect(mockCheckReferenced).not.toHaveBeenCalled();
+ });
+
+ it("handleTagSuggestionClose dispatches with tagSuggestionPluginKey when editor exists", () => {
+ const mockEditor = createMockEditor();
+ const { result } = renderHook(() =>
+ useSuggestionEffects({ ...defaultOptions, editor: mockEditor }),
+ );
+
+ act(() => {
+ result.current.handleTagSuggestionClose();
+ });
+
+ expect(mockEditor.view.state.tr.setMeta).toHaveBeenCalledWith(tagSuggestionPluginKey, {
+ close: true,
+ });
+ expect(mockEditor.dispatchSpy).toHaveBeenCalled();
+ });
});
diff --git a/src/components/editor/TiptapEditor/useSuggestionEffects.ts b/src/components/editor/TiptapEditor/useSuggestionEffects.ts
index 1b6309d7..f270e883 100644
--- a/src/components/editor/TiptapEditor/useSuggestionEffects.ts
+++ b/src/components/editor/TiptapEditor/useSuggestionEffects.ts
@@ -8,13 +8,16 @@ import {
slashSuggestionPluginKey,
type SlashSuggestionState,
} from "../extensions/slashSuggestionPlugin";
+import { tagSuggestionPluginKey, type TagSuggestionState } from "../extensions/tagSuggestionPlugin";
import type { SuggestionItem } from "../extensions/WikiLinkSuggestion";
+import type { TagSuggestionItem } from "../extensions/TagSuggestion";
import { useCheckGhostLinkReferenced } from "@/hooks/usePageQueries";
interface UseSuggestionEffectsOptions {
editor: Editor | null;
suggestionState: WikiLinkSuggestionState | null;
slashState: SlashSuggestionState | null;
+ tagSuggestionState: TagSuggestionState | null;
editorContainerRef: React.RefObject;
pageId: string;
handleInsertImageClick: () => void;
@@ -28,6 +31,7 @@ export function useSuggestionEffects({
editor,
suggestionState,
slashState,
+ tagSuggestionState,
editorContainerRef,
pageId,
handleInsertImageClick,
@@ -45,6 +49,14 @@ export function useSuggestionEffects({
*
*/
const [slashPos, setSlashPos] = useState<{ top: number; left: number } | null>(null);
+ /**
+ * Floating popover position for the `#name` tag suggestion. Issue #767 (Phase 2).
+ * `#name` タグサジェスト用のフローティング表示位置(issue #767 Phase 2)。
+ */
+ const [tagSuggestionPos, setTagSuggestionPos] = useState<{
+ top: number;
+ left: number;
+ } | null>(null);
// 依存はプリミティブに限定。suggestionState 自体は毎レンダーで新しい参照になるため
// オブジェクトを依存にすると setState → 再レンダ → effect 再実行の無限ループになる。
@@ -120,6 +132,29 @@ export function useSuggestionEffects({
}
}, [editor, slashActive, slashFrom, slashTo, editorContainerRef]);
+ // Tag suggestion position. WikiLink / Slash と同じプリミティブ依存パターン。
+ // Tag suggestion popover position; same primitive-dependency pattern.
+ const tagActive = tagSuggestionState?.active ?? false;
+ const tagFrom = tagSuggestionState?.range?.from ?? null;
+ const tagTo = tagSuggestionState?.range?.to ?? null;
+
+ useEffect(() => {
+ if (!editor || !tagActive || tagFrom === null) {
+ queueMicrotask(() => setTagSuggestionPos(null));
+ return;
+ }
+ const coords = editor.view.coordsAtPos(tagFrom);
+ const containerRect = editorContainerRef.current?.getBoundingClientRect();
+ if (containerRect) {
+ queueMicrotask(() =>
+ setTagSuggestionPos({
+ top: coords.bottom - containerRect.top + 4,
+ left: coords.left - containerRect.left,
+ }),
+ );
+ }
+ }, [editor, tagActive, tagFrom, tagTo, editorContainerRef]);
+
useEffect(() => {
/**
*
@@ -197,11 +232,79 @@ export function useSuggestionEffects({
editor.view.dispatch(editor.view.state.tr.setMeta(slashSuggestionPluginKey, { close: true }));
}, [editor]);
+ /**
+ * Tag (`#name`) サジェスト確定。範囲を `#name` に置換し、`tag` Mark を直接
+ * 付与する。`exists` / `targetId` は候補側で解決済みの値を流す(issue #767)。
+ * 解決失敗時は `referenced` を ghost_links から取得して埋める(WikiLink と
+ * 同じ手順)。
+ *
+ * Confirm a tag (`#name`) suggestion: replace the typed range with the
+ * styled mark and use the resolved attrs from the candidate. For ghost
+ * (non-existing) tags, also probe `ghost_links` for a `referenced` flag,
+ * the same shape the WikiLink suggestion uses.
+ */
+ const handleTagSuggestionSelect = useCallback(
+ async (item: TagSuggestionItem) => {
+ if (!editor || !tagSuggestionState?.range) return;
+ const { from, to } = tagSuggestionState.range;
+ let referenced = false;
+ if (!item.exists) {
+ // 既存ページが無いタグでも、別ページで `#name` として登場していれば
+ // `referenced=true`(WikiLink ゴーストと同じ判定)。`linkType: "tag"`
+ // を明示しないと `getGhostLinkSources` が既定の `"wiki"` バケットを
+ // 検索してしまい、タグ用ゴーストリンクが拾えず常に false になる
+ // (PR #778 devin-ai-integration レビュー指摘)。
+ // No real page yet, but the tag may already appear on other pages —
+ // mirror the WikiLink ghost referenced-check. We must pass
+ // `linkType: "tag"` explicitly: without it, `getGhostLinkSources`
+ // searches the default `"wiki"` bucket and tag ghosts are never
+ // found, so `referenced` stays false (PR #778 devin review).
+ referenced = await checkReferenced(item.name, pageId, "tag");
+ }
+ editor
+ .chain()
+ .focus()
+ .deleteRange({ from, to })
+ .insertContent([
+ {
+ type: "text",
+ marks: [
+ {
+ type: "tag",
+ attrs: {
+ name: item.name,
+ exists: item.exists,
+ referenced,
+ targetId: item.targetId,
+ },
+ },
+ ],
+ text: `#${item.name}`,
+ },
+ ])
+ .run();
+ // 確定後はサジェストを必ず閉じ、後続のキーストロークで新規入力規則が
+ // 自然に効くようにする。
+ // Always close the popover after confirm so subsequent typing flows
+ // through the input-rule path normally.
+ editor.view.dispatch(editor.view.state.tr.setMeta(tagSuggestionPluginKey, { close: true }));
+ },
+ [editor, tagSuggestionState, checkReferenced, pageId],
+ );
+
+ const handleTagSuggestionClose = useCallback(() => {
+ if (!editor) return;
+ editor.view.dispatch(editor.view.state.tr.setMeta(tagSuggestionPluginKey, { close: true }));
+ }, [editor]);
+
return {
suggestionPos,
slashPos,
+ tagSuggestionPos,
handleSuggestionSelect,
handleSuggestionClose,
handleSlashClose,
+ handleTagSuggestionSelect,
+ handleTagSuggestionClose,
};
}
diff --git a/src/components/editor/TiptapEditor/useTiptapEditorController.ts b/src/components/editor/TiptapEditor/useTiptapEditorController.ts
index ad75199c..825fc292 100644
--- a/src/components/editor/TiptapEditor/useTiptapEditorController.ts
+++ b/src/components/editor/TiptapEditor/useTiptapEditorController.ts
@@ -2,7 +2,9 @@ import { useRef, useState, type MutableRefObject, type RefObject } from "react";
import type { Editor } from "@tiptap/core";
import type { WikiLinkSuggestionState } from "../extensions/wikiLinkSuggestionPlugin";
import type { SlashSuggestionState } from "../extensions/slashSuggestionPlugin";
+import type { TagSuggestionState } from "../extensions/tagSuggestionPlugin";
import type { WikiLinkSuggestionHandle } from "../extensions/WikiLinkSuggestion";
+import type { TagSuggestionHandle } from "../extensions/TagSuggestion";
import type { SlashSuggestionHandle } from "./SlashSuggestionLayer";
import { useGeneralSettings } from "@/hooks/useGeneralSettings";
import { useWikiLinkNavigation } from "./useWikiLinkNavigation";
@@ -39,6 +41,7 @@ function useEditorControllers(args: {
handleLinkClick: (title: string) => void;
handleStateChange: (state: WikiLinkSuggestionState) => void;
handleSlashStateChange: (state: SlashSuggestionState) => void;
+ handleTagSuggestionStateChange: (state: TagSuggestionState) => void;
handleRetryUpload: (nodeId: string) => void;
handleRemoveUpload: (nodeId: string) => void;
getProviderLabel: (providerId?: string | null) => string;
@@ -47,8 +50,10 @@ function useEditorControllers(args: {
handleCopyImageUrl: (src: string) => void;
suggestionState: WikiLinkSuggestionState | null;
slashState: SlashSuggestionState | null;
+ tagSuggestionState: TagSuggestionState | null;
suggestionRef: RefObject;
slashRef: RefObject;
+ tagSuggestionRef: RefObject;
handleInsertImageClick: () => void;
handleInsertCameraImageClick: () => void;
handleImageUpload: (files: File[]) => Promise;
@@ -78,6 +83,7 @@ function useEditorControllers(args: {
handleLinkClick: args.handleLinkClick,
handleStateChange: args.handleStateChange,
handleSlashStateChange: args.handleSlashStateChange,
+ handleTagSuggestionStateChange: args.handleTagSuggestionStateChange,
handleRetryUpload: args.handleRetryUpload,
handleRemoveUpload: args.handleRemoveUpload,
getProviderLabel: args.getProviderLabel,
@@ -86,8 +92,10 @@ function useEditorControllers(args: {
handleCopyImageUrl: args.handleCopyImageUrl,
suggestionState: args.suggestionState,
slashState: args.slashState,
+ tagSuggestionState: args.tagSuggestionState,
suggestionRef: args.suggestionRef,
slashRef: args.slashRef,
+ tagSuggestionRef: args.tagSuggestionRef,
workspaceRoot: args.workspaceRoot,
noteId: args.noteId,
});
@@ -96,6 +104,7 @@ function useEditorControllers(args: {
editor,
suggestionState: args.suggestionState,
slashState: args.slashState,
+ tagSuggestionState: args.tagSuggestionState,
editorContainerRef: args.editorContainerRef,
pageId: args.pageId,
handleInsertImageClick: args.handleInsertImageClick,
@@ -132,7 +141,7 @@ function useEditorControllers(args: {
export function useTiptapEditorController({
content,
onChange,
- placeholder = "思考を書き始める...",
+ placeholder,
autoFocus = false,
pageId,
pageTitle = "",
@@ -214,6 +223,7 @@ export function useTiptapEditorController({
handleLinkClick,
handleStateChange: suggestionControllers.handleStateChange,
handleSlashStateChange: suggestionControllers.handleSlashStateChange,
+ handleTagSuggestionStateChange: suggestionControllers.handleTagSuggestionStateChange,
handleRetryUpload: imageUpload.handleRetryUpload,
handleRemoveUpload: imageUpload.handleRemoveUpload,
getProviderLabel,
@@ -222,8 +232,10 @@ export function useTiptapEditorController({
handleCopyImageUrl,
suggestionState: suggestionControllers.suggestionState,
slashState: suggestionControllers.slashState,
+ tagSuggestionState: suggestionControllers.tagSuggestionState,
suggestionRef: suggestionControllers.suggestionRef,
slashRef: suggestionControllers.slashRef,
+ tagSuggestionRef: suggestionControllers.tagSuggestionRef,
handleInsertImageClick: imageUpload.handleInsertImageClick,
handleInsertCameraImageClick: imageUpload.handleInsertCameraImageClick,
handleImageUpload: imageUpload.handleImageUpload,
@@ -258,6 +270,11 @@ export function useTiptapEditorController({
slashPos: editorControllers.slashPos,
slashRef: suggestionControllers.slashRef,
handleSlashClose: editorControllers.handleSlashClose,
+ tagSuggestionState: suggestionControllers.tagSuggestionState,
+ tagSuggestionPos: editorControllers.tagSuggestionPos,
+ tagSuggestionRef: suggestionControllers.tagSuggestionRef,
+ handleTagSuggestionSelect: editorControllers.handleTagSuggestionSelect,
+ handleTagSuggestionClose: editorControllers.handleTagSuggestionClose,
mermaidDialogOpen,
setMermaidDialogOpen,
handleInsertMermaid: editorControllers.handleInsertMermaid,
diff --git a/src/components/editor/extensions/MarkdownPasteExtension.test.ts b/src/components/editor/extensions/MarkdownPasteExtension.test.ts
index f3bc48ad..ab562d99 100644
--- a/src/components/editor/extensions/MarkdownPasteExtension.test.ts
+++ b/src/components/editor/extensions/MarkdownPasteExtension.test.ts
@@ -124,7 +124,7 @@ describe("MarkdownPaste extension", () => {
const parsedDoc = {
type: "doc",
content: [
- { type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: "Hello" }] },
+ { type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Hello" }] },
],
};
const { handlePaste, mockEditor } = getHandlePaste({
diff --git a/src/components/editor/extensions/TagExtension.test.ts b/src/components/editor/extensions/TagExtension.test.ts
index c65a0392..a2e671b3 100644
--- a/src/components/editor/extensions/TagExtension.test.ts
+++ b/src/components/editor/extensions/TagExtension.test.ts
@@ -1,5 +1,13 @@
import { describe, it, expect } from "vitest";
-import { Tag, TAG_PASTE_REGEX, extractTagName, isExcludedTagName } from "./TagExtension";
+import { Editor } from "@tiptap/core";
+import StarterKit from "@tiptap/starter-kit";
+import {
+ Tag,
+ TAG_INPUT_REGEX,
+ TAG_PASTE_REGEX,
+ extractTagName,
+ isExcludedTagName,
+} from "./TagExtension";
/**
* Tests for Tag mark extension (hashtag `#name` syntax).
@@ -189,6 +197,393 @@ describe("TagExtension paste rule", () => {
});
});
+/**
+ * Tests for the input-rule regex and the `addInputRules` wiring (issue #766).
+ * Phase 1 (#725) only registered `addPasteRules`, so typing `#tag` directly
+ * never produced the styled mark — only paste / pre-saved JSON did. The input
+ * rule fixes that gap by detecting `#name` followed by a terminator char in
+ * real time.
+ *
+ * 入力規則用正規表現と `addInputRules` 配線のテスト(issue #766)。Phase 1
+ * (#725) は paste rule のみを登録していたため、エディタに `#tag` を直接
+ * タイプしてもマークが付かなかった。本入力規則が `#name` + 終端文字を
+ * リアルタイム検知して埋める。
+ */
+describe("TagExtension input rule", () => {
+ describe("TAG_INPUT_REGEX", () => {
+ /**
+ * The regex must use exactly one capture group — `match[1]` is the
+ * `#name` literal that the input-rule handler converts to a document
+ * range. Exposing more captures would break the handler's index math
+ * silently; test pins the contract.
+ *
+ * キャプチャは 1 つだけ。`match[1]` が `#name` リテラルを表し、これを
+ * 元にハンドラがドキュメント範囲を計算する。誤って追加すると静かに壊れる
+ * ためここで固定する。
+ */
+ it("captures exactly one group for the `#name` literal", () => {
+ const m = "#tech ".match(TAG_INPUT_REGEX);
+ expect(m).not.toBeNull();
+ expect(m).toHaveLength(2); // [fullMatch, captureGroup]
+ expect(m?.[1]).toBe("#tech");
+ });
+
+ it("matches `#tech ` with a space terminator", () => {
+ expect("#tech ".match(TAG_INPUT_REGEX)?.[1]).toBe("#tech");
+ });
+
+ it("matches `#tech\\n` with a newline terminator", () => {
+ expect("#tech\n".match(TAG_INPUT_REGEX)?.[1]).toBe("#tech");
+ });
+
+ it("matches CJK names with Japanese punctuation terminators", () => {
+ // `、` / `。` のような和文句読点でも確定すること(受け入れ条件)。
+ // CJK punctuation must terminate as well per acceptance criteria.
+ expect("#技術、".match(TAG_INPUT_REGEX)?.[1]).toBe("#技術");
+ expect("#趣味。".match(TAG_INPUT_REGEX)?.[1]).toBe("#趣味");
+ });
+
+ it("matches names with hyphens and underscores", () => {
+ expect("#front-end ".match(TAG_INPUT_REGEX)?.[1]).toBe("#front-end");
+ expect("#back_end ".match(TAG_INPUT_REGEX)?.[1]).toBe("#back_end");
+ });
+
+ it("matches when `#` is preceded by a non-word boundary character", () => {
+ // 行中の空白直後に `#tag` をタイプしたケース。
+ // Typing `#tag` after a space mid-line.
+ expect("Hello #tech ".match(TAG_INPUT_REGEX)?.[1]).toBe("#tech");
+ });
+
+ it("matches at the very start of the input string", () => {
+ // 段落先頭から `#tag ` をタイプしたケース。
+ // Typing `#tag` at the start of a paragraph.
+ expect("#intro ".match(TAG_INPUT_REGEX)?.[1]).toBe("#intro");
+ });
+
+ it("does not match `# Heading` (Markdown ATX heading)", () => {
+ // `# ` は Markdown 見出しのため、タグ化対象外。
+ // `# ` is a Markdown heading marker, not a tag.
+ expect("# Heading".match(TAG_INPUT_REGEX)).toBeNull();
+ });
+
+ it("does not match `## Subheading` (Markdown level-2 heading)", () => {
+ expect("## Subheading".match(TAG_INPUT_REGEX)).toBeNull();
+ });
+
+ it("does not match `abc#tag ` (word boundary violation)", () => {
+ // 単語中の `#` はタグと見なさない(URL や ID の可能性)。
+ // `#` embedded in a word is not a tag (could be a URL fragment / id).
+ expect("abc#tag ".match(TAG_INPUT_REGEX)).toBeNull();
+ });
+
+ it("does not match `/page#anchor ` (slash-prefixed URL fragment)", () => {
+ expect("/page#anchor ".match(TAG_INPUT_REGEX)).toBeNull();
+ });
+
+ it("terminates on common punctuation (`,.!?:;`)", () => {
+ expect("#tech,".match(TAG_INPUT_REGEX)?.[1]).toBe("#tech");
+ expect("#tech.".match(TAG_INPUT_REGEX)?.[1]).toBe("#tech");
+ expect("#tech!".match(TAG_INPUT_REGEX)?.[1]).toBe("#tech");
+ expect("#tech?".match(TAG_INPUT_REGEX)?.[1]).toBe("#tech");
+ expect("#tech:".match(TAG_INPUT_REGEX)?.[1]).toBe("#tech");
+ expect("#tech;".match(TAG_INPUT_REGEX)?.[1]).toBe("#tech");
+ });
+
+ it("terminates on parentheses, brackets, and quotes", () => {
+ // 終端を `[^TAG_NAME_CHAR_CLASS]` まで広げたので、`(#tag)` のように
+ // 括弧で囲んだ場合や引用符で閉じた場合も `)` / `"` / `'` 入力時点で
+ // タグ化される(issue #769 レビュー反映)。
+ // The terminator class is `[^TAG_NAME_CHAR_CLASS]`, so `(#tag)`,
+ // `"#tag"`, `[#tag]` etc. all close the tag on the closing character
+ // (review feedback on issue #769).
+ expect("(#tag)".match(TAG_INPUT_REGEX)?.[1]).toBe("#tag");
+ expect("[#tag]".match(TAG_INPUT_REGEX)?.[1]).toBe("#tag");
+ expect("{#tag}".match(TAG_INPUT_REGEX)?.[1]).toBe("#tag");
+ expect('"#tag"'.match(TAG_INPUT_REGEX)?.[1]).toBe("#tag");
+ expect("'#tag'".match(TAG_INPUT_REGEX)?.[1]).toBe("#tag");
+ expect("「#技術」".match(TAG_INPUT_REGEX)?.[1]).toBe("#技術");
+ });
+
+ it("terminates when a second `#` is typed (e.g. `#tech#design`)", () => {
+ // `#` 自体も `TAG_NAME_CHAR_CLASS` に含まれないため終端扱いとなり、
+ // 連続して別タグを打ち始めた瞬間に直前のタグが確定する。
+ // `#` is not in `TAG_NAME_CHAR_CLASS`, so a second `#` terminates the
+ // previous tag — typing back-to-back tags `#tech#design` finalises
+ // `#tech` the moment the second `#` is typed.
+ expect("#tech#".match(TAG_INPUT_REGEX)?.[1]).toBe("#tech");
+ });
+
+ it("does not match a `#` with no following name", () => {
+ expect("# ".match(TAG_INPUT_REGEX)).toBeNull();
+ });
+
+ it("only matches the most recent `#name` segment (anchored to end)", () => {
+ // 入力規則は `$` でアンカーしているので、過去に登場した `#tech ` は
+ // 再マッチさせず、現在打鍵中の `#design ` だけに反応する。
+ // The regex is anchored with `$`, so a previously-entered tag like
+ // `#tech ` will not re-fire — only the just-completed `#design ` does.
+ const m = "#tech and #design ".match(TAG_INPUT_REGEX);
+ expect(m?.[1]).toBe("#design");
+ });
+ });
+
+ describe("addInputRules wiring", () => {
+ it("declares `addInputRules` as a function on the extension config", () => {
+ const extension = Tag.configure({});
+ expect(extension.config.addInputRules).toBeDefined();
+ expect(typeof extension.config.addInputRules).toBe("function");
+ });
+ });
+
+ describe("integration with a real Tiptap editor", () => {
+ /**
+ * Build a minimal editor with `StarterKit` (which contains Document,
+ * Paragraph, Text, Code, and basic input rules) plus the Tag mark under
+ * test. Using StarterKit instead of cherry-picked extensions keeps
+ * dependencies aligned with the rest of the codebase (it is already
+ * imported elsewhere) and avoids unlisted-dependency violations.
+ *
+ * Tag マークと StarterKit(Document / Paragraph / Text / Code を内包)で
+ * 編集インスタンスを作る。本体側でも StarterKit を使っており、
+ * テストの依存も合わせることで knip の unlisted dependency 検出を回避する。
+ */
+ function makeEditor(initialContent: string): Editor {
+ return new Editor({
+ extensions: [StarterKit, Tag],
+ content: initialContent,
+ });
+ }
+
+ /**
+ * Simulate the user typing `text` at position `pos` in the editor by
+ * dispatching the input-rule plugin's `handleTextInput`. Tiptap installs
+ * the input-rule plugin via `addInputRules`, and ProseMirror invokes
+ * `handleTextInput` for every keystroke; calling it directly is the
+ * canonical way to exercise input rules in unit tests without a real
+ * keyboard.
+ *
+ * `view.someProp("handleTextInput")` でタイプ操作を再現する。Tiptap が
+ * 登録した input-rule プラグインの `handleTextInput` が呼ばれ、規則が
+ * マッチすれば `tr` を組み立て自動的にディスパッチされる。
+ */
+ function typeAt(editor: Editor, pos: number, text: string): boolean | undefined {
+ const { view } = editor;
+ return view.someProp("handleTextInput", (handler) => handler(view, pos, pos, text));
+ }
+
+ it("applies the tag mark when `#tech ` is completed by a space", () => {
+ // 段落 `#tech` の末尾(pos = 6)に空白をタイプしたシナリオ。
+ // Paragraph contains `#tech`; user types a space at end (pos 6).
+ const editor = makeEditor("#tech
");
+ try {
+ const handled = typeAt(editor, 6, " ");
+ expect(handled).toBe(true);
+ const html = editor.getHTML();
+ expect(html).toContain('data-name="tech"');
+ expect(html).toContain("data-tag");
+ } finally {
+ editor.destroy();
+ }
+ });
+
+ it("applies the tag mark when `(#tag)` is closed with a `)`", () => {
+ // 括弧で囲んだケース:`(#tag` の後に `)` を打鍵すると `#tag` がタグ化。
+ // Closing `)` after `(#tag` finalises the tag — review feedback on #769.
+ const editor = makeEditor("(#tag
");
+ try {
+ const handled = typeAt(editor, 6, ")");
+ expect(handled).toBe(true);
+ const html = editor.getHTML();
+ expect(html).toContain('data-name="tag"');
+ expect(html).toContain("data-tag");
+ } finally {
+ editor.destroy();
+ }
+ });
+
+ it("applies the tag mark for `#技術、` with a Japanese punctuation terminator", () => {
+ // 和文句読点 `、` で確定するケース(受け入れ条件)。
+ // Acceptance criterion: `、` finalises CJK tag names.
+ const editor = makeEditor("#技術
");
+ try {
+ const handled = typeAt(editor, 4, "、");
+ expect(handled).toBe(true);
+ const html = editor.getHTML();
+ expect(html).toContain('data-name="技術"');
+ expect(html).toContain("data-tag");
+ } finally {
+ editor.destroy();
+ }
+ });
+
+ it("does not mark numeric-only `#1 ` (delegates to `isExcludedTagName`)", () => {
+ const editor = makeEditor("#1
");
+ try {
+ const handled = typeAt(editor, 3, " ");
+ // 入力規則がリジェクトする → ProseMirror のデフォルト挿入に委ねる。
+ // Rule rejects → handler returns null → no plugin claims the input.
+ expect(handled).toBeFalsy();
+ const html = editor.getHTML();
+ expect(html).not.toContain("data-tag");
+ } finally {
+ editor.destroy();
+ }
+ });
+
+ it("does not mark 6-char hex `#aabbcc ` (CSS color heuristic)", () => {
+ const editor = makeEditor("#aabbcc
");
+ try {
+ const handled = typeAt(editor, 8, " ");
+ expect(handled).toBeFalsy();
+ const html = editor.getHTML();
+ expect(html).not.toContain("data-tag");
+ } finally {
+ editor.destroy();
+ }
+ });
+
+ it("does not mark 8-char hex `#aabbccdd ` (CSS color with alpha)", () => {
+ const editor = makeEditor("#aabbccdd
");
+ try {
+ const handled = typeAt(editor, 10, " ");
+ expect(handled).toBeFalsy();
+ const html = editor.getHTML();
+ expect(html).not.toContain("data-tag");
+ } finally {
+ editor.destroy();
+ }
+ });
+
+ it("does not mark a Markdown heading `# Heading`", () => {
+ // 裸の `#` のあとに空白を打鍵しても、タグ規則は `#` 直後に最低 1 文字の
+ // 名前を要求するため発火しない。StarterKit の heading 入力規則が代わりに
+ // 反応する可能性があるが、本テストはあくまで「タグマークが付かない」
+ // ことだけを保証する。
+ // Bare `#` followed by space must not produce a tag mark — the regex
+ // demands at least one name character after `#`. StarterKit's heading
+ // input rule may fire instead, but we only assert that no `data-tag`
+ // appears in the result (the heading transform is StarterKit's concern).
+ const editor = makeEditor("#
");
+ try {
+ typeAt(editor, 2, " ");
+ const html = editor.getHTML();
+ expect(html).not.toContain("data-tag");
+ } finally {
+ editor.destroy();
+ }
+ });
+
+ it("does not mark `abc#tag ` (word-boundary violation)", () => {
+ const editor = makeEditor("abc#tag
");
+ try {
+ const handled = typeAt(editor, 8, " ");
+ expect(handled).toBeFalsy();
+ const html = editor.getHTML();
+ expect(html).not.toContain("data-tag");
+ } finally {
+ editor.destroy();
+ }
+ });
+
+ it("does not mark `#tag` typed inside an inline code mark", () => {
+ // `code` マークと共存させない (`excludes: "code"`) ため、コード内では
+ // タグマークが付与されないことを確認する。
+ // Tags must not appear inside inline code (`excludes: "code"`).
+ const editor = makeEditor("#tag
");
+ try {
+ const handled = typeAt(editor, 6, " ");
+ // 規則そのものはマッチしないか、衝突マークを検知して null を返す。
+ // 結果として `data-tag` 属性が文書に現れないことが本テストの保証。
+ // Either the rule does not match inside code or the handler skips
+ // due to mark exclusion; either way no `data-tag` should appear.
+ expect(handled).toBeFalsy();
+ const html = editor.getHTML();
+ expect(html).not.toContain("data-tag");
+ } finally {
+ editor.destroy();
+ }
+ });
+
+ it("preserves the user-typed terminator after applying the mark", () => {
+ // `markInputRule` は終端文字を削除してしまうため独自ハンドラを使った。
+ // 終端文字(空白)が消えていないことを後置確認する。
+ // Our custom handler intentionally avoids `markInputRule` so the
+ // terminator the user just typed stays in the doc — verify it.
+ const editor = makeEditor("#tech
");
+ try {
+ typeAt(editor, 6, " ");
+ const text = editor.state.doc.textContent;
+ // `#tech ` のままで、末尾空白が削除されていないこと。
+ expect(text).toBe("#tech ");
+ } finally {
+ editor.destroy();
+ }
+ });
+
+ it("defers to the suggestion popover while it is active (issue #767)", async () => {
+ // Phase 2: タグサジェストが open の間は入力規則を発火させない。確定操作は
+ // サジェスト側が `tag` Mark を直接挿入するため、入力規則経由で重複マーク化
+ // すると `exists` / `targetId` などの解決済み属性を上書きしてしまう。
+ // Phase 2: while the suggestion popover is open, the input rule must
+ // defer so the suggestion's confirm path retains its resolved attrs
+ // (issue #767). We install both extensions, place the caret at the end
+ // of `#tech` (which makes the suggestion plugin go active), then
+ // type a space. The rule must NOT fire — the popover owns the confirm
+ // path.
+ const { TagSuggestionPlugin, tagSuggestionPluginKey } = await import("./tagSuggestionPlugin");
+ const editor = new Editor({
+ extensions: [StarterKit, Tag, TagSuggestionPlugin],
+ content: "#tech
",
+ });
+ try {
+ // カーソル末尾セットでサジェストの `apply()` が走り active になる。
+ // Moving the caret runs the suggestion plugin's `apply()` and
+ // activates it (the regex sees `#tech` as text-before-cursor).
+ editor.commands.setTextSelection(6);
+ expect(tagSuggestionPluginKey.getState(editor.state)?.active).toBe(true);
+
+ const handled = typeAt(editor, 6, " ");
+ // 入力規則は null を返し、Tiptap デフォルトの空白挿入に委ねる。
+ // The rule returns null and lets Tiptap insert the space normally.
+ expect(handled).toBeFalsy();
+ const html = editor.getHTML();
+ expect(html).not.toContain("data-tag");
+ } finally {
+ editor.destroy();
+ }
+ });
+
+ it("falls through to the input rule after the suggestion is closed (issue #767 fallback)", async () => {
+ // 受け入れ条件のフォールバック契約: Esc で閉じた後に空白終端を打つと
+ // 入力規則経由でマーク化される。サジェストプラグインに `close` メタを
+ // 流して閉じてから空白を投入する。
+ // Acceptance-criteria fallback: closing the suggestion (Esc) then typing
+ // a terminator goes through the input-rule path. We activate the
+ // suggestion via cursor placement, dispatch the `close` meta to
+ // simulate Esc, then type a space.
+ const { TagSuggestionPlugin, tagSuggestionPluginKey } = await import("./tagSuggestionPlugin");
+ const editor = new Editor({
+ extensions: [StarterKit, Tag, TagSuggestionPlugin],
+ content: "#tech
",
+ });
+ try {
+ editor.commands.setTextSelection(6);
+ expect(tagSuggestionPluginKey.getState(editor.state)?.active).toBe(true);
+ editor.view.dispatch(editor.state.tr.setMeta(tagSuggestionPluginKey, { close: true }));
+ expect(tagSuggestionPluginKey.getState(editor.state)?.active).toBe(false);
+
+ const handled = typeAt(editor, 6, " ");
+ expect(handled).toBe(true);
+ const html = editor.getHTML();
+ expect(html).toContain('data-name="tech"');
+ expect(html).toContain("data-tag");
+ } finally {
+ editor.destroy();
+ }
+ });
+ });
+});
+
describe("Tag extension configuration", () => {
it("has addPasteRules defined", () => {
const extension = Tag.configure({});
diff --git a/src/components/editor/extensions/TagExtension.ts b/src/components/editor/extensions/TagExtension.ts
index a6a60118..4b3bfcdc 100644
--- a/src/components/editor/extensions/TagExtension.ts
+++ b/src/components/editor/extensions/TagExtension.ts
@@ -1,6 +1,7 @@
-import { Mark, markPasteRule, mergeAttributes } from "@tiptap/core";
+import { InputRule, Mark, markPasteRule, mergeAttributes } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { TAG_NAME_CHAR_CLASS } from "@zedi/shared/tagCharacterClass";
+import { tagSuggestionPluginKey } from "./tagSuggestionPlugin";
/**
* Regex matching hashtag patterns `#name` in pasted text.
@@ -39,6 +40,41 @@ import { TAG_NAME_CHAR_CLASS } from "@zedi/shared/tagCharacterClass";
*/
export const TAG_PASTE_REGEX = new RegExp(`(?({
];
},
+ addInputRules() {
+ // `this.type` を外側で捕捉し、ハンドラは PURE な関数として閉じ込める。
+ // Capture `this.type` outside the handler so the rule stays a pure closure.
+ const markType = this.type;
+
+ return [
+ new InputRule({
+ find: TAG_INPUT_REGEX,
+ handler: ({ state, range, match }) => {
+ const tagText = match[1];
+ const fullMatch = match[0];
+ if (!tagText || !fullMatch) return null;
+
+ // タグサジェスト (`TagSuggestionPlugin`, issue #767 Phase 2) が
+ // open の間は入力規則を発火させない。確定操作はサジェスト側が
+ // `tag` Mark の挿入を担当するため、ここで二重にマーク化すると
+ // `exists` / `targetId` 等の解決済み属性を上書きしてしまう。
+ // Esc でサジェストを閉じた後の空白終端は inactive 状態になるので
+ // 通常どおり入力規則経路でマーク化される(フォールバック契約)。
+ //
+ // While the tag suggestion popover is open (`TagSuggestionPlugin`,
+ // issue #767 Phase 2) the input rule must defer: confirming via the
+ // popover applies the mark with resolved `exists` / `targetId`,
+ // and re-running the input rule here would clobber those attrs
+ // with default values. After Esc the suggestion is inactive, so
+ // typing a terminator falls through to the input-rule path — the
+ // documented Esc-then-terminator fallback.
+ const suggestionState = tagSuggestionPluginKey.getState(state);
+ if (suggestionState?.active) return null;
+
+ // Re-use the same exclusion contract as the paste rule so typed and
+ // pasted input share their reject reasons (numeric-only / 6/8-hex).
+ // 貼り付け規則と同じ除外ルールに合わせる(数字のみ・6/8 桁 hex)。
+ const name = extractTagName(tagText);
+ if (!name || isExcludedTagName(name)) return null;
+
+ // Compute the document range of the `#name` portion: the regex may
+ // include a non-capturing leading boundary (`[^\w/#]`) and a trailing
+ // terminator (whitespace or punctuation), neither of which should be
+ // marked. Code-mark neighbourhoods are filtered upstream by the
+ // input-rule runner (it short-circuits on `mark.type.spec.code`), so
+ // the `excludes: "code"` invariant requires no extra guard here.
+ // `#name` 本体の文書内範囲を算出する。先頭境界(非単語文字)と
+ // 末尾の終端文字はマーク範囲に含めない。コードマーク内では Tiptap の
+ // 入力規則ランナー側で短絡されるためここで追加チェックは不要。
+ const tagOffsetInMatch = fullMatch.indexOf(tagText);
+ const tagStart = range.from + tagOffsetInMatch;
+ const tagEnd = tagStart + tagText.length;
+
+ // Tiptap の入力規則プラグインはハンドラ呼び出し時点でユーザーが
+ // タイプした文字(終端文字)をまだドキュメントに挿入していない。
+ // `tr.steps.length > 0` で `handleTextInput` が `true` を返す扱いと
+ // なり、ProseMirror デフォルトのテキスト挿入はスキップされるため、
+ // ここで終端文字を明示的に挿入してユーザー入力を保全する。終端文字は
+ // `fullMatch` のうちキャプチャ後ろに残る部分(通常 1 文字)に等しい。
+ // Tiptap's input-rule plugin invokes the handler before ProseMirror
+ // commits the just-typed character. Returning a transaction with
+ // `steps > 0` causes the plugin to dispatch it and skip the default
+ // text insertion, so we re-insert the terminator (the slice of
+ // `fullMatch` after the capture) ourselves to preserve the keystroke.
+ const typedTerminator = fullMatch.slice(tagOffsetInMatch + tagText.length);
+
+ // 注: `state.tr` は呼び出すたびに新しい Transaction を返すゲッター。
+ // Tiptap が `state` をラップして `tr` が同一インスタンスを返すように
+ // しているため、destructure して取り出した `tr` への変更が一括で
+ // ディスパッチされる。
+ // `state.tr` is a getter; Tiptap proxies `state` so accesses share a
+ // single `tr` instance which is then dispatched after the handler.
+ const { tr } = state;
+ if (typedTerminator.length > 0) {
+ // 終端文字をマーク範囲の直後に挿入する(範囲シフトを起こさないよう
+ // mark 適用より前に行う)。
+ // Insert the terminator first so the subsequent `addMark` range
+ // does not need rebasing.
+ tr.insertText(typedTerminator, range.to);
+ }
+ tr.addMark(
+ tagStart,
+ tagEnd,
+ markType.create({ name, exists: false, referenced: false, targetId: null }),
+ );
+ tr.removeStoredMark(markType);
+ },
+ }),
+ ];
+ },
+
addProseMirrorPlugins() {
const { onTagClick } = this.options;
diff --git a/src/components/editor/extensions/TagSuggestion.test.tsx b/src/components/editor/extensions/TagSuggestion.test.tsx
new file mode 100644
index 00000000..47372de5
--- /dev/null
+++ b/src/components/editor/extensions/TagSuggestion.test.tsx
@@ -0,0 +1,311 @@
+/**
+ * Tests for the `#name` suggestion popover (issue #767 Phase 2).
+ * `#name` 用サジェストポップオーバーのテスト(issue #767 Phase 2)。
+ *
+ * Pins the popup's items-list contract — especially the regression captured
+ * in the gemini-code-assist review on PR #778: an exact match must remain
+ * selectable even when more than `MAX_VISIBLE` candidates contain the query
+ * substring. Without the prioritising sort, the exact match was sliced off
+ * the visible list while still suppressing the "create new" fallback.
+ *
+ * gemini-code-assist の PR #778 レビュー指摘(完全一致候補が `MAX_VISIBLE` を
+ * 超えると表示から漏れる一方で「新規作成」項目も追加されず、ユーザが選べない
+ * 状態になる)を再発させないテストを置く。
+ */
+
+import { describe, expect, it, vi } from "vitest";
+import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
+import { createRef, type RefObject } from "react";
+import { TagSuggestion, type TagSuggestionHandle } from "./TagSuggestion";
+import type { TagSuggestionCandidate } from "@/hooks/useTagCandidates";
+
+/**
+ * 矢印キーの状態更新後に ref.current を最新の handle に追従させるためのヘルパー。
+ * `setSelectedIndex` は再レンダーをスケジュールし、その後に
+ * `useImperativeHandle` が ref を差し替える。`act` で同期 flush することで
+ * 次の pressKey が新しいクロージャに当たる。
+ *
+ * Helper that flushes the re-render scheduled by `setSelectedIndex` so the
+ * next `ref.current?.onKeyDown` call sees the updated imperative handle.
+ */
+function pressKey(ref: RefObject, key: string): boolean | undefined {
+ let handled: boolean | undefined;
+ act(() => {
+ handled = ref.current?.onKeyDown(new KeyboardEvent("keydown", { key }));
+ });
+ return handled;
+}
+
+/**
+ * 初期マウント時に `useEffect` が `queueMicrotask` で予約した
+ * `setSelectedIndex(0)` を flush するためのヘルパー。これを呼ばないと最初の
+ * 矢印キー押下時に「リセット → 矢印反映」の順で state が更新されて、
+ * テストの期待値と矛盾する。
+ *
+ * Drain the microtask that the component queues during initial mount via
+ * `useEffect` (the `setSelectedIndex(0)` reset). Without this, the first
+ * arrow-key press races the reset and ends up at index 0.
+ */
+async function settleInitialMicrotasks(): Promise {
+ await act(async () => {
+ await Promise.resolve();
+ });
+}
+
+function renderTagSuggestion(props: {
+ query: string;
+ candidates: TagSuggestionCandidate[];
+ onSelect?: (item: { name: string; exists: boolean; targetId: string | null }) => void;
+ onClose?: () => void;
+}) {
+ const ref: RefObject = createRef();
+ const onSelect = props.onSelect ?? vi.fn();
+ const onClose = props.onClose ?? vi.fn();
+ const utils = render(
+ ,
+ );
+ return { ...utils, ref, onSelect, onClose };
+}
+
+describe("TagSuggestion — items list", () => {
+ // 6 件以上の候補が部分一致するシナリオを再現するための雛形。`tec` を含む
+ // 候補を 7 件並べ、6 番目 (`tec`) がちょうど完全一致になる構成。
+ // Builds a candidate list of 7 entries that all match `tec`, with the 6th
+ // entry being the exact match — designed to reproduce the original bug.
+ function makeOverflowingCandidates(): TagSuggestionCandidate[] {
+ return [
+ { name: "techDeep", exists: true, targetId: "p1" },
+ { name: "technique", exists: true, targetId: "p2" },
+ { name: "techEarly", exists: true, targetId: "p3" },
+ { name: "technician", exists: true, targetId: "p4" },
+ { name: "techlead", exists: true, targetId: "p5" },
+ { name: "tec", exists: true, targetId: "p6" }, // exact match
+ { name: "tecArchive", exists: true, targetId: "p7" },
+ ];
+ }
+
+ it("keeps the exact match visible even when it sits beyond MAX_VISIBLE substring matches (issue #767 review)", () => {
+ // バグ前: `tec` 完全一致が 6 番目だったため `slice(0, 5)` で消え、なおかつ
+ // `exactMatch` が truthy だったため「新規作成」項目も追加されず選択不能。
+ // Pre-fix: with `tec` at position 6, the slice dropped it from the visible
+ // list and the "create" fallback was suppressed (exactMatch was truthy).
+ renderTagSuggestion({ query: "tec", candidates: makeOverflowingCandidates() });
+
+ const buttons = screen.getAllByRole("button");
+ // 完全一致 `#tec` が表示されている。
+ // The exact-match `#tec` row is rendered.
+ const tecButton = buttons.find((b) => b.textContent === "#tec");
+ expect(tecButton).toBeDefined();
+ });
+
+ it("places the exact match at the top of the visible list", () => {
+ // 並べ替えの安定性を固定: 完全一致が先頭に来る(残り順序は元のまま)。
+ // Pin sort stability — exact match leads, the rest keep insertion order.
+ renderTagSuggestion({ query: "tec", candidates: makeOverflowingCandidates() });
+ const buttons = screen.getAllByRole("button");
+ expect(buttons[0]?.textContent).toBe("#tec");
+ });
+
+ it("shows the create-new fallback only when no exact match exists", () => {
+ // 完全一致が無いクエリでは「新規作成」項目を出す。完全一致があるクエリでは
+ // 既存の挙動どおり出さない。
+ // Without an exact match the create option appears; with one, it does not.
+ const candidates: TagSuggestionCandidate[] = [
+ { name: "techDeep", exists: true, targetId: "p1" },
+ ];
+ renderTagSuggestion({ query: "tec", candidates });
+
+ expect(screen.getByText('"#tec" を作成')).toBeInTheDocument();
+ cleanup();
+
+ const withExact: TagSuggestionCandidate[] = [
+ ...candidates,
+ { name: "tec", exists: true, targetId: "p2" },
+ ];
+ renderTagSuggestion({ query: "tec", candidates: withExact });
+ expect(screen.queryByText('"#tec" を作成')).toBeNull();
+ });
+
+ it("returns null (renders nothing) when the candidate list is empty and the query is empty", () => {
+ const { container } = renderTagSuggestion({ query: "", candidates: [] });
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("invokes onSelect with the chosen item when a row is clicked", () => {
+ const onSelect = vi.fn();
+ const candidates: TagSuggestionCandidate[] = [
+ { name: "tech", exists: true, targetId: "p-uuid-1" },
+ { name: "design", exists: true, targetId: "p-uuid-2" },
+ ];
+ renderTagSuggestion({ query: "des", candidates, onSelect });
+
+ const button = screen.getByRole("button", { name: /#design/ });
+ fireEvent.click(button);
+
+ expect(onSelect).toHaveBeenCalledWith({
+ name: "design",
+ exists: true,
+ targetId: "p-uuid-2",
+ });
+ });
+});
+
+describe("TagSuggestion — keyboard navigation via imperative handle", () => {
+ it("Enter confirms the highlighted item", () => {
+ const onSelect = vi.fn();
+ const candidates: TagSuggestionCandidate[] = [
+ { name: "tech", exists: true, targetId: "p1" },
+ { name: "tea", exists: true, targetId: "p2" },
+ ];
+ const { ref } = renderTagSuggestion({ query: "te", candidates, onSelect });
+
+ const enterEvent = new KeyboardEvent("keydown", { key: "Enter" });
+ expect(ref.current?.onKeyDown(enterEvent)).toBe(true);
+ expect(onSelect).toHaveBeenCalledWith({
+ name: "tech",
+ exists: true,
+ targetId: "p1",
+ });
+ });
+
+ it("Tab confirms the highlighted item (acceptance criteria)", () => {
+ // 受け入れ条件で Tab も Enter と同じく確定操作として扱う。
+ // Acceptance criteria: Tab confirms just like Enter.
+ const onSelect = vi.fn();
+ const candidates: TagSuggestionCandidate[] = [{ name: "tech", exists: true, targetId: "p1" }];
+ const { ref } = renderTagSuggestion({ query: "tec", candidates, onSelect });
+
+ const tabEvent = new KeyboardEvent("keydown", { key: "Tab" });
+ expect(ref.current?.onKeyDown(tabEvent)).toBe(true);
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ });
+
+ it("Escape calls onClose", () => {
+ const onClose = vi.fn();
+ const candidates: TagSuggestionCandidate[] = [{ name: "tech", exists: true, targetId: "p1" }];
+ const { ref } = renderTagSuggestion({ query: "tec", candidates, onClose });
+
+ const escEvent = new KeyboardEvent("keydown", { key: "Escape" });
+ expect(ref.current?.onKeyDown(escEvent)).toBe(true);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("Escape still closes when the items list is empty (no matches yet)", () => {
+ // 候補ゼロでも Esc で閉じる経路を保証する(プラグインの close メタ送出への
+ // 入口)。それ以外のキーは素通し(タイピング継続を妨げない)。
+ // Esc must still close even when there are no items so the host can
+ // dispatch the close meta on the plugin. Other keys fall through to keep
+ // typing alive.
+ const onClose = vi.fn();
+ const { ref } = renderTagSuggestion({ query: "", candidates: [], onClose });
+
+ const escEvent = new KeyboardEvent("keydown", { key: "Escape" });
+ expect(ref.current?.onKeyDown(escEvent)).toBe(true);
+ expect(onClose).toHaveBeenCalledTimes(1);
+
+ const otherEvent = new KeyboardEvent("keydown", { key: "Enter" });
+ expect(ref.current?.onKeyDown(otherEvent)).toBe(false);
+ });
+
+ // 矢印キー / ラップアラウンド契約を固定する(受け入れ条件 + PR #778 レビュー反映)。
+ // Pin the arrow-key + wrap-around contract listed in the issue acceptance
+ // criteria (review feedback on PR #778 from coderabbitai).
+ it("ArrowUp from the first item wraps to the last and Enter confirms it", async () => {
+ // クエリ `tea` が `tea` と完全一致 + `team` の部分一致になり、`tech` には
+ // マッチしないため items は [tea (exact, 並び替え先頭), team] の 2 件、
+ // 「新規作成」項目は混ざらない。ArrowUp が末尾の `team` にラップする挙動を
+ // 純粋に検証できる。
+ // `tea` exactly matches one candidate and substring-matches `team`, so
+ // items is exactly [tea, team] without any "create new" suffix —
+ // letting us test ArrowUp wrap to the last real candidate cleanly.
+ const onSelect = vi.fn();
+ const candidates: TagSuggestionCandidate[] = [
+ { name: "tea", exists: true, targetId: "p2" },
+ { name: "team", exists: true, targetId: "p3" },
+ { name: "tech", exists: true, targetId: "p1" }, // does NOT match "tea"
+ ];
+ const { ref } = renderTagSuggestion({ query: "tea", candidates, onSelect });
+ await settleInitialMicrotasks();
+
+ expect(pressKey(ref, "ArrowUp")).toBe(true);
+ expect(pressKey(ref, "Enter")).toBe(true);
+ expect(onSelect).toHaveBeenLastCalledWith({
+ name: "team",
+ exists: true,
+ targetId: "p3",
+ });
+ });
+
+ it("ArrowDown from the last item wraps back to the first and Enter confirms it", async () => {
+ const onSelect = vi.fn();
+ const candidates: TagSuggestionCandidate[] = [
+ { name: "tea", exists: true, targetId: "p2" },
+ { name: "team", exists: true, targetId: "p3" },
+ ];
+ // query が完全一致するため "create new" は混ざらず items.length = 2。
+ // Exact match → no "create new" suffix, items.length is exactly 2.
+ const { ref } = renderTagSuggestion({ query: "tea", candidates, onSelect });
+ await settleInitialMicrotasks();
+
+ // 0 → 1 → 0(末尾を通り越して先頭へ巻き戻る)。
+ // 0 → 1 → 0 (wrap from last back to first).
+ expect(pressKey(ref, "ArrowDown")).toBe(true);
+ expect(pressKey(ref, "ArrowDown")).toBe(true);
+ expect(pressKey(ref, "Enter")).toBe(true);
+ expect(onSelect).toHaveBeenLastCalledWith({
+ name: "tea",
+ exists: true,
+ targetId: "p2",
+ });
+ });
+
+ it("ArrowDown advances the highlight one step at a time", async () => {
+ // 単発の ArrowDown でも次の候補にハイライトが進むことを Enter 経由で確認。
+ // A single ArrowDown advances by one — verified via Enter confirming the
+ // next candidate.
+ const onSelect = vi.fn();
+ const candidates: TagSuggestionCandidate[] = [
+ { name: "tech", exists: true, targetId: "p1" },
+ { name: "tea", exists: true, targetId: "p2" },
+ { name: "team", exists: true, targetId: "p3" },
+ ];
+ const { ref } = renderTagSuggestion({ query: "te", candidates, onSelect });
+ await settleInitialMicrotasks();
+
+ expect(pressKey(ref, "ArrowDown")).toBe(true);
+ expect(pressKey(ref, "Enter")).toBe(true);
+ expect(onSelect).toHaveBeenLastCalledWith({
+ name: "tea",
+ exists: true,
+ targetId: "p2",
+ });
+ });
+
+ it("Arrow keys are no-ops with a single candidate (no out-of-range index)", async () => {
+ // 候補が 1 件のとき、矢印キーで配列範囲外に飛ばないことを保証する。
+ // クエリは `tech` 完全一致にして「新規作成」項目が混ざらないようにする。
+ // With a single matching item (and an exact-match query so no "create
+ // new" suffix is added) the ref must not push the highlight out of range.
+ const onSelect = vi.fn();
+ const candidates: TagSuggestionCandidate[] = [{ name: "tech", exists: true, targetId: "p1" }];
+ const { ref } = renderTagSuggestion({ query: "tech", candidates, onSelect });
+ await settleInitialMicrotasks();
+
+ expect(pressKey(ref, "ArrowUp")).toBe(true);
+ expect(pressKey(ref, "ArrowDown")).toBe(true);
+ expect(pressKey(ref, "Enter")).toBe(true);
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ expect(onSelect).toHaveBeenLastCalledWith({
+ name: "tech",
+ exists: true,
+ targetId: "p1",
+ });
+ });
+});
diff --git a/src/components/editor/extensions/TagSuggestion.tsx b/src/components/editor/extensions/TagSuggestion.tsx
new file mode 100644
index 00000000..80217b3d
--- /dev/null
+++ b/src/components/editor/extensions/TagSuggestion.tsx
@@ -0,0 +1,200 @@
+import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState } from "react";
+import { cn } from "@zedi/ui";
+import { Hash, Plus } from "lucide-react";
+import type { TagSuggestionCandidate } from "@/hooks/useTagCandidates";
+
+/**
+ * 表示用の 1 アイテム。`exists=true` は既存ページに対応するタグ、`exists=false`
+ * は「このタグ名で確定する(新規 / ゴースト)」を示す。
+ *
+ * One row in the tag suggestion popover. `exists=false` represents the
+ * "create / accept as ghost" path — the same UX shape as
+ * `WikiLinkSuggestion`'s "create new" item.
+ */
+export interface TagSuggestionItem {
+ /** Display + insertion name (no leading `#`). 表示と挿入用の名前。 */
+ name: string;
+ /** 同名ページがスコープ内に存在するか / page with this name exists in scope */
+ exists: boolean;
+ /**
+ * 解決済みターゲットページ id。`exists === true` のときのみ非 null。
+ * Resolved target page id; non-null only when `exists === true`.
+ */
+ targetId: string | null;
+}
+
+interface TagSuggestionProps {
+ query: string;
+ /**
+ * `#name` 全体のドキュメント内範囲。確定時の置換に使うが、本コンポーネントは
+ * 表示のみ担当するためここでは保持しない(呼び出し側が `onSelect` で利用)。
+ *
+ * Document range covering `#name`. Consumed by the caller via `onSelect`;
+ * this component doesn't read it directly but keeps the prop so the parent
+ * layer matches `WikiLinkSuggestion`'s shape (issue #767 review feedback).
+ */
+ range: { from: number; to: number };
+ onSelect: (item: TagSuggestionItem) => void;
+ onClose: () => void;
+ /**
+ * 呼び出し側がスコープ別に絞り込んだ候補。`useTagCandidates` の出力を
+ * そのまま渡す。
+ *
+ * Pre-scoped candidates supplied by the caller (typically the result of
+ * `useTagCandidates`). The component stays pure-presentation so scope
+ * decisions live in the host.
+ */
+ candidates: TagSuggestionCandidate[];
+}
+
+/**
+ * `onKeyDown` が `true` を返すと呼び出し元は既定のキーハンドリングを抑止する。
+ * Imperative handle exposing `onKeyDown`; returning `true` tells the editor
+ * to suppress default key handling.
+ */
+export interface TagSuggestionHandle {
+ onKeyDown: (event: KeyboardEvent) => boolean;
+}
+
+const MAX_VISIBLE = 5;
+
+/**
+ * `#name` 用のサジェストポップアップ。WikiLink 用 `WikiLinkSuggestion` と
+ * 同じ操作モデル(矢印キーで移動、Enter / Tab で確定、Esc で閉じる)。
+ * 候補は呼び出し側で大文字小文字無視のスコープ絞り込み済みのものを受け取り、
+ * 本コンポーネントは表示と確定だけを担当する。
+ *
+ * Tag (`#name`) suggestion popup. Mirrors `WikiLinkSuggestion`'s key model
+ * (arrows to move, Enter / Tab to confirm, Esc to close). Candidates are
+ * pre-scoped + de-duplicated by the caller; this component only renders and
+ * forwards the confirm signal. See issue #767 (Phase 2).
+ */
+export const TagSuggestion = forwardRef(
+ ({ query, candidates, onSelect, onClose }, ref) => {
+ const [selectedIndex, setSelectedIndex] = useState(0);
+
+ const items = useMemo(() => {
+ const normalized = query.trim().toLowerCase();
+ // `MAX_VISIBLE` のスライス前に「完全一致を先頭」へ並べ替えるのが要点。
+ // これをやらないと、6 件目以降に完全一致がいた場合に表示から漏れたうえで
+ // `exactMatch` が truthy になり「新規作成」項目も追加されないため、ユーザが
+ // 完全一致候補を選択できなくなる(gemini-code-assist のレビュー指摘)。
+ // Sort exact-match candidates to the front BEFORE slicing to MAX_VISIBLE.
+ // Without this, a candidate matching the query exactly but sitting outside
+ // the first 5 includes-matches gets dropped from the visible list while
+ // `exactMatch` is still truthy, so the "create new" fallback also doesn't
+ // fire — leaving the user unable to select the exact tag (gemini review).
+ const filtered = normalized
+ ? candidates.filter((c) => c.name.toLowerCase().includes(normalized))
+ : candidates;
+ const sorted = [...filtered].sort((a, b) => {
+ const aExact = a.name.toLowerCase() === normalized;
+ const bExact = b.name.toLowerCase() === normalized;
+ if (aExact && !bExact) return -1;
+ if (!aExact && bExact) return 1;
+ return 0;
+ });
+ const matching = sorted.slice(0, MAX_VISIBLE).map((c) => ({
+ name: c.name,
+ exists: c.exists,
+ targetId: c.targetId,
+ }));
+
+ const exactMatch = candidates.find((c) => c.name.toLowerCase() === normalized);
+ const result = [...matching];
+ if (query.trim() && !exactMatch) {
+ result.push({ name: query.trim(), exists: false, targetId: null });
+ }
+ return result;
+ }, [query, candidates]);
+
+ // クエリ変更ごとに選択位置をリセット。再描画後に走るよう microtask で
+ // 倒すのは `WikiLinkSuggestion` と同じ手法(ちらつき防止)。
+ // Reset highlight on every query change; same microtask trick as
+ // `WikiLinkSuggestion` to avoid intermediate flicker.
+ useEffect(() => {
+ queueMicrotask(() => setSelectedIndex(0));
+ }, [query]);
+
+ const selectItem = useCallback(
+ (index: number) => {
+ const item = items[index];
+ if (item) onSelect(item);
+ },
+ [items, onSelect],
+ );
+
+ useImperativeHandle(ref, () => ({
+ onKeyDown: (event: KeyboardEvent) => {
+ if (items.length === 0) {
+ // Esc は常に閉じる、それ以外は素通し(タイピング継続を妨げない)。
+ // Esc still closes; everything else falls through to keep typing.
+ if (event.key === "Escape") {
+ onClose();
+ return true;
+ }
+ return false;
+ }
+
+ if (event.key === "ArrowUp") {
+ setSelectedIndex((prev) => (prev <= 0 ? items.length - 1 : prev - 1));
+ return true;
+ }
+
+ if (event.key === "ArrowDown") {
+ setSelectedIndex((prev) => (prev >= items.length - 1 ? 0 : prev + 1));
+ return true;
+ }
+
+ if (event.key === "Enter" || event.key === "Tab") {
+ // Enter / Tab どちらでも確定する(受け入れ条件)。
+ // Both Enter and Tab confirm — matches the issue's acceptance criteria.
+ selectItem(selectedIndex);
+ return true;
+ }
+
+ if (event.key === "Escape") {
+ onClose();
+ return true;
+ }
+
+ return false;
+ },
+ }));
+
+ if (items.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+ {items.map((item, index) => (
+
+ ))}
+
+
+ );
+ },
+);
+
+TagSuggestion.displayName = "TagSuggestion";
diff --git a/src/components/editor/extensions/tagSuggestionPlugin.test.ts b/src/components/editor/extensions/tagSuggestionPlugin.test.ts
new file mode 100644
index 00000000..9c4f8906
--- /dev/null
+++ b/src/components/editor/extensions/tagSuggestionPlugin.test.ts
@@ -0,0 +1,382 @@
+/**
+ * Tests for the `#name` tag suggestion ProseMirror plugin (issue #767, Phase 2).
+ * `#name` タグサジェスト用 ProseMirror プラグインのテスト(issue #767, Phase 2)。
+ *
+ * The plugin tracks `#`-triggered queries the same way `wikiLinkSuggestionPlugin`
+ * tracks `[[...]]` queries, but with the boundary rules and character class of
+ * `TagExtension` so behaviour stays in lock-step with the input/paste rules.
+ * These tests build a minimal ProseMirror schema + state and feed transactions
+ * directly so we can pin trigger conditions without spinning up a full Tiptap
+ * editor (the same pattern as `slashSuggestionPlugin.test.ts`).
+ *
+ * `wikiLinkSuggestionPlugin` の `[[...]]` 検出と同じ構造で `#name` クエリを
+ * 追跡する。`TagExtension` の境界ルール・文字クラスと整合させ、入力規則 /
+ * 貼り付け規則とブレが出ないように固定する。最小スキーマと直接トランザクション
+ * 適用で振る舞いを固定する(`slashSuggestionPlugin.test.ts` と同じ手法)。
+ */
+
+import type { Plugin } from "@tiptap/pm/state";
+import { EditorState, TextSelection } from "@tiptap/pm/state";
+import { Schema } from "@tiptap/pm/model";
+import { describe, expect, it, vi } from "vitest";
+import {
+ TagSuggestionPlugin,
+ tagSuggestionPluginKey,
+ type TagSuggestionState,
+} from "./tagSuggestionPlugin";
+
+const schema = new Schema({
+ nodes: {
+ doc: { content: "block+" },
+ paragraph: {
+ group: "block",
+ content: "text*",
+ toDOM: () => ["p", 0],
+ },
+ code_block: {
+ group: "block",
+ content: "text*",
+ code: true,
+ defining: true,
+ marks: "",
+ toDOM: () => ["pre", ["code", 0]],
+ },
+ text: { group: "inline" },
+ },
+ marks: {
+ code: {
+ // ProseMirror's spec uses `code: true` to mark the schema-spec for inline
+ // code; mirrors what StarterKit installs in production. Used by the
+ // plugin to skip activation inside inline code.
+ // ProseMirror で `code: true` のマークが付くスキーマ。プラグインは
+ // インラインコード内ではサジェストを起動しない仕様の検証に使う。
+ code: true,
+ toDOM: () => ["code", 0],
+ },
+ },
+});
+
+/**
+ * Pulls the bare ProseMirror plugin out of the Tiptap extension wrapper so we
+ * can install it on a hand-built `EditorState`.
+ * Tiptap 拡張ラッパーから素の ProseMirror プラグインを取り出す。
+ */
+function getPlugin(onStateChange?: (s: TagSuggestionState) => void): Plugin {
+ const extension = TagSuggestionPlugin.configure({ onStateChange });
+ const addPlugins = extension.config.addProseMirrorPlugins;
+ if (!addPlugins) throw new Error("addProseMirrorPlugins missing");
+ const plugins = addPlugins.call({ options: { onStateChange } } as never);
+ return plugins[0];
+}
+
+/**
+ * Creates a state with `text` inside a single paragraph and a collapsed cursor
+ * placed `caretFromEnd` characters before the end of the text (default 0).
+ *
+ * 1 段落のドキュメントを生成し、キャレットをテキスト末尾から
+ * `caretFromEnd` 文字戻した位置に置く(既定 0 = 末尾)。
+ */
+function makeParagraphState(text: string, plugin: Plugin, caretFromEnd = 0): EditorState {
+ const doc = schema.node("doc", null, [
+ schema.node("paragraph", null, text ? [schema.text(text)] : []),
+ ]);
+ const state = EditorState.create({ doc, schema, plugins: [plugin] });
+ const caret = 1 + text.length - caretFromEnd;
+ const tr = state.tr.setSelection(TextSelection.create(state.doc, caret));
+ return state.apply(tr);
+}
+
+/**
+ * Creates a state where `text` lives inside a `code_block` node. Used to
+ * verify the plugin does not fire while the cursor is in a code block.
+ *
+ * `text` を `code_block` 内に置いた状態を作る。コードブロック内では
+ * サジェストが起動しないことを検証する。
+ */
+function makeCodeBlockState(text: string, plugin: Plugin): EditorState {
+ const doc = schema.node("doc", null, [
+ schema.node("code_block", null, text ? [schema.text(text)] : []),
+ ]);
+ const state = EditorState.create({ doc, schema, plugins: [plugin] });
+ const tr = state.tr.setSelection(TextSelection.create(state.doc, 1 + text.length));
+ return state.apply(tr);
+}
+
+/**
+ * Creates a state with a single paragraph whose entire content is wrapped in
+ * an inline `code` mark, then puts the cursor at the end. Used to verify
+ * inline-code suppression.
+ *
+ * 段落の全文に inline `code` マークを掛けた状態を作る。インラインコード内
+ * では起動しない仕様の検証に使う。
+ */
+function makeInlineCodeState(text: string, plugin: Plugin): EditorState {
+ const codeMark = schema.marks.code.create();
+ const doc = schema.node("doc", null, [
+ schema.node("paragraph", null, text ? [schema.text(text, [codeMark])] : []),
+ ]);
+ const state = EditorState.create({ doc, schema, plugins: [plugin] });
+ const tr = state.tr.setSelection(TextSelection.create(state.doc, 1 + text.length));
+ return state.apply(tr);
+}
+
+describe("tagSuggestionPlugin — initial state", () => {
+ it("initialises with no active suggestion", () => {
+ const plugin = getPlugin();
+ const state = makeParagraphState("", plugin);
+ const pluginState = tagSuggestionPluginKey.getState(state);
+ expect(pluginState).toEqual({
+ active: false,
+ query: "",
+ range: null,
+ decorations: expect.any(Object),
+ });
+ });
+});
+
+describe("tagSuggestionPlugin — activation triggers", () => {
+ it("activates the moment `#` is typed at the start of a paragraph", () => {
+ // 段落先頭で `#` を打鍵した瞬間にサジェスト UI を出す(受け入れ条件)。
+ // The popup must appear immediately after the user types `#`.
+ const onStateChange = vi.fn();
+ const plugin = getPlugin(onStateChange);
+ const state = makeParagraphState("#", plugin);
+ const pluginState = tagSuggestionPluginKey.getState(state);
+
+ expect(pluginState?.active).toBe(true);
+ expect(pluginState?.query).toBe("");
+ // `#` 自体は pos 1〜2 の範囲。range は確定時に削除→挿入の範囲として使う。
+ // The hash itself sits at positions 1..2; range is later used to replace
+ // the typed `#name` with the styled mark on confirm.
+ expect(pluginState?.range).toEqual({ from: 1, to: 2 });
+ expect(onStateChange).toHaveBeenCalledWith(pluginState);
+ });
+
+ it("captures the query text after `#`", () => {
+ const plugin = getPlugin();
+ const state = makeParagraphState("#tec", plugin);
+ const pluginState = tagSuggestionPluginKey.getState(state);
+
+ expect(pluginState?.active).toBe(true);
+ expect(pluginState?.query).toBe("tec");
+ expect(pluginState?.range).toEqual({ from: 1, to: 5 });
+ });
+
+ it("activates on a `#` preceded by a non-word boundary (space)", () => {
+ const plugin = getPlugin();
+ const state = makeParagraphState("hello #tec", plugin);
+ const pluginState = tagSuggestionPluginKey.getState(state);
+
+ expect(pluginState?.active).toBe(true);
+ expect(pluginState?.query).toBe("tec");
+ // `hello ` (6 chars) → `#` at pos 7, query ends at pos 11.
+ expect(pluginState?.range).toEqual({ from: 7, to: 11 });
+ });
+
+ it("captures CJK characters in the query", () => {
+ const plugin = getPlugin();
+ const state = makeParagraphState("#技術", plugin);
+ const pluginState = tagSuggestionPluginKey.getState(state);
+ expect(pluginState?.active).toBe(true);
+ expect(pluginState?.query).toBe("技術");
+ });
+
+ it("captures hyphens and underscores in the query", () => {
+ const plugin = getPlugin();
+ const state = makeParagraphState("#front-end_v2", plugin);
+ const pluginState = tagSuggestionPluginKey.getState(state);
+ expect(pluginState?.active).toBe(true);
+ expect(pluginState?.query).toBe("front-end_v2");
+ });
+});
+
+describe("tagSuggestionPlugin — non-trigger inputs", () => {
+ it("does not activate when `#` is embedded in a word (`abc#tec`)", () => {
+ // 単語境界違反は paste rule / input rule と同じ扱いにする(受け入れ条件)。
+ // Word-boundary violation matches the paste/input rule contract.
+ const plugin = getPlugin();
+ const state = makeParagraphState("abc#tec", plugin);
+ const pluginState = tagSuggestionPluginKey.getState(state);
+ expect(pluginState?.active).toBe(false);
+ });
+
+ it("does not activate after a `/` (URL-fragment style boundary)", () => {
+ const plugin = getPlugin();
+ const state = makeParagraphState("/page#anchor", plugin);
+ const pluginState = tagSuggestionPluginKey.getState(state);
+ expect(pluginState?.active).toBe(false);
+ });
+
+ it("does not activate after a second `#` (`##heading`-like input)", () => {
+ // `##` の 2 つ目の `#` 位置でも、直前が `#` のため起動しない。
+ // `##` keeps the menu off — second `#` is preceded by another `#`.
+ const plugin = getPlugin();
+ const state = makeParagraphState("##heading", plugin);
+ const pluginState = tagSuggestionPluginKey.getState(state);
+ expect(pluginState?.active).toBe(false);
+ });
+
+ it("does not activate without any `#`", () => {
+ const plugin = getPlugin();
+ const state = makeParagraphState("plain text", plugin);
+ const pluginState = tagSuggestionPluginKey.getState(state);
+ expect(pluginState?.active).toBe(false);
+ });
+
+ it("does not activate inside an inline `code` mark", () => {
+ // `excludes: "code"` をプラグイン側でも担保する。コード内で `#` を打っても
+ // サジェスト UI は出ない。
+ // Plugin must mirror Tag mark's `excludes: "code"`: never activate inside
+ // inline code so we don't tease an action that addMark would block.
+ const plugin = getPlugin();
+ const state = makeInlineCodeState("#tec", plugin);
+ const pluginState = tagSuggestionPluginKey.getState(state);
+ expect(pluginState?.active).toBe(false);
+ });
+
+ it("does not activate inside a code_block", () => {
+ const plugin = getPlugin();
+ const state = makeCodeBlockState("#tec", plugin);
+ const pluginState = tagSuggestionPluginKey.getState(state);
+ expect(pluginState?.active).toBe(false);
+ });
+
+ it("does not activate when the candidate name is excluded (numeric `#1`)", () => {
+ // 入力規則と同じ除外(数字のみ・6/8 桁 hex)をサジェスト側でも適用する。
+ // 数字オンリーのクエリではポップアップを出さない。
+ // Mirror the input/paste rule exclusions (numeric-only / 6/8-char hex).
+ // `#1` should not raise the picker.
+ const plugin = getPlugin();
+ const state = makeParagraphState("#1", plugin);
+ const pluginState = tagSuggestionPluginKey.getState(state);
+ expect(pluginState?.active).toBe(false);
+ });
+
+ it("does not activate when the candidate name is a 6-char pure hex (`#aabbcc`)", () => {
+ const plugin = getPlugin();
+ const state = makeParagraphState("#aabbcc", plugin);
+ const pluginState = tagSuggestionPluginKey.getState(state);
+ expect(pluginState?.active).toBe(false);
+ });
+
+ it("does not activate when the candidate name is an 8-char pure hex (`#aabbccdd`)", () => {
+ const plugin = getPlugin();
+ const state = makeParagraphState("#aabbccdd", plugin);
+ const pluginState = tagSuggestionPluginKey.getState(state);
+ expect(pluginState?.active).toBe(false);
+ });
+
+ it("activates while the user is still typing toward an excluded value (e.g. `#aabbc`)", () => {
+ // 5 桁時点では除外ルール対象外(除外は 6/8 桁完成時のみ)。
+ // The exclusion only kicks in at 6/8 chars; intermediate states still show.
+ const plugin = getPlugin();
+ const state = makeParagraphState("#aabbc", plugin);
+ const pluginState = tagSuggestionPluginKey.getState(state);
+ expect(pluginState?.active).toBe(true);
+ expect(pluginState?.query).toBe("aabbc");
+ });
+});
+
+describe("tagSuggestionPlugin — deactivation", () => {
+ it("deactivates when the selection becomes a non-empty range", () => {
+ const onStateChange = vi.fn();
+ const plugin = getPlugin(onStateChange);
+
+ let state = makeParagraphState("#tec", plugin);
+ expect(tagSuggestionPluginKey.getState(state)?.active).toBe(true);
+ onStateChange.mockClear();
+
+ const tr = state.tr.setSelection(TextSelection.create(state.doc, 1, 5));
+ state = state.apply(tr);
+ const pluginState = tagSuggestionPluginKey.getState(state);
+ expect(pluginState?.active).toBe(false);
+ expect(pluginState?.range).toBeNull();
+ expect(pluginState?.query).toBe("");
+ expect(onStateChange).toHaveBeenCalledWith(pluginState);
+ });
+
+ it("turns off when the user types past the trigger and the regex no longer matches", () => {
+ const plugin = getPlugin();
+
+ let state = makeParagraphState("#tec", plugin);
+ expect(tagSuggestionPluginKey.getState(state)?.active).toBe(true);
+
+ // Replace the entire paragraph contents with text that no longer matches.
+ // 段落全体を非トリガなテキストに差し替える(pos 1〜5 が段落内 4 文字 + 末尾位置)。
+ const tr = state.tr.replaceWith(1, 5, schema.text("plain"));
+ state = state.apply(tr);
+ expect(tagSuggestionPluginKey.getState(state)?.active).toBe(false);
+ });
+
+ it("turns off when a terminator (space) is typed after the query", () => {
+ // Esc を押さずに空白を打鍵すると、regex が末尾 `$` でマッチしなくなり
+ // サジェストは閉じる(その後の確定は入力規則に任せる)。
+ // Typing a terminator (space) breaks the `$`-anchored regex and closes the
+ // popup. The input rule then takes over (Esc-then-terminator contract).
+ const plugin = getPlugin();
+ let state = makeParagraphState("#tec", plugin);
+ expect(tagSuggestionPluginKey.getState(state)?.active).toBe(true);
+
+ const tr = state.tr.insertText(" ", 5);
+ state = state.apply(tr);
+ expect(tagSuggestionPluginKey.getState(state)?.active).toBe(false);
+ });
+
+ it("explicit close meta clears the active state and notifies subscribers", () => {
+ const onStateChange = vi.fn();
+ const plugin = getPlugin(onStateChange);
+
+ let state = makeParagraphState("#tec", plugin);
+ expect(tagSuggestionPluginKey.getState(state)?.active).toBe(true);
+ onStateChange.mockClear();
+
+ const tr = state.tr.setMeta(tagSuggestionPluginKey, { close: true });
+ state = state.apply(tr);
+ const pluginState = tagSuggestionPluginKey.getState(state);
+ expect(pluginState).toEqual({
+ active: false,
+ query: "",
+ range: null,
+ decorations: expect.any(Object),
+ });
+ expect(onStateChange).toHaveBeenCalledWith(pluginState);
+ });
+});
+
+describe("tagSuggestionPlugin — decorations", () => {
+ it("creates a single `.tag-typing` decoration over the `#name` range when active", () => {
+ // CSS の `.tag-typing` を生かすため、active 中は `#name` 範囲に inline
+ // decoration を置く(issue #767 受け入れ条件、CSS は src/index.css に既存)。
+ // Decorate the live `#name` range with `.tag-typing` so the existing CSS
+ // colours the typing state (the class lives in src/index.css).
+ const plugin = getPlugin();
+ const state = makeParagraphState("#tec", plugin);
+ const pluginState = tagSuggestionPluginKey.getState(state);
+ expect(pluginState?.active).toBe(true);
+
+ const decos = pluginState?.decorations.find(0, state.doc.content.size) ?? [];
+ expect(decos).toHaveLength(1);
+ expect(decos[0].from).toBe(1);
+ expect(decos[0].to).toBe(5);
+ });
+
+ it("returns an empty decoration set when inactive", () => {
+ const plugin = getPlugin();
+ const state = makeParagraphState("plain", plugin);
+ const pluginState = tagSuggestionPluginKey.getState(state);
+ expect(pluginState?.active).toBe(false);
+ const decos = pluginState?.decorations.find(0, state.doc.content.size) ?? [];
+ expect(decos).toHaveLength(0);
+ });
+});
+
+describe("tagSuggestionPlugin — extension wiring", () => {
+ it("declares the expected extension name", () => {
+ expect(TagSuggestionPlugin.name).toBe("tagSuggestion");
+ });
+
+ it("exposes a default options object with no callback", () => {
+ const ext = TagSuggestionPlugin.configure();
+ expect(ext.options.onStateChange).toBeUndefined();
+ });
+});
diff --git a/src/components/editor/extensions/tagSuggestionPlugin.ts b/src/components/editor/extensions/tagSuggestionPlugin.ts
new file mode 100644
index 00000000..95196a86
--- /dev/null
+++ b/src/components/editor/extensions/tagSuggestionPlugin.ts
@@ -0,0 +1,287 @@
+import { Extension } from "@tiptap/core";
+import { Plugin, PluginKey } from "@tiptap/pm/state";
+import { Decoration, DecorationSet } from "@tiptap/pm/view";
+import type { EditorView } from "@tiptap/pm/view";
+import { TAG_NAME_CHAR_CLASS } from "@zedi/shared/tagCharacterClass";
+import { isExcludedTagName } from "./TagExtension";
+
+/**
+ * UI state for the `#name` tag suggestion picker.
+ *
+ * `#name` タグサジェスト UI の状態。
+ *
+ * - `active`: ポップアップを表示中か / popup is visible
+ * - `query`: `#` 直後のクエリ文字列(先頭 `#` は含めない)/ raw query after `#`
+ * - `range`: `#name` 全体のドキュメント内範囲(確定時に置換に使う)/
+ * document range covering `#name` (used to replace on confirm)
+ * - `decorations`: `.tag-typing` クラスを乗せる inline decoration セット /
+ * inline decoration set that paints the `.tag-typing` style
+ */
+export interface TagSuggestionState {
+ active: boolean;
+ query: string;
+ range: { from: number; to: number } | null;
+ decorations: DecorationSet;
+}
+
+/**
+ * ProseMirror plugin key for {@link TagSuggestionPlugin}.
+ * `TagSuggestionPlugin` 用の ProseMirror プラグインキー。
+ */
+export const tagSuggestionPluginKey = new PluginKey("tagSuggestion");
+
+/**
+ * Options for {@link TagSuggestionPlugin}.
+ * `TagSuggestionPlugin` のオプション。
+ */
+export interface TagSuggestionOptions {
+ /**
+ * Called whenever the plugin state changes (open / close / query update).
+ * The host React component drives the popover from this callback, mirroring
+ * how `WikiLinkSuggestionPlugin.onStateChange` feeds the WikiLink popup.
+ *
+ * 状態変化(オープン・クローズ・クエリ更新)のたびに呼ばれる。React 側で
+ * このコールバックを使ってポップオーバー UI を駆動する(WikiLink 同等)。
+ */
+ onStateChange?: (state: TagSuggestionState) => void;
+}
+
+/**
+ * Regex that detects a live `#name` query immediately before the cursor.
+ *
+ * - `(?:^|[^\w/#])` — same boundary contract as `TAG_PASTE_REGEX` and
+ * `TAG_INPUT_REGEX` so `abc#tec`, `/page#anchor`, `##` never trigger.
+ * - `#([${TAG_NAME_CHAR_CLASS}]*)` — captures the (possibly empty) name body
+ * so the popover can show suggestions the moment `#` is typed.
+ * - `$` — anchored to the end of the inspected text-before-cursor slice; the
+ * plugin re-evaluates on every transaction so this fires only when the
+ * caret is sitting right after `#name`.
+ *
+ * 入力規則 / 貼り付け規則と同じ境界条件で `#name` を検出するための正規表現。
+ * 「カーソル直前のテキスト」の末尾 (`$`) に `#name` がある瞬間だけマッチする。
+ * 空クエリ (`#` のみ) も拾うため、`#` を打鍵した瞬間からポップアップを出せる。
+ */
+const TAG_QUERY_REGEX = new RegExp(`(?:^|[^\\w/#])#([${TAG_NAME_CHAR_CLASS}]*)$`);
+
+/**
+ * Walk the resolved position to detect whether the caret sits inside an inline
+ * `code` mark. Tag marks are configured with `excludes: "code"`, so opening
+ * the popover here would offer an action the editor cannot perform.
+ *
+ * カーソル位置がインライン `code` マーク内にあるかを判定する。Tag マークは
+ * `excludes: "code"` のためコード内では確定不可。サジェストも出さない。
+ */
+function isInsideInlineCode(
+ $from: import("@tiptap/pm/model").ResolvedPos,
+ schemaMarks: Record,
+): boolean {
+ const codeMark = schemaMarks.code;
+ if (!codeMark) return false;
+ // `marks()` returns the marks active at the cursor. `isInSet` handles the
+ // empty-cursor case (no stored marks) by returning the literal mark or
+ // null; both mean "no code mark".
+ // `marks()` はカーソル位置に作用するマーク列を返す。`isInSet` は空カーソル
+ // 時にも安全に動き、null/値を返す。
+ return Boolean(codeMark.isInSet($from.marks()));
+}
+
+/**
+ * ProseMirror plugin that surfaces a `#name` autocomplete popover comparable
+ * to `WikiLinkSuggestionPlugin` for `[[...]]`. The host renders the actual
+ * UI and reacts to `onStateChange`; the plugin only owns query detection,
+ * the typing decoration (`.tag-typing`), and the close meta.
+ *
+ * `#name` 用の ProseMirror プラグイン。`WikiLinkSuggestionPlugin` と
+ * 同じ役割分担で、UI 描画は React 側に任せ、本プラグインはクエリ検出と
+ * `.tag-typing` 装飾、`close` メタの処理だけを担う。
+ *
+ * 入力規則 (`TagExtension.addInputRules`) との二重マーク化を避けるため、
+ * `TagExtension` 側のハンドラが `tagSuggestionPluginKey.getState(state).active`
+ * を見て発火を抑止する契約。Esc → 空白終端の順で打鍵された場合は本プラグインが
+ * 既に閉じているので入力規則経路でマーク化される(フォールバック)。
+ *
+ * Coordinated with `TagExtension.addInputRules`: while this plugin is active
+ * the input rule short-circuits to avoid double-marking on confirm. After Esc
+ * the suggestion state is `inactive`, so a typed terminator falls through to
+ * the input rule path (the documented fallback).
+ *
+ * See issue #767 (Phase 2) and parent #725 (Phase 1).
+ */
+export const TagSuggestionPlugin = Extension.create({
+ name: "tagSuggestion",
+
+ addOptions() {
+ return {
+ onStateChange: undefined,
+ };
+ },
+
+ addProseMirrorPlugins() {
+ const { onStateChange } = this.options;
+
+ return [
+ new Plugin({
+ key: tagSuggestionPluginKey,
+
+ state: {
+ init() {
+ return {
+ active: false,
+ query: "",
+ range: null,
+ decorations: DecorationSet.empty,
+ };
+ },
+
+ apply(tr, prev, _oldState, newState) {
+ const meta = tr.getMeta(tagSuggestionPluginKey);
+
+ // Handle explicit close (Esc, suggestion confirm, etc.)
+ // 明示的なクローズ(Esc、確定後のリセットなど)。
+ if (meta?.close) {
+ const nextState: TagSuggestionState = {
+ active: false,
+ query: "",
+ range: null,
+ decorations: DecorationSet.empty,
+ };
+ onStateChange?.(nextState);
+ return nextState;
+ }
+
+ const { selection, schema } = newState;
+ const { $from } = selection;
+
+ // Range selections do not represent a typing caret, so collapse to
+ // inactive (matches WikiLink/Slash plugin behaviour). 範囲選択中は
+ // タイピングではないので非アクティブに倒す(WikiLink / Slash と同じ)。
+ if (!selection.empty) {
+ if (prev.active) {
+ const nextState: TagSuggestionState = {
+ active: false,
+ query: "",
+ range: null,
+ decorations: DecorationSet.empty,
+ };
+ onStateChange?.(nextState);
+ return nextState;
+ }
+ return prev;
+ }
+
+ // Code-block / inline-code suppression. タグマーク自体が
+ // `excludes: "code"` のためコード周辺ではサジェストを出さない。
+ // Inside code blocks / inline code the Tag mark cannot be applied
+ // (`excludes: "code"`), so the popover is hidden too.
+ if ($from.parent.type.spec.code || isInsideInlineCode($from, schema.marks)) {
+ if (prev.active) {
+ const nextState: TagSuggestionState = {
+ active: false,
+ query: "",
+ range: null,
+ decorations: DecorationSet.empty,
+ };
+ onStateChange?.(nextState);
+ return nextState;
+ }
+ return prev;
+ }
+
+ //  = ProseMirror's object replacement char for non-text nodes.
+ // `` は ProseMirror が非テキストノードを 1 文字で置く位置合わせ用。
+ const textBefore = $from.parent.textBetween(0, $from.parentOffset, null, "");
+ const match = textBefore.match(TAG_QUERY_REGEX);
+
+ if (match) {
+ const query = match[1];
+
+ // Mirror the input/paste rule's `isExcludedTagName` so we don't
+ // surface the popover for values the rule would silently reject
+ // (numeric-only, 6/8-char hex). Empty query is allowed — typing
+ // `#` alone still opens the menu.
+ // 入力規則 / 貼り付け規則の `isExcludedTagName` と同じ除外を適用し、
+ // 規則側がリジェクトする値(数字のみ・6/8 桁 hex)でポップアップを
+ // 出さない。空クエリ(`#` のみ)は許容してメニュー表示を開始する。
+ if (query.length > 0 && isExcludedTagName(query)) {
+ if (prev.active) {
+ const nextState: TagSuggestionState = {
+ active: false,
+ query: "",
+ range: null,
+ decorations: DecorationSet.empty,
+ };
+ onStateChange?.(nextState);
+ return nextState;
+ }
+ return prev;
+ }
+
+ // `#name` のドキュメント内範囲。境界文字(先頭 1 文字)はキャプチャに
+ // 含まれないので、`from` は単純に「#」位置 = `$from.pos - 1 - query.length`。
+ // The boundary char (if any) is non-capturing, so `from` is the
+ // position of `#`, computed without inspecting `match[0].length`.
+ const from = $from.pos - 1 - query.length;
+ const to = $from.pos;
+
+ const decorations = DecorationSet.create(newState.doc, [
+ Decoration.inline(from, to, {
+ class: "tag-typing",
+ }),
+ ]);
+
+ const nextState: TagSuggestionState = {
+ active: true,
+ query,
+ range: { from, to },
+ decorations,
+ };
+
+ onStateChange?.(nextState);
+ return nextState;
+ }
+
+ // No match — collapse to inactive if previously open.
+ // マッチしないので前回開いていたら閉じる。
+ if (prev.active) {
+ const nextState: TagSuggestionState = {
+ active: false,
+ query: "",
+ range: null,
+ decorations: DecorationSet.empty,
+ };
+ onStateChange?.(nextState);
+ return nextState;
+ }
+
+ return prev;
+ },
+ },
+
+ props: {
+ decorations(state) {
+ return this.getState(state)?.decorations ?? DecorationSet.empty;
+ },
+
+ handleKeyDown(view: EditorView, event: KeyboardEvent) {
+ const pluginState = tagSuggestionPluginKey.getState(view.state);
+
+ if (!pluginState?.active) {
+ return false;
+ }
+
+ // Close on Escape — the React popover handles arrow keys / Enter /
+ // Tab via its imperative ref, so we only own the lifecycle event
+ // that hides the menu without inserting anything.
+ // Esc は閉じるだけ。矢印 / Enter / Tab は React 側のハンドルで処理する。
+ if (event.key === "Escape") {
+ view.dispatch(view.state.tr.setMeta(tagSuggestionPluginKey, { close: true }));
+ return true;
+ }
+
+ return false;
+ },
+ },
+ }),
+ ];
+ },
+});
diff --git a/src/components/editor/extensions/transformWikiLinksInContent.test.ts b/src/components/editor/extensions/transformWikiLinksInContent.test.ts
index d51492a5..318496d0 100644
--- a/src/components/editor/extensions/transformWikiLinksInContent.test.ts
+++ b/src/components/editor/extensions/transformWikiLinksInContent.test.ts
@@ -154,7 +154,7 @@ describe("transformWikiLinksInContent", () => {
content: [
{
type: "heading",
- attrs: { level: 1 },
+ attrs: { level: 2 },
content: [{ type: "text", text: "Title with [[Link]]" }],
},
],
diff --git a/src/components/layout/Header/HeaderLogo.tsx b/src/components/layout/Header/HeaderLogo.tsx
index be67d108..2b3ce322 100644
--- a/src/components/layout/Header/HeaderLogo.tsx
+++ b/src/components/layout/Header/HeaderLogo.tsx
@@ -2,15 +2,13 @@ import React from "react";
import { Link } from "react-router-dom";
/**
- *
+ * アプリ名ロゴ。ページの h1 ではないため見出し要素にしない(ブランド用テキスト)
+ * / App brand text in the global header. Not a page <h1>; keep as non-heading
*/
-export /**
- *
- */
-const HeaderLogo: React.FC = () => (
+export const HeaderLogo: React.FC = () => (
-
+
Zedi
-
+
);
diff --git a/src/components/layout/KeyboardShortcutsDialog.tsx b/src/components/layout/KeyboardShortcutsDialog.tsx
index 8420f6ca..1ed5ae98 100644
--- a/src/components/layout/KeyboardShortcutsDialog.tsx
+++ b/src/components/layout/KeyboardShortcutsDialog.tsx
@@ -4,28 +4,25 @@ import {
formatShortcutKey,
type ShortcutInfo,
} from "@/hooks/useKeyboardShortcuts";
+import { useTranslation } from "react-i18next";
interface KeyboardShortcutsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
-const categoryLabels: Record = {
- navigation: "ナビゲーション",
- page: "ページ操作",
- editor: "エディタ",
-};
-
const categoryOrder: ShortcutInfo["category"][] = ["navigation", "page", "editor"];
/**
*
*/
export function KeyboardShortcutsDialog({ open, onOpenChange }: KeyboardShortcutsDialogProps) {
- // Group shortcuts by category
- /**
- *
- */
+ const { t } = useTranslation();
+ const categoryLabels: Record = {
+ navigation: t("shortcuts.category.navigation"),
+ page: t("shortcuts.category.page"),
+ editor: t("shortcuts.category.editor"),
+ };
const groupedShortcuts = categoryOrder
.map((category) => ({
category,
@@ -38,7 +35,7 @@ export function KeyboardShortcutsDialog({ open, onOpenChange }: KeyboardShortcut
);
}
diff --git a/src/pages/NoteView/index.tsx b/src/pages/NoteView/index.tsx
index c67085f3..06a97d9e 100644
--- a/src/pages/NoteView/index.tsx
+++ b/src/pages/NoteView/index.tsx
@@ -114,6 +114,7 @@ const NoteView: React.FC = () => {
canManageMembers={canManageMembers}
isSignedIn={isSignedIn}
canView={Boolean(access?.canView)}
+ userRole={access?.role ?? "none"}
/>