diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c630e689..aa7b48dd 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -63,7 +63,24 @@ "Bash(node -e \"const {version}=require\\('./node_modules/electron/package.json'\\);console.log\\('electron:',version\\)\")", "Bash(node -e \"const p = require\\('/Users/user/Luie/node_modules/react-resizable-panels/package.json'\\); console.log\\(p.version\\)\")", "Bash(sed -n '74220,74260p' /Users/user/Luie/node_modules/react-resizable-panels/dist/react-resizable-panels.js)", - "Bash(node -e \"const fs=require\\('fs'\\); const c=fs.readFileSync\\('/Users/user/Luie/node_modules/react-resizable-panels/dist/react-resizable-panels.js','utf8'\\); const lines=c.split\\('\\\\n'\\); console.log\\(lines.slice\\(0,10\\).join\\('\\\\n'\\)\\)\")" + "Bash(node -e \"const fs=require\\('fs'\\); const c=fs.readFileSync\\('/Users/user/Luie/node_modules/react-resizable-panels/dist/react-resizable-panels.js','utf8'\\); const lines=c.split\\('\\\\n'\\); console.log\\(lines.slice\\(0,10\\).join\\('\\\\n'\\)\\)\")", + "Bash(node scripts/design/tokens-guard.mjs)", + "Bash(node scripts/design/unify-token-vocab.mjs --dry)", + "Bash(node scripts/design/unify-token-vocab.mjs)", + "Bash(grep -rEn '#[0-9a-fA-F]{3,8}\\\\b' src --include='*.tsx' --include='*.ts' --include='*.css')", + "mcp__web-reader__webReader", + "Bash(fd -e tsx -e ts 'Editor\\\\.tsx$|GoogleDocsHeader|SnapshotDiffModal' src/renderer/src)", + "Bash(pnpm typecheck *)", + "Bash(pnpm run *)", + "Bash(node scripts/check-i18n-parity.mjs)", + "Bash(sed -n '/embedding: {/,/llmfit: {/p' src/renderer/src/i18n/locales/en/base/settingsAdvanced.ts)", + "Bash(sed -n '/embedding: {/,/llmfit: {/p' src/renderer/src/i18n/locales/ja/base/settingsAdvanced.ts)", + "Bash(sed -n '/rebuildMemory: {/,+3p' src/renderer/src/i18n/locales/en/base/settingsAdvanced.ts)", + "Bash(sed -n '/rebuildMemory: {/,+3p' src/renderer/src/i18n/locales/ja/base/settingsAdvanced.ts)", + "Bash(npx eslint *)", + "Bash(xxd)", + "Bash(grep -ivE ' \\([0-9a-f]{2} \\){1,16} .*[a-z]')", + "Bash(xxd src/renderer/src/features/settings/components/tabs/ModelTab.tsx)" ] }, "enableAllProjectMcpServers": true, diff --git a/.clinerules/ponytail.md b/.clinerules/ponytail.md new file mode 100644 index 00000000..84d7ccc5 --- /dev/null +++ b/.clinerules/ponytail.md @@ -0,0 +1,30 @@ +# Ponytail, lazy senior dev mode + +You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written. + +Before writing any code, stop at the first rung that holds: + +1. Does this need to be built at all? (YAGNI) +2. Does it already exist in this codebase? Reuse the helper, util, or pattern that's already here, don't re-write it. +3. Does the standard library already do this? Use it. +4. Does a native platform feature cover it? Use it. +5. Does an already-installed dependency solve it? Use it. +6. Can this be one line? Make it one line. +7. Only then: write the minimum code that works. + +The ladder runs after you understand the problem, not instead of it: read the task and the code it touches, trace the real flow end to end, then climb. + +Bug fix = root cause, not symptom: a report names a symptom. Grep every caller of the function you touch and fix the shared function once — one guard there is a smaller diff than one per caller, and patching only the path the ticket names leaves a sibling caller still broken. + +Rules: + +- No abstractions that weren't explicitly requested. +- No new dependency if it can be avoided. +- No boilerplate nobody asked for. +- Deletion over addition. Boring over clever. Fewest files possible. +- Shortest working diff wins, but only once you understand the problem. The smallest change in the wrong place isn't lazy, it's a second bug. +- Question complex requests: "Do you actually need X, or does Y cover it?" +- Pick the edge-case-correct option when two stdlib approaches are the same size, lazy means less code, not the flimsier algorithm. +- Mark intentional simplifications with a `ponytail:` comment. If the shortcut has a known ceiling (global lock, O(n²) scan, naive heuristic), the comment names the ceiling and the upgrade path. + +Not lazy about: understanding the problem (read it fully and trace the real flow before picking a rung, a small diff you don't understand is just laziness dressed up as efficiency), input validation at trust boundaries, error handling that prevents data loss, security, accessibility, the calibration real hardware needs (the platform is never the spec ideal, a clock drifts, a sensor reads off), anything explicitly requested. Lazy code without its check is unfinished: non-trivial logic leaves ONE runnable check behind, the smallest thing that fails if the logic breaks (an assert-based demo/self-check or one small test file; no frameworks, no fixtures). Trivial one-liners need no test. diff --git a/.gitignore b/.gitignore index 8974a0c1..475307aa 100644 --- a/.gitignore +++ b/.gitignore @@ -104,4 +104,4 @@ resources/models/ dist/ dist/ dist/ -novel +study diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000..227f661b --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,354 @@ +# Luie — Design System (DESIGN.md) + +> Single source of truth for Luie's visual & interaction design. Every rule here +> is grounded in this repository's code (file paths cited inline). External +> references are folded in where they shaped a decision. Written so an agent +> (Codex/Claude) can reproduce Luie's look without guessing. +> +> **Golden rule:** never hardcode a color, radius, font, or z-index. Consume the +> tokens. If a value isn't a token yet, add it to +> [`global.tokens.css`](src/renderer/src/styles/global.tokens.css) first. + +--- + +## 1. What Luie is, visually + +Luie is an Electron desktop **writing app** (novel/worldbuilding). The design +intent is encoded directly in the token comments: + +- A **"recessed editor" model** (see [`global.tokens.css:231`](src/renderer/src/styles/global.tokens.css)): + the editor is the dark focal writing surface; the chrome (sidebar, toolbar, + footer) sits **one calm step lighter** as the frame. In light themes it + inverts — the editor is paper, the chrome is a slightly tinted frame. +- **Calm, neutral, professional.** Zinc neutral scale, a single blue brand + accent, no pure black, subtle cool tint in dark (pure greys "read drab"). The + comments explicitly calibrate against **Notion, Linear, Vercel, Tokyo Night, + Gruvbox, Everforest, Solarized**. +- **Writer-focused.** A dedicated Sepia ("warm paper") theme exists for long-form + writing comfort, plus serif font support (KoPub Batang etc.). +- **App-like, not web-like.** `body { overflow: hidden }` + ([`global.behaviors.css:21`](src/renderer/src/styles/global.behaviors.css)); the + whole surface is a fixed-viewport workspace of resizable panels, not a + scrolling document. + +Design north star: **the writing surface is sacred; chrome recedes.** When in +doubt, lower the contrast/visual weight of chrome and raise it on content. + +--- + +## 2. Theming architecture + +Luie uses **Tailwind CSS v4** with a CSS-first config (there is **no** +`tailwind.config.js` — it was removed). The wiring lives in +[`global.css`](src/renderer/src/styles/global.css): + +```css +@import "tailwindcss"; +@import "./global.tokens.css"; /* tokens + @theme */ +@import "./global.behaviors.css"; /* base element styles + motion */ +@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); +``` + +### Two-layer token model + +1. **Raw tokens** — `--bg-app`, `--text-primary`, `--accent-bg`, … defined on + `:root` and **redefined per theme** via attribute selectors. +2. **Tailwind `@theme` aliases** — map raw tokens to utility namespaces so + `bg-app`, `text-fg`, `border`, `text-accent`, etc. become real classes + ([`global.tokens.css:1-72`](src/renderer/src/styles/global.tokens.css)). + +Because utilities resolve to `var(--raw-token)`, **switching a theme attribute +re-themes the entire app instantly** with no class changes. + +### Theme is driven by HTML attributes (runtime, user-selectable) + +| Attribute | Values | Effect | +|---|---|---| +| `data-theme` | `light` (default) · `dark` · `sepia` | Full palette swap | +| `data-temp` | `cool` · `warm` (neutral = unset) | Re-tints only the bg ladder + primary text | +| `data-contrast` | `high` (unset = normal) | Stronger text + borders (a11y) | +| `data-animations` | `on` · `off` | Master motion switch (see §8) | +| `data-layout-restoring` | `true` while panels reflow | Suppresses transitions during programmatic layout | + +`data-temp` × `data-theme` produces a matrix (e.g. `dark+cool` ≈ Tokyo Night, +`light+warm` ≈ Solarized Light, `sepia+warm` = deep amber paper). Structure never +changes across temperatures — only the background ladder and text. + +> **External alignment:** the Vercel _Web Interface Guidelines_ require honoring +> contrast and reduced-motion. Luie implements both as first-class user controls +> (`data-contrast="high"`, `data-animations="off"`) rather than relying only on +> OS media queries — see §8 for the reduced-motion gap to close. + +--- + +## 3. Color system + +**Always use the semantic utility, never the raw hex.** The raw values exist +only so themes can redefine them. + +### Semantic surfaces (the "recessed" ladder) + +| Token / utility | Light | Dark (neutral) | Sepia | Role | +|---|---|---|---|---| +| `bg-app` | `#ffffff` | `#1a1a1c` | `#fbf0d9` | Editor / main canvas (focal) | +| `bg-sidebar` | `#f4f4f5` | `#212123` | `#f5e6cc` | Chrome frame (one step off app) | +| `bg-panel` | `#ffffff` | `#28282b` | `#fbf0d9` | Floating surfaces (modal/inspector/dropdown) | +| `bg-surface` | `#ffffff` | `#28282b` | `#fffefb` | Cards / inputs / popovers | +| `bg-element` | `#ffffff` | `#313135` | `#fffefb` | Top-of-stack controls/inputs | +| `bg-surface-hover` / `bg-active` | alpha black | alpha white | alpha brown | Hover/active overlays (alpha, not solid) | + +**Hover/active are alpha overlays** (`rgba(0,0,0,0.04)` light, `rgba(255,255,255,0.08)` +dark), Vercel-style — so they compose over any surface. Don't invent solid hover +colors. + +### Text + +| Utility | Light | Dark | Role | +|---|---|---|---| +| `text-fg` | `#18181b` | `#d7d7da` | Primary | +| `text-muted` | `#71717a` | `#989aa2` | Secondary | +| `text-subtle` | `#a1a1aa` | `#6c6e77` | Tertiary / placeholder | +| `text-accent` | `#2563eb` | `#60a5fa` | Links / interactive | + +### Brand + semantic + +- **One accent only:** blue (`--accent-bg #2563eb`). Alternate accent swatches + were deliberately removed ([`global.behaviors.css:2`](src/renderer/src/styles/global.behaviors.css)). + In Sepia the accent becomes warm orange `#cc7832`. +- `--success-fg` green, `--danger-fg` red, `--accent-fg` (`on-accent`) white. +- Borders: `border` (`--border-default`), `border-active`, `border-focus`. In + dark, borders are **white at 8% opacity** — "enough for definition, not a grid + prison" ([`global.tokens.css:266`](src/renderer/src/styles/global.tokens.css)). +- **Wiki** ("Namuwiki") tokens are aliased to core tokens so the wiki shares + Luie's single palette — never give the wiki its own colors. + +--- + +## 4. Typography + +Font stacks ([`global.tokens.css:198-206`](src/renderer/src/styles/global.tokens.css)), +Korean-first: + +- `--font-sans`: `-apple-system, "Apple SD Gothic Neo", "Malgun Gothic", "Segoe UI", Roboto …` +- `--font-serif`: `"KoPub Batang", "Noto Serif KR", "Nanum Myeongjo", Merriweather, Georgia …` (long-form writing) +- `--font-mono`: `"D2Coding", "NanumGothicCoding", ui-monospace, Menlo …` + +`body` enables ligatures + contextual alternates and antialiasing +([`global.behaviors.css:6-22`](src/renderer/src/styles/global.behaviors.css)): +`font-feature-settings: "liga" 1, "calt" 1`, `-webkit-font-smoothing: antialiased`, +`text-rendering: optimizeLegibility`. + +Type scale is **context-scoped via CSS variables**, not a global ramp — e.g. +`--context-panel-body-font-size: 13px`, `--memo-tag-font-size: 9px`, +`--world-overview-font-size: 14px`. When building a feature, prefer a +feature-scoped size token over an inline `text-[13px]`. + +`--font-weight-semibold: 600` is the heaviest UI weight; chrome text is 500. + +--- + +## 5. Spacing, radius, shadow, z-index + +From the `@theme` block ([`global.tokens.css:55-85`](src/renderer/src/styles/global.tokens.css)): + +| Token | Value | Use | +|---|---|---| +| `--radius-control` (`rounded-control`) | `0.625rem` (10px) | Buttons, inputs, small controls | +| `--radius-panel` (`rounded-panel`) | `0.875rem` (14px) | Panels, cards, dialogs | +| `--shadow-panel` | `0 10px 28px rgba(15,23,42,.14)` | Floating panels | +| `--shadow-sm/md/lg` | standard ramp | Elevation | +| `--spacing-control-x / -y` | `0.75rem` / `0.5rem` | Control padding | +| `--spacing-panel-pad` | `1.25rem` | Panel inner padding | +| `--spacing-panel-gap` | `1rem` | Gap between panel items | + +**Z-index is a 4-step named scale** — do **not** reintroduce ad-hoc +`z-[9999]`. Use the `@utility` classes: + +| Utility | Value | Layer | +|---|---|---| +| `z-dropdown` | 50 | context menus, popovers, tooltips | +| `z-banner` | 100 | sticky top banners (offline) | +| `z-toast` | 150 | transient notifications | +| `z-modal` | 1000 | full-surface dialogs + floating panels | + +Fixed app dimensions: `--header-height: 48px`, `--sidebar-width: 260px`, +`--panel-width: 320px`. + +--- + +## 6. Icons + +- Library: **lucide-react** (e.g. `PanelLeftClose`, `PanelRightOpen` in + [`MainLayout.tsx`](src/renderer/src/features/workspace/components/layout/MainLayout.tsx)). +- Sizes are tokenized (`--icon-size-xs 12 → -xxxl 32`) with `.icon-xs … .icon-xxxl` + utility classes ([`global.behaviors.css:24-58`](src/renderer/src/styles/global.behaviors.css)). + Toolbar icons are 16px (`--editor-toolbar-icon-size`). +- **Icon-only buttons must carry `aria-label`** (and `title`) — see the sidebar + toggles in `MainLayout.tsx` for the pattern. This is also a hard rule in the + Vercel guidelines. + +--- + +## 7. Layout system (the defining structure) + +Luie has **four top-level layouts**, selected by app mode, all built on +**`react-resizable-panels` v4** (`Group` / `Panel` / `Separator` API): + +| Layout | File | Shape | +|---|---|---| +| **Main** (default) | [`MainLayout.tsx`](src/renderer/src/features/workspace/components/layout/MainLayout.tsx) | Sidebar │ Editor │ Binder (context) | +| **Editor** | `EditorRoot.tsx` | Focused editor variant | +| **Google Docs** | `GoogleDocsLayout.tsx` | Doc-style with right rail | +| **Scrivener** | `ScrivenerLayout.tsx` | Sidebar │ Editor split │ Inspector | + +### The three-region model (Main) + +``` +┌──────────┬───────────────────────────┬──────────┐ +│ sidebar │ main-content │ context │ +│ (left, │ (editor; nested split │ (binder; │ +│ collaps- │ group for split view) │ right, │ +│ ible) │ │ collaps- │ +│ │ │ ible) │ +└──────────┴───────────────────────────┴──────────┘ + one `main-layout-group` (horizontal, %-based) +``` + +Key, non-obvious layout rules learned from this codebase (uphold them): + +- **One horizontal `PanelGroup` holds all three panels.** Both end panels + (`sidebar-panel`, `context-panel`) are `collapsible collapsedSize={0}`; the + middle (`main-content-panel`) is the flex absorber. Dragging a separator must + only move the adjacent pair — the middle absorbs the delta. +- **Sizes persist as ratios/px in the UI store**, keyed by *surface* + (`default.sidebar`, `default.panel`, …) — see + [`layoutSizing.ts`](src/renderer/src/shared/constants/layoutSizing.ts) and + [`useLayoutPersist.ts`](src/renderer/src/features/workspace/hooks/useLayoutPersist.ts). + Sidebar and binder use **different keys** — they are not coupled state. +- **Open/close uses a presence hook** + ([`useResizablePanelPresence.ts`](src/renderer/src/features/workspace/hooks/useResizablePanelPresence.ts)) + that drives the panel imperatively and exposes `isOpening`/`isClosing`/`shouldRender`. +- **Research/inner sidebars** use a fixed-pixel layout hook + ([`useFixedPixelPanelGroupLayout.ts`](src/renderer/src/features/workspace/hooks/useFixedPixelPanelGroupLayout.ts)): + the sidebar holds a fixed px width, the content panel flexes. Their widths + persist **per feature** (`characterSidebar`, `eventSidebar`, …) and only + **user drags** persist — programmatic relayouts must not write width (guarded + via `data-layout-restoring`, see + [`useSidebarResizeCommit.ts`](src/renderer/src/features/workspace/hooks/useSidebarResizeCommit.ts)). + +--- + +## 8. Motion & animation + +Motion is **globally gated by attributes**, not scattered media queries +([`global.behaviors.css:65-89`](src/renderer/src/styles/global.behaviors.css)): + +- `html[data-animations="off"] *` → all `animation-duration` / `transition-duration` + forced to `0ms`. This is a **user setting** (Settings → Appearance → 애니메이션). +- `html[data-layout-restoring="true"] *` → same kill-switch **while panels are + being programmatically laid out**, so reflow never animates/flickers. +- **Panel resize transition** is opt-in per element: + `html[data-animations="on"] [data-panel][data-panel-animated="true"]` + transitions **only** `flex-basis, flex-grow, width, max-width` over `200ms` + `cubic-bezier(0.2,0,0,1)`. Critically, `data-panel-animated` is gated so it is + **off during an active separator drag** (otherwise the eased flex writes make + the opposite panel visibly drift). Enter/exit slides use + `tailwindcss-animate` (`animate-in slide-in-from-left`, `slide-out-to-right`), + applied only during the opening/closing window. + +**Standard duration: 200ms.** Standard easing: `cubic-bezier(0.2,0,0,1)` +(decelerate). Match these. + +> **External alignment & the one gap to close:** the Vercel guidelines say +> *animate `transform`/`opacity` only*, *never `transition: all`*, *honor +> `prefers-reduced-motion`*. Luie already lists properties explicitly (never +> `all`) and has a master off-switch — **but it keys off the in-app +> `data-animations` flag, not the OS `prefers-reduced-motion` media query.** +> When adding motion, also respect `@media (prefers-reduced-motion: reduce)` so +> OS-level users are covered without toggling the in-app setting. + +--- + +## 9. Component conventions (how to build UI in Luie) + +Do: + +- **Compose semantic utilities**: `bg-panel`, `bg-surface-hover`, `text-fg`, + `text-muted`, `border border-border`, `rounded-control`, `rounded-panel`, + `shadow-panel`, `z-dropdown`. These already theme correctly. +- **Hover/active/focus must raise contrast** above rest (guideline + Luie's alpha + overlays). Every interactive element needs a visible `hover:` and a + `focus-visible:` ring (`--color-ring` → accent). +- **Icon buttons**: `aria-label` + `title`; use the `MainLayout` toggle buttons as + the canonical example. +- **Dark mode**: use the `dark:` variant (wired to `[data-theme="dark"]`) or just + let tokens cascade — prefer token cascade. +- **Per-feature sizing tokens** over inline pixel values. + +Don't: + +- ❌ Hardcode hex / `rgb()` in components — breaks 6 theme combinations. +- ❌ `z-[9999]` or new magic z-indices — use the named scale. +- ❌ `transition: all` / `transition-all` — list properties (matches the + `data-panel-animated` rule). +- ❌ Solid hover backgrounds — use the alpha `bg-surface-hover` / `bg-active`. +- ❌ Give the wiki/binder/any feature a private color palette. +- ❌ `outline-none` without a `focus-visible` replacement. + +--- + +## 10. State & persistence that shapes design + +- **Zustand** is the only state lib. UI/layout state lives in the workspace + `uiStore` (persisted, versioned, zod-validated, migrated — see + [`uiStore.persist.ts`](src/renderer/src/features/workspace/stores/uiStore.persist.ts)). +- **Theme/appearance** (`data-theme`, `data-temp`, `data-contrast`, + `enableAnimations`) is user state persisted across sessions and applied as the + HTML attributes above. Treat these attributes as the rendering contract. +- **Panel widths, collapsed state, open/closed** all persist (globally and + per-project). Respect the rule: *only user interaction writes layout state; + programmatic relayout must not.* + +--- + +## 11. Accessibility checklist (Luie + Vercel Web Interface Guidelines) + +External source: **Vercel Web Interface Guidelines** +(`https://github.com/vercel-labs/web-interface-guidelines`). Apply on every UI +change: + +- [ ] Interactive elements are real ` + + ); +} +``` + +Note: this removes the `useEffectiveCharacterSections` call from this file — the character view passes already-effective sections (see Task 2 wrapper / the character hook already provides them upstream). The character caller will be updated in Step 2 to pass effective sections. + +- [ ] **Step 2: Update the character caller in `WikiDetailView.tsx`** + +The character view must now (a) pass `i18nPrefix="character"` and (b) pass effective sections through the model, since `WikiContentPanel` no longer calls `useEffectiveCharacterSections` itself. + +In `src/renderer/src/features/research/components/wiki/WikiDetailView.tsx`: + +Add the hook import next to the existing `useCharacterWikiAttrs` import (around line 14): + +```tsx +import { useEffectiveCharacterSections } from "./hooks/useEffectiveCharacterSections"; +``` + +Inside `WikiDetailView` (after `const attrs = useCharacterWikiAttrs();`, around line 103), add: + +```tsx +const effectiveSections = useEffectiveCharacterSections(attrs.sections); +``` + +Then wrap the model at the `` call site (around line 337). Replace: + +```tsx + +``` + +with: + +```tsx + +``` + +- [ ] **Step 3: Verify typecheck + build** + +Run: `pnpm typecheck` +Expected: clean (no errors). + +Run: `pnpm build` +Expected: exit 0. + +- [ ] **Step 4: Visual check (character wiki view)** + +Launch the app, open a character's wiki view. Confirm: TOC, sections, add-section button render exactly as before (no visual change). Confirm adding/renaming/deleting a section still works and persists. + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/src/features/research/components/wiki/WikiContentPanel.tsx \ + src/renderer/src/features/research/components/wiki/WikiDetailView.tsx +git commit -m "$(cat <<'EOF' +refactor(wiki): generalize WikiContentPanel to structural model + i18n prefix + +No visual change. Lifts the CharacterWikiAttrs coupling and hardcoded +character.* i18n namespace so event/faction can reuse the same TOC + +sections + add-section block. + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +### Task 2: Extract shared `NotionDocumentView`; make `CharacterDocumentView` a thin wrapper + +Move `PropertyRow`, `MarkdownDocumentEditor`, `decomposeBody`, and the `DocumentPropertyRow` type out of `CharacterDocumentView` into a new shared file. `CharacterDocumentView` keeps only its character-specific property assembly and delegates rendering. No visual change. + +**Files:** +- Create: `src/renderer/src/features/research/components/shared/NotionDocumentView.tsx` +- Modify: `src/renderer/src/features/research/components/wiki/CharacterDocumentView.tsx` + +**Interfaces:** +- Produces: `NotionDocumentView` (default export) with props: + +```ts +type NotionDocumentViewProps = { + properties: DocumentPropertyRow[]; // header property rows (label + value/onSave) + sections: WikiSectionData[]; // effective sections (single source with wiki view) + getSectionContent: (id: string) => string; + setSections: (sections: WikiSectionData[]) => void; + setSectionContent: (id: string, value: string) => void; + bodyPlaceholder: string; +}; +``` + +…where `DocumentPropertyRow` is the existing type (now exported from the shared file): + +```ts +export type DocumentPropertyRow = { + label: string; + value?: string; + placeholder?: string; + onSave?: (value: string) => void; // omit for readonly rows + readonlyValue?: string; +}; +``` + +- Consumes: `WikiSectionData` from `features/research/components/wiki/types`. + +- [ ] **Step 1: Create `shared/NotionDocumentView.tsx`** + +```tsx +import { useEffect, useRef, useState } from "react"; +import { useEditor, EditorContent } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import Placeholder from "@tiptap/extension-placeholder"; +import { Markdown } from "tiptap-markdown"; +import { BufferedInput } from "@shared/ui/BufferedInput"; +import type { WikiSectionData } from "@renderer/features/research/components/wiki/types"; + +/** tiptap-markdown augments editor.storage at runtime; type it locally. */ +type MarkdownStorage = { markdown?: { getMarkdown?: () => string } }; + +/** A property row shown in the document header. */ +export type DocumentPropertyRow = { + label: string; + value?: string; + placeholder?: string; + onSave?: (value: string) => void; + readonlyValue?: string; +}; + +type NotionDocumentViewProps = { + properties: DocumentPropertyRow[]; + sections: WikiSectionData[]; + getSectionContent: (id: string) => string; + setSections: (sections: WikiSectionData[]) => void; + setSectionContent: (id: string, value: string) => void; + bodyPlaceholder: string; +}; + +const AUTOSAVE_DELAY_MS = 500; + +/** + * Notion-style document view shared by all entity types. The header is a list + * of property rows; the body is ONE Markdown document where each wiki section + * is an `# h1` heading. h1 headings map 1:1 to wiki sections, so editing here + * (rename/add/remove a heading, edit body) writes straight back to `sections` + * + their content strings — the exact data the wiki view reads. + */ +export default function NotionDocumentView({ + properties, + sections, + getSectionContent, + setSections, + setSectionContent, + bodyPlaceholder, +}: NotionDocumentViewProps) { + // Compose into one markdown document — once per mount; the parent keys this + // view by entity id (re-mount on switch). + const [initialBody] = useState(() => + sections + .map((s) => `# ${s.label}\n\n${getSectionContent(s.id)}`.trim()) + .join("\n\n"), + ); + + const saveBody = (markdown: string) => { + const { sections: nextSections, contentById } = decomposeBody(markdown, sections); + setSections(nextSections); + for (const [id, content] of Object.entries(contentById)) { + setSectionContent(id, content); + } + }; + + return ( +
+
+ {/* ── Properties ─────────────────────────────────────────────── */} +
+ {properties.map((row) => ( + + {row.onSave ? ( + + ) : null} + + ))} +
+ + {/* ── Body: one Markdown document; # headings = wiki sections ── */} + +
+
+ ); +} + +function PropertyRow({ + label, + children, + readonlyValue, +}: { + label: string; + children?: React.ReactNode; + readonlyValue?: string; +}) { + return ( +
+ {label} +
+ {readonlyValue !== undefined ? ( + {readonlyValue} + ) : ( + children + )} +
+
+ ); +} + +function MarkdownDocumentEditor({ + initialMarkdown, + placeholder, + onSave, +}: { + initialMarkdown: string; + placeholder: string; + onSave: (markdown: string) => void; +}) { + const saveTimer = useRef(null); + const onSaveRef = useRef(onSave); + useEffect(() => { + onSaveRef.current = onSave; + }, [onSave]); + + const editor = useEditor({ + extensions: [ + StarterKit, + Placeholder.configure({ placeholder }), + Markdown.configure({ html: false }), + ], + content: initialMarkdown, + editorProps: { attributes: { class: "ProseMirror focus:outline-none" } }, + onUpdate: ({ editor }) => { + if (saveTimer.current !== null) window.clearTimeout(saveTimer.current); + saveTimer.current = window.setTimeout(() => { + const md = + (editor.storage as MarkdownStorage).markdown?.getMarkdown?.() ?? + editor.getText(); + onSaveRef.current(md); + }, AUTOSAVE_DELAY_MS); + }, + }); + + useEffect(() => { + return () => { + if (saveTimer.current !== null) { + window.clearTimeout(saveTimer.current); + if (editor) { + const md = + (editor.storage as MarkdownStorage).markdown?.getMarkdown?.() ?? + editor.getText(); + onSaveRef.current(md); + } + } + }; + }, [editor]); + + return ( +
+ +
+ ); +} + +/** + * Split a markdown document into wiki sections by top-level `#` headings. + * Section ids are matched to the existing sections by order so wiki references + * (and per-section content keys) are preserved across edits. + */ +function decomposeBody( + markdown: string, + oldSections: WikiSectionData[], +): { sections: WikiSectionData[]; contentById: Record } { + const lines = markdown.split("\n"); + const parsed: Array<{ label: string; content: string[] }> = []; + let current: { label: string; content: string[] } | null = null; + + for (const line of lines) { + const heading = /^#\s+(.+?)\s*$/.exec(line); + if (heading) { + current = { label: heading[1], content: [] }; + parsed.push(current); + } else if (current) { + current.content.push(line); + } + // Text before the first heading has no section and is dropped. + } + + const sections: WikiSectionData[] = parsed.map((p, index) => ({ + id: oldSections[index]?.id ?? `section_${Date.now()}_${index}`, + label: p.label, + })); + + const contentById: Record = {}; + parsed.forEach((p, index) => { + contentById[sections[index].id] = p.content.join("\n").trim(); + }); + + return { sections, contentById }; +} +``` + +Note: `saveBody` here calls `setSections` then per-id `setSectionContent`, instead of the character-specific `attrs.setManyAttrs`. The character wrapper in Step 2 wires these to `attrs`. + +- [ ] **Step 2: Rewrite `CharacterDocumentView.tsx` as a thin wrapper** + +Replace the entire file with: + +```tsx +import { useTranslation } from "react-i18next"; +import NotionDocumentView, { + type DocumentPropertyRow, +} from "@renderer/features/research/components/shared/NotionDocumentView"; +import { useEffectiveCharacterSections } from "./hooks/useEffectiveCharacterSections"; +import type { CharacterWikiAttrs } from "./hooks/useCharacterWikiAttrs"; + +export type { DocumentPropertyRow }; + +type CharacterDocumentViewProps = { + classification: string; + description: string; + onDescriptionSave: (value: string) => void; + properties: DocumentPropertyRow[]; + attrs: CharacterWikiAttrs; +}; + +/** + * Character wrapper around the shared Notion-style document view. Builds the + * header property rows (classification / description / infobox fields) and + * delegates rendering + body editing to NotionDocumentView. + */ +export function CharacterDocumentView({ + classification, + description, + onDescriptionSave, + properties, + attrs, +}: CharacterDocumentViewProps) { + const { t } = useTranslation(); + + const effectiveSections = useEffectiveCharacterSections(attrs.sections); + + const headerRows: DocumentPropertyRow[] = [ + { + label: t("character.classificationLabel"), + readonlyValue: classification, + }, + { + label: t("character.wiki.descriptionLabel", "설명"), + value: description, + placeholder: t("character.uncategorized"), + onSave: onDescriptionSave, + }, + ...properties, + ]; + + return ( + + ); +} +``` + +- [ ] **Step 3: Verify typecheck + build** + +Run: `pnpm typecheck` +Expected: clean. + +Run: `pnpm build` +Expected: exit 0. + +- [ ] **Step 4: Visual check (character document view)** + +Open a character, switch to the **문서** view. Confirm: property rows render, body markdown editor loads existing section content as `# headings`, editing body + blur auto-saves and the change is visible when switching back to the **위키** view (round-trip intact). + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/src/features/research/components/shared/NotionDocumentView.tsx \ + src/renderer/src/features/research/components/wiki/CharacterDocumentView.tsx +git commit -m "$(cat <<'EOF' +refactor(document): extract shared NotionDocumentView; character becomes wrapper + +Moves PropertyRow / MarkdownDocumentEditor / decomposeBody out of +CharacterDocumentView into shared/NotionDocumentView so event/faction can +reuse the same Notion-style surface. No visual change for characters. + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +### Task 3: Migrate `EntityDetailView` wiki mode to the canonical language + +Replace the namuwiki-style header/TOC/add-button with the character view's clean equivalents, and consume the now-shared `WikiContentPanel`. This is the core wiki-view unification. + +**Files:** +- Modify: `src/renderer/src/features/research/components/wiki/EntityDetailView.tsx` + +**Interfaces:** +- Consumes: `WikiContentPanel` (Task 1) with `attrs: WikiContentModel` + `i18nPrefix`. + +- [ ] **Step 1: Add the `WikiContentPanel` import** + +In `src/renderer/src/features/research/components/wiki/EntityDetailView.tsx`, add to the imports near the existing `WikiSection` import (line 10): + +```tsx +import { WikiContentPanel, type WikiContentModel } from "./WikiContentPanel"; +``` + +Remove the now-unused `WikiSection` import (line 10) — `WikiSection` is rendered inside `WikiContentPanel`, no longer directly by this file. (Run `pnpm typecheck` after; if `WikiSection` is still referenced anywhere in this file, keep it. It should not be after Step 3.) + +- [ ] **Step 2: Build the `WikiContentModel` adapter for events/factions** + +Inside the `EntityDetailView` component, after `sections` is computed (after line 100) and before the `return`, add: + +```tsx +const contentModel: WikiContentModel = { + sections, + getSectionContent: (id) => (attributes[id] as string) || "", + setSectionContent: (id, value) => handleAttrUpdate(id, value), + setSections: (next) => handleAttrUpdate("sections", next), +}; +``` + +- [ ] **Step 3: Rewrite the header (replace namuwiki-style block)** + +Replace the header block currently at lines 211–254: + +```tsx +
+
+ handleUpdate("name", val)} + /> +
+ + +
+
+
+ {t(`${prefix}.classificationLabel`, "Classification")} + + {t(`${prefix}.template.basic`, templateFallback)} + + | + handleUpdate("description", val)} + /> +
+
+``` + +with the character-aligned header: + +```tsx +
+
+ handleUpdate("name", val)} + /> +
+ + +
+
+ +
+ {t(`${prefix}.classificationLabel`, "Classification")} + · + {t(`${prefix}.template.basic`, templateFallback)} + · + handleUpdate("description", val)} + /> +
+
+``` + +(Keep the existing two ` diff --git a/src/renderer/src/app/shell/QuitOverlay.tsx b/src/renderer/src/app/shell/QuitOverlay.tsx index f12e87fb..e4e6228d 100644 --- a/src/renderer/src/app/shell/QuitOverlay.tsx +++ b/src/renderer/src/app/shell/QuitOverlay.tsx @@ -20,7 +20,7 @@ export function QuitOverlay({ return (
-
+

{t("bootstrap.quit")}

{quitOverlayMessage}

diff --git a/src/renderer/src/app/shell/useThemeAttributes.ts b/src/renderer/src/app/shell/useThemeAttributes.ts index 8ed32d80..633f2626 100644 --- a/src/renderer/src/app/shell/useThemeAttributes.ts +++ b/src/renderer/src/app/shell/useThemeAttributes.ts @@ -6,34 +6,30 @@ export function useThemeAttributes({ themeAccent, themeContrast, themeTemp, - themeTexture, }: { enableAnimations: boolean; theme: string; themeAccent: string | null; themeContrast: string | null; themeTemp: string | null; - themeTexture: boolean; }) { useLayoutEffect(() => { document.documentElement.setAttribute("data-theme", theme); - if (themeTemp) - document.documentElement.setAttribute("data-temp", themeTemp); if (themeContrast) document.documentElement.setAttribute("data-contrast", themeContrast); if (themeAccent) document.documentElement.setAttribute("data-accent", themeAccent); - document.documentElement.setAttribute("data-texture", String(themeTexture)); + if (themeTemp) + document.documentElement.setAttribute("data-temp", themeTemp); document.documentElement.setAttribute( "data-animations", enableAnimations ? "on" : "off", ); }, [ theme, - themeTemp, themeContrast, themeAccent, - themeTexture, + themeTemp, enableAnimations, ]); } diff --git a/src/renderer/src/components/ui/button.tsx b/src/renderer/src/components/ui/button.tsx index e967d13c..db7539a3 100644 --- a/src/renderer/src/components/ui/button.tsx +++ b/src/renderer/src/components/ui/button.tsx @@ -7,7 +7,7 @@ import { Slot } from "radix-ui" import { cn } from "@renderer/lib/utils" const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "group/button inline-flex shrink-0 items-center justify-center rounded-panel border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { @@ -25,14 +25,14 @@ const buttonVariants = cva( size: { default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-panel has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-panel has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", icon: "size-8", "icon-xs": - "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-panel [&_svg:not([class*='size-'])]:size-3", "icon-sm": - "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", + "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-panel", "icon-lg": "size-9", }, }, diff --git a/src/renderer/src/features/canvas/__fixtures__/mockExplorerData.ts b/src/renderer/src/features/canvas/__fixtures__/mockExplorerData.ts deleted file mode 100644 index 70ab74aa..00000000 --- a/src/renderer/src/features/canvas/__fixtures__/mockExplorerData.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { FileNode } from "../types/canvas.types"; - -export const mockExplorerData: FileNode[] = [ - { id: "folder-interview", name: "면접", type: "folder" }, - { id: "folder-untitled", name: "무제", type: "folder" }, - { id: "folder-idea", name: "아이디어?", type: "folder" }, - { id: "folder-cs", name: "CS", type: "folder" }, - { id: "folder-dev", name: "Dev", type: "folder" }, - { - id: "folder-luie", - name: "Luie", - type: "folder", - children: [ - { - id: "folder-feature", - name: "feature", - type: "folder", - children: [ - { id: "canvas-vis", name: "시각화", type: "canvas" }, - { id: "file-anything", name: "아무거나 적자", type: "file" }, - { id: "file-err", name: "Err", type: "file" }, - { id: "canvas-luie", name: "Luie Canvas", type: "canvas" }, - { id: "canvas-flow", name: "Luie flow", type: "canvas" }, - { id: "file-rag", name: "RAG", type: "file" }, - { id: "file-todo", name: "TODO", type: "file" }, - ], - }, - { id: "folder-luie-todo", name: "Luie TODO", type: "folder" }, - { id: "folder-memory", name: "Memory Engine", type: "folder" }, - { id: "folder-prd", name: "PRD", type: "folder" }, - { id: "folder-ui", name: "UI", type: "folder" }, - ], - }, - { id: "file-24todo", name: "24 TODO", type: "file" }, - { id: "file-intro", name: "30초 자기소개", type: "file" }, - { id: "canvas-main", name: "캔버스", type: "canvas" }, -]; diff --git a/src/renderer/src/features/canvas/components/binder/CanvasNodeInspector.tsx b/src/renderer/src/features/canvas/components/binder/CanvasNodeInspector.tsx index 99b4e3e6..9f4b62fa 100644 --- a/src/renderer/src/features/canvas/components/binder/CanvasNodeInspector.tsx +++ b/src/renderer/src/features/canvas/components/binder/CanvasNodeInspector.tsx @@ -1,31 +1,27 @@ /** * CanvasNodeInspector — BinderBar panel for the selected canvas node. - * + * * 분기 처리: - * - node.entityType === "Character" -> WikiDetailView 이식 - * - node.entityType === "Event" -> EventDetailView 이식 - * - node.entityType === "Chapter" -> 전용 온디맨드 요약 및 등장인물/복선 리스트 연동 + * - node.entityType === "Character" -> CharacterInspectorView + * - node.entityType === "Event" -> EventInspectorView + * - node.entityType === "Chapter" -> ChapterInspectorView + * - 기타 -> GenericEntityInspector */ -import { useEffect, useState, useCallback, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { X, Sparkles, RefreshCw, User, HelpCircle } from "lucide-react"; import { useCanvasViewStore } from "@renderer/features/canvas/stores"; import { useWorldBuildingStore } from "@renderer/features/research/stores/worldBuildingStore"; -import { useProjectStore } from "@renderer/features/project/stores/projectStore"; import { CANVAS_NODE_KIND_COLOUR, ENTITY_TYPE_TO_NODE_KIND, } from "@renderer/features/canvas/types"; import type { CanvasNodeKind } from "@renderer/features/canvas/types"; -import { cn } from "@shared/types/utils"; -import { createLogger } from "@shared/logger"; -// Namu Wiki & Research Detail Views -import WikiDetailView from "@renderer/features/research/components/wiki/WikiDetailView"; -import EventDetailView from "@renderer/features/research/components/event/EventDetailView"; - -const logger = createLogger("CanvasNodeInspector"); +// 분할된 인스펙터 뷰 +import CharacterInspectorView from "./inspectors/CharacterInspectorView"; +import EventInspectorView from "./inspectors/EventInspectorView"; +import ChapterInspectorView from "./inspectors/ChapterInspectorView"; +import GenericEntityView from "./inspectors/GenericEntityView"; interface CanvasNodeInspectorProps { nodeId: string; @@ -35,13 +31,13 @@ export default function CanvasNodeInspector({ nodeId }: CanvasNodeInspectorProps const { t } = useTranslation(); const graphData = useWorldBuildingStore((state) => state.graphData); const clearSelection = useCanvasViewStore((s) => s.clearSelection); - const currentProjectId = useProjectStore((state) => state.currentProject?.id); + const selectNode = useCanvasViewStore((s) => s.selectNode); const node = graphData?.nodes.find((n) => n.id === nodeId) ?? null; if (!node) { return ( -
+
{t("canvas.status.empty")}
); @@ -51,401 +47,45 @@ export default function CanvasNodeInspector({ nodeId }: CanvasNodeInspectorProps // 캐릭터 위키 재활용 if (normalizedType === "character") { - return ( -
-
- -
- -
- ); + return ; } // 사건 타임라인 상세 재활용 if (normalizedType === "event") { - return ( -
-
- -
- -
- ); + return ; } - // 원고(챕터) 노드 전용 디테일 뷰 + // 챕터 전용 인스펙터 if (normalizedType === "chapter") { - return ( -
- {/* Header */} -
-
- - - {t("canvas.node.kind.chapter")} - -
- -
- - {/* Scrollable Body */} -
-
-

