From 4e7cc1c6507590cb2c25c35dee83704cbbc0c422 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Mon, 1 Jun 2026 15:30:38 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E5=8F=96=E3=82=8A=E8=BE=BC?= =?UTF-8?q?=E3=81=BF=E8=A3=9C=E5=8A=A9=E3=81=AB=20Markdown=20=E5=AF=BE?= =?UTF-8?q?=E5=BF=9C=E3=81=A8=20D&D=20=E5=B0=8E=E7=B7=9A=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 職務経歴書フォームの取り込み補助(PDF 原本からの流し込み)を Markdown ファイルにも拡張し、ファイル取り込み導線を右カラムのドロップゾーンに一本化する。 - Markdown を marked + DOMPurify で sanitize 描画する renderMarkdown を utils に抽出(MarkdownTextarea の重複ロジックも統合) - 原本ビューを kind で PDF / Markdown のレンダラー切替に変更。 MarkdownDocumentView を新設し、選択→流し込みは既存の getSelection 機構を流用 - useResumeImportAssist に kind 判定・種別別サイズ上限(PDF 20MB / MD 2MB)・ ドロップ受け入れ用 acceptFile を追加 - 取り込み導線をヘッダーボタンから右カラムのドロップゾーン(D&D / クリック)へ一本化 - 折り畳みトグルをサイドバーと同じシェブロン UI に統一 - PDF 限定の命名を一般化(ResumePdfTracePanel→ResumeSourceTracePanel、 usePdfPanelLayout→useImportPanelLayout、メッセージ文言・UI_MESSAGES) Co-Authored-By: Claude Opus 4.8 --- .claude/rules/frontend/architecture.md | 2 +- .../forms/CareerResumeForm.module.css | 44 +++--- .../src/components/forms/CareerResumeForm.tsx | 36 ++--- .../components/forms/MarkdownDocumentView.tsx | 70 +++++++++ .../src/components/forms/MarkdownTextarea.tsx | 11 +- .../src/components/forms/PdfDocumentView.tsx | 4 +- .../components/forms/ProjectModal.test.tsx | 10 +- .../src/components/forms/ProjectModal.tsx | 8 +- .../components/forms/ResumePdfTracePanel.tsx | 84 ---------- ....css => ResumeSourceTracePanel.module.css} | 84 ++++++++++ .../forms/ResumeSourceTracePanel.tsx | 146 ++++++++++++++++++ frontend/src/constants/messages.ts | 30 ++-- ...t.test.ts => useImportPanelLayout.test.ts} | 12 +- ...PanelLayout.ts => useImportPanelLayout.ts} | 24 +-- .../career/useResumeImportAssist.test.ts | 68 +++++++- .../src/hooks/career/useResumeImportAssist.ts | 119 ++++++++++---- frontend/src/utils/markdown.test.ts | 28 ++++ frontend/src/utils/markdown.ts | 16 ++ 18 files changed, 586 insertions(+), 210 deletions(-) create mode 100644 frontend/src/components/forms/MarkdownDocumentView.tsx delete mode 100644 frontend/src/components/forms/ResumePdfTracePanel.tsx rename frontend/src/components/forms/{ResumePdfTracePanel.module.css => ResumeSourceTracePanel.module.css} (55%) create mode 100644 frontend/src/components/forms/ResumeSourceTracePanel.tsx rename frontend/src/hooks/career/{usePdfPanelLayout.test.ts => useImportPanelLayout.test.ts} (86%) rename frontend/src/hooks/career/{usePdfPanelLayout.ts => useImportPanelLayout.ts} (76%) create mode 100644 frontend/src/utils/markdown.test.ts create mode 100644 frontend/src/utils/markdown.ts 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/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..bfdcecde --- /dev/null +++ b/frontend/src/components/forms/MarkdownDocumentView.tsx @@ -0,0 +1,70 @@ +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; + 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("