diff --git a/.claude/rules/frontend/architecture.md b/.claude/rules/frontend/architecture.md index 8eab3f7c..b772a46a 100644 --- a/.claude/rules/frontend/architecture.md +++ b/.claude/rules/frontend/architecture.md @@ -43,7 +43,7 @@ frontend/src/ │ ├── useAuthSession.ts # 認証セッション状態 │ ├── useTheme.ts │ ├── blog/ # useBlogAccountManager -│ └── career/ # useCareerDirty / useCareerExperienceMutators / useProjectModalForm / useProjectModalState / useProjectFormDirty / usePhotoUpload / usePdfPanelLayout / useResumeImportAssist +│ └── career/ # useCareerDirty / useCareerExperienceMutators / useProjectModalForm / useProjectModalState / useProjectFormDirty / usePhotoUpload / useImportPanelLayout / useResumeImportAssist ├── api/ │ ├── client.ts # fetch ラッパー(Cookie 認証、401 ハンドリング) │ └── *.ts # ドメイン別 API モジュール(auth, blog, resumes, master-data, notifications, download, ai-resume, githubLink) diff --git a/frontend/e2e/career-import-assist.spec.ts b/frontend/e2e/career-import-assist.spec.ts new file mode 100644 index 00000000..abc6f599 --- /dev/null +++ b/frontend/e2e/career-import-assist.spec.ts @@ -0,0 +1,147 @@ +import { test, expect, type Locator, type Page } from "@playwright/test"; + +import { IMPORT_ASSIST_MESSAGES } from "../src/constants/messages"; +import { setupAuth, waitForAuthenticatedLayout } from "./helpers/auth"; + +/** + * 職務経歴書のファイル取り込み補助(原本ビュー)E2E。 + * + * 取り込み導線は右カラムの原本ビューに一本化されており、ドラッグ&ドロップ / クリック選択で + * PDF・Markdown を読み込む。ここでは Markdown の描画と、種別・サイズのエラーパターンを検証する。 + * + * シナリオ: + * 1. Markdown をファイル選択で読み込む → 原本が整形描画される + * 2. Markdown をドラッグ&ドロップで読み込む → 原本が整形描画される + * 3. 原本上で選択した文字が、フォーカス中の入力欄へ流し込まれる + * 4. 拡張子が pdf/md 以外(.txt)→ 未対応エラーを表示し描画しない + * 5. サイズ上限超過の Markdown → サイズ超過エラーを表示し描画しない + */ + +/** 職務経歴書の最小 API モック(マスタ・最新取得)。 */ +async function setupResumeApi(page: Page) { + await page.route("**/api/master-data/qualification", (route) => + route.fulfill({ status: 200, contentType: "application/json", body: "[]" }), + ); + await page.route("**/api/master-data/technology-stack", (route) => + route.fulfill({ status: 200, contentType: "application/json", body: "[]" }), + ); + await page.route("**/api/resumes/latest", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + id: "resume-1", + full_name: "山田 太郎", + career_summary: "サマリー", + self_pr: "自己PR", + experiences: [], + qualifications: [], + }), + }), + ); +} + +/** 原本ビュー内の隠しファイル input(aria-label で一意)。 */ +function importInput(page: Page): Locator { + return page.locator(`input[aria-label="${IMPORT_ASSIST_MESSAGES.SELECT_FILE}"]`); +} + +/** 描画された Markdown 原本の本文要素。 */ +function markdownBody(page: Page): Locator { + return page.locator("[class*='markdownBody']"); +} + +const SAMPLE_MD = "# E2Eマークダウン見出し\n\n- 取り込みテスト項目\n"; + +test.describe("職務経歴書 ファイル取り込み補助", () => { + test.beforeEach(async ({ page }) => { + await setupAuth(page); + await setupResumeApi(page); + await page.goto("/career"); + await waitForAuthenticatedLayout(page); + }); + + test("ファイル選択で Markdown を読み込むと原本が整形描画される", async ({ page }) => { + await importInput(page).setInputFiles({ + name: "resume.md", + mimeType: "text/markdown", + buffer: Buffer.from(SAMPLE_MD, "utf-8"), + }); + + // 見出し(h1)とリスト項目が原本ビュー内に描画される + await expect(markdownBody(page).getByText("E2Eマークダウン見出し")).toBeVisible(); + await expect(markdownBody(page).getByText("取り込みテスト項目")).toBeVisible(); + // タブにファイル名が出る + await expect(page.getByText("resume.md")).toBeVisible(); + }); + + test("Markdown をドラッグ&ドロップで読み込むと原本が整形描画される", async ({ page }) => { + // ファイルを載せた DataTransfer を生成し、ドロップゾーンへ drop イベントを発火する。 + const dataTransfer = await page.evaluateHandle((content) => { + const dt = new DataTransfer(); + dt.items.add(new File([content], "dropped.md", { type: "text/markdown" })); + return dt; + }, SAMPLE_MD); + + const dropzone = page.locator("[class*='dropzone']"); + await dropzone.dispatchEvent("dragover", { dataTransfer }); + await dropzone.dispatchEvent("drop", { dataTransfer }); + + await expect(markdownBody(page).getByText("E2Eマークダウン見出し")).toBeVisible(); + await expect(page.getByText("dropped.md")).toBeVisible(); + }); + + test("原本上で選択した文字がフォーカス中の入力欄へ流し込まれる", async ({ page }) => { + await importInput(page).setInputFiles({ + name: "resume.md", + mimeType: "text/markdown", + buffer: Buffer.from(SAMPLE_MD, "utf-8"), + }); + await expect(markdownBody(page).getByText("取り込みテスト項目")).toBeVisible(); + + // 流し込み先として氏名欄をフォーカス(focusin で「最後にフォーカスした入力欄」に記録される) + const nameInput = page.getByPlaceholder("例: 山田 太郎"); + await nameInput.click(); + + // 原本のリスト項目テキストを選択し、mouseup を発火して流し込む + await page.evaluate(() => { + const body = document.querySelector("[class*='markdownBody']"); + const target = body?.querySelector("li") ?? body; + if (!target) throw new Error("markdown body not found"); + const range = document.createRange(); + range.selectNodeContents(target); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + }); + + await expect(nameInput).toHaveValue("取り込みテスト項目"); + }); + + test("拡張子が pdf/md 以外(.txt)だと未対応エラーを表示し描画しない", async ({ page }) => { + await importInput(page).setInputFiles({ + name: "note.txt", + mimeType: "text/plain", + buffer: Buffer.from("ただのテキスト", "utf-8"), + }); + + await expect(page.getByText(IMPORT_ASSIST_MESSAGES.UNSUPPORTED_TYPE)).toBeVisible(); + // 原本は描画されず、ドロップゾーンが残る + await expect(markdownBody(page)).toHaveCount(0); + await expect(page.getByText(IMPORT_ASSIST_MESSAGES.DROPZONE)).toBeVisible(); + }); + + test("サイズ上限を超える Markdown はサイズ超過エラーを表示し描画しない", async ({ page }) => { + // Markdown 上限は 2MB。3MB のダミーを渡して弾かれることを確認する。 + await importInput(page).setInputFiles({ + name: "huge.md", + mimeType: "text/markdown", + buffer: Buffer.alloc(3 * 1024 * 1024, 0x61), + }); + + await expect(page.getByText(IMPORT_ASSIST_MESSAGES.TOO_LARGE(2))).toBeVisible(); + await expect(markdownBody(page)).toHaveCount(0); + await expect(page.getByText(IMPORT_ASSIST_MESSAGES.DROPZONE)).toBeVisible(); + }); +}); diff --git a/frontend/src/components/forms/CareerResumeForm.module.css b/frontend/src/components/forms/CareerResumeForm.module.css index 80d0e7ac..f066a6b4 100644 --- a/frontend/src/components/forms/CareerResumeForm.module.css +++ b/frontend/src/components/forms/CareerResumeForm.module.css @@ -146,48 +146,56 @@ padding-left: 1rem; } -/* PDF パネルの開閉トグル。展開時はカラム右上に小さく配置する。 */ +/* 原本パネルの開閉トグル。展開時はカラム右上にシェブロンだけを配置する。 + サイドバーの折りたたみボタン(.sidebarCollapseButton)と同じく、枠なし・ + 背景なしのシェブロンで表現する。 */ .pdfToggle { align-self: flex-end; flex-shrink: 0; margin-bottom: 0.4rem; - padding: 0.15rem 0.5rem; - font-size: 0.85rem; + background: none; + border: none; + color: var(--text-secondary); + font-size: 1.1rem; line-height: 1; - border: 1px solid var(--border-input); - border-radius: 4px; - background: var(--bg-input); - color: var(--text-primary); + padding: 0 0.5rem; cursor: pointer; + transition: color 0.15s, background 0.15s; } .pdfToggle:hover { + color: var(--text-primary); background: var(--ghost-hover); } -/* 折りたたみ時: 幅を CSS 変数に依らず固定の細いレールにし、トグルを縦書きで全高表示する。 +/* 折りたたみ時: 幅を固定の細いレールにし、上部にトグルだけを置く。 panel 本体は JSX 側で非レンダリングなので、ここはレールの見た目だけを定義する。 */ .pdfColCollapsed { - position: relative; width: 2.5rem; align-items: center; padding-left: 0; } +/* 折りたたみ時の再展開ボタン。サイドバーの再展開ボタン(.sidebarOpenButton)と同じく + 枠付き・角丸・影付きのコンパクトなシェブロンにする。 */ .pdfColCollapsed .pdfToggle { - writing-mode: vertical-rl; align-self: center; + margin-top: 0.4rem; margin-bottom: 0; - padding: 0.6rem 0.2rem; - height: 100%; + padding: 0.3rem 0.55rem; + font-size: 1rem; + border: 1px solid var(--border-input); + border-radius: 6px; + background: var(--bg-input); + color: var(--text-primary); + box-shadow: var(--shadow-card); } /* 折りたたみレールに表示する取り込み補助エラーのインジケータ。 - トグルの上に小さな赤丸として固定し、クリックでパネルを展開する。 */ + トグルの下に小さな赤丸として並べ、クリックでパネルを展開する。 */ .collapsedError { - position: absolute; - top: 0.4rem; align-self: center; + margin-top: 0.4rem; width: 1.4rem; height: 1.4rem; border: none; @@ -228,7 +236,7 @@ padding-top: 1rem; } - /* 縦積みかつ折りたたみ時は、全幅の薄いバーにしてトグルを横書きへ戻す + /* 縦積みかつ折りたたみ時は、全幅の薄いバーにしてトグルを左寄せにする (細い縦レールのままだと縦積みレイアウトで不自然なため)。 */ .pdfColCollapsed { width: 100%; @@ -238,9 +246,7 @@ } .pdfColCollapsed .pdfToggle { - writing-mode: horizontal-tb; align-self: flex-start; - height: auto; - padding: 0.3rem 0.6rem; + margin-top: 0; } } diff --git a/frontend/src/components/forms/CareerResumeForm.tsx b/frontend/src/components/forms/CareerResumeForm.tsx index 91dfbd97..bd177a01 100644 --- a/frontend/src/components/forms/CareerResumeForm.tsx +++ b/frontend/src/components/forms/CareerResumeForm.tsx @@ -9,10 +9,10 @@ import { getLatestCareerResume, updateCareerResume, } from "../../api"; -import { IMPORT_ASSIST_MESSAGES, UI_MESSAGES } from "../../constants/messages"; +import { UI_MESSAGES } from "../../constants/messages"; import { createInitialCareerForm, mapCareerResumeToForm } from "../../formMappers"; import { useCareerDirty } from "../../hooks/career/useCareerDirty"; -import { usePdfPanelLayout } from "../../hooks/career/usePdfPanelLayout"; +import { useImportPanelLayout } from "../../hooks/career/useImportPanelLayout"; import { useResumeImportAssist } from "../../hooks/career/useResumeImportAssist"; import { useDocumentForm } from "../../hooks/useDocumentForm"; import { buildCareerPayload } from "../../payloadBuilders"; @@ -23,7 +23,7 @@ import shared from "../../styles/shared.module.css"; import { ConfirmDialog } from "../ConfirmDialog"; import { Skeleton } from "../ui/Skeleton"; import { PdfPreviewModal } from "./PdfPreviewModal"; -import { ResumePdfTracePanel } from "./ResumePdfTracePanel"; +import { ResumeSourceTracePanel } from "./ResumeSourceTracePanel"; import layout from "./CareerResumeForm.module.css"; import { CareerBasicInfoSection } from "./sections/CareerBasicInfoSection"; import { CareerExperienceSection } from "./sections/CareerExperienceSection"; @@ -35,9 +35,8 @@ export function CareerResumeForm() { // PDF 原本ビュー(右カラム)の折りたたみ状態。折りたたむと入力フォームが全幅に広がる。 const [pdfCollapsed, setPdfCollapsed] = useState(false); const assist = useResumeImportAssist(); - const pdfInputRef = useRef(null); const splitRef = useRef(null); - const { width: pdfWidth, startResize } = usePdfPanelLayout(splitRef); + const { width: pdfWidth, startResize } = useImportPanelLayout(splitRef); const { form, setForm, @@ -125,20 +124,7 @@ export function CareerResumeForm() {