- {node.name} -

- {node.description && ( -

- {node.description} -

- )} -
- -
-
- ); + return ; } - // 그 외 일반 엔티티 범용 뷰포트 + // 기타 엔티티 (Faction, Term, WorldEntity) const kind: CanvasNodeKind = - ENTITY_TYPE_TO_NODE_KIND[node.entityType] ?? "world-entity"; - const colour = CANVAS_NODE_KIND_COLOUR[kind]; - - const relations = - graphData?.edges.filter( - (e) => e.sourceId === nodeId || e.targetId === nodeId, - ) ?? []; - - const connectedNodeIds = new Set( - relations - .flatMap((e) => [e.sourceId, e.targetId]) - .filter((id) => id !== nodeId), + ENTITY_TYPE_TO_NODE_KIND[node.entityType as keyof typeof ENTITY_TYPE_TO_NODE_KIND] ?? "world-entity"; + const kindColor = CANVAS_NODE_KIND_COLOUR[kind] ?? CANVAS_NODE_KIND_COLOUR["world-entity"]; + + const connectedNodes = (graphData?.nodes ?? []).filter((n) => + graphData?.edges.some( + (edge) => + (edge.sourceId === nodeId && edge.targetId === n.id) || + (edge.targetId === nodeId && edge.sourceId === n.id), + ), ); - const connectedNodes = - graphData?.nodes.filter((n) => connectedNodeIds.has(n.id)) ?? []; - return ( -
- {/* Header */} -
-
- - - {t(`canvas.node.kind.${kind}` as never)} - - -
-

