diff --git a/.gitignore b/.gitignore index a1be097..44a48ed 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,4 @@ /apps/desktop/dist/ /.pre04-runs/ /docs/* -!/docs/renderer-architecture.md /tsconfig.tsbuildinfo diff --git a/apps/desktop/package.json b/apps/desktop/package.json index fbc21b1..ad2cfb4 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,4 +1,4 @@ { - "name": "@metis/desktop", "version": "0.2.4", "private": true, + "name": "@metis/desktop", "version": "0.2.5", "private": true, "main": "dist/main/main.cjs" } diff --git a/apps/desktop/src/renderer/app/main.tsx b/apps/desktop/src/renderer/app/main.tsx index 02027e3..85d38da 100644 --- a/apps/desktop/src/renderer/app/main.tsx +++ b/apps/desktop/src/renderer/app/main.tsx @@ -59,6 +59,7 @@ async function call(work: () => Promise>): Promise> { } function App() { const documents = useRef(null); + const [inspectorTarget, setInspectorTarget] = useState(null); const [helpOpen, setHelpOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [hideHints, setHideHints] = useState(() => { try { return localStorage.getItem('metis.welcome.hidden.v1') === 'true'; } catch { return false; } }); @@ -66,6 +67,9 @@ function App() { const [session, setSession] = useState(); const layout = useLayout(session?.viewKey); const appearance = useAppearance(session?.viewKey); + useEffect(() => { + window.document.documentElement.setAttribute('data-theme', appearance.value.previewTheme); + }, [appearance.value.previewTheme]); const [stylesheet, setStylesheet] = useState({ active: false }); const [stylesheetVersion, setStylesheetVersion] = useState(0); const menu = useRef(null), filesButton = useRef(null), outlineButton = useRef(null); @@ -80,6 +84,21 @@ function App() { const [activeAnalysis, setActiveAnalysis] = useState(); const [centerView, setCenterView] = useState<'documents' | 'graph'>('documents'); const [graphOpen, setGraphOpen] = useState(false); + const [editorStats, setEditorStats] = useState<{ line: number; col: number; words: number; chars: number }>(); + const [tabContextMenu, setTabContextMenu] = useState<{ x: number; y: number; path: string; isGraph?: boolean } | null>(null); + useEffect(() => { + if (!tabContextMenu) return; + const dismiss = (e: MouseEvent | KeyboardEvent) => { + if (e instanceof KeyboardEvent && e.key !== 'Escape') return; + setTabContextMenu(null); + }; + window.addEventListener('click', dismiss); + window.addEventListener('keydown', dismiss); + return () => { + window.removeEventListener('click', dismiss); + window.removeEventListener('keydown', dismiss); + }; + }, [tabContextMenu]); const [error, setError] = useState(''); const [warning, setWarning] = useState(''); const [status, setStatus] = useState('폴더를 열어 시작하세요.'); @@ -143,7 +162,7 @@ function App() { if (event.defaultPrevented || event.isComposing || event.keyCode === 229 || event.repeat || event.altKey || window.document.querySelector('dialog[open]') || !(event.ctrlKey || event.metaKey)) return; const key = event.key.toLowerCase(); if (key === 'p' && event.shiftKey) { event.preventDefault(); if (!locked.current) setPaletteOpen(true); return; } - const id = key === 'p' && !event.shiftKey ? 'search.files' : key === 'f' && event.shiftKey ? 'search.text' : key === 's' && !event.shiftKey ? 'document.save' : undefined; + const id = (key === 'p' || key === 'o') && !event.shiftKey ? 'search.files' : key === 'f' && event.shiftKey ? 'search.text' : key === 's' && !event.shiftKey ? 'document.save' : undefined; if (id) { event.preventDefault(); void dispatch(id); } }; window.addEventListener('keydown', shortcut); return () => window.removeEventListener('keydown', shortcut); @@ -251,16 +270,20 @@ function App() { function beginCreate(kind: 'workspace' | 'directory' | 'document') { createInvoker.current = window.document.activeElement as HTMLElement; setName(kind === 'document' ? 'untitled.adoc' : ''); setError(''); setCreating(kind); } async function create(event: React.FormEvent) { event.preventDefault(); - if (!validFolderName(name) || locked.current) return; - if (creating === 'workspace') return changeWorkspace(() => window.metis.createWorkspace({ requestId: requestId(), name })); + let finalName = name.trim(); + if (creating === 'document' && !/\.adoc$/i.test(finalName)) { + finalName = `${finalName}.adoc`; + } + if (!validFolderName(finalName) || locked.current) return; + if (creating === 'workspace') return changeWorkspace(() => window.metis.createWorkspace({ requestId: requestId(), name: finalName })); if (!session) return; locked.current = true; setBusy(true); setError(''); try { if (creating === 'document') { - const result = await call(() => window.metis.createDocument({ ...scope(session), relativePath: folder, name })); + const result = await call(() => window.metis.createDocument({ ...scope(session), relativePath: folder, name: finalName })); if (result.ok) { setCreating(undefined); await list(session, folder); documents.current?.open(result.value); setStatus('문서를 만들었습니다.'); } else report(result); } else { - const result = await call(() => window.metis.createDirectory({ ...scope(session), relativePath: folder, name })); + const result = await call(() => window.metis.createDirectory({ ...scope(session), relativePath: folder, name: finalName })); if (result.ok) { setCreating(undefined); await list(session, folder); setStatus('폴더를 만들었습니다.'); } else report(result); } } finally { locked.current = false; setBusy(false); } @@ -337,7 +360,7 @@ function App() { { id: 'workspace.refresh', label: '폴더 새로 고침', reason: folderReady, run: () => list(session!, folder) }, { id: 'workspace.newDocument', label: '새 문서', reason: writable, run: () => beginCreate('document') }, { id: 'workspace.newDirectory', label: '새 폴더', reason: writable, run: () => beginCreate('directory') }, - { id: 'search.text', label: '검색', shortcut: 'Ctrl/Cmd+Shift+F', reason: needsWorkspace, run: () => { setSearchMode('text'); if (layout.narrow) layout.setMobile('left'); else layout.update({ ...layout.value, left: { ...layout.value.left, open: true, active: 'search' } }); } }, + { id: 'search.text', label: '검색', shortcut: 'Ctrl/Cmd+Shift+F', reason: needsWorkspace, run: () => { setSearchMode('text'); if (layout.narrow) layout.setMobile('left'); layout.update({ ...layout.value, left: { ...layout.value.left, open: true, active: 'search' } }); } }, { id: 'search.files', label: '빠른 열기', shortcut: 'Ctrl/Cmd+P', reason: needsWorkspace, run: () => setSearchMode('files') }, { id: 'proposal.open', label: '변경 제안 검토', reason: () => needsWorkspace() ?? (!document ? '먼저 문서를 열어 주세요.' : undefined), run: () => setProposalOpen(true) }, { id: 'semantic.open', label: '의미·텍스트 비교', reason: () => needsWorkspace() ?? (!document ? '먼저 문서를 열어 주세요.' : undefined), run: () => setSemanticOpen(true) }, @@ -376,7 +399,7 @@ function App() { '--document-font-family': appearance.value.documentFontFamily, '--document-font-size': `${appearance.value.documentFontSize}px` } as React.CSSProperties; - return
+ return
} - {layout.value.left.active === 'search' && session && { if (layout.narrow) layout.setMobile(undefined); else layout.update({ ...layout.value, left: { ...layout.value.left, open: false } }); }} open={hit => { void read({ name: hit.label, relativePath: hit.relativePath, kind: 'document' }, hit.kind === 'file' ? undefined : hit.line, hit.revision); }} />} + {layout.value.left.active === 'search' && session && { if (layout.narrow) layout.setMobile(undefined); layout.update({ ...layout.value, left: { ...layout.value.left, active: 'files', open: layout.narrow ? layout.value.left.open : false } }); window.document.querySelector('.ribbon-button[aria-label="검색"]')?.focus(); }} open={hit => { void read({ name: hit.label, relativePath: hit.relativePath, kind: 'document' }, hit.kind === 'file' ? undefined : hit.line, hit.revision); }} />} {layout.value.left.active === 'favorites' &&

즐겨찾기

{favorites.length ? favorites.map(path =>
) :

즐겨찾기한 문서가 없습니다.

}
} {leftVisible && !layout.narrow && }
-
{ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; const tabs = [...event.currentTarget.querySelectorAll('[role=tab]')], index = tabs.indexOf(event.target as HTMLButtonElement); if (index < 0) return; event.preventDefault(); tabs[event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : Math.min(tabs.length - 1, Math.max(0, index + (event.key === 'ArrowRight' ? 1 : -1)))].focus(); tabs[event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : Math.min(tabs.length - 1, Math.max(0, index + (event.key === 'ArrowRight' ? 1 : -1)))].click(); }}>{documentTabs.map(tab =>
)}{graphOpen &&
}
+
{ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; const tabs = [...event.currentTarget.querySelectorAll('[role=tab]')], index = tabs.indexOf(event.target as HTMLButtonElement); if (index < 0) return; event.preventDefault(); tabs[event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : Math.min(tabs.length - 1, Math.max(0, index + (event.key === 'ArrowRight' ? 1 : -1)))].focus(); tabs[event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : Math.min(tabs.length - 1, Math.max(0, index + (event.key === 'ArrowRight' ? 1 : -1)))].click(); }}>{documentTabs.map(tab =>
{ event.preventDefault(); setTabContextMenu({ x: event.clientX, y: event.clientY, path: tab.path }); }} onAuxClick={event => { if (event.button === 1) { event.preventDefault(); void documents.current?.closeTab(tab.path); } }}>
)}{graphOpen &&
{ event.preventDefault(); setTabContextMenu({ x: event.clientX, y: event.clientY, path: 'graph', isGraph: true }); }} onAuxClick={event => { if (event.button === 1) { event.preventDefault(); setGraphOpen(false); setCenterView('documents'); } }}>
}
+ {tabContextMenu && ( +
e.stopPropagation()} + > + {tabContextMenu.isGraph ? ( + <> + + + + ) : ( + <> + + + + +
+ + + + )} +
+ )} {error && !creating &&

작업 실패 — {error}

{document &&
편집 보존·복구 작업

저장·외부 변경 문제라면 현재 편집을 보존하고 원문 상태를 확인하세요.

{commandButton('document.preserve')}{commandButton('document.inspect')}{commandButton('recovery.open')}{commandButton('recovery.folder')}
}
}{warning &&
{warning}
} - + {session && graphOpen && } {centerView === 'documents' && !document &&
M

{session ? '작업 공간을 열었습니다.' : '문서가 있는 곳에서 시작하세요.'}

@@ -427,11 +476,11 @@ function App() {
}
{rightVisible && !layout.narrow && } - {settingsOpen && setSettingsOpen(false)} />}
{error ? '작업 실패 · 오류 안내와 해결 행동을 확인하세요.' : busy ? '작업 진행 중 · 완료될 때까지 기다려 주세요.' : status}
{paletteOpen && setPaletteOpen(false)} execute={id => { void dispatch(id); }} />} + {settingsOpen && setSettingsOpen(false)} />}
{error ? '작업 실패 · 오류 안내와 해결 행동을 확인하세요.' : busy ? '작업 진행 중 · 완료될 때까지 기다려 주세요.' : status}{document && {editorStats && {editorStats.words.toLocaleString()} 단어 · {editorStats.chars.toLocaleString()} 자}{editorStats && 줄 {editorStats.line}, 열 {editorStats.col}}UTF-8{document.bom ? ' BOM' : ''}{document.eol.toUpperCase()}{document.readOnly ? '읽기 전용' : '편집 가능'}}
{paletteOpen && setPaletteOpen(false)} execute={id => { void dispatch(id); }} />} {helpOpen && setHelpOpen(false)} showHints={() => { setHintsHidden(false); setHelpOpen(false); setStatus('시작 화면 안내를 다시 표시합니다. 문서가 열려 있으면 탭을 닫은 빈 화면에서 확인하세요.'); }} />} {extensionsOpen && setExtensionsOpen(false)} />} {extensionView && setExtensionView(undefined)} />} diff --git a/apps/desktop/src/renderer/app/styles/style.css b/apps/desktop/src/renderer/app/styles/style.css index 066f6b1..5f8173c 100644 --- a/apps/desktop/src/renderer/app/styles/style.css +++ b/apps/desktop/src/renderer/app/styles/style.css @@ -29,6 +29,63 @@ --radius: 6px; } +:root[data-theme="dark"], .app[data-theme="dark"] { + color-scheme: dark; + color: #e6edf3; + background: #0d1117; + --canvas: #0d1117; + --surface: #161b22; + --surface-raised: #1c2128; + --surface-hover: #21262d; + --surface-selected: #1f3547; + --border: #30363d; + --border-strong: #484f58; + --text: #e6edf3; + --muted: #8b949e; + --faint: #6e7681; + --primary: #161b22; + --primary-hover: #21262d; + --secondary: #58a6ff; + --secondary-soft: #1f3547; + --accent: #f2c75c; + --accent-strong: #d6a84b; + --accent-soft: #382c16; + --success: #3fb950; + --warning: #d29922; + --warning-soft: #382c16; + --danger: #f85149; + --shadow: 0 12px 32px rgba(0, 0, 0, .5); +} + +:root[data-theme="sepia"], .app[data-theme="sepia"] { + color-scheme: light; + color: #463c2c; + background: #f7f0df; + --canvas: #f7f0df; + --surface: #eee3ca; + --surface-raised: #fbf7ee; + --surface-hover: #e5d8bc; + --surface-selected: #eddcb5; + --border: #cbbd9d; + --border-strong: #aa9368; + --text: #463c2c; + --muted: #765a2d; + --faint: #927546; + --primary: #3b2f1e; + --primary-hover: #4e3f2a; + --secondary: #8c5d26; + --secondary-soft: #eeddbb; + --accent: #b07d2b; + --accent-strong: #8c5d26; + --accent-soft: #fcf1d5; + --success: #3f7d52; + --warning: #9a6c28; + --warning-soft: #fff4cf; + --danger: #a84242; + --shadow: 0 12px 32px rgba(70, 60, 44, .18); +} + + * { box-sizing: border-box; } body { margin: 0; background: var(--canvas); } button, input, select, textarea { font: inherit; } @@ -70,17 +127,141 @@ main { flex: 1; min-width: 0; display: flex; flex-direction: column; background: .source { flex: 1; overflow: auto; background: var(--surface-raised); } .cm-editor { min-height: 100%; font-size: var(--document-font-size, 15px); color: var(--text); } .cm-content { font-family: var(--document-font-family, "Cascadia Code", Consolas, monospace); padding: 28px 0 120px; line-height: 1.65; caret-color: var(--secondary); } -.cm-line { padding-left: 22px; padding-right: 32px; } +.cm-line { padding-left: 18px; padding-right: 32px; } .cm-activeLine { background: rgba(20, 119, 131, .06) !important; } .cm-selectionBackground { background: var(--secondary-soft) !important; } .cm-gutters { background: var(--canvas) !important; border-right: 1px solid var(--border) !important; color: var(--faint) !important; } +/* CodeMirror Folding Gutter */ +.cm-foldGutter { width: 14px; cursor: pointer; color: var(--faint); text-align: center; } +.cm-foldGutter:hover { color: var(--secondary); } +.cm-foldPlaceholder { background: var(--surface-hover); border: 1px solid var(--border); border-radius: 3px; padding: 0 5px; margin: 0 3px; color: var(--muted); font-size: 0.85em; cursor: pointer; } + +/* CodeMirror Link Hover Indicator */ +.cm-content.cm-ctrl-pressed .cm-link { cursor: pointer; text-decoration: underline; color: var(--secondary) !important; } + +/* CodeMirror Hover Tooltips */ +.cm-tooltip.cm-tooltip-asciidoc { + padding: 6px 10px; + background: var(--surface); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.16); + font-size: 12px; + max-width: 320px; + line-height: 1.45; + pointer-events: none; + z-index: 100; +} +.cm-tooltip.cm-tooltip-asciidoc code { + color: var(--secondary); + background: var(--surface-hover); + padding: 1px 4px; + border-radius: 3px; + font-family: var(--document-font-family, monospace); + font-size: 0.9em; +} +.cm-tooltip.cm-tooltip-asciidoc .tooltip-title { + font-weight: 600; + margin-bottom: 2px; +} +.cm-tooltip.cm-tooltip-asciidoc .tooltip-body { + margin-bottom: 3px; + word-break: break-all; +} +.cm-tooltip.cm-tooltip-asciidoc .tooltip-meta { + font-size: 11px; + color: var(--muted); +} + +/* Tab Context Menu */ +.tab-context-menu { + position: fixed; + z-index: 1000; + background: var(--surface); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18); + padding: 4px; + min-width: 160px; + display: flex; + flex-direction: column; + gap: 1px; +} +.tab-context-menu button { + text-align: left; + background: none; + border: none; + padding: 6px 12px; + font-size: 13px; + color: var(--text); + border-radius: 4px; + cursor: pointer; + width: 100%; +} +.tab-context-menu button:hover:not(:disabled) { + background: var(--secondary-soft); + color: var(--secondary); +} +.tab-context-menu button:disabled { + opacity: 0.4; + cursor: not-allowed; +} +.tab-context-menu .menu-divider { + height: 1px; + background: var(--border); + margin: 3px 0; +} + +/* Dark Theme Specific Overrides */ +.app[data-theme="dark"] .cm-gutters { background: var(--canvas) !important; border-right-color: var(--border) !important; color: var(--muted) !important; } +.app[data-theme="dark"] .cm-activeLine { background: rgba(88, 166, 255, .08) !important; } +.app[data-theme="dark"] .cm-selectionBackground { background: rgba(56, 139, 253, .3) !important; } +.app[data-theme="dark"] .modal form, .app[data-theme="dark"] .create-dialog { background: var(--surface-raised); color: var(--text); } +.app[data-theme="dark"] .ribbon-button { color: #8b949e; } +.app[data-theme="dark"] .ribbon-button:hover:not(:disabled), .app[data-theme="dark"] .ribbon-button.active { background: #21262d; color: var(--accent); } +.app[data-theme="dark"] .sidebar-tooltip { background: var(--surface); color: var(--text); border-color: var(--border); } +.app[data-theme="dark"] .sidebar-tooltip::before { background: var(--surface); } + +/* Sepia Theme Specific Overrides */ +.app[data-theme="sepia"] .modal form, .app[data-theme="sepia"] .create-dialog { background: var(--surface-raised); color: var(--text); } +.app[data-theme="sepia"] .ribbon-button { color: #765a2d; } +.app[data-theme="sepia"] .ribbon-button:hover:not(:disabled), .app[data-theme="sepia"] .ribbon-button.active { background: #eeddbb; color: var(--accent); } + footer { min-height: 28px; padding: 6px 14px; border-top: 1px solid var(--border); background: var(--surface); - font-size:calc(11px * var(--ui-font-scale, 1)); + font-size: calc(11px * var(--ui-font-scale, 1)); + color: var(--muted); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.footer-status-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} +.footer-document-stats { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} +.footer-stat-badge { + padding: 2px 6px; + border-radius: 3px; + background: var(--surface-raised); + border: 1px solid var(--border); + font-size: calc(10px * var(--ui-font-scale, 1)); color: var(--muted); } [role=alert] { background: var(--warning-soft); color: var(--warning); padding: 12px 18px; } + diff --git a/apps/desktop/src/renderer/features/relations/related.tsx b/apps/desktop/src/renderer/features/relations/related.tsx index 44828a7..57aefa3 100644 --- a/apps/desktop/src/renderer/features/relations/related.tsx +++ b/apps/desktop/src/renderer/features/relations/related.tsx @@ -9,22 +9,31 @@ export function RelatedView({ index, selected, initialMode = 'list', open }: { i const model = useMemo(() => relatedDocuments(index?.edges ?? [], selected, 12), [index, selected]); const detail = model.documents.find(doc => doc.path === focused); const names = [selected, ...model.documents.map(doc => doc.path)].filter(Boolean); - const position = (name: string, i: number) => positions[name] ?? (i === 0 ? { x: 450, y: 40 } : { x: 150 + ((i - 1) % 3) * 300, y: 145 + Math.floor((i - 1) / 3) * 95 }); + const position = (name: string, i: number) => { + if (positions[name]) return positions[name]; + if (i === 0) return { x: 450, y: 250 }; + const count = Math.max(1, names.length - 1); + const angle = (2 * Math.PI * (i - 1)) / count - Math.PI / 2; + const radius = 170 + ((i % 2) * 35); + const x = Math.round(450 + radius * Math.cos(angle) * 1.4); + const y = Math.round(250 + radius * Math.sin(angle) * 0.95); + return { x: Math.max(80, Math.min(820, x)), y: Math.max(40, Math.min(460, y)) }; + }; const move = (name: string, x: number, y: number) => setPositions(values => ({ ...values, [name]: { x: Math.max(100, Math.min(800, x)), y: Math.max(30, Math.min(470, y)) } })); return

관련 문서

-

직접 참조·포함과 공통 참조를 근거로 합니다. 문서 내용의 의미 유사도를 추론하지 않습니다.

+

직접 참조·포함과 공통 참조를 근거로 합니다. 더블 클릭하면 해당 문서를 엽니다.

{!index ?

새 관계 조사 결과를 기다리고 있습니다.

: <> {(model.truncated || index.partial) &&

일부 관계만 표시합니다. 관련 문서 최대 12개·문서별 근거 최대 5개입니다. 조사 범위 경고도 확인하세요.

} {!model.documents.length &&

확인된 관련 문서가 없습니다.

} {mode === 'list' ?
    {model.documents.map(doc =>
  • )}
: <> -

실선: 직접 참조·포함 · 점선: 같은 대상을 참조하는 간접 관계. 노드를 선택하면 근거를 표시합니다. 드래그 또는 방향키는 화면 배치만 바꿉니다.

+

실선: 직접 참조·포함 · 점선: 같은 대상을 참조하는 간접 관계. 노드를 선택하면 근거를 표시하며 더블 클릭하면 바로 엽니다.

- {names.map((name, i) => { const point = position(name, i); return {running && }

Escape로 검색어를 지우지 않고 창을 닫습니다. 글자 조합 중에는 창을 유지합니다. 필터는 받은 결과 안에서 적용됩니다. 검색어에서 아래 방향키로 결과 이동, 위·아래/Home/End로 선택, Enter로 열 수 있습니다.

@@ -61,6 +61,6 @@ export function Search({ session, initialMode, memory, close, open, embedded = f {kinds[hit.kind]} · {hit.label}{hit.relativePath}:{hit.line}{hit.context})}
} ; return embedded - ?
{ container.current = node; }} aria-labelledby="search-title">{content}
+ ?
{ container.current = node; }} aria-labelledby="search-title">{content}
: { container.current = node; }} aria-labelledby="search-title" onCancel={event => { event.preventDefault(); close(); }}>{content}; } diff --git a/apps/desktop/src/renderer/shared/lib/accessibility.ts b/apps/desktop/src/renderer/shared/lib/accessibility.ts index cbf5f99..82b3961 100644 --- a/apps/desktop/src/renderer/shared/lib/accessibility.ts +++ b/apps/desktop/src/renderer/shared/lib/accessibility.ts @@ -25,7 +25,7 @@ export function useSearchDialogEscape(close: () => void) { return { onCompositionStartCapture: () => { composing.current = true; }, onCompositionEndCapture: () => { composing.current = false; }, - onKeyDownCapture: (event: KeyboardEvent) => { + onKeyDownCapture: (event: KeyboardEvent) => { if (event.key !== 'Escape') return; event.preventDefault(); event.stopPropagation(); diff --git a/apps/desktop/src/renderer/widgets/document-workspace/asciidoc-folding.test.ts b/apps/desktop/src/renderer/widgets/document-workspace/asciidoc-folding.test.ts new file mode 100644 index 0000000..0bf526a --- /dev/null +++ b/apps/desktop/src/renderer/widgets/document-workspace/asciidoc-folding.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { EditorState } from '@codemirror/state'; +import { foldable } from '@codemirror/language'; +import { asciidocFolding } from './asciidoc-language'; + +describe('asciidocFolding', () => { + it('folds section headings based on heading level', () => { + const doc = [ + '= Document Title', + 'Intro paragraph 1', + 'Intro paragraph 2', + '== Section 1', + 'Content for section 1', + '=== Sub Section 1.1', + 'Sub content', + '== Section 2', + 'Content for section 2' + ].join('\n'); + + const state = EditorState.create({ doc, extensions: [asciidocFolding] }); + + // Title line (line 1, from 0 to 16) + const titleFold = foldable(state, 0, 16); + expect(titleFold).not.toBeNull(); + expect(titleFold?.from).toBe(16); + expect(titleFold?.to).toBe(state.doc.length); + + // Section 1 line (line 4) + const sec1Line = state.doc.line(4); + const sec1Fold = foldable(state, sec1Line.from, sec1Line.to); + expect(sec1Fold).not.toBeNull(); + // Should fold up to the start of Section 2 (line 7 end) + const subContentLine = state.doc.line(7); + expect(sec1Fold?.to).toBe(subContentLine.to); + + // Sub Section 1.1 (line 6) + const subLine = state.doc.line(6); + const subFold = foldable(state, subLine.from, subLine.to); + expect(subFold).not.toBeNull(); + expect(subFold?.to).toBe(subContentLine.to); + }); + + it('folds delimited blocks like source and quote blocks', () => { + const doc = [ + '= Title', + '[source,typescript]', + '----', + 'const x = 1;', + 'const y = 2;', + '----', + 'After block' + ].join('\n'); + + const state = EditorState.create({ doc, extensions: [asciidocFolding] }); + + const openDelimLine = state.doc.line(3); + const blockFold = foldable(state, openDelimLine.from, openDelimLine.to); + expect(blockFold).not.toBeNull(); + const closeDelimLine = state.doc.line(6); + expect(blockFold?.to).toBe(closeDelimLine.to); + }); +}); diff --git a/apps/desktop/src/renderer/widgets/document-workspace/asciidoc-language.ts b/apps/desktop/src/renderer/widgets/document-workspace/asciidoc-language.ts index c14394c..1857698 100644 --- a/apps/desktop/src/renderer/widgets/document-workspace/asciidoc-language.ts +++ b/apps/desktop/src/renderer/widgets/document-workspace/asciidoc-language.ts @@ -1,4 +1,5 @@ -import { StreamLanguage, syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language'; +import { StreamLanguage, syntaxHighlighting, defaultHighlightStyle, HighlightStyle, foldService } from '@codemirror/language'; +import { tags } from '@lezer/highlight'; // Lexical editing aid only. Semantic sections and diagnostics come from Asciidoctor. export const asciidocLanguage = StreamLanguage.define({ startState: () => ({ delimiter: '' }), @@ -26,4 +27,52 @@ export const asciidocLanguage = StreamLanguage.define({ stream.next(); return null; } }); -export const asciidocHighlighting = syntaxHighlighting(defaultHighlightStyle); + +export const asciidocHighlightStyle = HighlightStyle.define([ + { tag: tags.heading, fontWeight: '700', color: 'var(--text)' }, + { tag: tags.link, color: 'var(--secondary)', textDecoration: 'underline' }, + { tag: tags.emphasis, fontStyle: 'italic' }, + { tag: tags.strong, fontWeight: '700' }, + { tag: tags.monospace, color: 'var(--secondary)' }, + { tag: tags.keyword, color: 'var(--secondary)', fontWeight: '600' }, + { tag: tags.propertyName, color: 'var(--primary-hover)', fontWeight: '500' }, + { tag: tags.comment, color: 'var(--faint)', fontStyle: 'italic' }, + { tag: tags.meta, color: 'var(--muted)' } +]); + +export const asciidocHighlighting = [ + syntaxHighlighting(asciidocHighlightStyle), + syntaxHighlighting(defaultHighlightStyle, { fallback: true }) +]; + + +export const asciidocFolding = foldService.of((state, lineStart) => { + const line = state.doc.lineAt(lineStart); + const text = line.text; + + const headingMatch = text.match(/^(=+)\s+/); + if (headingMatch) { + const level = headingMatch[1].length; + let end = line.to; + for (let i = line.number + 1; i <= state.doc.lines; i++) { + const nextLine = state.doc.line(i); + const nextMatch = nextLine.text.match(/^(=+)\s+/); + if (nextMatch && nextMatch[1].length <= level) break; + end = nextLine.to; + } + if (end > line.to) return { from: line.to, to: end }; + } + + const delimMatch = text.match(/^(----+|\.\.\.\.+|\+\+\+\+|\/\/\/\/|\*\*\*\*|\|===+)$/); + if (delimMatch) { + const delim = delimMatch[1]; + for (let i = line.number + 1; i <= state.doc.lines; i++) { + const nextLine = state.doc.line(i); + if (nextLine.text.trim() === delim) { + return { from: line.to, to: nextLine.to }; + } + } + } + return null; +}); + diff --git a/apps/desktop/src/renderer/widgets/document-workspace/completions.ts b/apps/desktop/src/renderer/widgets/document-workspace/completions.ts index 0e144de..d6c3353 100644 --- a/apps/desktop/src/renderer/widgets/document-workspace/completions.ts +++ b/apps/desktop/src/renderer/widgets/document-workspace/completions.ts @@ -6,9 +6,36 @@ export function relativeTarget(from: string, to: string) { return [...base.map(() => '..'), ...target].join('/'); } export function complete(context: CompletionContext, path: string, analysis?: Analysis): CompletionResult | null { - if (!analysis) return null; const line = context.state.doc.lineAt(context.pos), before = line.text.slice(0, context.pos - line.from); const after = line.text.slice(context.pos - line.from); + + const slashMatch = /(?:^|\s)\/([a-zA-Z0-9-]*)$/.exec(before); + if (slashMatch) { + const slashWord = slashMatch[1]; + const slashFrom = context.pos - slashWord.length - 1; + const slashOptions = [ + { label: '/note', apply: '[NOTE]\n====\n\n====\n', detail: '참고 경고문 (Note admonition)', type: 'keyword' }, + { label: '/tip', apply: '[TIP]\n====\n\n====\n', detail: '팁 블록 (Tip admonition)', type: 'keyword' }, + { label: '/warning', apply: '[WARNING]\n====\n\n====\n', detail: '경고 블록 (Warning admonition)', type: 'keyword' }, + { label: '/important', apply: '[IMPORTANT]\n====\n\n====\n', detail: '중요 블록 (Important admonition)', type: 'keyword' }, + { label: '/caution', apply: '[CAUTION]\n====\n\n====\n', detail: '주의 블록 (Caution admonition)', type: 'keyword' }, + { label: '/code', apply: '[source]\n----\n\n----\n', detail: '소스 코드 블록 (Source code block)', type: 'keyword' }, + { label: '/table', apply: '|===\n| 열 1 | 열 2\n\n| 항목 1 | 항목 2\n|===\n', detail: 'AsciiDoc 표 (Table)', type: 'keyword' }, + { label: '/mermaid', apply: '[mermaid]\n----\nflowchart TD\n A[시작] --> B[완료]\n----\n', detail: 'Mermaid 다이어그램 블록', type: 'keyword' }, + { label: '/quote', apply: '[quote]\n____\n\n____\n', detail: '인용 블록 (Quote block)', type: 'keyword' }, + { label: '/sidebar', apply: '****\n\n****\n', detail: '사이드바 블록 (Sidebar block)', type: 'keyword' }, + { label: '/example', apply: '====\n\n====\n', detail: '예제 블록 (Example block)', type: 'keyword' }, + { label: '/todo', apply: '* [ ] ', detail: '체크리스트 할 일 (Task checklist)', type: 'keyword' }, + { label: '/h1', apply: '= ', detail: '문서 제목 / 레벨 1 제목', type: 'keyword' }, + { label: '/h2', apply: '== ', detail: '섹션 제목 / 레벨 2 제목', type: 'keyword' }, + { label: '/h3', apply: '=== ', detail: '소제목 / 레벨 3 제목', type: 'keyword' }, + { label: '/include', apply: 'include::[]', detail: '문서 포함 지시자 (Include)', type: 'keyword' }, + { label: '/xref', apply: 'xref:[]', detail: '문서 상호 참조 (Cross reference)', type: 'keyword' } + ]; + return { from: slashFrom, options: slashOptions, validFor: /^\/[a-zA-Z0-9-]*$/ }; + } + + if (!analysis) return null; const macro = /(include::|xref:)([^\s\[]*)$/.exec(before), short = /<<([^>,]*)$/.exec(before), attr = /(? tab.text !== normalized(tab.baseline.text) || !!tab.external?.missing; interface Visit { path: string; text: string; selection: ReturnType; top: number; left: number; mode: 'source' | 'split' | 'preview' } export interface DocumentTabSnapshot { path: string; dirty: boolean } -export interface DocumentsHandle { extensionModel(): import('@metis/contracts').ExtensionModel | undefined; commands(): Command[]; open(document: DocumentSnapshot, line?: number): void; activate(path: string): void; closeTab(path: string): Promise; draft(path: string): string | undefined; restoreDraft(disk: DocumentSnapshot, text: string, expectedDraft: string | undefined): Promise; isDirty(path: string): boolean; fileChanged(source: string, destination: string, affected: string[]): void; clear(): void; allowLeave(): Promise } +export interface DocumentsHandle { extensionModel(): import('@metis/contracts').ExtensionModel | undefined; commands(): Command[]; open(document: DocumentSnapshot, line?: number): void; activate(path: string): void; closeTab(path: string): Promise; closeOtherTabs?(path: string): Promise; closeTabsToRight?(path: string): Promise; closeAllTabs?(): Promise; draft(path: string): string | undefined; restoreDraft(disk: DocumentSnapshot, text: string, expectedDraft: string | undefined): Promise; isDirty(path: string): boolean; fileChanged(source: string, destination: string, affected: string[]): void; clear(): void; allowLeave(): Promise } function jump(tab: Tab, line: number) { if (!tab.view) { tab.jumpLine = line; return; } const position = tab.view.state.doc.line(Math.max(1, Math.min(tab.view.state.doc.lines, line))).from; tab.view.dispatch({ selection: { anchor: position }, effects: EditorView.scrollIntoView(position, { y: 'center' }) }); tab.view.focus(); } -function Editor({ tab, changed, save }: { tab: Tab; changed(): void; save(): void }) { +function findLinkAt(text: string, col: number): { path?: string; anchor?: string; label?: string; start: number; end: number } | null { + const xrefRegex = /xref:([^\s\[]+)(?:\[(.*?)\])?/g; + let match; + while ((match = xrefRegex.exec(text)) !== null) { + if (col >= match.index && col <= match.index + match[0].length) { + const [path, anchor] = match[1].split('#'); + return { path: path || undefined, anchor, label: match[2], start: match.index, end: match.index + match[0].length }; + } + } + const includeRegex = /include::([^\s\[]+)\[(.*?)\]/g; + while ((match = includeRegex.exec(text)) !== null) { + if (col >= match.index && col <= match.index + match[0].length) { + return { path: match[1], label: match[2], start: match.index, end: match.index + match[0].length }; + } + } + const anchorRegex = /<<([^\s>,]+)(?:,\s*([^>]*))?>>/g; + while ((match = anchorRegex.exec(text)) !== null) { + if (col >= match.index && col <= match.index + match[0].length) { + const raw = match[1]; + const [path, anchor] = raw.includes('#') ? raw.split('#') : ['', raw]; + return { path: path || undefined, anchor, label: match[2], start: match.index, end: match.index + match[0].length }; + } + } + return null; +} +function asciidocHoverTooltip(tab: Tab) { + return hoverTooltip((view, pos) => { + const line = view.state.doc.lineAt(pos); + const col = pos - line.from; + const text = line.text; + + const attrRegex = /(?= match.index && col <= match.index + match[0].length) { + const attrName = match[1]; + const attr = tab.analysis?.attributes.find(a => a.name === attrName && a.applied); + return { + pos: line.from + match.index, + end: line.from + match.index + match[0].length, + above: true, + create() { + const dom = document.createElement('div'); + dom.className = 'cm-tooltip-asciidoc'; + if (attr) { + dom.innerHTML = `
속성 {${attrName}}
${attr.value}
${attr.relativePath}:${attr.line}
`; + } else { + dom.innerHTML = `
속성 {${attrName}}
정의되지 않았거나 미적용 속성
`; + } + return { dom }; + } + }; + } + } + + const link = findLinkAt(text, col); + if (link) { + return { + pos: line.from + link.start, + end: line.from + link.end, + above: true, + create() { + const dom = document.createElement('div'); + dom.className = 'cm-tooltip-asciidoc'; + const targetStr = link.path ? (link.anchor ? `${link.path}#${link.anchor}` : link.path) : `#${link.anchor}`; + dom.innerHTML = `
링크 대상: ${targetStr}
Ctrl+클릭하여 바로 이동
`; + return { dom }; + } + }; + } + return null; + }); +} +function resolveDocPath(fromDoc: string, targetPath: string): string { + if (!targetPath) return fromDoc; + const parts = fromDoc.includes('/') ? fromDoc.split('/').slice(0, -1) : []; + for (const segment of targetPath.split('/')) { + if (segment === '.' || !segment) continue; + if (segment === '..') parts.pop(); + else parts.push(segment); + } + return parts.join('/'); +} +function Editor({ tab, changed, save, onSource, onJump, onStats }: { tab: Tab; changed(): void; save(): void; onSource?(entry: OutlineEntry): void; onJump?(line: number): void; onStats?(stats: { line: number; col: number; words: number; chars: number }): void }) { const saveCurrent = useRef(save); saveCurrent.current = save; + const onSourceCurrent = useRef(onSource); onSourceCurrent.current = onSource; + const onJumpCurrent = useRef(onJump); onJumpCurrent.current = onJump; + const onStatsCurrent = useRef(onStats); onStatsCurrent.current = onStats; const element = useRef(null); const access = useRef(new Compartment()); useEffect(() => { const readonly = !!tab.baseline.readOnly || tab.baseline.eol === 'mixed'; - tab.view = new EditorView({ parent: element.current!, state: EditorState.create({ doc: tab.text, extensions: [lineNumbers(), history(), asciidocLanguage, asciidocHighlighting, + const initText = tab.text; + const initWords = initText.trim() ? initText.trim().split(/\s+/).length : 0; + onStatsCurrent.current?.({ line: 1, col: 1, words: initWords, chars: initText.length }); + tab.view = new EditorView({ parent: element.current!, state: EditorState.create({ doc: tab.text, extensions: [ + lineNumbers(), foldGutter(), asciidocFolding, history(), asciidocLanguage, asciidocHighlighting, autocompletion({ override: [context => complete(context, tab.baseline.relativePath, tab.analysis)] }), - keymap.of([{ key: 'Mod-s', run: () => { saveCurrent.current(); return true; } }, ...defaultKeymap, ...historyKeymap]), + asciidocHoverTooltip(tab), + keymap.of([{ key: 'Mod-s', run: () => { saveCurrent.current(); return true; } }, ...defaultKeymap, ...historyKeymap, ...foldKeymap]), access.current.of([EditorState.readOnly.of(readonly), EditorView.editable.of(!readonly)]), EditorView.lineWrapping, EditorView.contentAttributes.of({ 'aria-label': '문서 편집기' }), - EditorView.updateListener.of(update => { if (update.docChanged) { tab.text = update.state.doc.toString(); changed(); } })] }) }); + EditorView.domEventHandlers({ + keydown(event, view) { + if (event.key === 'Control' || event.key === 'Meta') view.contentDOM.classList.add('cm-ctrl-pressed'); + }, + keyup(event, view) { + if (event.key === 'Control' || event.key === 'Meta') view.contentDOM.classList.remove('cm-ctrl-pressed'); + }, + click(event, view) { + if ((event.ctrlKey || event.metaKey) && event.button === 0) { + const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }); + if (pos !== null) { + const line = view.state.doc.lineAt(pos); + const link = findLinkAt(line.text, pos - line.from); + if (link) { + event.preventDefault(); + const targetDoc = resolveDocPath(tab.baseline.relativePath, link.path ?? ''); + if (targetDoc === tab.baseline.relativePath && link.anchor) { + const match = tab.analysis?.anchors.find(a => a.id === link.anchor); + if (match) { onJumpCurrent.current?.(match.line); return true; } + } + onSourceCurrent.current?.({ relativePath: targetDoc, line: 1, id: link.anchor ?? '', title: link.label ?? '', level: 1 }); + return true; + } + } + } + } + }), + EditorView.updateListener.of(update => { + if (update.docChanged) { tab.text = update.state.doc.toString(); changed(); } + if (update.docChanged || update.selectionSet) { + const head = update.state.selection.main.head; + const line = update.state.doc.lineAt(head); + const col = head - line.from + 1; + const text = update.state.doc.toString(); + const words = text.trim() ? text.trim().split(/\s+/).length : 0; + onStatsCurrent.current?.({ line: line.number, col, words, chars: text.length }); + } + })] }) }); if (tab.jumpLine) { jump(tab, tab.jumpLine); tab.jumpLine = undefined; } return () => { tab.view?.destroy(); tab.view = undefined; }; }, [tab]); useEffect(() => { const readonly = !!tab.baseline.readOnly || tab.baseline.eol === 'mixed'; tab.view?.dispatch({ effects: access.current.reconfigure([EditorState.readOnly.of(readonly), EditorView.editable.of(!readonly)]) }); }, [tab, tab.baseline.readOnly, tab.baseline.eol]); return
; } -export const Documents = forwardRef(function Documents(props, ref) { + +export const Documents = forwardRef(function Documents(props, ref) { const tabs = useRef(new Map()); const [active, setActive] = useState(''); const activeRef = useRef(''); @@ -76,7 +206,21 @@ export const Documents = forwardRef undefined); } - const select = (path: string) => { activeRef.current = path; setActive(path); }; + const select = (path: string) => { + activeRef.current = path; setActive(path); + if (current.current.onStats) { + const tab = tabs.current.get(path); + if (tab) { + const text = tab.text; + const words = text.trim() ? text.trim().split(/\s+/).length : 0; + const head = tab.view?.state.selection.main.head ?? 0; + const line = tab.view ? tab.view.state.doc.lineAt(head) : { number: 1, from: 0 }; + current.current.onStats({ line: line.number, col: head - line.from + 1, words, chars: text.length }); + } else { + current.current.onStats(undefined); + } + } + }; function location(): Visit | undefined { const tab = tabs.current.get(active), view = tab?.view; if (!tab || !view) return; @@ -227,6 +371,21 @@ export const Documents = forwardRef= 0) { + for (const p of allPaths.slice(idx + 1)) await close(p); + } + }, + async closeAllTabs() { + for (const p of Array.from(tabs.current.keys())) await close(p); + }, clear() { tabs.current.clear(); back.current = []; forward.current = []; setReview(undefined); select(''); props.onActive(undefined); changed(); }, allowLeave })); async function save(path: string) { @@ -294,8 +453,9 @@ export const Documents = forwardRef{selected.external.message}
{selected.external.disk && }
setCopyName(event.target.value)} />
} {review && setReview(undefined)} resolve={resolve} />}
- - {selected && props.session && selected.view?.state.doc.lineAt(selected.view.state.selection.main.head).number ?? 1} reveal={() => setMode('split')} session={props.session} relativePath={active} text={selected.text} mode={mode} onAnalysis={value => { selected.analysis = value; selected.analysisText = analyzedText; props.onAnalysis(active, value); }} navigate={entry => { setMode('split'); if (entry.relativePath === active) jump(selected, entry.line); else props.onSource(entry); }} />} + + {selected && props.session && selected.view?.state.doc.lineAt(selected.view.state.selection.main.head).number ?? 1} reveal={() => setMode('split')} session={props.session} relativePath={active} text={selected.text} mode={mode} onAnalysis={value => { selected.analysis = value; selected.analysisText = analyzedText; props.onAnalysis(active, value); }} navigate={entry => { setMode('split'); if (entry.relativePath === active) jump(selected, entry.line); else props.onSource(entry); }} />}
+ ; }); diff --git a/apps/desktop/src/renderer/widgets/document-workspace/preview.tsx b/apps/desktop/src/renderer/widgets/document-workspace/preview.tsx index 68e9bb8..a741e7e 100644 --- a/apps/desktop/src/renderer/widgets/document-workspace/preview.tsx +++ b/apps/desktop/src/renderer/widgets/document-workspace/preview.tsx @@ -1,3 +1,4 @@ +import { createPortal } from 'react-dom'; import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import DOMPurify from 'dompurify'; import { OperationStatus } from '../../shared/ui/operation-status'; @@ -21,7 +22,7 @@ export function safePreview(html: string, appearance: WorkspaceAppearanceV1 = de return `${fragment.body.innerHTML}`; } export interface PreviewPosition { text?: string; html?: string; x: number; y: number; outline: number } -export function Preview({ session, relativePath, text, mode, navigate, onAnalysis, position, editorLine, reveal, appearance, stylesheetVersion, inspector = true }: { session: Session; relativePath: string; text: string; mode: 'source' | 'split' | 'preview'; navigate(entry: OutlineEntry): void; onAnalysis(value: Analysis): void; position: PreviewPosition; editorLine(): number; reveal(): void; appearance: WorkspaceAppearanceV1; stylesheetVersion: number; inspector?: boolean }) { +export function Preview({ session, relativePath, text, mode, navigate, onAnalysis, position, editorLine, reveal, appearance, stylesheetVersion, inspector = true, inspectorTarget }: { session: Session; relativePath: string; text: string; mode: 'source' | 'split' | 'preview'; navigate(entry: OutlineEntry): void; onAnalysis(value: Analysis): void; position: PreviewPosition; editorLine(): number; reveal(): void; appearance: WorkspaceAppearanceV1; stylesheetVersion: number; inspector?: boolean; inspectorTarget?: HTMLElement | null }) { const [analysis, setAnalysis] = useState(); const [basis, setBasis] = useState(); const current = !!analysis && basis === text; @@ -116,8 +117,7 @@ export function Preview({ session, relativePath, text, mode, navigate, onAnalysi if (pendingId.current && mode !== 'source' && current && !running) { const id = pendingId.current; pendingId.current = undefined; scrollToId(id); } }; useEffect(() => { const custom = frame.current?.contentDocument?.querySelector('style[data-metis-custom]'); if (custom) custom.textContent = customCss; }, [customCss]); - return
- {inspector && ; + return
+ {inspectorTarget ? createPortal(inspectorContent, inspectorTarget) : inspector && inspectorContent}