職務経歴書

- {/* PDF 取り込み(従来のヘッダー位置)。選択した PDF を右カラムに原本表示する */} - - + {/* ファイル取り込みは右カラムの原本ビュー(ドラッグ&ドロップ / クリック)に集約。 */} @@ -253,7 +239,7 @@ export function CareerResumeForm() { /> )} - {/* 右: PDF 原本ビュー(独立スクロール)。文字を選択して入力欄へ流し込む。 + {/* 右: 原本ビュー(独立スクロール)。文字を選択して入力欄へ流し込む。 幅は CSS 変数 --pdf-col-width を CSS 側で参照(縦積み時は全幅へ上書き)。 折りたたみ時は細いレールになり、トグルだけを表示する。 */}
diff --git a/frontend/src/components/forms/MarkdownDocumentView.tsx b/frontend/src/components/forms/MarkdownDocumentView.tsx new file mode 100644 index 00000000..a8d9b5a3 --- /dev/null +++ b/frontend/src/components/forms/MarkdownDocumentView.tsx @@ -0,0 +1,73 @@ +import { useCallback, useEffect, useState } from "react"; + +import { IMPORT_ASSIST_MESSAGES } from "../../constants/messages"; +import { renderMarkdown } from "../../utils/markdown"; +import styles from "./ResumeSourceTracePanel.module.css"; + +type Props = { + /** 描画対象の Markdown ファイル */ + file: File; + /** ズーム倍率(1 = 等倍)。フォントサイズの em 倍率として適用する。 */ + zoom?: number; + /** 原本上で選択された文字列を流し込むコールバック */ + onFill: (text: string) => void; + /** 描画エラー・エラー解除を親へ伝えるコールバック */ + onError: (message: string | null) => void; +}; + +/** + * Markdown を整形描画し、テキスト選択を「流し込み」に変換する内側ビュー。 + * + * {@link PdfDocumentView} と対になる Markdown 用レンダラー。 + * sanitize は {@link renderMarkdown}(DOMPurify 経由)に一元化しており、 + * `dangerouslySetInnerHTML` にはその出力のみを渡す。流し込み先の決定(最後に + * フォーカスした入力欄)は useResumeImportAssist 側が担うため、ここは + * 「選択文字列を onFill に渡す」ことだけに専念する。 + */ +export default function MarkdownDocumentView({ file, zoom = 1, onFill, onError }: Props) { + const [html, setHtml] = useState(null); + + // ファイル本文をテキストとして読み込み、sanitize 済み HTML へ変換する。 + // 別ファイルへの切り替えは呼び出し側が key で再マウントするため、ここでの破棄は不要。 + useEffect(() => { + let cancelled = false; + file + .text() + .then((text) => { + if (cancelled) return; + onError(null); + setHtml(renderMarkdown(text)); + }) + .catch(() => { + if (cancelled) return; + // 失敗時も loading(RENDERING)表示から抜けるよう html を確定させる。 + // 本文は空にし、失敗の通知は onError 経由でパネルのエラー欄に出す。 + setHtml(""); + onError(IMPORT_ASSIST_MESSAGES.RENDER_FAILED); + }); + return () => { + cancelled = true; + }; + }, [file, onError]); + + const handleSelection = useCallback(() => { + const text = window.getSelection()?.toString() ?? ""; + if (!text.trim()) return; + onFill(text); + // 流し込み後は選択を解除し、次にどこを掴んだか分かりやすくする。 + window.getSelection()?.removeAllRanges(); + }, [onFill]); + + if (html === null) { + return

{IMPORT_ASSIST_MESSAGES.RENDERING}

; + } + + return ( +
+ ); +} diff --git a/frontend/src/components/forms/MarkdownTextarea.tsx b/frontend/src/components/forms/MarkdownTextarea.tsx index fe6cfbe9..932cb1b7 100644 --- a/frontend/src/components/forms/MarkdownTextarea.tsx +++ b/frontend/src/components/forms/MarkdownTextarea.tsx @@ -1,6 +1,5 @@ import { useMemo, type ReactNode } from "react"; -import { marked } from "marked"; -import DOMPurify from "dompurify"; +import { renderMarkdown } from "../../utils/markdown"; import shared from "../../styles/shared.module.css"; import styles from "./MarkdownTextarea.module.css"; @@ -25,13 +24,7 @@ type Props = { * Markdownテキストエリア。入力内容をリアルタイムでプレビュー表示する。 */ export function MarkdownTextarea({ label, value, onChange, rows = 3, placeholder, required, labelAdornment }: Props) { - const renderedHtml = useMemo(() => { - if (!value) return ""; - // marked は HTML をサニタイズしないため(v5 以降 sanitize オプション廃止)、 - // DOMPurify を通して XSS("); + expect(html).not.toContain("