- {node.name} -

-
- - {/* Body */} -
- {node.description && ( -
-

- {t("canvas.node.description")} -

-

- {node.description} -

-
- )} - -
-

- {t("canvas.node.connections")} ({connectedNodes.length}) -

- {connectedNodes.length === 0 ? ( -

- {t("canvas.status.empty")} -

- ) : ( -
    - {connectedNodes.map((cnNode) => { - const cnKind = - ENTITY_TYPE_TO_NODE_KIND[cnNode.entityType] ?? "world-entity"; - const cnColour = CANVAS_NODE_KIND_COLOUR[cnKind]; - const rel = relations.find( - (e) => - (e.sourceId === nodeId && e.targetId === cnNode.id) || - (e.targetId === nodeId && e.sourceId === cnNode.id), - ); - return ( -
  • - - {cnNode.name} - {rel?.relation && ( - - {rel.relation} - - )} -
  • - ); - })} -
- )} -
-
-
+ const connectedEdges = (graphData?.edges ?? []).filter( + (edge) => edge.sourceId === nodeId || edge.targetId === nodeId, ); -} - -/* ─────────────────────────────────────────── Chapter Node Detail Inner Component */ - -interface ChapterNodeDetailProps { - nodeId: string; - projectId: string; - graphData: { - nodes: Array<{ id: string; name: string; entityType: string; description?: string | null }>; - edges: Array<{ sourceId: string; targetId: string; relation?: string | null }>; - } | null; -} - -function ChapterNodeDetail({ nodeId, projectId, graphData }: ChapterNodeDetailProps) { - const { t } = useTranslation(); - const [summary, setSummary] = useState(null); - const [loading, setLoading] = useState(false); - const [generating, setGenerating] = useState(false); - const timeoutRef = useRef | null>(null); - - // 컴포넌트 언마운트 시 setTimeout 클린업을 통한 메모리 누수 방지 - useEffect(() => { - return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - }; - }, []); - - const loadSummary = useCallback(async () => { - if (typeof window === "undefined" || !window.api || !window.api.memory) { - return; - } - try { - setLoading(true); - const res = await window.api.memory.getChapterSummary(nodeId); - if (res && res.success && res.data) { - setSummary(res.data.summary); - } else { - setSummary(null); - } - } catch (err) { - logger.error("Failed to load chapter summary info", err); - } finally { - setLoading(false); - } - }, [nodeId]); - - useEffect(() => { - let cancelled = false; - queueMicrotask(() => { - if (cancelled) return; - void loadSummary(); - }); - return () => { - cancelled = true; - }; - }, [loadSummary]); - - const handleGenerateSummary = useCallback(async () => { - if (typeof window === "undefined" || !window.api || !window.api.memoryAdmin) { - return; - } - try { - setGenerating(true); - logger.info("Triggering summary generation manually on-demand", { nodeId }); - await window.api.memoryAdmin.rebuildChunks({ - projectId, - sourceType: "chapter", - sourceId: nodeId, - }); - - // 3초 후 요약본 재로드를 시도하는 복원 메커니즘 - timeoutRef.current = setTimeout(async () => { - await loadSummary(); - setGenerating(false); - }, 3000); - } catch (err) { - logger.error("Failed to trigger manual summary build", err); - setGenerating(false); - } - }, [nodeId, projectId, loadSummary]); - - // 챕터와 연결된 등장인물 분석 - const relations = - graphData?.edges.filter( - (e) => e.sourceId === nodeId || e.targetId === nodeId, - ) ?? []; - const connectedNodeIds = new Set( - relations - .flatMap((e) => [e.sourceId, e.targetId]) - .filter((id) => id !== nodeId), - ); - const connectedCharacters = - graphData?.nodes.filter( - (n) => connectedNodeIds.has(n.id) && n.entityType.toLowerCase() === "character", - ) ?? []; - - // 챕터와 연결된 메모/사건들을 미해결 복선으로 수집 - const connectedMemosAndEvents = - graphData?.nodes.filter( - (n) => - connectedNodeIds.has(n.id) && - (n.entityType.toLowerCase() === "memo" || n.entityType.toLowerCase() === "event"), - ) ?? []; return ( -
- {/* 3줄 요약 섹션 */} -
-
- -

- {t("canvas.graph.episode")} -

-
- {loading ? ( -

- {t("canvas.status.loading")} -

- ) : summary ? ( -
- {summary.split("\n").map((line, idx) => ( -

- - {line} -

- ))} -
- ) : ( -
-

- {t("canvas.status.empty")} -

- -
- )} -
- - {/* 등장인물 리스트 */} -
-
- -

- {t("canvas.graph.character")} -

-
- {connectedCharacters.length === 0 ? ( -

- {t("canvas.status.empty")} -

- ) : ( -
- {connectedCharacters.map((char) => ( - - {char.name} - - ))} -
- )} -
- - {/* 미해결 복선/남은 떡밥 */} -
-
- -

- {t("canvas.graph.relations")} -

-
- {connectedMemosAndEvents.length === 0 ? ( -

- {t("canvas.status.empty")} -

- ) : ( -
    - {connectedMemosAndEvents.map((item) => ( -
  • - - [{t(`canvas.node.kind.${item.entityType.toLowerCase()}` as never)}] - {" "} - {item.name} - {item.description && ( - - {item.description} - - )} -
  • - ))} -
- )} -
-
+ ); } diff --git a/src/renderer/src/features/canvas/components/binder/GraphNodeInspector.tsx b/src/renderer/src/features/canvas/components/binder/GraphNodeInspector.tsx index dbc82591..da114406 100644 --- a/src/renderer/src/features/canvas/components/binder/GraphNodeInspector.tsx +++ b/src/renderer/src/features/canvas/components/binder/GraphNodeInspector.tsx @@ -1,8 +1,9 @@ import { memo, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Info, BookOpen, Users, Quote, ArrowRight } from "lucide-react"; -import { MOCK_GRAPH_NODES } from "../../constants/graphMockData"; import { useGraphStore } from "../../stores/graph/graphStore"; +import { useWorldBuildingStore } from "@renderer/features/research/stores/worldBuildingStore"; +import { buildGraphSurfaceData } from "../../utils/graphSurfaceData"; interface GraphNodeInspectorProps { nodeId: string; @@ -11,14 +12,15 @@ interface GraphNodeInspectorProps { function GraphNodeInspector({ nodeId }: GraphNodeInspectorProps) { const { t } = useTranslation(); const setFocusId = useGraphStore((state) => state.setFocusId); + const graphData = useWorldBuildingStore((state) => state.graphData); const activeNode = useMemo(() => { - return MOCK_GRAPH_NODES.find((node) => node.id === nodeId) ?? null; - }, [nodeId]); + return buildGraphSurfaceData(graphData).sourceNodes.find((node) => node.id === nodeId) ?? null; + }, [graphData, nodeId]); if (!activeNode) { return ( -
+

{t("canvas.graph.details.notFound", "설정 정보가 존재하지 않습니다.")}

@@ -37,7 +39,7 @@ function GraphNodeInspector({ nodeId }: GraphNodeInspectorProps) {
@@ -56,7 +58,7 @@ function GraphNodeInspector({ nodeId }: GraphNodeInspectorProps) { )}
{activeNode.data.description && ( -

+

{activeNode.data.description}

)} @@ -75,15 +77,15 @@ function GraphNodeInspector({ nodeId }: GraphNodeInspectorProps) { {activeNode.data.relationships.map((rel, index) => (
- {activeNode.data.label} + {activeNode.data.label}
{rel.type}
- {rel.targetName} + {rel.targetName}
{rel.details && ( @@ -109,7 +111,7 @@ function GraphNodeInspector({ nodeId }: GraphNodeInspectorProps) { {activeNode.data.relatedChapters.map((chapter, index) => ( {chapter} @@ -129,12 +131,11 @@ function GraphNodeInspector({ nodeId }: GraphNodeInspectorProps) {
{activeNode.data.sourceTexts.map((text, index) => ( -
-

"{text}"

diff --git a/src/renderer/src/features/canvas/components/binder/inspectors/ChapterInspectorView.tsx b/src/renderer/src/features/canvas/components/binder/inspectors/ChapterInspectorView.tsx new file mode 100644 index 00000000..6b4bfe93 --- /dev/null +++ b/src/renderer/src/features/canvas/components/binder/inspectors/ChapterInspectorView.tsx @@ -0,0 +1,162 @@ +/** + * ChapterInspectorView — 챕터 노드 인스펙터 뷰 + * + * 챕터의 AI 요약, 등장인물, 복선/떡밥 정보를 표시합니다. + */ + +import { useEffect, useState, useCallback, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { X } from "lucide-react"; +import { useCanvasViewStore } from "@renderer/features/canvas/stores"; +import { useWorldBuildingStore } from "@renderer/features/research/stores/worldBuildingStore"; +import { useProjectStore } from "@renderer/features/project/stores/projectStore"; +import { createLogger } from "@shared/logger"; +import { ChapterSummarySection } from "./ChapterSummarySection"; +import { ConnectedCharactersSection } from "./ConnectedCharactersSection"; +import { ConnectedMemosSection } from "./ConnectedMemosSection"; + +const logger = createLogger("ChapterInspectorView"); + +interface ChapterInspectorViewProps { + nodeId: string; + nodeName: string; +} + +export default function ChapterInspectorView({ nodeId, nodeName }: ChapterInspectorViewProps) { + const { t } = useTranslation(); + const clearSelection = useCanvasViewStore((s) => s.clearSelection); + const currentProjectId = useProjectStore((state) => state.currentProject?.id); + const graphData = useWorldBuildingStore((state) => state.graphData); + const timeoutRef = useRef | null>(null); + + // 챕터 관련 데이터 추출 + const relations = graphData?.edges.filter( + (e) => e.sourceId === nodeId || e.targetId === nodeId, + ) ?? []; + const connectedNodeIds = new Set( + relations + .flatMap((e) => [e.sourceId, e.targetId]) + .filter((id) => id !== nodeId), + ); + const connectedCharacters = graphData?.nodes.filter( + (n) => connectedNodeIds.has(n.id) && n.entityType.toLowerCase() === "character", + ) ?? []; + const connectedMemosAndEvents = graphData?.nodes.filter( + (n) => + connectedNodeIds.has(n.id) && + (n.entityType.toLowerCase() === "memo" || n.entityType.toLowerCase() === "event"), + ) ?? []; + + // AI 요약 상태 + const [summary, setSummary] = useState(null); + const [generating, setGenerating] = useState(false); + const [loading, setLoading] = useState(false); + + // 컴포넌트 언마운트 시 setTimeout 클린업 + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + // 저장된 요약 로드 + const loadSummary = useCallback(async () => { + if (typeof window === "undefined" || !window.api || !window.api.memory) { + return; + } + try { + setLoading(true); + const res = await window.api.memory.getChapterSummary(nodeId); + if (res && res.success && res.data) { + setSummary(res.data.summary); + } else { + setSummary(null); + } + } catch (err) { + logger.error("Failed to load chapter summary info", err); + } finally { + setLoading(false); + } + }, [nodeId]); + + useEffect(() => { + let cancelled = false; + queueMicrotask(() => { + if (cancelled) return; + void loadSummary(); + }); + return () => { + cancelled = true; + }; + }, [loadSummary]); + + const handleGenerateSummary = useCallback(async () => { + if (typeof window === "undefined" || !window.api || !window.api.memoryAdmin) { + return; + } + try { + setGenerating(true); + logger.info("Triggering summary generation manually on-demand", { nodeId }); + await window.api.memoryAdmin.rebuildChunks({ + projectId: currentProjectId ?? "", + sourceType: "chapter", + sourceId: nodeId, + }); + + // 3초 후 요약본 재로드 + timeoutRef.current = setTimeout(async () => { + await loadSummary(); + setGenerating(false); + }, 3000); + } catch (err) { + logger.error("Failed to trigger manual summary build", err); + setGenerating(false); + } + }, [nodeId, currentProjectId, loadSummary]); + + return ( +
+
+ +
+ +
+ {/* 헤더 */} +
+
+ CH +
+
+

{nodeName}

+ {t("canvas.node.kind.chapter")} +
+
+ + {/* AI 요약 섹션 */} + + + {/* 등장인물 리스트 */} + + + {/* 미해결 복선/남은 떡밥 */} + +
+
+ ); +} diff --git a/src/renderer/src/features/canvas/components/binder/inspectors/ChapterSummarySection.tsx b/src/renderer/src/features/canvas/components/binder/inspectors/ChapterSummarySection.tsx new file mode 100644 index 00000000..03f7ddcc --- /dev/null +++ b/src/renderer/src/features/canvas/components/binder/inspectors/ChapterSummarySection.tsx @@ -0,0 +1,65 @@ +/** + * ChapterSummarySection — 챕터 AI 요약 섹션 + */ + +import type { TFunction } from "i18next"; +import { Sparkles, RefreshCw } from "lucide-react"; +import { cn } from "@shared/types/utils"; + +interface ChapterSummarySectionProps { + loading: boolean; + summary: string | null; + generating: boolean; + onGenerate: () => void; + t: TFunction; +} + +export function ChapterSummarySection({ + loading, + summary, + generating, + onGenerate, + t, +}: ChapterSummarySectionProps) { + return ( +
+
+ +

+ {t("canvas.graph.episode")} +

+
+ {loading ? ( +

+ {t("canvas.status.loading")} +

+ ) : summary ? ( +
+ {summary.split("\n").map((line, idx) => ( +

+ + {line} +

+ ))} +
+ ) : ( +
+

+ {t("canvas.status.empty")} +

+ +
+ )} +
+ ); +} diff --git a/src/renderer/src/features/canvas/components/binder/inspectors/CharacterInspectorView.tsx b/src/renderer/src/features/canvas/components/binder/inspectors/CharacterInspectorView.tsx new file mode 100644 index 00000000..c05f83e1 --- /dev/null +++ b/src/renderer/src/features/canvas/components/binder/inspectors/CharacterInspectorView.tsx @@ -0,0 +1,36 @@ +/** + * CharacterInspectorView — 캐릭터 노드 인스펙터 뷰 + * + * WikiDetailView를 재활용하여 캐릭터 상세 정보를 표시합니다. + */ + +import { useTranslation } from "react-i18next"; +import { X } from "lucide-react"; +import { useCanvasViewStore } from "@renderer/features/canvas/stores"; +import WikiDetailView from "@renderer/features/research/components/wiki/WikiDetailView"; + +interface CharacterInspectorViewProps { + nodeId: string; +} + +export default function CharacterInspectorView({ nodeId }: CharacterInspectorViewProps) { + const { t } = useTranslation(); + const clearSelection = useCanvasViewStore((s) => s.clearSelection); + + return ( +
+
+ +
+ +
+ ); +} diff --git a/src/renderer/src/features/canvas/components/binder/inspectors/ConnectedCharactersSection.tsx b/src/renderer/src/features/canvas/components/binder/inspectors/ConnectedCharactersSection.tsx new file mode 100644 index 00000000..6b947892 --- /dev/null +++ b/src/renderer/src/features/canvas/components/binder/inspectors/ConnectedCharactersSection.tsx @@ -0,0 +1,44 @@ +/** + * ConnectedCharactersSection — 연결된 등장인물 섹션 + */ + +import type { TFunction } from "i18next"; +import { User } from "lucide-react"; +import type { WorldGraphNode } from "@shared/types"; + +interface ConnectedCharactersSectionProps { + characters: WorldGraphNode[]; + t: TFunction; +} + +export function ConnectedCharactersSection({ + characters, + t, +}: ConnectedCharactersSectionProps) { + return ( +
+
+ +

+ {t("canvas.graph.character")} +

+
+ {characters.length === 0 ? ( +

+ {t("canvas.status.empty")} +

+ ) : ( +
+ {characters.map((char) => ( + + {char.name} + + ))} +
+ )} +
+ ); +} diff --git a/src/renderer/src/features/canvas/components/binder/inspectors/ConnectedMemosSection.tsx b/src/renderer/src/features/canvas/components/binder/inspectors/ConnectedMemosSection.tsx new file mode 100644 index 00000000..586a64aa --- /dev/null +++ b/src/renderer/src/features/canvas/components/binder/inspectors/ConnectedMemosSection.tsx @@ -0,0 +1,49 @@ +/** + * ConnectedMemosSection — 연결된 메모/이벤트 섹션 + */ + +import type { TFunction } from "i18next"; +import { HelpCircle } from "lucide-react"; +import type { WorldGraphNode } from "@shared/types"; + +interface ConnectedMemosSectionProps { + items: WorldGraphNode[]; + t: TFunction; +} + +export function ConnectedMemosSection({ + items, + t, +}: ConnectedMemosSectionProps) { + return ( +
+
+ +

+ {t("canvas.graph.relations")} +

+
+ {items.length === 0 ? ( +

+ {t("canvas.status.empty")} +

+ ) : ( +
    + {items.map((item) => ( +
  • + + [{t(`canvas.node.kind.${item.entityType.toLowerCase()}` as never)}] + {" "} + {item.name} + {item.description && ( + + {item.description} + + )} +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/renderer/src/features/canvas/components/binder/inspectors/EventInspectorView.tsx b/src/renderer/src/features/canvas/components/binder/inspectors/EventInspectorView.tsx new file mode 100644 index 00000000..856e0772 --- /dev/null +++ b/src/renderer/src/features/canvas/components/binder/inspectors/EventInspectorView.tsx @@ -0,0 +1,36 @@ +/** + * EventInspectorView — 이벤트 노드 인스펙터 뷰 + * + * EventDetailView를 재활용하여 이벤트 상세 정보를 표시합니다. + */ + +import { useTranslation } from "react-i18next"; +import { X } from "lucide-react"; +import { useCanvasViewStore } from "@renderer/features/canvas/stores"; +import EventDetailView from "@renderer/features/research/components/event/EventDetailView"; + +interface EventInspectorViewProps { + nodeId: string; +} + +export default function EventInspectorView({ nodeId }: EventInspectorViewProps) { + const { t } = useTranslation(); + const clearSelection = useCanvasViewStore((s) => s.clearSelection); + + return ( +
+
+ +
+ +
+ ); +} diff --git a/src/renderer/src/features/canvas/components/binder/inspectors/GenericEntityView.tsx b/src/renderer/src/features/canvas/components/binder/inspectors/GenericEntityView.tsx new file mode 100644 index 00000000..cd855ab6 --- /dev/null +++ b/src/renderer/src/features/canvas/components/binder/inspectors/GenericEntityView.tsx @@ -0,0 +1,143 @@ +/** + * GenericEntityView — 일반 엔티티(Faction, Term, WorldEntity) 인스펙터 뷰 + */ + +import { useTranslation } from "react-i18next"; +import { X } from "lucide-react"; +import type { CanvasNodeKind } from "@renderer/features/canvas/types"; +import { + CANVAS_NODE_KIND_COLOUR, + ENTITY_TYPE_TO_NODE_KIND, +} from "@renderer/features/canvas/types"; +import type { WorldGraphNode, EntityRelation } from "@shared/types"; + +interface GenericEntityViewProps { + node: WorldGraphNode; + kind: CanvasNodeKind; + kindColor: string; + connectedNodes: WorldGraphNode[]; + connectedEdges: EntityRelation[]; + onSelectNode: (nodeId: string) => void; + onClearSelection: () => void; +} + +export default function GenericEntityView({ + node, + kind, + kindColor, + connectedNodes, + connectedEdges, + onSelectNode, + onClearSelection, +}: GenericEntityViewProps) { + const { t } = useTranslation(); + + return ( +
+ {/* 헤더 */} +
+
+
+
+ + {kind.charAt(0).toUpperCase()} + +
+
+

{node.name}

+ + {t(`canvas.node.kind.${kind}`)} + +
+
+ +
+
+ +
+ {/* 설명 */} + {node.description && ( +
+

+ {t("canvas.node.description")} +

+

+ {node.description} +

+
+ )} + + {/* 연결 통계 */} +
+

+ {t("canvas.node.connections")} +

+
+
+
{connectedNodes.length}
+
{t("canvas.node.nodes")}
+
+
+
{connectedEdges.length}
+
{t("canvas.node.edges")}
+
+
+
+ + {/* 연결된 노드 리스트 */} + {connectedNodes.length > 0 && ( +
+

+ {t("canvas.node.connectedNodes")} +

+
+ {connectedNodes.map((connectedNode) => { + const connectedKind: CanvasNodeKind = + ENTITY_TYPE_TO_NODE_KIND[connectedNode.entityType as keyof typeof ENTITY_TYPE_TO_NODE_KIND] ?? "world-entity"; + const connectedColor = + CANVAS_NODE_KIND_COLOUR[connectedKind] ?? CANVAS_NODE_KIND_COLOUR["world-entity"]; + + return ( + + ); + })} +
+
+ )} +
+
+ ); +} diff --git a/src/renderer/src/features/canvas/components/graph/GraphSurface.tsx b/src/renderer/src/features/canvas/components/graph/GraphSurface.tsx index 6fbcfcbb..94b52aae 100644 --- a/src/renderer/src/features/canvas/components/graph/GraphSurface.tsx +++ b/src/renderer/src/features/canvas/components/graph/GraphSurface.tsx @@ -3,27 +3,22 @@ import ReactFlow, { Background, BackgroundVariant, PanOnScrollMode, - MarkerType, - type Edge, type Node, useNodesState, - useEdgesState + useEdgesState, + useReactFlow } from "reactflow"; import { useTranslation } from "react-i18next"; import { HelpCircle } from "lucide-react"; import PensiveNode from "./PensiveNode"; -import type { GraphNodeData, GraphNodeType } from "../../types/graph"; +import type { GraphNodeData } from "../../types/graph"; import { useGraphStore } from "../../stores/graph/graphStore"; import { useUIStore } from "@renderer/features/workspace/stores/uiStore"; import { useWorldBuildingStore } from "@renderer/features/research/stores/worldBuildingStore"; import { calculateForceLayout } from "../../utils/graphLayout"; -import { GRAPH_CONSTELLATION_EDGE_DEFAULTS } from "../../constants/edge"; +import { buildGraphSurfaceData } from "../../utils/graphSurfaceData"; import { CANVAS_ZOOM_MAX, CANVAS_ZOOM_MIN } from "@renderer/shared/constants/canvasSizing"; -import type { WorldGraphData } from "@shared/types"; import { - EDGE_FALLBACK_OPACITY, - EDGE_FALLBACK_STROKE_WIDTH, - EDGE_FOCUS_OPACITY, FIT_VIEW_OPTIONS, GraphHoverCard, GraphLegendModal, @@ -32,71 +27,21 @@ import { LAYOUT_ITERATIONS_CHARACTER, LAYOUT_ITERATIONS_EVENT, PRO_OPTIONS, + useGraphDataFiltering, + useFocusSync, } from "./graphSurfaceParts"; const nodeTypes = { pensive: PensiveNode, }; -const toGraphNodeType = (entityType: string): GraphNodeType => { - if (entityType === "Character") return "character"; - if (entityType === "Faction") return "faction"; - if (entityType === "Event" || entityType === "Scene") return "event"; - if (entityType === "Chapter") return "chapter"; - return "world-entity"; -}; - -const buildGraphSurfaceData = ( - graphData: WorldGraphData | null, -): { sourceNodes: Node[]; sourceEdges: Edge[] } => { - if (!graphData) return { sourceNodes: [], sourceEdges: [] }; - - const nodeNameById = new Map(graphData.nodes.map((node) => [node.id, node.name])); - const relationshipsByNodeId = new Map>(); - for (const edge of graphData.edges) { - const sourceRelationships = relationshipsByNodeId.get(edge.sourceId) ?? []; - sourceRelationships.push({ - targetName: nodeNameById.get(edge.targetId) ?? edge.targetId, - type: edge.relation, - details: edge.relation, - }); - relationshipsByNodeId.set(edge.sourceId, sourceRelationships); - } - - return { - sourceNodes: graphData.nodes.map((node): Node => ({ - id: node.id, - type: "pensive", - position: { - x: Number.isFinite(node.positionX) ? node.positionX : 0, - y: Number.isFinite(node.positionY) ? node.positionY : 0, - }, - data: { - label: node.name, - type: toGraphNodeType(node.entityType), - description: node.description ?? "", - relatedChapters: [], - relationships: relationshipsByNodeId.get(node.id) ?? [], - sourceTexts: [], - }, - })), - sourceEdges: graphData.edges.map((edge): Edge => ({ - id: edge.id, - source: edge.sourceId, - target: edge.targetId, - data: { - label: edge.relation, - strength: 1, - }, - })), - }; -}; - export default function GraphSurface() { const { t } = useTranslation(); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [isGuideModalOpen, setIsGuideModalOpen] = useState(false); + const { fitView } = useReactFlow(); + const hasInitialFitView = useRef(false); const nodesRef = useRef(nodes); useEffect(() => { @@ -118,6 +63,8 @@ export default function GraphSurface() { [graphData], ); + const isEmpty = sourceNodes.length === 0; + // hoverId에 대응하는 노드 데이터를 실시간 추적하여 호버 플로팅 카드에 공급 const hoverNode = useMemo(() => { if (!hoverId) return null; @@ -125,115 +72,13 @@ export default function GraphSurface() { }, [hoverId, nodes]); // 2. 모드 및 필터 조건에 부합하는 동적 그래프 데이터 파이프라인 (Constellation Monotone Rule) - const { filteredNodes, filteredEdges } = useMemo(() => { - // A. 에지 필터링 및 스타일 빌드 - const computedEdges = sourceEdges.map((edge) => { - const strength = edge.data?.strength ?? 1; - const isCharacterMode = activeMode === "character"; - - const cfg = isCharacterMode - ? GRAPH_CONSTELLATION_EDGE_DEFAULTS.character - : GRAPH_CONSTELLATION_EDGE_DEFAULTS.event; - - const edgeStyle: React.CSSProperties = { - stroke: cfg.stroke, - strokeWidth: strength * cfg.widthMultiplier, - opacity: cfg.opacityBase + strength * cfg.opacityMultiplier, - }; - - if ("dasharray" in cfg) { - edgeStyle.strokeDasharray = cfg.dasharray; - } - - // 엣지 라벨 스타일 정의 (다크 럭셔리 & 피그마 감성) - const labelStyle: React.CSSProperties = { - fill: "var(--text-secondary)", // 테마 변수로 교체 - fontSize: 9, - fontWeight: 700, - fontFamily: "var(--font-sans, Inter, system-ui, sans-serif)", - letterSpacing: "-0.01em", - }; - - const labelBgStyle: React.CSSProperties = { - fill: "var(--bg-panel)", // 하드코딩 블랙 탈피, 테마 변수 적용 - fillOpacity: 0.95, - stroke: "var(--border-default)", // 테마 테두리 - strokeWidth: 1.2, - rx: 6, // 둥근 라운딩 처리 - ry: 6, - }; - - return { - ...edge, - type: "straight", // 직선 에지로 꼬임 0% 통제 - label: edge.data?.label, - labelStyle, - labelBgStyle, - labelBgPadding: [8, 4] as [number, number], - animated: !isCharacterMode && strength >= 2, - markerEnd: isCharacterMode ? undefined : { - type: MarkerType.ArrowClosed, - width: cfg.markerSize, - height: cfg.markerSize, - color: "currentColor", - }, - style: edgeStyle, - }; - }); - - // B. 노드 크기 및 별자리 발광 속성 동적 연산 - const computedNodes = sourceNodes.map((node): Node => { - const degree = computedEdges.filter( - (edge) => edge.source === node.id || edge.target === node.id, - ).length; - const starGrade: "prime" | "major" | "minor" = - degree >= 3 ? "prime" : degree >= 1 ? "major" : "minor"; - - // 특정 캐릭터/사건 빠른 필터 포커싱 시, 대상 노드가 아닌 것들은 감쇠 처리 - let filterFocusedOpacity = 1.0; - if (selectedFocusNode !== "all") { - if (node.id !== selectedFocusNode) { - // 직접적으로 에지 연결되지 않은 노드는 거의 투명화 - const isConnected = computedEdges.some( - (e) => (e.source === selectedFocusNode && e.target === node.id) || - (e.target === selectedFocusNode && e.source === node.id) || - node.id === selectedFocusNode - ); - filterFocusedOpacity = isConnected ? 0.95 : 0.15; - } - } - - // 캔버스 내 직접 클릭 포커스 격리 (Focus Isolation): 비관련 노드는 0% 투명화 소멸 - let canvasFocusedOpacity = 1.0; - let isInteractivePointerEvents = true; - if (focusId) { - if (node.id !== focusId) { - const isNeighbor = computedEdges.some( - (e) => (e.source === focusId && e.target === node.id) || - (e.target === focusId && e.source === node.id) - ); - canvasFocusedOpacity = isNeighbor ? 0.95 : 0.0; - isInteractivePointerEvents = isNeighbor; - } - } - - return { - ...node, - data: { - ...node.data, - starGrade, - opacity: filterFocusedOpacity * canvasFocusedOpacity, - isInteractive: isInteractivePointerEvents, - isFocused: false, - }, - }; - }); - - return { - filteredNodes: computedNodes, - filteredEdges: computedEdges, - }; - }, [activeMode, selectedFocusNode, focusId, sourceNodes, sourceEdges]); + const { filteredNodes, filteredEdges } = useGraphDataFiltering({ + sourceNodes, + sourceEdges, + activeMode, + selectedFocusNode, + focusId, + }); // 3. 필터 변경 또는 마운트 시 Force Layout 기동 (모드별 중심점 및 물리력 분기 대응) useEffect(() => { @@ -260,93 +105,20 @@ export default function GraphSurface() { }, [filteredNodes, filteredEdges, setNodes, setEdges]); // focusId 상태가 전역으로 변동될 때 노드 및 에지의 focus/강조 상태를 동기화 - useEffect(() => { - // 1. 노드 포커스 갱신 - setNodes((prevNodes) => - prevNodes.map((node) => ({ - ...node, - data: { - ...node.data, - isFocused: node.id === focusId, - }, - })) - ); - - // 2. 에지 포커스 및 네온 라이팅 효과 동기화 - setEdges((prevEdges) => - prevEdges.map((edge) => { - if (!focusId) { - // 포커스가 해제된 경우: 원래 스타일 복원 - return { - ...edge, - animated: edge.data?.animatedBackup ?? edge.animated, - style: { - ...edge.style, - opacity: edge.data?.opacityBackup ?? edge.style?.opacity ?? EDGE_FALLBACK_OPACITY, - strokeWidth: edge.data?.strokeWidthBackup ?? edge.style?.strokeWidth ?? EDGE_FALLBACK_STROKE_WIDTH, - stroke: edge.data?.strokeBackup ?? edge.style?.stroke, - }, - labelStyle: { - ...edge.labelStyle, - opacity: 1.0, - }, - labelBgStyle: { - ...edge.labelBgStyle, - opacity: 1.0, - stroke: edge.data?.labelBgStrokeBackup ?? edge.labelBgStyle?.stroke, - } - }; - } - - // 특정 노드가 포커스된 경우 - const isRelated = edge.source === focusId || edge.target === focusId; - - // 백업 상태 저장 (최초 1회) - const opacityBackup = edge.data?.opacityBackup ?? edge.style?.opacity ?? EDGE_FALLBACK_OPACITY; - const strokeWidthBackup = edge.data?.strokeWidthBackup ?? edge.style?.strokeWidth ?? EDGE_FALLBACK_STROKE_WIDTH; - const strokeBackup = edge.data?.strokeBackup ?? edge.style?.stroke ?? "currentColor"; - const animatedBackup = edge.data?.animatedBackup ?? edge.animated ?? false; - const labelBgStrokeBackup = edge.data?.labelBgStrokeBackup ?? edge.labelBgStyle?.stroke; - - const isCharacterMode = activeMode === "character"; - const relationColor = isCharacterMode ? "rgba(165, 180, 252, 0.95)" : "rgba(248, 113, 113, 0.95)"; + useFocusSync({ focusId, setNodes, setEdges }); - // strokeWidthBackup이 숫자형인지 강제 안전 변환 및 NaN 방지 고도화 - const baseWidth = typeof strokeWidthBackup === "number" ? strokeWidthBackup : (Number(strokeWidthBackup) || EDGE_FALLBACK_STROKE_WIDTH); - - return { - ...edge, - data: { - ...edge.data, - opacityBackup, - strokeWidthBackup, - strokeBackup, - animatedBackup, - labelBgStrokeBackup, - }, - // 관련 에지는 반드시 애니메이션 활성화 (에너지 흐름 선사) - animated: isRelated ? true : false, - style: { - ...edge.style, - // 관련 에지는 선명하게, 관련 없는 에지는 시야에서 전면 투명 소거 - opacity: isRelated ? EDGE_FOCUS_OPACITY : 0, - strokeWidth: isRelated ? (baseWidth + 1.2) : baseWidth, - stroke: isRelated ? relationColor : strokeBackup, - pointerEvents: isRelated ? "auto" : "none", // 비관련 에지 이벤트 완전 차단 - }, - labelStyle: { - ...edge.labelStyle, - opacity: isRelated ? 1.0 : 0, - }, - labelBgStyle: { - ...edge.labelBgStyle, - opacity: isRelated ? 1.0 : 0, - stroke: isRelated ? relationColor : labelBgStrokeBackup, - } - }; - }) - ); - }, [focusId, activeMode, setNodes, setEdges, filteredEdges]); + // 초기 로딩 시에만 fitView 적용 (노드 변경 시마다 리셋 방지) + useEffect(() => { + if (filteredNodes.length > 0 && !hasInitialFitView.current) { + hasInitialFitView.current = true; + // 약간의 지연을 주어 노드 렌더링 완료 후 fitView 호출 + const timeoutId = setTimeout(() => { + fitView({ padding: 0.2, duration: 200 }); + }, 100); + return () => clearTimeout(timeoutId); + } + return undefined; + }, [filteredNodes, fitView]); const onNodeClick = useCallback( (_event: React.MouseEvent, node: Node) => { @@ -370,8 +142,31 @@ export default function GraphSurface() { [setNodes] ); + if (isEmpty) { + return ( +
+
+
+ +
+
+

+ {t("canvas.graph.empty.title", "그래프 데이터가 없습니다")} +

+

+ {t( + "canvas.graph.empty.description", + "캐릭터, 사건, 단체 등을 추가하면 관계 그래프가 생성됩니다." + )} +

+
+
+
+ ); + } + return ( -
+
{/* React Flow Canvas */} setIsGuideModalOpen(true)} - className="h-9 w-9 rounded-full bg-panel/90 hover:bg-panel border border-border/40 hover:border-border/80 flex items-center justify-center text-muted hover:text-fg shadow-xl backdrop-blur-md transition-all cursor-pointer" + className="h-9 w-9 rounded-full bg-panel hover:bg-panel border border-border/40 hover:border-border/80 flex items-center justify-center text-muted hover:text-fg shadow-panel transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50" title={t("canvas.graph.legend.open", "그래프 범례 보기")} + aria-label={t("canvas.graph.legend.open", "그래프 범례 보기")} > diff --git a/src/renderer/src/features/canvas/components/graph/GraphWorkspace.tsx b/src/renderer/src/features/canvas/components/graph/GraphWorkspace.tsx index 5d77cdb8..0e1aab58 100644 --- a/src/renderer/src/features/canvas/components/graph/GraphWorkspace.tsx +++ b/src/renderer/src/features/canvas/components/graph/GraphWorkspace.tsx @@ -4,7 +4,7 @@ import GraphSurface from "./GraphSurface"; export default function GraphWorkspace() { return ( -
+
diff --git a/src/renderer/src/features/canvas/components/graph/PensiveNode.tsx b/src/renderer/src/features/canvas/components/graph/PensiveNode.tsx index b9c222bb..88f16ac3 100644 --- a/src/renderer/src/features/canvas/components/graph/PensiveNode.tsx +++ b/src/renderer/src/features/canvas/components/graph/PensiveNode.tsx @@ -1,60 +1,46 @@ import { memo } from "react"; -import { Handle, Position, type NodeProps, useViewport } from "reactflow"; +import { Handle, Position, type NodeProps } from "reactflow"; import { useTranslation } from "react-i18next"; import { cn } from "@shared/types/utils"; import type { GraphNodeData } from "../../types/graph"; import { useGraphStore } from "../../stores/graph/graphStore"; -// 등급별 가변 크기 계산 (Figma 포스 디렉티드 스펙) +// 등급별 가변 크기 계산 (더 큰 크기로 상향 조정) const SIZE_CLASSES = { - prime: "h-8 w-8", // 중심 거성 (32px) - major: "h-4.5 w-4.5", // 중간 조연/사건 (18px) - minor: "h-2.5 w-2.5", // 주변 노드 (10px) + prime: "h-16 w-16", // 중심 거성 (64px) - 2배 증가 + major: "h-12 w-12", // 중간 조연/사건 (48px) - 2.6배 증가 + minor: "h-9 w-9", // 주변 노드 (36px) - 3.6배 증가 } as const; -// 줌 역스케일링 배율의 안전 한계치 상수화 -const INVERSE_SCALE_MIN = 0.75 as const; -const INVERSE_SCALE_MAX = 4.5 as const; - function PensiveNode({ id, data, selected }: NodeProps) { const { t } = useTranslation(); - const { zoom } = useViewport(); const setHoverId = useGraphStore((state) => state.setHoverId); const isChapter = data.type === "chapter"; const isFocused = selected || data.isFocused; - // 줌아웃에 따른 역스케일링 배율 산출 (상수 기반의 안전 경계 연산) - const inverseScale = Math.min(Math.max(1 / zoom, INVERSE_SCALE_MIN), INVERSE_SCALE_MAX); - - // 등급별 기하 형태 분기 (인물은 원형, 사건은 다이아몬드, 단체는 사각형, 챕터는 초소형 큐브) + // 등급별 기하 형태 분기 (인물은 원형, 사건은 다이아몬드, 단체는 사각형, 챕터는 소형 큐브) const shapeClass = isChapter - ? "rounded-sm" + ? "rounded-lg" : data.type === "character" ? "rounded-full" : data.type === "event" - ? "rotate-45 rounded-md" + ? "rotate-45 rounded-lg" : "rounded-xl"; - // 포커스 발광 섀도우를 인물(블루-퍼플) vs 사건(네온 레드) 테마 색으로 역동적 아우라 연출 - const isEvent = data.type === "event"; - const glowShadow = isEvent - ? "shadow-[0_0_22px_rgba(248,113,113,0.7),0_0_10px_rgba(248,113,113,0.4)] ring-red-400/40" - : "shadow-[0_0_22px_rgba(165,180,252,0.7),0_0_10px_rgba(165,180,252,0.4)] ring-indigo-400/40"; - - // 등급별 링 및 발광 섀도우 효과 (웹소설 수사 단서판 & 성운 광배 융합 이펙트 - 테마 변수 기반) + // 포커스 강조: 토큰 기반 링으로 톤다운 const starGradeClass = isChapter ? isFocused - ? "bg-foreground ring-4 ring-foreground/30 shadow-[0_0_18px_var(--accent-bg)] scale-110" - : "bg-muted-foreground/60 border border-border/40 shadow-[0_0_8px_var(--border-default)] hover:scale-125 hover:bg-foreground" + ? "bg-fg ring-4 ring-accent/60 shadow-lg" + : "bg-muted/70 border-2 border-border/50 hover:bg-fg hover:shadow-md" : data.starGrade === "prime" - ? `bg-foreground ring-4 ${glowShadow}` + ? "bg-fg ring-4 ring-accent/50 shadow-lg" : data.starGrade === "major" ? isFocused - ? `bg-foreground ring-4 ${glowShadow} scale-110` - : "bg-muted-foreground/80 border border-border/50 shadow-[0_0_10px_var(--border-default)] hover:scale-125 hover:bg-foreground" + ? "bg-fg ring-4 ring-accent/50 shadow-lg" + : "bg-muted/85 border-2 border-border/60 hover:bg-fg hover:shadow-md" : isFocused - ? `bg-foreground ring-4 ${glowShadow} scale-110` - : "bg-muted/40 border border-border/30 shadow-[0_0_6px_var(--border-default)] hover:scale-125 hover:bg-foreground"; + ? "bg-fg ring-4 ring-accent/50 shadow-lg" + : "bg-muted/50 border-2 border-border/40 hover:bg-fg hover:shadow-md"; return (
) { onMouseEnter={() => setHoverId(id)} onMouseLeave={() => setHoverId(null)} className={cn( - "group relative flex items-center justify-center transition-all duration-300 cursor-pointer", + "group relative flex items-center justify-center transition-[background-color,border-color,box-shadow,opacity] duration-200 cursor-pointer", shapeClass, SIZE_CLASSES[data.starGrade ?? "minor"], starGradeClass, @@ -71,41 +57,18 @@ function PensiveNode({ id, data, selected }: NodeProps) { > - {/* Label & Character Details Hover Card (역스케일링 적용 반응형 모달 - 테마 최적화) */} -
-
- {data.label} - {data.type && ( - - {t(`canvas.node.kind.${data.type}` as never, data.type)} - - )} -
- - {data.description && ( -

- {data.description} -

- )} - - {data.starGrade === "prime" && ( -
- - {t("canvas.node.coreBadge")} - -
+ {/* 항상 표시되는 라벨 (노드 하단) */} +
+ {data.label} + {data.type && ( + + {t(`canvas.node.kind.${data.type}` as never, data.type)} + )}
+ {/* 호버 시 상세 정보는 GraphHoverCard에서 처리 */} +
); diff --git a/src/renderer/src/features/canvas/components/graph/graphSurfaceParts/GraphHoverCard.tsx b/src/renderer/src/features/canvas/components/graph/graphSurfaceParts/GraphHoverCard.tsx index 43634ea4..87c20c4c 100644 --- a/src/renderer/src/features/canvas/components/graph/graphSurfaceParts/GraphHoverCard.tsx +++ b/src/renderer/src/features/canvas/components/graph/graphSurfaceParts/GraphHoverCard.tsx @@ -20,12 +20,12 @@ export function GraphHoverCard({ return (
-

{hoverNode.data.label}

+

{hoverNode.data.label}

{hoverNode.data.type && ( {t(`canvas.node.kind.${hoverNode.data.type}` as never, hoverNode.data.type)} @@ -34,7 +34,7 @@ export function GraphHoverCard({
{hoverNode.data.description && ( -

+

{hoverNode.data.description}

)} @@ -50,17 +50,17 @@ export function GraphHoverCard({ {hoverNode.data.relationships.slice(0, 3).map((rel, index) => (
- {hoverNode.data.label} - + {hoverNode.data.label} + {rel.type} - {rel.targetName} + {rel.targetName}
{rel.details && ( - + {rel.details} )} @@ -81,7 +81,7 @@ export function GraphHoverCard({ {hoverNode.data.relatedChapters.map((chapter, index) => ( {chapter} diff --git a/src/renderer/src/features/canvas/components/graph/graphSurfaceParts/GraphLegendModal.tsx b/src/renderer/src/features/canvas/components/graph/graphSurfaceParts/GraphLegendModal.tsx index 1a13be61..9e195775 100644 --- a/src/renderer/src/features/canvas/components/graph/graphSurfaceParts/GraphLegendModal.tsx +++ b/src/renderer/src/features/canvas/components/graph/graphSurfaceParts/GraphLegendModal.tsx @@ -15,58 +15,60 @@ export function GraphLegendModal({ if (!isOpen) return null; return ( -
-
+
+
-

+

{t("canvas.graph.legend.title", "그래프 범례")}

-

+

Graph Legend Map

- + {t("canvas.graph.legend.nodes", "노드 (개체)")}
-
- +
+ {t("canvas.graph.legend.node.prime", "핵심 주연 캐릭터")}
-
- +
+ {t("canvas.graph.legend.node.major", "조연 / 연관 세력")}
-
- +
+ {t("canvas.graph.legend.node.chapter", "집필 회차 (챕터)")}
- + {t("canvas.graph.legend.edges", "에지 (관계)")}
-
- +
+ {t("canvas.graph.legend.edge.character", "성간 인물 관계선")}
-
- +
+ {t("canvas.graph.legend.edge.event", "인과 관계 수사선 (붉은 실)")}
@@ -77,7 +79,7 @@ export function GraphLegendModal({ diff --git a/src/renderer/src/features/canvas/components/graph/graphSurfaceParts/index.ts b/src/renderer/src/features/canvas/components/graph/graphSurfaceParts/index.ts index 6bccd334..8289aab4 100644 --- a/src/renderer/src/features/canvas/components/graph/graphSurfaceParts/index.ts +++ b/src/renderer/src/features/canvas/components/graph/graphSurfaceParts/index.ts @@ -1,5 +1,7 @@ export { GraphHoverCard } from "./GraphHoverCard"; export { GraphLegendModal } from "./GraphLegendModal"; +export { useGraphDataFiltering } from "./useGraphDataFiltering"; +export { useFocusSync } from "./useFocusSync"; export { EDGE_FALLBACK_OPACITY, EDGE_FALLBACK_STROKE_WIDTH, diff --git a/src/renderer/src/features/canvas/components/graph/graphSurfaceParts/useFocusSync.ts b/src/renderer/src/features/canvas/components/graph/graphSurfaceParts/useFocusSync.ts new file mode 100644 index 00000000..96530b5c --- /dev/null +++ b/src/renderer/src/features/canvas/components/graph/graphSurfaceParts/useFocusSync.ts @@ -0,0 +1,114 @@ +import { useEffect } from "react"; +import { type Node, type Edge } from "reactflow"; +import type { GraphNodeData } from "../../../types/graph"; +import { + EDGE_FALLBACK_OPACITY, + EDGE_FALLBACK_STROKE_WIDTH, + EDGE_FOCUS_OPACITY, +} from "./constants"; + +interface UseFocusSyncParams { + focusId: string | null; + setNodes: (updater: (prev: Node[]) => Node[]) => void; + setEdges: (updater: (prev: Edge[]) => Edge[]) => void; +} + +export function useFocusSync({ focusId, setNodes, setEdges }: UseFocusSyncParams) { + useEffect(() => { + // 1. 노드 포커스 갱신 + setNodes((prevNodes) => + prevNodes.map((node) => ({ + ...node, + data: { + ...node.data, + isFocused: node.id === focusId, + }, + })), + ); + + // 2. 에지 포커스 및 네온 라이팅 효과 동기화 + setEdges((prevEdges) => + prevEdges.map((edge) => { + if (!focusId) { + // 포커스가 해제된 경우: 원래 스타일 복원 + return { + ...edge, + animated: edge.data?.animatedBackup ?? edge.animated, + style: { + ...edge.style, + opacity: edge.data?.opacityBackup ?? edge.style?.opacity ?? EDGE_FALLBACK_OPACITY, + strokeWidth: + edge.data?.strokeWidthBackup ?? + edge.style?.strokeWidth ?? + EDGE_FALLBACK_STROKE_WIDTH, + stroke: edge.data?.strokeBackup ?? edge.style?.stroke, + }, + labelStyle: { + ...edge.labelStyle, + opacity: 1.0, + }, + labelBgStyle: { + ...edge.labelBgStyle, + opacity: 1.0, + stroke: edge.data?.labelBgStrokeBackup ?? edge.labelBgStyle?.stroke, + }, + }; + } + + // 특정 노드가 포커스된 경우 + const isRelated = edge.source === focusId || edge.target === focusId; + + // 백업 상태 저장 (최초 1회) + const opacityBackup = + edge.data?.opacityBackup ?? edge.style?.opacity ?? EDGE_FALLBACK_OPACITY; + const strokeWidthBackup = + edge.data?.strokeWidthBackup ?? + edge.style?.strokeWidth ?? + EDGE_FALLBACK_STROKE_WIDTH; + const strokeBackup = edge.data?.strokeBackup ?? edge.style?.stroke ?? "currentColor"; + const animatedBackup = edge.data?.animatedBackup ?? edge.animated ?? false; + const labelBgStrokeBackup = + edge.data?.labelBgStrokeBackup ?? edge.labelBgStyle?.stroke; + + const relationColor = "var(--accent)"; + + // strokeWidthBackup이 숫자형인지 강제 안전 변환 및 NaN 방지 고도화 + const baseWidth = + typeof strokeWidthBackup === "number" + ? strokeWidthBackup + : Number(strokeWidthBackup) || EDGE_FALLBACK_STROKE_WIDTH; + + return { + ...edge, + data: { + ...edge.data, + opacityBackup, + strokeWidthBackup, + strokeBackup, + animatedBackup, + labelBgStrokeBackup, + }, + // 평형 다이어그램: 에지 애니메이션 미사용 + animated: false, + style: { + ...edge.style, + // 관련 에지는 선명하게, 관련 없는 에지는 시야에서 전면 투명 소거 + opacity: isRelated ? EDGE_FOCUS_OPACITY : 0, + strokeWidth: isRelated ? baseWidth + 1.2 : baseWidth, + stroke: isRelated ? relationColor : strokeBackup, + pointerEvents: isRelated ? "auto" : "none", // 비관련 에지 이벤트 완전 차단 + }, + labelStyle: { + ...edge.labelStyle, + opacity: isRelated ? 1.0 : 0, + }, + labelBgStyle: { + ...edge.labelBgStyle, + opacity: isRelated ? 1.0 : 0, + stroke: isRelated ? relationColor : labelBgStrokeBackup, + }, + }; + }), + ); + }, [focusId, setNodes, setEdges]); +} diff --git a/src/renderer/src/features/canvas/components/graph/graphSurfaceParts/useGraphDataFiltering.ts b/src/renderer/src/features/canvas/components/graph/graphSurfaceParts/useGraphDataFiltering.ts new file mode 100644 index 00000000..0243f07d --- /dev/null +++ b/src/renderer/src/features/canvas/components/graph/graphSurfaceParts/useGraphDataFiltering.ts @@ -0,0 +1,133 @@ +import { useMemo } from "react"; +import { MarkerType, type Node, type Edge } from "reactflow"; +import type { GraphNodeData } from "../../../types/graph"; +import { GRAPH_CONSTELLATION_EDGE_DEFAULTS } from "../../../constants/edge"; + +interface UseGraphDataFilteringParams { + sourceNodes: Node[]; + sourceEdges: Edge[]; + activeMode: "character" | "event"; + selectedFocusNode: string; + focusId: string | null; +} + +export function useGraphDataFiltering({ + sourceNodes, + sourceEdges, + activeMode, + selectedFocusNode, + focusId, +}: UseGraphDataFilteringParams) { + return useMemo(() => { + // A. 에지 필터링 및 스타일 빌드 + const computedEdges = sourceEdges.map((edge) => { + const strength = edge.data?.strength ?? 1; + const isCharacterMode = activeMode === "character"; + + const cfg = isCharacterMode + ? GRAPH_CONSTELLATION_EDGE_DEFAULTS.character + : GRAPH_CONSTELLATION_EDGE_DEFAULTS.event; + + const edgeStyle: React.CSSProperties = { + stroke: cfg.stroke, + strokeWidth: strength * cfg.widthMultiplier, + opacity: cfg.opacityBase + strength * cfg.opacityMultiplier, + }; + + if ("dasharray" in cfg) { + edgeStyle.strokeDasharray = cfg.dasharray; + } + + // 엣지 라벨 스타일 정의 (다크 럭셔리 & 피그마 감성) + const labelStyle: React.CSSProperties = { + fill: "var(--text-secondary)", + fontSize: 9, + fontWeight: 700, + fontFamily: "var(--font-sans, Inter, system-ui, sans-serif)", + letterSpacing: "-0.01em", + }; + + const labelBgStyle: React.CSSProperties = { + fill: "var(--bg-panel)", + fillOpacity: 0.95, + stroke: "var(--border-default)", + strokeWidth: 1.2, + rx: 6, + ry: 6, + }; + + return { + ...edge, + type: "straight", + label: edge.data?.label, + labelStyle, + labelBgStyle, + labelBgPadding: [8, 4] as [number, number], + animated: false, + markerEnd: isCharacterMode + ? undefined + : { + type: MarkerType.ArrowClosed, + width: cfg.markerSize, + height: cfg.markerSize, + color: "currentColor", + }, + style: edgeStyle, + }; + }); + + // B. 노드 크기 및 별자리 발광 속성 동적 연산 + const computedNodes = sourceNodes.map((node): Node => { + const degree = computedEdges.filter( + (edge) => edge.source === node.id || edge.target === node.id, + ).length; + const starGrade: "prime" | "major" | "minor" = + degree >= 3 ? "prime" : degree >= 1 ? "major" : "minor"; + + // 특정 캐릭터/사건 빠른 필터 포커싱 시, 대상 노드가 아닌 것들은 감쇠 처리 + let filterFocusedOpacity = 1.0; + if (selectedFocusNode !== "all") { + if (node.id !== selectedFocusNode) { + const isConnected = computedEdges.some( + (e) => + (e.source === selectedFocusNode && e.target === node.id) || + (e.target === selectedFocusNode && e.source === node.id) || + node.id === selectedFocusNode, + ); + filterFocusedOpacity = isConnected ? 0.95 : 0.15; + } + } + + // 캔버스 내 직접 클릭 포커스 격리 (Focus Isolation): 비관련 노드는 0% 투명화 소멸 + let canvasFocusedOpacity = 1.0; + let isInteractivePointerEvents = true; + if (focusId) { + if (node.id !== focusId) { + const isNeighbor = computedEdges.some( + (e) => + (e.source === focusId && e.target === node.id) || + (e.target === focusId && e.source === node.id), + ); + canvasFocusedOpacity = isNeighbor ? 0.95 : 0.0; + isInteractivePointerEvents = isNeighbor; + } + } + + return { + ...node, + data: { + ...node.data, + starGrade, + opacity: filterFocusedOpacity * canvasFocusedOpacity, + isInteractive: isInteractivePointerEvents, + isFocused: false, + }, + }; + }); + + return { + filteredNodes: computedNodes, + filteredEdges: computedEdges, + }; + }, [activeMode, selectedFocusNode, focusId, sourceNodes, sourceEdges]); +} diff --git a/src/renderer/src/features/canvas/components/shell/CanvasActivityShell.tsx b/src/renderer/src/features/canvas/components/shell/CanvasActivityShell.tsx index cdbc3025..a037323b 100644 --- a/src/renderer/src/features/canvas/components/shell/CanvasActivityShell.tsx +++ b/src/renderer/src/features/canvas/components/shell/CanvasActivityShell.tsx @@ -1,32 +1,38 @@ /** - * CanvasActivityShell — Obsidian 스타일 파일 탐색기 shell과 graph mode sidebar 분기. + * CanvasActivityShell — Redesigned minimal sidebar for canvas explorer. + * + * Design decisions: + * - Single compact header (no tab bar — search/bookmark were stubs) + * - Toolbar actions integrated into header row + * - Cleaner file tree with better visual hierarchy + * - Graph mode renders GraphFilterSidebar (Phase 4 redesign) */ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { - ArrowUpDown, - Bookmark, ChevronsUpDown, FilePlus, - Files, FolderPlus, - Search, X, } from "lucide-react"; import { Button } from "@renderer/components/ui/button"; import { ScrollArea } from "@renderer/components/ui/scroll-area"; -import { useToast } from "@shared/ui/ToastContext"; -import { mockExplorerData } from "../../__fixtures__/mockExplorerData"; -import type { FileNode } from "../../types/canvas.types"; import { useCanvasViewStore } from "../../stores/canvasViewStore"; +import { useProjectStore } from "@renderer/features/project/stores/projectStore"; +import { useCharacterStore } from "@renderer/features/research/stores/characterStore"; +import { useEventStore } from "@renderer/features/research/stores/eventStore"; +import { useFactionStore } from "@renderer/features/research/stores/factionStore"; +import { useMemoStore } from "@renderer/features/research/stores/memoStore"; +import { useWorldBuildingStore } from "@renderer/features/research/stores/worldBuildingStore"; import { GraphFilterSidebar, - TAB_I18N_KEYS, - TOOLBAR_ACTION_KEYS, TreeNode, getAllFolderIds, + CATEGORY_FOLDERS, + useExplorerData, + useCanvasFileActions, } from "./canvasActivityShellParts"; interface CanvasActivityShellProps { @@ -35,53 +41,61 @@ interface CanvasActivityShellProps { export default function CanvasActivityShell({ onClose }: CanvasActivityShellProps) { const { t } = useTranslation(); - const { showToast } = useToast(); const activePanel = useCanvasViewStore((state) => state.activePanel); const isGraphMode = activePanel === "graph"; + const currentProject = useProjectStore((state) => state.currentProject); + const characters = useCharacterStore((state) => state.items); + const events = useEventStore((state) => state.items); + const factions = useFactionStore((state) => state.items); + const notes = useMemoStore((state) => state.notes); + const graphData = useWorldBuildingStore((state) => state.graphData); + const loadGraph = useWorldBuildingStore((state) => state.loadGraph); const [selectedNodeId, setSelectedNodeId] = useState(null); const [expandedFolders, setExpandedFolders] = useState>({ - "folder-luie": true, - "folder-feature": true, + [CATEGORY_FOLDERS.characters]: true, + [CATEGORY_FOLDERS.events]: true, + [CATEGORY_FOLDERS.scraps]: true, + [CATEGORY_FOLDERS.factions]: true, }); - const toggleFolder = useCallback((folderId: string) => { - setExpandedFolders((prev) => ({ - ...prev, - [folderId]: !prev[folderId], - })); - }, []); - - const handleNodeClick = useCallback((node: FileNode) => { - if (node.type === "folder") { - toggleFolder(node.id); - } else { - setSelectedNodeId(node.id); - showToast( - t("canvas.graph.demoNotImplemented", { actionName: node.name }), - "info", - ); - } - }, [toggleFolder, t, showToast]); - - const handleTabChange = useCallback((tabKey: "explorer" | "search" | "bookmark") => { - showToast( - t("canvas.graph.demoNotImplemented", { - actionName: t(TAB_I18N_KEYS[tabKey]), - }), - "info", + const canvasFiles = graphData?.canvasFiles ?? []; + + useEffect(() => { + const projectId = currentProject?.id; + if (!projectId) return; + void useCharacterStore.getState().loadCharacters(projectId); + void useEventStore.getState().loadEvents(projectId); + void useFactionStore.getState().loadFactions(projectId); + void useMemoStore.getState().loadNotes( + projectId, + currentProject.projectPath ?? null, ); - }, [showToast, t]); - - const handleToolbarAction = useCallback((actionKey: "new-file" | "new-folder" | "sort") => { - showToast( - t("canvas.graph.demoNotImplemented", { - actionName: t(TOOLBAR_ACTION_KEYS[actionKey]), - }), - "info", - ); - }, [t, showToast]); + void loadGraph(projectId); + }, [currentProject?.id, currentProject?.projectPath, loadGraph]); + + const explorerData = useExplorerData({ + characters, + events, + factions, + notes, + canvasFiles, + }); + + const { + toggleFolder, + handleNodeClick, + handleToolbarAction, + handleRenameNode, + handleDeleteNode, + } = useCanvasFileActions({ + explorerData, + selectedNodeId, + canvasFiles, + setSelectedNodeId, + setExpandedFolders, + }); const toggleAllFolders = useCallback(() => { setExpandedFolders((prev) => { @@ -90,64 +104,31 @@ export default function CanvasActivityShell({ onClose }: CanvasActivityShellProp return {}; } - const allIds = getAllFolderIds(mockExplorerData); + const allIds = getAllFolderIds(explorerData); return allIds.reduce((acc, id) => ({ ...acc, [id]: true }), {}); }); - }, []); + }, [explorerData]); if (isGraphMode) { return ; } return ( -
-
-
- - - - - -
- -
- -
-
- -
-
+
+ {/* Compact header: title + actions in one row */} +
+ + {t("canvas.activity.explorer", "Explorer")} + + +
@@ -157,7 +138,8 @@ export default function CanvasActivityShell({ onClose }: CanvasActivityShellProp size="icon-xs" onClick={() => handleToolbarAction("new-folder")} title={t("canvas.activity.newFolder")} - className="h-6 w-6 text-muted-foreground/75 hover:bg-muted/40 hover:text-foreground [&_svg]:h-3.5 [&_svg]:w-3.5" + aria-label={t("canvas.activity.newFolder")} + className="h-6 w-6 text-muted/75 hover:bg-surface-hover hover:text-fg [&_svg]:h-3.5 [&_svg]:w-3.5 rounded-control transition-colors" > @@ -165,28 +147,31 @@ export default function CanvasActivityShell({ onClose }: CanvasActivityShellProp -
- +
+ + +
- -
- {mockExplorerData.map((node) => ( + +
+ {explorerData.map((node) => ( ))}
diff --git a/src/renderer/src/features/canvas/components/shell/CanvasDocumentView.tsx b/src/renderer/src/features/canvas/components/shell/CanvasDocumentView.tsx new file mode 100644 index 00000000..52196357 --- /dev/null +++ b/src/renderer/src/features/canvas/components/shell/CanvasDocumentView.tsx @@ -0,0 +1,185 @@ +import { useEffect, useState } from "react"; +import { AlignLeft, FileText, Tag } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import "@renderer/styles/components/canvas.css"; +import { BufferedInput } from "@shared/ui/BufferedInput"; +import { useMemoStore } from "@renderer/features/research/stores/memoStore"; +import { parseStructuredAttributes } from "@renderer/features/research/utils/parseStructuredAttributes"; +import { useEditorConfig } from "@renderer/features/editor/hooks/useEditorConfig"; +import type { CanvasEntityPreview } from "../../types"; +import { + CANVAS_DOCUMENT_MARKDOWN_KEY, + composeMarkdown, + decomposeMarkdown, + getKindLabel, + getSections, + getString, + getTagValues, + type EntityKind, +} from "./document/canvasDocumentModel"; +import { + DocumentShell, + PropertyLine, + TagList, +} from "./document/CanvasDocumentChrome"; +import { CanvasMarkdownEditor } from "./document/CanvasMarkdownEditor"; +import { useCanvasEntity } from "./document/useCanvasEntity"; + +interface CanvasDocumentViewProps { + preview: CanvasEntityPreview; +} + +export default function CanvasDocumentView({ + preview, +}: CanvasDocumentViewProps) { + if (preview.kind === "memo") { + return ; + } + return ; +} + +function EntityDocumentView({ + preview, +}: { + preview: Extract; +}) { + const { t } = useTranslation(); + const entityState = useCanvasEntity(preview); + const [descriptionDraft, setDescriptionDraft] = useState(""); + const kindLabel = getKindLabel(preview.kind, t); + const { fontFamilyCss } = useEditorConfig(); + + useEffect(() => { + void entityState.load(preview.id); + }, [entityState.load, preview.id]); + + useEffect(() => { + // eslint-disable-next-line + setDescriptionDraft(entityState.entity?.description ?? ""); + }, [entityState.entity?.description, entityState.entity?.id]); + + if (!entityState.entity) { + return ( + +
+ {t("canvas.preview.entityNotFound", "문서를 찾을 수 없습니다.")} +
+
+ ); + } + + const entity = entityState.entity; + const attrs = parseStructuredAttributes(entity.attributes); + const sections = getSections(preview.kind, attrs); + const initialMarkdown = + getString(attrs[CANVAS_DOCUMENT_MARKDOWN_KEY]) || composeMarkdown(sections, attrs); + + return ( + +
+ + void entityState.update({ + id: entity.id, + attributes: { + ...attrs, + [CANVAS_DOCUMENT_MARKDOWN_KEY]: markdown, + ...decomposeMarkdown(markdown, sections), + }, + }) + } + > +
+ void entityState.update({ id: entity.id, name })} + /> + +
+ } + label={t("canvas.document.description", "집필 요약")} + > +