v0.6.0 BigUpdate
Example document / build (push) Has been cancelled
Python application / build (push) Has been cancelled

This commit is contained in:
Igor20264
2026-10-20 11:38:06 +03:00
parent daa84f8bce
commit 8223e0ac14
77 changed files with 12158 additions and 117 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
.wordcraft/
out/
dist/
*.tsbuildinfo
+32
View File
@@ -0,0 +1,32 @@
import { resolve } from 'node:path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
},
preload: {
plugins: [externalizeDepsPlugin()],
// CJS, не ESM: Electron дважды выполняет ESM-preload, и contextBridge падает.
build: {
rollupOptions: {
output: {
format: 'cjs',
inlineDynamicImports: true,
entryFileNames: 'index.cjs',
},
},
},
},
renderer: {
resolve: {
alias: {
'@renderer': resolve('src/renderer/src'),
'@shared': resolve('src/shared'),
},
},
plugins: [react(), tailwindcss()],
},
})
+4333
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
{
"name": "ghosteditor-desktop",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./out/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json"
},
"dependencies": {
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-markdown": "^6.5.2",
"@codemirror/language": "^6.13.1",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.14",
"@fontsource/inter": "^5.3.0",
"@fontsource/jetbrains-mono": "^5.3.0",
"@fontsource/space-grotesk": "^5.3.0",
"@lezer/highlight": "^1.2.5",
"@tailwindcss/vite": "^4.3.3",
"@tiptap/markdown": "^3.31.4",
"@tiptap/pm": "^3.31.4",
"@tiptap/react": "^3.31.4",
"@tiptap/starter-kit": "^3.31.4",
"material-symbols": "^0.48.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"tailwindcss": "^4.3.3"
},
"devDependencies": {
"@types/node": "^26.6.5",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^5.2.0",
"electron": "^44.7.0",
"electron-vite": "^5.0.0",
"typescript": "^7.0.2",
"vite": "^7.3.7"
}
}
+75
View File
@@ -0,0 +1,75 @@
import { app, BrowserWindow, ipcMain } from 'electron'
import { join } from 'node:path'
import { HOST_NOT_CONNECTED, type EditorFile } from '../shared/host'
import { openInWordcraft } from './wordcraft'
function registerHost(): void {
for (const channel of ['host:open', 'host:save', 'host:check', 'host:build'] as const) {
ipcMain.removeHandler(channel)
ipcMain.handle(channel, () => HOST_NOT_CONNECTED)
}
ipcMain.removeHandler('host:edit')
ipcMain.handle('host:edit', (_event, files: EditorFile[], activeId: string) => openInWordcraft(files, activeId))
}
function registerChrome(): void {
ipcMain.removeHandler('window:minimize')
ipcMain.removeHandler('window:toggleMaximize')
ipcMain.removeHandler('window:close')
ipcMain.handle('window:minimize', (event) => {
BrowserWindow.fromWebContents(event.sender)?.minimize()
})
ipcMain.handle('window:toggleMaximize', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
if (win.isMaximized()) win.unmaximize()
else win.maximize()
})
ipcMain.handle('window:close', (event) => {
BrowserWindow.fromWebContents(event.sender)?.close()
})
}
function createWindow(): void {
const win = new BrowserWindow({
width: 1440,
height: 900,
minWidth: 1180,
minHeight: 720,
show: false,
frame: false,
backgroundColor: '#0b1326',
title: 'GhostEditor',
autoHideMenuBar: true,
webPreferences: {
preload: join(__dirname, '../preload/index.cjs'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
})
win.on('ready-to-show', () => win.show())
const devUrl = process.env.ELECTRON_RENDERER_URL
if (devUrl) {
void win.loadURL(devUrl)
} else {
void win.loadFile(join(__dirname, '../renderer/index.html'))
}
}
app.whenReady().then(() => {
registerHost()
registerChrome()
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
+60
View File
@@ -0,0 +1,60 @@
import { spawn } from 'node:child_process'
import { existsSync, readdirSync } from 'node:fs'
import { mkdir, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { app } from 'electron'
import type { EditorFile, HostResult } from '../shared/host'
const ROOT = join(tmpdir(), 'ghosteditor-docs')
export function findWordcraft(): string | null {
const fromEnv = process.env.WORDCRAFT_EXE
if (fromEnv && existsSync(fromEnv)) return fromEnv
const roots = [process.cwd(), app.getAppPath(), join(app.getAppPath(), '..'), join(app.getAppPath(), '../..')]
for (const root of roots) {
const found = findExe(join(root, '.wordcraft', 'app'))
if (found) return found
}
return null
}
function findExe(dir: string): string | null {
if (!existsSync(dir)) return null
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name)
if (entry.isFile() && entry.name.toLowerCase() === 'wordcraft.exe') return full
if (entry.isDirectory()) {
const nested = join(full, 'wordcraft.exe')
if (existsSync(nested)) return nested
const deeper = findExe(full)
if (deeper) return deeper
}
}
return null
}
function safeRelative(id: string): string | null {
const parts = id.replace(/\\/g, '/').split('/').filter(Boolean)
if (parts.length === 0 || parts.some((part) => part === '..' || part.includes(':'))) return null
return parts.join('/')
}
export async function openInWordcraft(files: EditorFile[], activeId: string): Promise<HostResult> {
const exe = findWordcraft()
if (!exe) return { ok: false, error: 'WordCraft не найден' }
const active = safeRelative(activeId)
if (!active) return { ok: false, error: 'некорректный путь файла' }
for (const file of files) {
const relative = safeRelative(file.id)
if (!relative) continue
const path = join(ROOT, relative)
await mkdir(dirname(path), { recursive: true })
await writeFile(path, file.text, 'utf8')
}
const child = spawn(exe, [join(ROOT, active)], { detached: true, stdio: 'ignore', windowsHide: false })
child.unref()
return { ok: true }
}
+21
View File
@@ -0,0 +1,21 @@
import { contextBridge, ipcRenderer } from 'electron'
import type { ChromeApi } from '../shared/chrome'
import type { HostApi, HostResult } from '../shared/host'
const host: HostApi = {
open: () => ipcRenderer.invoke('host:open') as Promise<HostResult>,
save: (text) => ipcRenderer.invoke('host:save', text) as Promise<HostResult>,
check: (text) => ipcRenderer.invoke('host:check', text) as Promise<HostResult>,
build: (text) => ipcRenderer.invoke('host:build', text) as Promise<HostResult>,
edit: (files, activeId) => ipcRenderer.invoke('host:edit', files, activeId) as Promise<HostResult>,
}
const chrome: ChromeApi = {
platform: process.platform,
minimize: () => ipcRenderer.invoke('window:minimize') as Promise<void>,
toggleMaximize: () => ipcRenderer.invoke('window:toggleMaximize') as Promise<void>,
close: () => ipcRenderer.invoke('window:close') as Promise<void>,
}
contextBridge.exposeInMainWorld('host', host)
contextBridge.exposeInMainWorld('frame', chrome)
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GhostEditor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
import { EditorHost } from './components/EditorHost'
import { SettingsPanel } from './components/SettingsPanel'
import { Sidebar } from './components/Sidebar'
import { StatusBar } from './components/StatusBar'
import { TitleBar } from './components/TitleBar'
import { SessionProvider } from './session'
import { SettingsProvider } from './settings'
export function App() {
return (
<SessionProvider>
<SettingsProvider>
<Shell />
</SettingsProvider>
</SessionProvider>
)
}
function Shell() {
return (
<div className="flex h-full min-h-0 flex-col bg-surface text-on-surface">
<TitleBar />
<div className="flex min-h-0 flex-1">
<Sidebar />
<EditorHost />
<SettingsPanel />
</div>
<StatusBar />
</div>
)
}
@@ -0,0 +1,88 @@
import { useCallback, useRef } from 'react'
import { NotionEditor } from '../editor/NotionEditor'
import { SourceEditor } from '../editor/SourceEditor'
import { useSession } from '../session'
import { useSettings } from '../settings'
import { WordPreview } from './WordPreview'
export function EditorHost() {
const { text, setText, mode, setCursor, focusLine, focusToken, editorCommand, editorCommandToken, files, activeId } = useSession()
const { settings } = useSettings()
const previewRef = useRef<HTMLDivElement>(null)
const lock = useRef(false)
const editorScroll = useRef<(ratio: number) => void>(() => {})
const source = mode !== 'notion'
const onEditorScroll = useCallback(
(ratio: number) => {
if (!settings.syncScroll || !settings.split || lock.current) return
const pane = previewRef.current
if (!pane) return
const max = pane.scrollHeight - pane.clientHeight
lock.current = true
pane.scrollTop = ratio * Math.max(0, max)
lock.current = false
},
[settings.syncScroll, settings.split],
)
const bindScrollTo = useCallback((scrollTo: (ratio: number) => void) => {
editorScroll.current = scrollTo
}, [])
function onPreviewScroll(): void {
if (!settings.syncScroll || lock.current) return
const pane = previewRef.current
if (!pane) return
const max = pane.scrollHeight - pane.clientHeight
const ratio = max <= 0 ? 0 : pane.scrollTop / max
lock.current = true
editorScroll.current(ratio)
lock.current = false
}
return (
<div className="flex min-h-0 min-w-0 flex-1">
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-surface-container-lowest" style={{ zoom: settings.zoom / 100 }}>
{text.trimStart().startsWith('<svg') && !settings.split ? (
<div className="flex max-h-72 shrink-0 items-center justify-center border-b border-outline-variant/40 bg-[#1a2438] p-6">
<div
className="max-h-60 max-w-full bg-white p-4 [&_svg]:h-auto [&_svg]:max-h-52 [&_svg]:max-w-full"
dangerouslySetInnerHTML={{ __html: text }}
/>
</div>
) : null}
<div className="min-h-0 flex-1">
<SourceEditor
active={source}
text={text}
focusLine={focusLine}
focusToken={focusToken}
fontSize={settings.fontSize}
gutterSize={settings.gutterSize}
showLineNumbers={settings.showLineNumbers}
lineWrapping={settings.lineWrapping}
highlightLine={settings.highlightLine}
editorCommand={editorCommand}
editorCommandToken={editorCommandToken}
onScrollRatio={onEditorScroll}
bindScrollTo={bindScrollTo}
onChange={setText}
onCursor={(line, col) => setCursor({ line, col })}
/>
<NotionEditor active={mode === 'notion'} text={text} onChange={setText} />
</div>
</div>
{settings.split ? (
<WordPreview
ref={previewRef}
text={text}
fileName={files[activeId]?.name ?? ''}
fileId={activeId}
files={files}
onScroll={onPreviewScroll}
/>
) : null}
</div>
)
}
@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react'
import { HELP_TOPICS, type HelpTopic } from '../helpTopics'
export function HelpPanel({ topic, onClose }: { topic: HelpTopic; onClose: () => void }) {
const [current, setCurrent] = useState(topic.id)
useEffect(() => {
setCurrent(topic.id)
}, [topic.id])
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
const shown = HELP_TOPICS.find((item) => item.id === current) ?? topic
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55" onMouseDown={onClose}>
<div
className="flex max-h-[70vh] w-[40rem] overflow-hidden rounded-lg border border-outline-variant bg-surface-container-low shadow-2xl"
onMouseDown={(event) => event.stopPropagation()}
>
<nav className="flex w-44 shrink-0 flex-col gap-1 border-r border-outline-variant/60 bg-surface-container-lowest p-2">
{HELP_TOPICS.map((item) => (
<button
key={item.id}
type="button"
onClick={() => setCurrent(item.id)}
className={`rounded px-2 py-1.5 text-left text-[13px] ${
item.id === shown.id
? 'bg-surface-container-highest text-on-surface'
: 'text-on-surface-variant hover:bg-surface-container hover:text-on-surface'
}`}
>
{item.title}
</button>
))}
</nav>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center justify-between px-4 pt-3">
<h2 className="font-headline text-headline-md text-on-surface">{shown.title}</h2>
<button
type="button"
title="Закрыть справку"
onClick={onClose}
className="h-7 rounded px-2 font-code text-label-sm text-on-surface-variant hover:bg-surface-container-high hover:text-on-surface"
>
Закрыть
</button>
</div>
<div className="min-h-0 flex-1 overflow-auto px-4 pt-3 pb-4">
{shown.paragraphs.map((paragraph) => (
<p key={paragraph} className="mb-3 text-[13px] leading-5 text-on-surface-variant">
{paragraph}
</p>
))}
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,7 @@
export function Icon({ name, size = 16 }: { name: string; size?: number }) {
return (
<span className="material-symbols-outlined align-middle" style={{ fontSize: size }} aria-hidden>
{name}
</span>
)
}
@@ -0,0 +1,283 @@
import { useEffect, useState, type ReactNode } from 'react'
import type { HostResult } from '@shared/host'
import { HELP_TOPICS, type HelpTopic } from '../helpTopics'
import { useSession, type EditorCommand } from '../session'
import { useSettings } from '../settings'
import { HelpPanel } from './HelpPanel'
import { Icon } from './Icon'
type MenuId = 'file' | 'edit' | 'select' | 'view' | 'help'
export function MenuBar() {
const [menu, setMenu] = useState<MenuId | null>(null)
const [about, setAbout] = useState(false)
const [help, setHelp] = useState<HelpTopic | null>(null)
const { text, files, activeId, setMode, setNotice, runEditorCommand } = useSession()
const { settings, updateSettings, openSettings } = useSettings()
useEffect(() => {
if (!menu && !about) return
const onKey = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return
if (menu) setMenu(null)
else setAbout(false)
}
const onPointer = (event: MouseEvent) => {
const target = event.target
const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null
if (element?.closest('[data-menu-root]')) return
setMenu(null)
}
window.addEventListener('keydown', onKey)
window.addEventListener('mousedown', onPointer)
return () => {
window.removeEventListener('keydown', onKey)
window.removeEventListener('mousedown', onPointer)
}
}, [menu, about])
function edit(command: EditorCommand): void {
setMode('code')
runEditorCommand(command)
setMenu(null)
}
async function host(task: () => Promise<HostResult>): Promise<void> {
setMenu(null)
try {
const result = await task()
setNotice(result.ok ? null : result.error)
} catch {
setNotice('не подключено')
}
}
function openMenu(id: MenuId): void {
setMenu((current) => (current === id ? null : id))
}
return (
<div data-menu-root className="no-drag flex h-full items-stretch">
<div className="relative flex h-full">
<MenuButton label="Файлы" open={menu === 'file'} onClick={() => openMenu('file')} onHover={() => menu && setMenu('file')} />
{menu === 'file' ? (
<Drop>
<Item label="Открыть…" onClick={() => void host(() => window.host.open())} />
<Item label="Сохранить" onClick={() => void host(() => window.host.save(text))} />
<Item
label="Открыть в WordCraft"
onClick={() =>
void host(() =>
window.host.edit(
Object.values(files).map((file) => ({ id: file.id, text: file.id === activeId ? text : file.text })),
activeId,
),
)
}
/>
<Rule />
<Item
label="Настройки"
onClick={() => {
openSettings()
setMenu(null)
}}
/>
<Rule />
<Item
label="Выход"
onClick={() => {
setMenu(null)
void window.frame.close()
}}
/>
</Drop>
) : null}
</div>
<div className="relative flex h-full">
<MenuButton label="Правка" open={menu === 'edit'} onClick={() => openMenu('edit')} onHover={() => menu && setMenu('edit')} />
{menu === 'edit' ? (
<Drop>
<Item label="Отменить" shortcut="Ctrl+Z" onClick={() => edit('undo')} />
<Item label="Повторить" shortcut="Ctrl+Y" onClick={() => edit('redo')} />
</Drop>
) : null}
</div>
<div className="relative flex h-full">
<MenuButton
label="Выделение"
open={menu === 'select'}
onClick={() => openMenu('select')}
onHover={() => menu && setMenu('select')}
/>
{menu === 'select' ? (
<Drop>
<Item label="Выделить всё" shortcut="Ctrl+A" onClick={() => edit('selectAll')} />
</Drop>
) : null}
</div>
<div className="relative flex h-full">
<MenuButton label="Вид" open={menu === 'view'} onClick={() => openMenu('view')} onHover={() => menu && setMenu('view')} />
{menu === 'view' ? (
<Drop>
<Item
label="Разделить экран"
checked={settings.split}
onClick={() => {
updateSettings({ split: !settings.split })
setMenu(null)
}}
/>
<Item
label="Синхронная прокрутка"
checked={settings.split && settings.syncScroll}
onClick={() => {
if (!settings.split) updateSettings({ split: true, syncScroll: true })
else updateSettings({ syncScroll: !settings.syncScroll })
setMenu(null)
}}
/>
<Rule />
<Item
label="Номера строк"
checked={settings.showLineNumbers}
onClick={() => updateSettings({ showLineNumbers: !settings.showLineNumbers })}
/>
<Item
label="Перенос строк"
checked={settings.lineWrapping}
onClick={() => updateSettings({ lineWrapping: !settings.lineWrapping })}
/>
<Item
label="Подсветка строки"
checked={settings.highlightLine}
onClick={() => updateSettings({ highlightLine: !settings.highlightLine })}
/>
<Rule />
<Item
label="Увеличить"
onClick={() => {
updateSettings({ zoom: Math.min(180, settings.zoom + 10) })
setMenu(null)
}}
/>
<Item
label="Уменьшить"
onClick={() => {
updateSettings({ zoom: Math.max(70, settings.zoom - 10) })
setMenu(null)
}}
/>
</Drop>
) : null}
</div>
<div className="relative flex h-full">
<MenuButton label="Справка" open={menu === 'help'} onClick={() => openMenu('help')} onHover={() => menu && setMenu('help')} />
{menu === 'help' ? (
<Drop>
{HELP_TOPICS.map((topic) => (
<Item
key={topic.id}
label={topic.title}
onClick={() => {
setMenu(null)
setHelp(topic)
}}
/>
))}
<Item
label="О программе"
onClick={() => {
setMenu(null)
setAbout(true)
}}
/>
</Drop>
) : null}
</div>
{help ? <HelpPanel topic={help} onClose={() => setHelp(null)} /> : null}
{about ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55" onMouseDown={() => setAbout(false)}>
<div
className="w-80 rounded-lg border border-outline-variant bg-surface-container-low p-4 shadow-2xl"
onMouseDown={(event) => event.stopPropagation()}
>
<h2 className="font-headline text-headline-md text-on-surface">GhostEditor</h2>
<p className="mt-2 text-[13px] leading-5 text-on-surface-variant">
Черновик отчёта пишется здесь. Страницу с полями, стилями и лентой открывает WordCraft. Сборку по ГОСТ по-прежнему
делает конвейер md2gost.
</p>
<button
type="button"
onClick={() => setAbout(false)}
className="mt-4 h-7 rounded bg-primary-container px-3 font-code text-label-sm text-on-primary-container"
>
Закрыть
</button>
</div>
</div>
) : null}
</div>
)
}
function MenuButton({
label,
open,
onClick,
onHover,
}: {
label: string
open: boolean
onClick: () => void
onHover: () => void
}) {
return (
<button
type="button"
onClick={onClick}
onMouseEnter={onHover}
className={`px-2.5 text-[13px] ${open ? 'bg-surface-container-high text-on-surface' : 'text-on-surface-variant hover:bg-surface-container hover:text-on-surface'}`}
>
{label}
</button>
)
}
function Drop({ children }: { children: ReactNode }) {
return (
<div className="absolute top-full left-0 z-40 min-w-56 border border-outline-variant bg-surface-container py-1 shadow-[0_8px_24px_rgba(0,0,0,0.45)]">
{children}
</div>
)
}
function Item({
label,
shortcut,
checked,
onClick,
}: {
label: string
shortcut?: string
checked?: boolean
onClick: () => void
}) {
return (
<button
type="button"
role="menuitem"
onClick={onClick}
className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-[13px] text-on-surface hover:bg-surface-container-highest"
>
<span className="flex w-4 shrink-0 justify-center text-primary">{checked ? <Icon name="check" size={14} /> : null}</span>
<span className="flex-1">{label}</span>
{shortcut ? <span className="font-code text-[11px] text-outline">{shortcut}</span> : null}
</button>
)
}
function Rule() {
return <div className="my-1 h-px bg-outline-variant/60" />
}
@@ -0,0 +1,297 @@
import { useEffect, useState } from 'react'
import { useSettings, type PreviewMargin, type PreviewSpacing } from '../settings'
import { Icon } from './Icon'
type CategoryId = 'editor' | 'view' | 'preview' | 'document'
const CATEGORIES: Array<{ id: CategoryId; label: string }> = [
{ id: 'editor', label: 'Редактор' },
{ id: 'view', label: 'Вид' },
{ id: 'preview', label: 'Предпросмотр' },
{ id: 'document', label: 'Документ' },
]
const SPACING: Array<{ id: PreviewSpacing; label: string }> = [
{ id: 100, label: '1' },
{ id: 115, label: '1,15' },
{ id: 150, label: '1,5' },
]
const MARGINS: Array<{ id: PreviewMargin; label: string }> = [
{ id: 'narrow', label: 'Узкие' },
{ id: 'normal', label: 'Обычные' },
{ id: 'wide', label: 'Широкие' },
]
export function SettingsPanel() {
const { settings, updateSettings, resetEditorSettings, resetPreviewSettings, open, toggleOpen } = useSettings()
const [category, setCategory] = useState<CategoryId>('editor')
useEffect(() => {
if (!open) return
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') toggleOpen()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, toggleOpen])
if (!open) return null
return (
<aside className="flex w-[26rem] shrink-0 flex-col border-l border-outline-variant/50 bg-surface-container-low">
<div className="flex h-9 shrink-0 items-center justify-between px-3">
<span className="font-body text-[11px] font-semibold tracking-wider text-on-surface-variant uppercase">
Настройки
</span>
<button
type="button"
title="Закрыть настройки"
onClick={toggleOpen}
className="flex h-[22px] w-[22px] items-center justify-center rounded text-on-surface-variant hover:bg-surface-container-high hover:text-on-surface"
>
<Icon name="close" size={16} />
</button>
</div>
<div className="flex min-h-0 flex-1">
<nav className="flex w-36 shrink-0 flex-col border-r border-outline-variant/40 py-1">
{CATEGORIES.map((item) => (
<button
key={item.id}
type="button"
onClick={() => setCategory(item.id)}
className={`mx-1 h-7 rounded px-2 text-left text-[13px] ${
category === item.id
? 'bg-surface-container-highest text-on-surface'
: 'text-on-surface-variant hover:bg-surface-container hover:text-on-surface'
}`}
>
{item.label}
</button>
))}
</nav>
<div className="min-h-0 flex-1 overflow-auto px-3 py-3">
{category === 'editor' ? (
<div className="flex flex-col gap-3">
<Slider
label="Шрифт кода"
min={11}
max={20}
value={settings.fontSize}
suffix="px"
onChange={(fontSize) => updateSettings({ fontSize })}
/>
<Slider
label="Номера строк"
min={8}
max={16}
value={settings.gutterSize}
suffix="px"
onChange={(gutterSize) => updateSettings({ gutterSize })}
/>
<Check
label="Показывать номера строк"
checked={settings.showLineNumbers}
onChange={(showLineNumbers) => updateSettings({ showLineNumbers })}
/>
<Check
label="Переносить длинные строки"
checked={settings.lineWrapping}
onChange={(lineWrapping) => updateSettings({ lineWrapping })}
/>
<Check
label="Подсвечивать активную строку"
checked={settings.highlightLine}
onChange={(highlightLine) => updateSettings({ highlightLine })}
/>
<button
type="button"
onClick={resetEditorSettings}
className="h-7 self-start rounded px-2 font-code text-label-sm text-on-surface-variant hover:bg-surface-container-high hover:text-on-surface"
>
Сбросить редактор
</button>
</div>
) : null}
{category === 'view' ? (
<div className="flex flex-col gap-3">
<Slider
label="Масштаб"
min={70}
max={180}
step={10}
value={settings.zoom}
suffix="%"
onChange={(zoom) => updateSettings({ zoom })}
/>
<Check
label="Разделить экран"
checked={settings.split}
onChange={(split) => updateSettings({ split })}
/>
<Check
label="Синхронная прокрутка"
checked={settings.syncScroll}
onChange={(syncScroll) => updateSettings({ syncScroll, split: syncScroll ? true : settings.split })}
/>
</div>
) : null}
{category === 'preview' ? (
<div className="flex flex-col gap-3">
<Slider
label="Шрифт страницы"
min={12}
max={20}
value={settings.previewFontSize}
suffix="px"
onChange={(previewFontSize) => updateSettings({ previewFontSize })}
/>
<Choice
label="Межстрочный интервал"
value={settings.previewSpacing}
options={SPACING}
onChange={(previewSpacing) => updateSettings({ previewSpacing })}
/>
<Choice
label="Поля"
value={settings.previewMargin}
options={MARGINS}
onChange={(previewMargin) => updateSettings({ previewMargin })}
/>
<button
type="button"
onClick={resetPreviewSettings}
className="h-7 self-start rounded px-2 font-code text-label-sm text-on-surface-variant hover:bg-surface-container-high hover:text-on-surface"
>
Сбросить предпросмотр
</button>
</div>
) : null}
{category === 'document' ? (
<div className="flex flex-col gap-3">
<TextField
label="ФИО"
value={settings.student}
placeholder="Иванов Иван Иванович"
onChange={(student) => updateSettings({ student })}
/>
<TextField
label="Группа"
value={settings.group}
placeholder="ИКБО-00-00"
onChange={(group) => updateSettings({ group })}
/>
<p className="text-[11px] leading-4 text-outline">Профиль документа выбирается в левой панели.</p>
</div>
) : null}
</div>
</div>
</aside>
)
}
function Slider({
label,
min,
max,
step = 1,
value,
suffix,
onChange,
}: {
label: string
min: number
max: number
step?: number
value: number
suffix: string
onChange: (value: number) => void
}) {
return (
<label className="flex flex-col gap-1">
<span className="flex items-center justify-between text-[13px] text-on-surface">
{label}
<span className="font-code text-label-sm text-primary">
{value}
{suffix}
</span>
</span>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(event) => onChange(Number(event.target.value))}
className="accent-primary"
/>
</label>
)
}
function Check({ label, checked, onChange }: { label: string; checked: boolean; onChange: (checked: boolean) => void }) {
return (
<label className="flex items-center gap-2 text-[13px] text-on-surface">
<input type="checkbox" checked={checked} onChange={(event) => onChange(event.target.checked)} className="accent-primary" />
{label}
</label>
)
}
function Choice<T extends string | number>({
label,
value,
options,
onChange,
}: {
label: string
value: T
options: ReadonlyArray<{ id: T; label: string }>
onChange: (value: T) => void
}) {
return (
<div className="flex flex-col gap-1">
<span className="text-[13px] text-on-surface">{label}</span>
<div className="flex flex-wrap gap-1">
{options.map((option) => (
<button
key={String(option.id)}
type="button"
onClick={() => onChange(option.id)}
className={`h-7 rounded px-2 font-code text-label-sm ${
option.id === value
? 'bg-primary-container text-on-primary-container'
: 'bg-surface-container text-on-surface-variant hover:text-on-surface'
}`}
>
{option.label}
</button>
))}
</div>
</div>
)
}
function TextField({
label,
value,
placeholder,
onChange,
}: {
label: string
value: string
placeholder: string
onChange: (value: string) => void
}) {
return (
<label className="flex flex-col gap-1">
<span className="text-[13px] text-on-surface">{label}</span>
<input
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className="h-8 rounded border border-outline-variant bg-surface-container-lowest px-2 font-body text-[13px] text-on-surface outline-none select-text placeholder:text-outline focus:border-primary"
/>
</label>
)
}
@@ -0,0 +1,626 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
import { folderIds, type WorkspaceFile } from '../documents'
import {
DEFAULT_EXPANDED,
WORKSPACE_NAME,
documentHeadings,
parentMap,
visibleRows,
type Heading,
type TreeNode,
} from '../explorer'
import { useSession } from '../session'
import { buildTree } from '../tree'
import { DOC_PROFILES, useSettings, type DocProfileId } from '../settings'
import { Icon } from './Icon'
type SideView = 'files' | 'search' | 'outline'
const VIEWS: Array<{ id: SideView; icon: string; title: string }> = [
{ id: 'files', icon: 'folder', title: 'Проводник' },
{ id: 'search', icon: 'search', title: 'Поиск' },
{ id: 'outline', icon: 'toc', title: 'Структура' },
]
const ACTIVITY_WIDTH = 48
const PANEL_MIN = 200
const PANEL_MAX = 420
const PANEL_DEFAULT = 240
const WIDTH_KEY = 'ghosteditor.sidebar.width'
function readWidth(): number {
try {
const raw = localStorage.getItem(WIDTH_KEY)
const value = raw ? Number(raw) : PANEL_DEFAULT
if (!Number.isFinite(value)) return PANEL_DEFAULT
return Math.min(PANEL_MAX, Math.max(PANEL_MIN, value))
} catch {
return PANEL_DEFAULT
}
}
function clampWidth(value: number): number {
return Math.min(PANEL_MAX, Math.max(PANEL_MIN, value))
}
export function Sidebar() {
const { open: settingsOpen, toggleOpen } = useSettings()
const [view, setView] = useState<SideView>('files')
const [panelOpen, setPanelOpen] = useState(true)
const [width, setWidth] = useState(readWidth)
const [resizing, setResizing] = useState(false)
function choose(next: SideView): void {
if (view === next && panelOpen) {
setPanelOpen(false)
return
}
setView(next)
setPanelOpen(true)
}
function startResize(event: ReactPointerEvent<HTMLDivElement>): void {
event.preventDefault()
const sash = event.currentTarget
const startX = event.clientX
const startW = width
setResizing(true)
const onMove = (ev: PointerEvent) => setWidth(clampWidth(startW + ev.clientX - startX))
const onUp = (ev: PointerEvent) => {
sash.removeEventListener('pointermove', onMove)
sash.removeEventListener('pointerup', onUp)
sash.removeEventListener('pointercancel', onUp)
setResizing(false)
const next = clampWidth(startW + ev.clientX - startX)
setWidth(next)
try {
localStorage.setItem(WIDTH_KEY, String(next))
} catch {
/* ширина просто не запомнится */
}
}
sash.setPointerCapture(event.pointerId)
sash.addEventListener('pointermove', onMove)
sash.addEventListener('pointerup', onUp)
sash.addEventListener('pointercancel', onUp)
}
return (
<div
className="flex h-full min-h-0 shrink-0 overflow-hidden"
style={{ width: panelOpen ? ACTIVITY_WIDTH + width : ACTIVITY_WIDTH }}
>
<nav className="flex h-full w-12 shrink-0 flex-col items-center border-r border-outline-variant/50 bg-surface-container-lowest py-1">
{VIEWS.map((item) => {
const active = panelOpen && view === item.id
return (
<button
key={item.id}
type="button"
title={active ? `Скрыть «${item.title}»` : item.title}
aria-pressed={active}
onClick={() => choose(item.id)}
className={`relative flex h-12 w-12 items-center justify-center ${
active ? 'text-on-surface' : 'text-outline hover:text-on-surface'
}`}
>
{active ? <span className="absolute top-3 bottom-3 left-0 w-0.5 rounded-r bg-primary" /> : null}
<Icon name={item.icon} size={22} />
</button>
)
})}
<button
type="button"
title={settingsOpen ? 'Закрыть настройки' : 'Настройки'}
aria-pressed={settingsOpen}
onClick={toggleOpen}
className={`relative mt-auto flex h-12 w-12 items-center justify-center hover:text-on-surface ${
settingsOpen ? 'text-on-surface' : 'text-outline'
}`}
>
{settingsOpen ? <span className="absolute top-3 bottom-3 left-0 w-0.5 rounded-r bg-primary" /> : null}
<Icon name="settings" size={22} />
</button>
</nav>
{panelOpen ? (
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden border-r border-outline-variant/50 bg-surface-container-low">
<ProfileRow />
{view === 'files' ? <Explorer /> : null}
{view === 'search' ? <SearchPanel /> : null}
{view === 'outline' ? <OutlinePanel /> : null}
<div
role="separator"
aria-orientation="vertical"
title="Потяните, чтобы изменить ширину"
onPointerDown={startResize}
className={`absolute top-0 right-0 z-10 h-full w-1 cursor-ew-resize touch-none ${
resizing ? 'bg-primary' : 'hover:bg-primary/70'
}`}
/>
</div>
) : null}
</div>
)
}
function Explorer() {
const { text, files, folders, activeId, openFile, createFile, createFolder, activity, setMode, focusLineAt } = useSession()
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(DEFAULT_EXPANDED))
const [selected, setSelected] = useState(activeId)
const [sections, setSections] = useState({ files: true, outline: false, log: false })
const treeRef = useRef<HTMLDivElement>(null)
const filesRef = useRef(files)
filesRef.current = files
const roots = useMemo(() => buildTree(Object.values(files), folders), [files, folders])
const parents = useMemo(() => parentMap(roots), [roots])
const rows = visibleRows(roots, expanded)
const headings = documentHeadings(text)
useEffect(() => {
setSelected(activeId)
const file = filesRef.current[activeId]
if (!file) return
setExpanded((prev) => {
const ids = folderIds(file.dir)
if (ids.every((id) => prev.has(id))) return prev
const next = new Set(prev)
for (const id of ids) next.add(id)
return next
})
}, [activeId])
useEffect(() => {
const node = treeRef.current?.querySelector(`[data-tree-id="${CSS.escape(selected)}"]`)
if (node instanceof HTMLElement) node.scrollIntoView({ block: 'nearest' })
}, [selected, expanded])
function toggle(id: string): void {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function onKeyDown(event: KeyboardEvent<HTMLDivElement>): void {
const index = rows.findIndex((row) => row.node.id === selected)
if (index < 0) return
const current = rows[index]
if (!current) return
if (event.key === 'ArrowDown') {
event.preventDefault()
const next = rows[index + 1]
if (next) setSelected(next.node.id)
} else if (event.key === 'ArrowUp') {
event.preventDefault()
const prev = rows[index - 1]
if (prev) setSelected(prev.node.id)
} else if (event.key === 'ArrowRight' && current.node.kind === 'folder') {
event.preventDefault()
const next = rows[index + 1]
if (!expanded.has(current.node.id)) toggle(current.node.id)
else if (next && next.depth > current.depth) setSelected(next.node.id)
} else if (event.key === 'ArrowLeft') {
event.preventDefault()
if (current.node.kind === 'folder' && expanded.has(current.node.id)) toggle(current.node.id)
else {
const parent = parents.get(current.node.id)
if (parent) setSelected(parent)
}
} else if (event.key === 'Enter') {
event.preventDefault()
if (current.node.kind === 'folder') toggle(current.node.id)
else openFile(current.node.id)
}
}
function jump(line: number): void {
setMode('code')
focusLineAt(line)
}
return (
<div className="flex min-h-0 flex-1 flex-col">
<ViewHeader title="Проводник">
<HeaderButton
icon="note_add"
title="Новый файл в mirea"
onClick={() => {
createFile()
}}
/>
<HeaderButton
icon="create_new_folder"
title="Новая папка в mirea"
onClick={() => {
const id = createFolder()
setExpanded((prev) => new Set(prev).add('dir:mirea').add(id))
setSelected(id)
}}
/>
<HeaderButton
icon="refresh"
title="Вернуть раскрытие по умолчанию"
onClick={() => {
setExpanded(new Set(DEFAULT_EXPANDED))
setSelected(activeId)
}}
/>
<HeaderButton
icon="unfold_less"
title="Свернуть папки"
onClick={() => {
setExpanded(new Set())
setSelected((current) => (roots.some((node) => node.id === current) ? current : 'dir:mirea'))
}}
/>
</ViewHeader>
<div className="flex min-h-0 flex-1 flex-col">
<Section
title={WORKSPACE_NAME}
open={sections.files}
grow
onToggle={() => setSections((prev) => ({ ...prev, files: !prev.files }))}
>
<div
ref={treeRef}
role="tree"
tabIndex={0}
onKeyDown={onKeyDown}
className="h-full overflow-auto py-0.5 outline-none"
>
{rows.map(({ node, depth }) => (
<TreeRow
key={node.id}
node={node}
depth={depth}
expanded={expanded.has(node.id)}
selected={node.id === selected}
dirty={isDirty(files[node.id])}
openInEditor={node.id === activeId}
onSelect={() => {
setSelected(node.id)
if (node.kind === 'folder') toggle(node.id)
else openFile(node.id)
treeRef.current?.focus()
}}
/>
))}
</div>
</Section>
<Section
title="Структура"
ruled
open={sections.outline}
onToggle={() => setSections((prev) => ({ ...prev, outline: !prev.outline }))}
>
<HeadingList headings={headings} onJump={jump} />
</Section>
<Section
title="Журнал"
ruled
open={sections.log}
onToggle={() => setSections((prev) => ({ ...prev, log: !prev.log }))}
>
{activity.length === 0 ? (
<p className="px-3 py-1.5 text-[13px] text-on-surface-variant">Действий ещё не было</p>
) : (
activity.map((entry, index) => (
<p key={`${entry}-${index}`} className="truncate px-3 py-0.5 text-[13px] text-on-surface-variant">
{entry}
</p>
))
)}
</Section>
</div>
</div>
)
}
function isDirty(file: WorkspaceFile | undefined): boolean {
return Boolean(file && file.text !== file.initial)
}
type SearchHit = {
key: string
fileId: string
label: string
directory: string
line?: number
icon: string
tone: string
}
function searchHits(files: WorkspaceFile[], needle: string): SearchHit[] {
const hits: SearchHit[] = []
for (const file of files) {
const directory = [WORKSPACE_NAME, ...file.dir].join(' / ')
if (file.name.toLocaleLowerCase('ru').includes(needle)) {
hits.push({
key: `name:${file.id}`,
fileId: file.id,
label: file.name,
directory,
icon: file.icon,
tone: file.tone,
})
}
const lines = file.text.split('\n')
for (let index = 0; index < lines.length; index += 1) {
if (hits.length >= 40) return hits
const line = lines[index] ?? ''
if (!line.toLocaleLowerCase('ru').includes(needle)) continue
const label = line.trim()
if (!label) continue
hits.push({
key: `line:${file.id}:${index}`,
fileId: file.id,
label: label.slice(0, 96),
directory: file.name,
line: index + 1,
icon: file.icon,
tone: file.tone,
})
}
}
return hits
}
function SearchPanel() {
const { files, openFile } = useSession()
const [query, setQuery] = useState('')
const [selected, setSelected] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const needle = query.trim().toLocaleLowerCase('ru')
const hits = needle ? searchHits(Object.values(files), needle) : []
useEffect(() => {
inputRef.current?.focus()
}, [])
return (
<div className="flex min-h-0 flex-1 flex-col">
<ViewHeader title="Поиск" />
<div className="px-3 pb-2">
<input
ref={inputRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Имя файла или текст"
className="h-7 w-full rounded border border-outline-variant bg-surface-container-lowest px-2 font-body text-[13px] text-on-surface outline-none placeholder:text-outline focus:border-primary"
/>
</div>
<div className="min-h-0 flex-1 overflow-auto">
{!needle ? <p className="px-3 py-1 text-[13px] text-outline">Введите имя файла или фрагмент текста</p> : null}
{needle && hits.length === 0 ? <p className="px-3 py-1 text-[13px] text-outline">Ничего не найдено</p> : null}
{hits.map((hit) => (
<button
key={hit.key}
type="button"
title={hit.line ? `${hit.directory}, строка ${hit.line}` : hit.directory}
onClick={() => {
setSelected(hit.key)
openFile(hit.fileId, hit.line ?? 1)
}}
className={`flex h-[22px] w-full items-center gap-1.5 px-3 text-left text-[13px] ${
selected === hit.key
? 'bg-surface-container-highest text-on-surface'
: 'text-on-surface hover:bg-surface-container'
}`}
>
<span className={hit.tone}>
<Icon name={hit.icon} size={16} />
</span>
<span className="truncate">{highlightName(hit.label, query)}</span>
<span className="ml-auto shrink-0 pl-2 text-[11px] text-outline">
{hit.line ? `:${hit.line}` : hit.directory}
</span>
</button>
))}
</div>
</div>
)
}
function OutlinePanel() {
const { text, setMode, focusLineAt } = useSession()
const headings = documentHeadings(text)
return (
<div className="flex min-h-0 flex-1 flex-col">
<ViewHeader title="Структура" />
<div className="min-h-0 flex-1 overflow-auto">
<HeadingList
headings={headings}
onJump={(line) => {
setMode('code')
focusLineAt(line)
}}
/>
</div>
</div>
)
}
function ViewHeader({ title, children }: { title: string; children?: ReactNode }) {
return (
<div className="flex h-9 shrink-0 items-center justify-between gap-2 pr-2 pl-4">
<span className="truncate font-body text-[11px] font-semibold tracking-wider text-on-surface-variant uppercase">
{title}
</span>
<div className="flex items-center">{children}</div>
</div>
)
}
function HeaderButton({ icon, title, onClick }: { icon: string; title: string; onClick?: () => void }) {
return (
<button
type="button"
title={title}
onClick={onClick}
className="flex h-[22px] w-[22px] items-center justify-center rounded text-on-surface-variant hover:bg-surface-container-high hover:text-on-surface"
>
<Icon name={icon} size={16} />
</button>
)
}
function Section({
title,
open,
onToggle,
grow = false,
ruled = false,
children,
}: {
title: string
open: boolean
onToggle: () => void
grow?: boolean
ruled?: boolean
children?: ReactNode
}) {
return (
<section
className={`flex flex-col ${ruled ? 'border-t border-outline-variant/50' : ''} ${
open && grow ? 'min-h-0 flex-1' : 'shrink-0'
}`}
>
<button
type="button"
onClick={onToggle}
className="flex h-[22px] shrink-0 items-center gap-0.5 px-0.5 text-left hover:bg-surface-container"
>
<span className="text-on-surface-variant">
<Icon name={open ? 'expand_more' : 'chevron_right'} size={16} />
</span>
<span className="truncate font-body text-[11px] font-semibold tracking-wide text-on-surface-variant uppercase">
{title}
</span>
</button>
{open ? (
<div className={grow ? 'min-h-0 flex-1 overflow-hidden' : 'max-h-48 overflow-auto'}>{children}</div>
) : null}
</section>
)
}
function TreeRow({
node,
depth,
expanded,
selected,
dirty,
openInEditor,
onSelect,
}: {
node: TreeNode
depth: number
expanded: boolean
selected: boolean
dirty: boolean
openInEditor: boolean
onSelect: () => void
}) {
const folder = node.kind === 'folder'
const title = folder ? node.name : openInEditor ? `${node.name} — открыт` : node.name
return (
<div
role="treeitem"
aria-expanded={folder ? expanded : undefined}
aria-selected={selected}
data-tree-id={node.id}
title={title}
onClick={onSelect}
className={`relative flex h-[22px] cursor-pointer items-center pr-2 text-[13px] leading-none ${
selected ? 'bg-surface-container-highest text-on-surface' : 'text-on-surface hover:bg-surface-container'
}`}
style={{ paddingLeft: depth * 12 }}
>
{Array.from({ length: depth }, (_, level) => (
<span
key={level}
aria-hidden
className="pointer-events-none absolute top-0 bottom-0 w-px bg-outline-variant/70"
style={{ left: level * 12 + 7 }}
/>
))}
<span className="flex w-4 shrink-0 items-center justify-center text-on-surface-variant">
{folder ? <Icon name={expanded ? 'expand_more' : 'chevron_right'} size={16} /> : null}
</span>
<span className={node.tone}>
<Icon name={folder && expanded ? 'folder_open' : node.icon} size={16} />
</span>
<span className="ml-1.5 min-w-0 flex-1 truncate">{node.name}</span>
{dirty ? (
<span className="ml-2 flex shrink-0 items-center gap-1.5" title="Есть несохранённые правки">
<span className="font-code text-[11px] text-tertiary">M</span>
<span className="h-1.5 w-1.5 rounded-full bg-tertiary" />
</span>
) : openInEditor ? (
<span className="ml-2 h-1.5 w-1.5 shrink-0 rounded-full bg-primary" />
) : null}
</div>
)
}
function HeadingList({ headings, onJump }: { headings: Heading[]; onJump: (line: number) => void }) {
if (headings.length === 0) {
return <p className="px-3 py-1.5 text-[13px] text-outline">В документе нет заголовков</p>
}
return (
<div className="py-0.5">
{headings.map((heading) => (
<button
key={`${heading.line}:${heading.title}`}
type="button"
title={`Перейти к строке ${heading.line}`}
onClick={() => onJump(heading.line)}
className="flex h-[22px] w-full items-center gap-1.5 pr-2 text-left text-[13px] text-on-surface hover:bg-surface-container"
style={{ paddingLeft: 8 + (heading.level - 1) * 12 }}
>
<span className="w-4 shrink-0 text-center font-code text-[10px] text-outline">{heading.level}</span>
<span className="truncate">{heading.title}</span>
</button>
))}
</div>
)
}
function ProfileRow() {
const { settings, updateSettings } = useSettings()
return (
<label className="flex h-9 shrink-0 items-center gap-2 border-b border-outline-variant/50 px-3">
<span className="shrink-0 font-body text-[11px] font-semibold tracking-wide text-on-surface-variant uppercase">
Профиль
</span>
<select
value={settings.profile}
title="Профиль документа"
onChange={(event) => updateSettings({ profile: event.target.value as DocProfileId })}
className="h-7 min-w-0 flex-1 cursor-pointer rounded border border-outline-variant bg-surface-container-lowest px-1.5 font-code text-[12px] text-on-surface outline-none focus:border-primary"
>
{DOC_PROFILES.map((item) => (
<option key={item.id} value={item.id} className="bg-surface-container-high">
{item.label}
</option>
))}
</select>
</label>
)
}
function highlightName(name: string, query: string): ReactNode {
const needle = query.trim()
const index = name.toLocaleLowerCase('ru').indexOf(needle.toLocaleLowerCase('ru'))
if (!needle || index < 0) return name
return (
<>
{name.slice(0, index)}
<span className="text-primary">{name.slice(index, index + needle.length)}</span>
{name.slice(index + needle.length)}
</>
)
}
@@ -0,0 +1,52 @@
import { documentStats } from '../documents'
import { useSession } from '../session'
import { useSettings } from '../settings'
import { Icon } from './Icon'
export function StatusBar() {
const { text, cursor, mode, notice, files, activeId } = useSession()
const { settings, updateSettings } = useSettings()
const stats = documentStats(text)
const file = files[activeId]
const zoom = settings.zoom
const dirty = Boolean(file && file.text !== file.initial)
return (
<footer className="flex h-status-bar-height shrink-0 items-center justify-between gap-gutter-md bg-surface-container px-gutter-md font-code text-label-sm text-on-surface-variant">
<div className="flex min-w-0 items-center gap-gutter-md">
<span className="flex max-w-52 items-center gap-1 truncate" title={file?.id ?? ''}>
<Icon name="description" size={13} />
{file?.name ?? 'документ'}
{dirty ? <span className="text-tertiary">M</span> : null}
</span>
<span className="flex items-center gap-1">
<Icon name="data_object" size={13} />
UTF-8
</span>
{mode === 'notion' ? (
<span>Блоки</span>
) : (
<span>
Ln {cursor.line}, Col {cursor.col}
</span>
)}
<span>{stats.words.toLocaleString('ru-RU')} слов</span>
<span>{stats.tables} табл.</span>
<span>{stats.figures} рис.</span>
<span>{stats.formulas} форм.</span>
{notice ? <span className="truncate text-error">{notice}</span> : null}
</div>
<div className="flex items-center gap-gutter-md">
<span className="flex items-center gap-2">
<button type="button" title="Уменьшить" onClick={() => updateSettings({ zoom: Math.max(70, zoom - 10) })}>
<Icon name="zoom_out" size={14} />
</button>
<span className="text-on-surface">{zoom}%</span>
<button type="button" title="Увеличить" onClick={() => updateSettings({ zoom: Math.min(180, zoom + 10) })}>
<Icon name="zoom_in" size={14} />
</button>
</span>
</div>
</footer>
)
}
@@ -0,0 +1,228 @@
import type { ReactNode } from 'react'
import type { HostResult } from '@shared/host'
import { Icon } from './Icon'
import { MenuBar } from './MenuBar'
import type { InsertKind } from '../documents'
import { useSession, type EditorMode } from '../session'
import { useSettings } from '../settings'
const MODES: Array<{ id: EditorMode; icon: string; title: string }> = [
{ id: 'code', icon: 'code', title: 'Код' },
{ id: 'notion', icon: 'view_agenda', title: 'Блоки' },
]
const INSERTS: Array<{ icon: string; label: string; title: string; kind: InsertKind }> = [
{ icon: 'title', label: 'Введение', title: 'Вставить введение', kind: 'intro' },
{ icon: 'table_chart', label: 'Таблица', title: 'Вставить таблицу', kind: 'table' },
{ icon: 'image', label: 'Рисунок', title: 'Вставить рисунок', kind: 'figure' },
{ icon: 'functions', label: '$$', title: 'Вставить формулу', kind: 'formula' },
{ icon: 'account_tree', label: 'Схема', title: 'Вставить схему', kind: 'diagram' },
{ icon: 'format_quote', label: 'Ссылка', title: 'Вставить ссылку на источник', kind: 'ref' },
{ icon: 'insert_page_break', label: 'Разрыв', title: 'Вставить разрыв страницы', kind: 'break' },
]
export function TitleBar() {
const { mode, setMode, text, files, activeId, setNotice, insertSnippet } = useSession()
const { settings, updateSettings } = useSettings()
async function call(task: () => Promise<HostResult>): Promise<void> {
try {
const result = await task()
setNotice(result.ok ? null : result.error)
} catch {
setNotice('не подключено')
}
}
const mac = window.frame.platform === 'darwin'
return (
<header className="flex flex-col bg-surface-container-low shadow-[0_1px_8px_rgba(0,0,0,0.5)]">
<div className="flex h-window-bar-height items-stretch bg-surface-container-lowest">
{mac ? <MacControls /> : null}
<div
className="drag-region flex min-w-0 flex-1 items-center gap-gutter-md px-gutter-md"
onDoubleClick={(event) => {
if ((event.target as HTMLElement).closest('.no-drag')) return
void window.frame.toggleMaximize()
}}
>
<MenuBar />
<div className="no-drag ml-auto flex h-7 shrink-0 items-center gap-2">
<div className="flex h-7 items-center rounded bg-surface-container-high p-0.5">
{MODES.map((item) => (
<button
key={item.id}
type="button"
title={item.title}
onClick={() => setMode(item.id)}
className={
mode === item.id
? 'flex h-6 items-center gap-1 rounded bg-primary-container px-2 font-code text-label-sm text-on-primary-container'
: 'flex h-6 items-center gap-1 rounded px-2 font-code text-label-sm text-on-surface-variant hover:bg-surface-variant hover:text-on-surface'
}
>
<Icon name={item.icon} size={14} />
{item.title}
</button>
))}
</div>
<IconToggle
on={settings.split}
title={settings.split ? 'Закрыть предпросмотр' : 'Разделить экран'}
icon="vertical_split"
onClick={() => updateSettings({ split: !settings.split })}
/>
<IconToggle
on={settings.split && settings.syncScroll}
title={settings.split && settings.syncScroll ? 'Выключить синхронную прокрутку' : 'Синхронная прокрутка'}
icon="sync"
onClick={() => {
if (!settings.split) updateSettings({ split: true, syncScroll: true })
else updateSettings({ syncScroll: !settings.syncScroll })
}}
/>
<button
type="button"
title="Открыть страницу в WordCraft"
onClick={() =>
void call(() =>
window.host.edit(
Object.values(files).map((file) => ({ id: file.id, text: file.id === activeId ? text : file.text })),
activeId,
),
)
}
className="flex h-6 items-center gap-1 rounded bg-surface-container-high px-2 font-code text-label-sm text-on-surface hover:bg-surface-variant"
>
<Icon name="article" size={13} />
WordCraft
</button>
<button
type="button"
onClick={() => void call(() => window.host.build(text))}
className="flex h-6 items-center gap-1 rounded bg-primary-container px-2 font-code text-label-sm font-semibold text-on-primary-container hover:bg-primary-fixed"
>
<Icon name="check_circle" size={13} />
Собрать DOCX
</button>
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-primary text-on-primary">
<Icon name="person" size={16} />
</div>
</div>
</div>
{mac ? null : <WinControls />}
</div>
<div className="flex h-9 items-center gap-gutter-md px-gutter-md">
<div className="no-drag flex min-w-0 items-center gap-0.5 overflow-x-auto">
{INSERTS.map((item, index) => (
<span key={item.label} className="flex items-center">
{index === 1 || index === 4 ? <span className="mx-1 h-4 w-px shrink-0 bg-outline-variant" /> : null}
<button
type="button"
title={item.title}
onClick={() => insertSnippet(item.kind)}
className="flex h-7 shrink-0 items-center gap-1 rounded px-2 font-code text-label-sm text-on-surface-variant hover:bg-surface-container-high hover:text-on-surface"
>
<Icon name={item.icon} size={14} />
<span className="leading-none">{item.label}</span>
</button>
</span>
))}
</div>
</div>
</header>
)
}
function IconToggle({
on,
title,
icon,
onClick,
}: {
on: boolean
title: string
icon: string
onClick: () => void
}) {
return (
<button
type="button"
title={title}
aria-pressed={on}
onClick={onClick}
className={`flex h-7 w-7 items-center justify-center rounded ${
on ? 'bg-surface-container-high text-primary' : 'text-on-surface-variant hover:bg-surface-container-high hover:text-on-surface'
}`}
>
<Icon name={icon} size={16} />
</button>
)
}
function MacControls() {
return (
<div className="no-drag flex items-center gap-2 pl-gutter-md">
<TrafficLight color="bg-[#ff5f57]" title="Закрыть" onClick={() => void window.frame.close()} />
<TrafficLight color="bg-[#febc2e]" title="Свернуть" onClick={() => void window.frame.minimize()} />
<TrafficLight
color="bg-[#28c840]"
title="Развернуть"
onClick={() => void window.frame.toggleMaximize()}
/>
</div>
)
}
function TrafficLight({ color, title, onClick }: { color: string; title: string; onClick: () => void }) {
return <button type="button" title={title} onClick={onClick} className={`h-3 w-3 rounded-full ${color}`} />
}
function WinControls() {
return (
<div className="no-drag flex h-full text-on-surface">
<CaptionButton title="Свернуть" onClick={() => void window.frame.minimize()}>
<span className="block h-px w-2.5 bg-current" />
</CaptionButton>
<CaptionButton title="Развернуть" onClick={() => void window.frame.toggleMaximize()}>
<span className="block h-2.5 w-2.5 border border-current" />
</CaptionButton>
<CaptionButton title="Закрыть" close onClick={() => void window.frame.close()}>
<span className="relative block h-2.5 w-2.5">
<span className="absolute top-1/2 left-0 h-px w-full -translate-y-1/2 rotate-45 bg-current" />
<span className="absolute top-1/2 left-0 h-px w-full -translate-y-1/2 -rotate-45 bg-current" />
</span>
</CaptionButton>
</div>
)
}
function CaptionButton({
title,
onClick,
close = false,
children,
}: {
title: string
onClick: () => void
close?: boolean
children: ReactNode
}) {
return (
<button
type="button"
title={title}
onClick={onClick}
className={
close
? 'flex h-full w-[46px] items-center justify-center hover:bg-[#e81123] hover:text-white'
: 'flex h-full w-[46px] items-center justify-center hover:bg-white/10'
}
>
{children}
</button>
)
}
@@ -0,0 +1,442 @@
import { forwardRef, type ReactNode } from 'react'
import type { WorkspaceFile } from '../documents'
import { previewBlocks, type PreviewBlock } from '../preview'
import {
diagramLayout,
isDiagramLang,
parseBib,
parseDiagram,
parseYamlPreview,
previewKind,
resolveWorkspacePath,
type DiagramModel,
type DiagramNode,
type PreviewKind,
} from '../previewFile'
import { useSettings, type PreviewMargin } from '../settings'
const MARGINS: Record<PreviewMargin, string> = {
narrow: '12mm',
normal: '20mm',
wide: '28mm',
}
const LABELS: Record<PreviewKind, string> = {
markdown: 'Предпросмотр',
svg: 'Рисунок',
yaml: 'Поля',
bib: 'Источники',
diagram: 'Схема',
text: 'Текст',
}
type WordPreviewProps = {
text: string
fileName: string
fileId: string
files: Record<string, WorkspaceFile>
onScroll: () => void
}
export const WordPreview = forwardRef<HTMLDivElement, WordPreviewProps>(function WordPreview(
{ text, fileName, fileId, files, onScroll },
ref,
) {
const kind = previewKind(fileName, text)
return (
<section className="flex min-h-0 min-w-0 flex-1 flex-col border-l border-outline-variant/50 bg-[#1a2438]">
<div className="flex h-7 shrink-0 items-center gap-2 px-3 font-code text-label-sm text-on-surface-variant">
<span>{LABELS[kind]}</span>
{fileName ? <span className="truncate text-outline">{fileName}</span> : null}
</div>
<div ref={ref} onScroll={onScroll} className="min-h-0 flex-1 overflow-auto px-4 py-6">
{kind === 'svg' ? <SvgSheet text={text} /> : null}
{kind === 'yaml' ? <YamlSheet text={text} /> : null}
{kind === 'bib' ? <BibSheet text={text} /> : null}
{kind === 'diagram' ? <DiagramSheet text={text} /> : null}
{kind === 'text' ? <TextSheet text={text} /> : null}
{kind === 'markdown' ? <MarkdownSheet text={text} fileId={fileId} files={files} /> : null}
</div>
</section>
)
})
function Sheet({ children, mono = false }: { children: ReactNode; mono?: boolean }) {
const { settings } = useSettings()
return (
<article
className="mx-auto min-h-[297mm] bg-white text-black shadow-[0_8px_28px_rgba(0,0,0,0.35)]"
style={{
width: 'min(180mm, 100%)',
padding: MARGINS[settings.previewMargin],
fontFamily: mono ? '"JetBrains Mono", ui-monospace, monospace' : '"Times New Roman", Times, serif',
fontSize: `${settings.previewFontSize}px`,
lineHeight: settings.previewSpacing / 100,
}}
>
{children}
</article>
)
}
function SvgSheet({ text }: { text: string }) {
const svg = text.trim()
return (
<Sheet>
{svg.startsWith('<svg') ? (
<div className="flex min-h-[120mm] items-center justify-center [&_svg]:h-auto [&_svg]:max-h-[160mm] [&_svg]:max-w-full" dangerouslySetInnerHTML={{ __html: svg }} />
) : (
<p className="text-[#8a8a8a]">В файле нет рисунка SVG</p>
)}
</Sheet>
)
}
function YamlSheet({ text }: { text: string }) {
const rows = parseYamlPreview(text)
return (
<Sheet>
{rows.length === 0 ? <p className="text-[#8a8a8a]">Пустой файл</p> : null}
{rows.map((row, index) =>
row.note ? (
<p key={index} className="text-[#666] italic" style={{ paddingLeft: `${row.depth * 0.6}em` }}>
{row.value}
</p>
) : (
<p key={index} className="my-[0.15em]" style={{ paddingLeft: `${row.depth * 0.6}em` }}>
{row.key ? <span className="font-bold">{row.key}</span> : null}
{row.key && row.value ? <span>: </span> : null}
{row.value}
</p>
),
)}
</Sheet>
)
}
function BibSheet({ text }: { text: string }) {
const entries = parseBib(text)
return (
<Sheet>
{entries.length === 0 ? <p className="text-[#8a8a8a]">В файле нет записей</p> : null}
{entries.map((entry) => {
const title = entry.fields.find((field) => field.key === 'title')?.value ?? entry.id
const author = entry.fields.find((field) => field.key === 'author')?.value
return (
<section key={`${entry.type}:${entry.id}`} className="mb-[1em]">
<p className="mb-[0.15em] text-[0.8em] tracking-wide text-[#555] uppercase">{entry.type}</p>
<p className="font-bold">{title}</p>
{author ? <p>{author}</p> : null}
<p className="text-[0.9em] text-[#333]">
{entry.fields
.filter((field) => field.key !== 'title' && field.key !== 'author')
.map((field) => `${field.key}: ${field.value}`)
.join(' · ')}
</p>
</section>
)
})}
</Sheet>
)
}
function TextSheet({ text }: { text: string }) {
return (
<Sheet mono>
{text.trim() ? <pre className="font-mono whitespace-pre-wrap">{text}</pre> : <p className="text-[#8a8a8a]">Пустой файл</p>}
</Sheet>
)
}
function DiagramSheet({ text }: { text: string }) {
const model = parseDiagram(text)
return (
<Sheet>
<DiagramView model={model} source={text} />
</Sheet>
)
}
function DiagramView({ model, source }: { model: DiagramModel; source: string }) {
if (model.nodes.length === 0) {
return <pre className="font-mono text-[0.85em] whitespace-pre-wrap">{source.trim() || 'Пустая схема'}</pre>
}
const layout = diagramLayout(model)
return (
<div>
{model.title ? <p className="mb-[0.8em] text-center font-bold">{model.title}</p> : null}
{layout === 'sequence' ? <SequenceDiagram model={model} /> : null}
{layout === 'entities' ? <EntityDiagram model={model} /> : null}
{layout === 'blocks' ? <BlockDiagram model={model} /> : null}
</div>
)
}
function nodeName(model: DiagramModel, id: string): string {
return model.nodes.find((node) => node.id === id)?.label ?? id
}
function SequenceDiagram({ model }: { model: DiagramModel }) {
const columns = model.nodes.filter((node) => node.kind === 'actor' || node.kind === 'participant' || node.kind === 'database')
const index = new Map(columns.map((node, place) => [node.id, place]))
return (
<div className="overflow-x-auto">
<div className="grid gap-x-3" style={{ gridTemplateColumns: `repeat(${Math.max(columns.length, 1)}, minmax(6.5em, 1fr))` }}>
{columns.map((node) => (
<div key={node.id} className="border border-[#222] px-[0.3em] py-[0.45em] text-center text-[0.9em]">
{node.label}
</div>
))}
{model.edges.map((edge, edgeIndex) => {
const from = index.get(edge.from) ?? 0
const to = index.get(edge.to) ?? from
const start = Math.min(from, to) + 1
const span = Math.abs(to - from) + 1
return (
<div
key={edgeIndex}
className="mt-[0.35em] border-b border-[#222] pb-[0.15em] text-center text-[0.8em]"
style={{ gridColumn: `${start} / span ${span}`, gridRow: edgeIndex + 2 }}
>
{edge.label} {to >= from ? '→' : '←'}
</div>
)
})}
</div>
</div>
)
}
function EntityDiagram({ model }: { model: DiagramModel }) {
return (
<div>
<div className="flex flex-wrap justify-center gap-[1em]">
{model.nodes
.filter((node) => node.kind === 'entity')
.map((node) => (
<table key={node.id} className="border-collapse">
<caption className="border border-[#222] bg-[#f3f3f3] px-[0.6em] py-[0.2em] font-bold">{node.label}</caption>
<tbody>
{node.fields.map((field) => (
<tr key={field}>
<td className="border border-[#222] px-[0.5em] py-[0.1em]">{field}</td>
</tr>
))}
</tbody>
</table>
))}
</div>
<EdgeList model={model} />
</div>
)
}
function BlockDiagram({ model }: { model: DiagramModel }) {
return (
<div>
<div className="flex flex-wrap items-start justify-center gap-[1em]">
{model.nodes.map((node) => (
<BlockCard key={node.id} node={node} />
))}
</div>
<EdgeList model={model} />
</div>
)
}
function BlockCard({ node }: { node: DiagramNode }) {
const rounded = node.kind === 'usecase' || node.kind === 'person' || node.kind === 'actor'
return (
<div
className="min-w-[8em] max-w-[14em] border border-[#222] px-[0.7em] py-[0.45em] text-center"
style={{ borderRadius: rounded ? '999px' : '2px', background: node.kind === 'external' ? '#f7f7f7' : '#fff' }}
>
<div className="text-[0.75em] tracking-wide text-[#555] uppercase">{kindLabel(node.kind)}</div>
<div className="font-bold">{node.label}</div>
{node.detail ? <div className="text-[0.85em]">{node.detail}</div> : null}
</div>
)
}
function kindLabel(kind: DiagramNode['kind']): string {
if (kind === 'person' || kind === 'actor') return 'человек'
if (kind === 'external') return 'внешняя'
if (kind === 'database') return 'хранилище'
if (kind === 'usecase') return 'вариант'
if (kind === 'entity') return 'сущность'
if (kind === 'participant') return 'участник'
return 'система'
}
function EdgeList({ model }: { model: DiagramModel }) {
if (model.edges.length === 0) return null
return (
<ul className="mt-[1em] list-none pl-0">
{model.edges.map((edge, index) => (
<li key={index} className="my-[0.2em] text-center">
{nodeName(model, edge.from)} → {nodeName(model, edge.to)}
{edge.label ? ` — ${edge.label}` : ''}
</li>
))}
</ul>
)
}
function MarkdownSheet({
text,
fileId,
files,
}: {
text: string
fileId: string
files: Record<string, WorkspaceFile>
}) {
const blocks = previewBlocks(text)
return (
<Sheet>
{blocks.length === 0 ? <p className="text-[#8a8a8a]">Пустой документ</p> : null}
{blocks.map((block, index) => (
<MarkdownBlock key={index} block={block} fileId={fileId} files={files} />
))}
</Sheet>
)
}
function MarkdownBlock({
block,
fileId,
files,
}: {
block: PreviewBlock
fileId: string
files: Record<string, WorkspaceFile>
}) {
if (block.type === 'h') {
const size = block.level === 1 ? '1.35em' : block.level === 2 ? '1.15em' : '1.05em'
return (
<h2 className="mt-[0.8em] mb-[0.35em] font-bold" style={{ fontSize: size, textAlign: block.level === 1 ? 'center' : 'left' }}>
<Rich text={block.text} />
</h2>
)
}
if (block.type === 'ul' || block.type === 'ol') {
const Tag = block.type === 'ul' ? 'ul' : 'ol'
return (
<Tag className="my-[0.4em] list-outside pl-[1.4em]">
{block.items.map((item, itemIndex) => (
<li key={itemIndex} className={block.type === 'ul' ? 'list-disc' : 'list-decimal'}>
<Rich text={item} />
</li>
))}
</Tag>
)
}
if (block.type === 'quote') {
return (
<blockquote className="my-[0.5em] border-l-2 border-[#8a8a8a] pl-[0.8em] text-[#333] italic">
<Rich text={block.text} />
</blockquote>
)
}
if (block.type === 'code' && isDiagramLang(block.lang)) {
return (
<div className="my-[0.7em]">
<DiagramView model={parseDiagram(block.text)} source={block.text} />
</div>
)
}
if (block.type === 'code') {
return <pre className="my-[0.6em] overflow-x-auto bg-[#f3f3f3] p-[0.6em] font-mono text-[0.85em] whitespace-pre-wrap">{block.text}</pre>
}
if (block.type === 'table') {
return (
<table className="my-[0.6em] w-full border-collapse">
<tbody>
{block.rows.map((row, rowIndex) => (
<tr key={rowIndex}>
{row.map((cell, cellIndex) => (
<td key={cellIndex} className="border border-[#333] px-[0.4em] py-[0.15em]" style={{ fontWeight: rowIndex === 0 ? 700 : 400 }}>
<Rich text={cell} />
</td>
))}
</tr>
))}
</tbody>
</table>
)
}
if (block.type === 'formula') return <p className="my-[0.6em] text-center">{block.text}</p>
if (block.type === 'caption') {
return (
<p className="mt-[0.7em] mb-[0.2em] text-center font-bold">
<Rich text={block.text} />
</p>
)
}
if (block.type === 'figure') return <FigureBlock alt={block.alt} src={block.src} fileId={fileId} files={files} />
if (block.type === 'break') return <hr className="my-[1em] border-dashed border-[#bdbdbd]" />
return (
<p className="my-[0.35em] text-justify" style={{ textIndent: '1.25cm' }}>
<Rich text={block.text} />
</p>
)
}
function FigureBlock({
alt,
src,
fileId,
files,
}: {
alt: string
src: string
fileId: string
files: Record<string, WorkspaceFile>
}) {
const linked = files[resolveWorkspacePath(fileId, src)]
const svg = linked?.text.trimStart().startsWith('<svg') ? linked.text : src.trimStart().startsWith('<svg') ? src : ''
return (
<figure className="my-[0.7em] text-center">
{svg ? (
<div className="mx-auto max-w-full [&_svg]:mx-auto [&_svg]:h-auto [&_svg]:max-h-[80mm] [&_svg]:max-w-full" dangerouslySetInnerHTML={{ __html: svg }} />
) : (
<div className="mx-auto flex h-[4.5em] w-[10em] items-center justify-center border border-[#bdbdbd] text-[0.85em] text-[#666]">рисунок</div>
)}
<figcaption className="mt-[0.3em]">{alt}</figcaption>
</figure>
)
}
function Rich({ text }: { text: string }) {
const chunks = text.split(
/(\*\*[^*]+?\*\*|\*[^*]+?\*|`[^`]+`|\[[^\]]+?\]\([^)]+?\)|@(?:Рисунок|Таблица|Листинг|Формула):[A-Za-z0-9_-]+)/g,
)
return chunks.map((chunk, index) => {
if (chunk.startsWith('**') && chunk.endsWith('**') && chunk.length > 4) return <strong key={index}>{chunk.slice(2, -2)}</strong>
if (chunk.startsWith('*') && chunk.endsWith('*') && chunk.length > 2) return <em key={index}>{chunk.slice(1, -1)}</em>
if (chunk.startsWith('`') && chunk.endsWith('`') && chunk.length > 2) {
return (
<code key={index} className="font-mono text-[0.9em]">
{chunk.slice(1, -1)}
</code>
)
}
if (chunk.startsWith('@')) {
return (
<span key={index} className="text-[#0563c1]">
{chunk}
</span>
)
}
const link = /^\[([^\]]+)\]\([^)]+\)$/.exec(chunk)
if (link) {
return (
<span key={index} className="text-[#0563c1] underline">
{link[1]}
</span>
)
}
return <span key={index}>{chunk}</span>
})
}
+1
View File
@@ -0,0 +1 @@
declare module '*.css' {}
+370
View File
@@ -0,0 +1,370 @@
export type WorkspaceFile = {
id: string
name: string
dir: string[]
icon: string
tone: string
text: string
initial: string
}
export type InsertKind = 'intro' | 'table' | 'figure' | 'formula' | 'diagram' | 'ref' | 'break'
export const ACTIVE_FILE_ID = 'mirea/coursework_report.md'
const COURSEWORK = `# *СОДЕРЖАНИЕ
[TOC]
# *ВВЕДЕНИЕ
В настоящей работе рассматривается задача построения информационной системы учёта. Цель работы — спроектировать архитектуру и реализовать прототип модуля обработки данных.
# Анализ предметной области
Предметная область связана с обработкой структурированных данных. Обзор подходов приведён в работах [1], [2].
Структура системы показана на @Рисунок:arch.
![Архитектура](figures/context.svg "%arch Архитектура информационной системы")
Сравнение вариантов реализации представлено в @Таблица:cmp.
%cmp Сравнение подходов
| Подход | Сложность | Гибкость |
|--------|-----------|----------|
| Монолит | низкая | низкая |
| Модульный | средняя | высокая |
| Микросервисы | высокая | высокая |
Основные требования к системе:
- поддержка импорта данных;
- формирование отчётности;
- разграничение прав доступа.
Порядок разработки включает следующие этапы:
1. анализ требований;
2. проектирование архитектуры;
3. реализация прототипа.
# Проектирование
Ключевое соотношение для оценки объёма данных задаётся формулой:
%eq1
$$
V = N \\times S
$$
где $N$ — число записей, $S$ — средний размер записи. Использование зависимости приведено в (@Формула:eq1).
Фрагмент реализации:
%code1 Процедура расчёта объёма
\`\`\`python
def volume(n: int, s: float) -> float:
return n * s
\`\`\`
Пример вызова приведён в @Листинг:code1.
Диаграмма контейнеров лежит в файле schemas/uml_c4.puml, модель данных — в schemas/er.puml.
# *ЗАКЛЮЧЕНИЕ
В работе выполнен анализ предметной области, спроектирована архитектура и реализован прототип модуля расчёта.
# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ
[1]: Иванов И. И. Информационные системы. — М.: Наука, 2023. — 120 с.
[2]: Петров П. П. Базы данных: учебник. — СПб.: Питер, 2022. — 200 с.
[3]: ГОСТ 7.32-2017. Отчет о научно-исследовательской работе. — М., 2022.
[4]: Сидоров С. С. Разработка на Python. — М.: ДМК Пресс, 2024. — 300 с.
[5]: Козлов К. К. Анализ данных. — М.: Бином, 2022. — 150 с.
[6]: Смирнова А. А. Проектирование ПО. — М.: Инфра-М, 2023. — 180 с.
# *ПРИЛОЖЕНИЯ
## Приложение А Исходный код модуля
Дополнительный код модуля вынесен в приложение.
`
const EXAMPLE = `# *ВВЕДЕНИЕ
Краткий пример отчёта. Его можно править отдельно от курсовой: у каждого файла свой текст.
# 1 Постановка задачи
Нужно описать модуль и сослаться на источник [1].
%table1 Состав модуля
| Часть | Назначение |
|-------|------------|
| Интерфейс | ввод данных |
| Расчёт | обработка |
# *ЗАКЛЮЧЕНИЕ
Пример показывает разделы, таблицу и ссылку на источник.
# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ
[1]: ГОСТ 7.32-2017. Отчет о научно-исследовательской работе. — М., 2022.
`
const SPEC = `# Черновик профиля отчёта.
# Сборка DOCX этот файл не читает: ядро не подключено.
# Поля совпадают с тем, что позже уйдёт в спецификацию профиля.
profile: coursework
document: Курсовая работа
standard: ГОСТ 7.32
language: ru
institute: МИРЭА
department: Кафедра
discipline: Проектирование информационных систем
supervisor: Иванов И. И.
numbering:
figures: по разделам
tables: по разделам
formulas: сквозная для формул со ссылкой
sections:
- "*СОДЕРЖАНИЕ"
- "*ВВЕДЕНИЕ"
- Анализ предметной области
- Проектирование
- "*ЗАКЛЮЧЕНИЕ"
- "*СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ"
- "*ПРИЛОЖЕНИЯ"
`
const INFO = `# Данные титульного листа рядом с отчётом.
# ФИО и группа задаются в настройках окна, не здесь.
work_number: "0000"
year: 2026
institute: МИРЭА
department: Кафедра
discipline: Проектирование информационных систем
teacher: Иванов И. И.
theme: Информационная система учёта
`
const BIB = `@book{ivanov2023,
author = {Иванов, И. И.},
title = {Информационные системы},
publisher = {Наука},
address = {М.},
year = {2023},
pages = {120}
}
@book{petrov2022,
author = {Петров, П. П.},
title = {Базы данных},
publisher = {Питер},
address = {СПб.},
year = {2022},
pages = {200}
}
@standard{gost732,
title = {ГОСТ 7.32-2017. Отчет о научно-исследовательской работе},
address = {М.},
year = {2022}
}
`
const C4 = `@startuml
!include <C4/C4_Context>
title Контекст информационной системы учёта
Person(user, "Пользователь", "Ведёт учёт")
System(app, "Система учёта", "Принимает данные и строит отчёт")
System_Ext(store, "Хранилище", "База данных")
Rel(user, app, "Вводит записи")
Rel(app, store, "Читает и пишет")
@enduml
`
const ER = `@startuml
entity "Запись" as row {
* id
--
created_at
payload
}
entity "Пользователь" as person {
* id
--
name
}
person ||--o{ row : создаёт
@enduml
`
const SEQUENCE = `@startuml
actor Пользователь
participant Интерфейс
participant Расчёт
database Хранилище
Пользователь -> Интерфейс : ввод
Интерфейс -> Расчёт : volume(n, s)
Расчёт --> Интерфейс : V
Интерфейс -> Хранилище : сохранить
@enduml
`
const CONTEXT_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="420" height="180" viewBox="0 0 420 180">
<rect x="20" y="50" width="140" height="70" rx="8" fill="#e8f6ff" stroke="#0b1326"/>
<text x="90" y="90" text-anchor="middle" font-family="sans-serif" font-size="14" fill="#0b1326">Пользователь</text>
<rect x="250" y="40" width="150" height="90" rx="8" fill="#d7f0ff" stroke="#0b1326"/>
<text x="325" y="90" text-anchor="middle" font-family="sans-serif" font-size="14" fill="#0b1326">Система учёта</text>
<line x1="160" y1="85" x2="250" y2="85" stroke="#0b1326" marker-end="url(#arrow)"/>
<defs>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
<path d="M0,0 L6,3 L0,6 Z" fill="#0b1326"/>
</marker>
</defs>
</svg>
`
const CONTAINER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="440" height="200" viewBox="0 0 440 200">
<rect x="16" y="16" width="408" height="168" rx="10" fill="#f4f8fc" stroke="#0b1326"/>
<text x="32" y="40" font-family="sans-serif" font-size="13" fill="#0b1326">Система учёта</text>
<rect x="40" y="60" width="150" height="90" rx="6" fill="#ffffff" stroke="#0b1326"/>
<text x="115" y="110" text-anchor="middle" font-family="sans-serif" font-size="13" fill="#0b1326">Интерфейс</text>
<rect x="240" y="60" width="150" height="90" rx="6" fill="#ffffff" stroke="#0b1326"/>
<text x="315" y="110" text-anchor="middle" font-family="sans-serif" font-size="13" fill="#0b1326">Расчёт</text>
</svg>
`
function file(
id: string,
dir: string[],
name: string,
icon: string,
tone: string,
text: string,
): WorkspaceFile {
return { id, name, dir, icon, tone, text, initial: text }
}
export function seedFiles(): Record<string, WorkspaceFile> {
return {
[ACTIVE_FILE_ID]: file(ACTIVE_FILE_ID, ['mirea'], 'coursework_report.md', 'description', 'text-primary', COURSEWORK),
'mirea/mirea_spec.yaml': file('mirea/mirea_spec.yaml', ['mirea'], 'mirea_spec.yaml', 'data_object', 'text-secondary', SPEC),
'mirea/info_conv.yaml': file('mirea/info_conv.yaml', ['mirea'], 'info_conv.yaml', 'data_object', 'text-secondary', INFO),
'mirea/bibliography.bib': file('mirea/bibliography.bib', ['mirea'], 'bibliography.bib', 'menu_book', 'text-tertiary', BIB),
'mirea/schemas/uml_c4.puml': file(
'mirea/schemas/uml_c4.puml',
['mirea', 'schemas'],
'uml_c4.puml',
'account_tree',
'text-primary',
C4,
),
'mirea/schemas/er.puml': file('mirea/schemas/er.puml', ['mirea', 'schemas'], 'er.puml', 'account_tree', 'text-primary', ER),
'mirea/schemas/sequence.puml': file(
'mirea/schemas/sequence.puml',
['mirea', 'schemas'],
'sequence.puml',
'account_tree',
'text-primary',
SEQUENCE,
),
'mirea/figures/context.svg': file(
'mirea/figures/context.svg',
['mirea', 'figures'],
'context.svg',
'image',
'text-primary-fixed-dim',
CONTEXT_SVG,
),
'mirea/figures/container.svg': file(
'mirea/figures/container.svg',
['mirea', 'figures'],
'container.svg',
'image',
'text-primary-fixed-dim',
CONTAINER_SVG,
),
'example.md': file('example.md', [], 'example.md', 'description', 'text-primary', EXAMPLE),
}
}
export function folderIds(dir: string[]): string[] {
const ids: string[] = []
for (let index = 0; index < dir.length; index += 1) {
ids.push(`dir:${dir.slice(0, index + 1).join('/')}`)
}
return ids
}
export function makeSnippet(kind: InsertKind, n: number): string {
if (kind === 'intro') return '# *ВВЕДЕНИЕ\n\n'
if (kind === 'table') {
return `%table${n} Название таблицы\n\n| Показатель | Значение |\n|---|---|\n| | |\n\n`
}
if (kind === 'figure') return `![Описание](figures/context.svg "%fig${n} Название рисунка")\n\n`
if (kind === 'formula') return `%eq${n}\n$$\nE = mc^2\n$$\n\n`
if (kind === 'diagram') {
return `%scheme${n} Название схемы\n\n\`\`\`plantuml\n@startuml\nactor Пользователь\nПользователь --> (Система)\n@enduml\n\`\`\`\n\n`
}
if (kind === 'ref') return '[1]'
return '---\n\n'
}
export function insertAtCursor(
source: string,
line: number,
col: number,
snippet: string,
): { text: string; line: number } {
const lines = source.split('\n')
const index = Math.min(Math.max(lines.length - 1, 0), Math.max(0, line - 1))
const current = lines[index] ?? ''
const at = Math.min(current.length, Math.max(0, col - 1))
const block = snippet.includes('\n')
const chunk = `${block && current.slice(0, at).trim() !== '' ? '\n' : ''}${snippet}`
lines[index] = current.slice(0, at) + chunk + current.slice(at)
return { text: lines.join('\n'), line: index + chunk.split('\n').length }
}
export function documentStats(text: string): { words: number; tables: number; figures: number; formulas: number } {
const lines = text.split('\n')
let tables = 0
let figures = 0
let formulas = 0
let inMath = false
let inFence = false
for (const line of lines) {
if (/^(```|~~~)/.test(line)) {
if (!inFence && /^(```|~~~)(uml|plantuml|mermaid|mmd|idef0|dfd|c4|usecase)\b/i.test(line)) figures += 1
inFence = !inFence
continue
}
if (line.trim() === '$$') {
if (!inMath) formulas += 1
inMath = !inMath
continue
}
const trimmed = line.trim()
if (trimmed.includes('|') && trimmed.includes('-') && /^[\s|:-]+$/.test(trimmed)) tables += 1
if (/!\[[^\]]*]\(/.test(line)) figures += 1
}
const words = text.trim() ? text.trim().split(/\s+/).length : 0
return { words, tables, figures, formulas }
}
@@ -0,0 +1,325 @@
import { useEffect, useRef, useState } from 'react'
import { Icon } from '../components/Icon'
import {
BLOCK_KINDS,
createBlock,
moveBlock,
parseBlocks,
serializeBlocks,
type Block,
type BlockType,
} from './blocks'
type NotionEditorProps = {
active: boolean
text: string
onChange: (text: string) => void
}
type MenuState = { blockId: string; mode: 'insert' | 'convert' } | null
export function NotionEditor({ active, text, onChange }: NotionEditorProps) {
const onChangeRef = useRef(onChange)
const textRef = useRef(text)
const opened = useRef(false)
const owned = useRef(text)
const [blocks, setBlocks] = useState<Block[]>(() => parseBlocks(text))
const blocksRef = useRef(blocks)
onChangeRef.current = onChange
textRef.current = text
blocksRef.current = blocks
useEffect(() => {
if (active) {
opened.current = true
if (serializeBlocks(blocksRef.current) !== text) setBlocks(parseBlocks(text))
owned.current = text
return
}
if (!opened.current) return
const markdown = serializeBlocks(blocksRef.current)
if (markdown !== owned.current) onChangeRef.current(markdown)
}, [active, text])
useEffect(() => {
if (!active) return
const markdown = serializeBlocks(blocks)
if (markdown !== textRef.current) onChangeRef.current(markdown)
}, [active, blocks])
return (
<div className={active ? 'notion-editor h-full overflow-auto' : 'hidden'}>
<div className="mx-auto w-full max-w-[760px] px-16 py-10">
<BlockStack blocks={blocks} onChange={setBlocks} />
</div>
</div>
)
}
function BlockStack({ blocks, onChange }: { blocks: Block[]; onChange: (blocks: Block[]) => void }) {
const [menu, setMenu] = useState<MenuState>(null)
function update(id: string, patch: Partial<Block>) {
onChange(blocks.map((block) => (block.id === id ? { ...block, ...patch } : block)))
}
function insertAfter(id: string, type: BlockType) {
const index = blocks.findIndex((block) => block.id === id)
const next = blocks.slice()
next.splice(index + 1, 0, createBlock(type))
onChange(next)
setMenu(null)
}
function remove(id: string) {
if (blocks.length === 1) {
onChange([createBlock('paragraph')])
return
}
onChange(blocks.filter((block) => block.id !== id))
}
function choose(type: BlockType) {
if (!menu) return
if (menu.mode === 'convert') update(menu.blockId, { type })
else insertAfter(menu.blockId, type)
setMenu(null)
}
return (
<div className="flex flex-col gap-1" onClick={() => setMenu(null)}>
{blocks.map((block) => (
<BlockRow
key={block.id}
block={block}
menu={menu?.blockId === block.id ? menu.mode : null}
onText={(value) => update(block.id, { text: value })}
onOpenMenu={(mode) => setMenu({ blockId: block.id, mode })}
onChoose={choose}
onEnter={() => insertAfter(block.id, block.type === 'bullet' ? 'bullet' : 'paragraph')}
onDelete={() => remove(block.id)}
onDrop={(fromId) => onChange(moveBlock(blocks, fromId, block.id))}
/>
))}
<button
type="button"
className="mt-3 flex h-9 items-center gap-2 rounded border border-dashed border-outline-variant px-3 font-body text-body-md text-on-surface-variant hover:border-primary hover:text-on-surface"
onClick={(event) => {
event.stopPropagation()
const last = blocks[blocks.length - 1]
insertAfter(last.id, 'paragraph')
}}
>
<Icon name="add" size={16} />
Добавить блок
</button>
</div>
)
}
function BlockRow({
block,
menu,
onText,
onOpenMenu,
onChoose,
onEnter,
onDelete,
onDrop,
}: {
block: Block
menu: 'insert' | 'convert' | null
onText: (text: string) => void
onOpenMenu: (mode: 'insert' | 'convert') => void
onChoose: (type: BlockType) => void
onEnter: () => void
onDelete: () => void
onDrop: (fromId: string) => void
}) {
const kind = BLOCK_KINDS.find((item) => item.type === block.type) ?? BLOCK_KINDS[0]
return (
<div
className="group relative"
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault()
const fromId = event.dataTransfer.getData('text/plain')
if (fromId) onDrop(fromId)
}}
>
<div className="absolute top-1 -left-14 flex items-center gap-0.5 opacity-0 group-hover:opacity-100">
<button
type="button"
title="Вставить блок"
className="flex h-6 w-6 items-center justify-center rounded text-on-surface-variant hover:bg-surface-container-high hover:text-on-surface"
onClick={(event) => {
event.stopPropagation()
onOpenMenu('insert')
}}
>
<Icon name="add" size={16} />
</button>
<button
type="button"
title="Перетащить"
draggable
className="flex h-6 w-6 cursor-grab items-center justify-center rounded text-on-surface-variant hover:bg-surface-container-high hover:text-on-surface"
onDragStart={(event) => {
event.dataTransfer.setData('text/plain', block.id)
event.dataTransfer.effectAllowed = 'move'
}}
>
<Icon name="drag_indicator" size={16} />
</button>
</div>
<div className="flex items-start gap-2 rounded px-1 py-0.5 hover:bg-surface-container/70">
<button
type="button"
title="Сменить тип блока"
className="mt-1 flex h-5 min-w-16 shrink-0 items-center justify-center rounded bg-surface-container px-1.5 font-code text-[10px] text-on-surface-variant hover:text-primary"
onClick={(event) => {
event.stopPropagation()
onOpenMenu('convert')
}}
>
{kind.chip}
</button>
<div className="min-w-0 flex-1">
{block.type === 'bullet' ? (
<div className="flex items-start gap-2">
<span className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-primary" />
<BlockText block={block} onText={onText} onEnter={onEnter} onDelete={onDelete} />
</div>
) : (
<BlockText block={block} onText={onText} onEnter={onEnter} onDelete={onDelete} />
)}
</div>
</div>
{menu ? (
<div
className="absolute top-8 left-0 z-20 w-64 rounded-lg border border-outline-variant bg-surface-container-high p-1 shadow-[0_8px_24px_rgba(0,0,0,0.45)]"
onClick={(event) => event.stopPropagation()}
>
<div className="px-2 py-1 font-code text-label-sm tracking-wider text-outline uppercase">
{menu === 'insert' ? 'Новый блок' : 'Превратить в'}
</div>
{BLOCK_KINDS.map((item) => (
<button
key={item.type}
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-surface-container-highest"
onClick={() => onChoose(item.type)}
>
<Icon name={item.icon} size={16} />
<span className="min-w-0">
<span className="block font-body text-body-md text-on-surface">{item.label}</span>
<span className="block font-body text-body-sm text-on-surface-variant">{item.hint}</span>
</span>
</button>
))}
</div>
) : null}
</div>
)
}
function BlockText({
block,
onText,
onEnter,
onDelete,
}: {
block: Block
onText: (text: string) => void
onEnter: () => void
onDelete: () => void
}) {
const ref = useRef<HTMLDivElement>(null)
const focused = useRef(false)
useEffect(() => {
const node = ref.current
if (!node || focused.current) return
if (node.innerText !== block.text) node.innerText = block.text
}, [block.text])
const multiline = block.type === 'code'
return (
<div className="relative min-w-0 flex-1">
{block.text === '' ? (
<span className={`pointer-events-none absolute inset-x-0 top-0 text-outline ${blockClass(block.type)}`}>
{placeholder(block.type)}
</span>
) : null}
<div
ref={ref}
contentEditable
role="textbox"
spellCheck={false}
className={`block-text relative ${blockClass(block.type)}`}
onFocus={() => {
focused.current = true
}}
onBlur={() => {
focused.current = false
}}
onInput={() => onText(readText(ref.current))}
onKeyDown={(event) => {
if (event.key === 'Enter' && !multiline && !event.shiftKey) {
event.preventDefault()
onEnter()
}
if (event.key === 'Backspace' && readText(ref.current) === '') {
event.preventDefault()
onDelete()
}
}}
/>
</div>
)
}
function readText(node: HTMLDivElement | null): string {
if (!node) return ''
return node.innerText.replace(/\n$/, '')
}
function placeholder(type: BlockType): string {
switch (type) {
case 'h1':
return 'Заголовок'
case 'h2':
return 'Подзаголовок'
case 'h3':
return 'Пункт'
case 'bullet':
return 'Пункт списка'
case 'quote':
return 'Цитата'
case 'code':
return 'Код'
default:
return 'Пустой блок'
}
}
function blockClass(type: BlockType): string {
switch (type) {
case 'h1':
return 'block-h1'
case 'h2':
return 'block-h2'
case 'h3':
return 'block-h3'
case 'quote':
return 'block-quote'
case 'code':
return 'block-code'
default:
return 'block-paragraph'
}
}
@@ -0,0 +1,277 @@
import { defaultKeymap, history, historyKeymap, redo, selectAll, undo } from '@codemirror/commands'
import { markdown } from '@codemirror/lang-markdown'
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { Compartment, EditorState, type Extension } from '@codemirror/state'
import {
drawSelection,
EditorView,
highlightActiveLine,
highlightActiveLineGutter,
keymap,
lineNumbers,
} from '@codemirror/view'
import { tags } from '@lezer/highlight'
import { useEffect, useRef } from 'react'
const ghostHighlight = HighlightStyle.define([
{ tag: tags.heading, color: '#8ed5ff', fontWeight: '600' },
{ tag: [tags.heading1, tags.heading2, tags.heading3], color: '#8ed5ff' },
{ tag: tags.strong, fontWeight: '700' },
{ tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.monospace, color: '#7bd0ff' },
{ tag: tags.url, color: '#7bd0ff' },
{ tag: tags.link, color: '#c0c1ff' },
{ tag: tags.quote, color: '#bdc8d1' },
{ tag: tags.processingInstruction, color: '#c0c1ff' },
{ tag: tags.meta, color: '#87929a' },
])
const ghostTheme = EditorView.theme(
{
'&': {
height: '100%',
backgroundColor: '#060e20',
color: '#dae2fd',
},
'.cm-scroller': {
overflow: 'auto',
},
'.cm-content': {
padding: '12px 0 48px',
caretColor: '#38bdf8',
},
'.cm-gutters': {
backgroundColor: '#060e20',
color: '#87929a',
borderRight: '1px solid #3e484f',
},
'.cm-activeLine': {
backgroundColor: 'rgba(56, 189, 248, 0.06)',
},
'.cm-activeLineGutter': {
backgroundColor: 'transparent',
color: '#8ed5ff',
},
'&.cm-focused': { outline: 'none' },
'.cm-cursor, .cm-dropCursor': { borderLeftColor: '#38bdf8' },
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground': {
backgroundColor: 'rgba(56, 189, 248, 0.22)',
},
},
{ dark: true },
)
const behaviorCompartment = new Compartment()
const appearanceCompartment = new Compartment()
function behaviorExtension(showLineNumbers: boolean, lineWrapping: boolean, highlightLine: boolean): Extension {
return [
showLineNumbers ? [lineNumbers(), highlightActiveLineGutter()] : [],
lineWrapping ? EditorView.lineWrapping : [],
highlightLine ? highlightActiveLine() : [],
]
}
function appearanceExtension(fontSize: number, gutterSize: number): Extension {
const lineHeight = `${Math.round(fontSize * 1.7)}px`
return EditorView.theme({
'.cm-scroller': {
fontFamily: '"JetBrains Mono", ui-monospace, monospace',
fontSize: `${fontSize}px`,
lineHeight,
},
'.cm-gutters': {
fontFamily: '"JetBrains Mono", ui-monospace, monospace',
fontSize: `${gutterSize}px`,
},
'.cm-lineNumbers .cm-gutterElement': {
padding: '0 6px 0 8px',
lineHeight,
minWidth: '1.25rem',
fontSize: `${gutterSize}px`,
},
})
}
function baseExtensions(
onChange: (text: string) => void,
onCursor: (line: number, col: number) => void,
options: {
fontSize: number
gutterSize: number
showLineNumbers: boolean
lineWrapping: boolean
highlightLine: boolean
},
): Extension {
return [
behaviorCompartment.of(behaviorExtension(options.showLineNumbers, options.lineWrapping, options.highlightLine)),
drawSelection(),
history(),
keymap.of([...defaultKeymap, ...historyKeymap]),
markdown(),
syntaxHighlighting(ghostHighlight),
ghostTheme,
appearanceCompartment.of(appearanceExtension(options.fontSize, options.gutterSize)),
EditorView.updateListener.of((update) => {
if (update.docChanged) onChange(update.state.doc.toString())
if (update.docChanged || update.selectionSet) {
const pos = update.state.selection.main.head
const line = update.state.doc.lineAt(pos)
onCursor(line.number, pos - line.from + 1)
}
}),
]
}
type SourceEditorProps = {
active: boolean
text: string
focusLine: number
focusToken: number
fontSize: number
gutterSize: number
showLineNumbers: boolean
lineWrapping: boolean
highlightLine: boolean
editorCommand: 'undo' | 'redo' | 'selectAll'
editorCommandToken: number
onScrollRatio?: (ratio: number) => void
bindScrollTo?: (scrollTo: (ratio: number) => void) => void
onChange: (text: string) => void
onCursor: (line: number, col: number) => void
}
export function SourceEditor({
active,
text,
focusLine,
focusToken,
fontSize,
gutterSize,
showLineNumbers,
lineWrapping,
highlightLine,
editorCommand,
editorCommandToken,
onScrollRatio,
bindScrollTo,
onChange,
onCursor,
}: SourceEditorProps) {
const hostRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<EditorView | null>(null)
const onChangeRef = useRef(onChange)
const onCursorRef = useRef(onCursor)
const onScrollRatioRef = useRef(onScrollRatio)
const activeRef = useRef(active)
const optionsRef = useRef({ fontSize, gutterSize, showLineNumbers, lineWrapping, highlightLine })
onChangeRef.current = onChange
onCursorRef.current = onCursor
onScrollRatioRef.current = onScrollRatio
activeRef.current = active
optionsRef.current = { fontSize, gutterSize, showLineNumbers, lineWrapping, highlightLine }
useEffect(() => {
const parent = hostRef.current
if (!parent || viewRef.current) return
const view = new EditorView({
parent,
state: EditorState.create({
doc: text,
extensions: baseExtensions(
(next) => onChangeRef.current(next),
(line, col) => onCursorRef.current(line, col),
optionsRef.current,
),
}),
})
viewRef.current = view
const head = view.state.selection.main.head
const line = view.state.doc.lineAt(head)
onCursorRef.current(line.number, head - line.from + 1)
return () => {
view.destroy()
viewRef.current = null
}
}, [])
useEffect(() => {
const view = viewRef.current
if (!view || !active) return
const current = view.state.doc.toString()
if (current !== text) {
const anchor = Math.min(view.state.selection.main.anchor, text.length)
view.dispatch({
changes: { from: 0, to: current.length, insert: text },
selection: { anchor },
})
}
}, [active, text])
useEffect(() => {
const view = viewRef.current
if (!view || !active) return
view.requestMeasure()
view.focus()
}, [active])
useEffect(() => {
const view = viewRef.current
if (!view) return
if (behaviorCompartment.get(view.state) === undefined || appearanceCompartment.get(view.state) === undefined) return
view.dispatch({
effects: [
behaviorCompartment.reconfigure(behaviorExtension(showLineNumbers, lineWrapping, highlightLine)),
appearanceCompartment.reconfigure(appearanceExtension(fontSize, gutterSize)),
],
})
view.requestMeasure()
}, [fontSize, gutterSize, showLineNumbers, lineWrapping, highlightLine])
useEffect(() => {
const view = viewRef.current
if (!view || editorCommandToken === 0) return
if (editorCommand === 'undo') undo(view)
else if (editorCommand === 'redo') redo(view)
else selectAll(view)
view.focus()
}, [editorCommand, editorCommandToken])
useEffect(() => {
const view = viewRef.current
if (!view) return
const scroller = view.scrollDOM
const emit = () => {
const max = scroller.scrollHeight - scroller.clientHeight
onScrollRatioRef.current?.(max <= 0 ? 0 : scroller.scrollTop / max)
}
scroller.addEventListener('scroll', emit, { passive: true })
return () => scroller.removeEventListener('scroll', emit)
}, [active])
useEffect(() => {
bindScrollTo?.((ratio) => {
const scroller = viewRef.current?.scrollDOM
if (!scroller) return
const max = scroller.scrollHeight - scroller.clientHeight
scroller.scrollTop = ratio * Math.max(0, max)
})
}, [bindScrollTo])
useEffect(() => {
const view = viewRef.current
if (!view || !activeRef.current || focusToken === 0) return
const lineNo = Math.min(Math.max(1, focusLine), view.state.doc.lines)
const line = view.state.doc.line(lineNo)
view.dispatch({
selection: { anchor: line.from },
scrollIntoView: true,
})
view.focus()
}, [focusLine, focusToken])
return <div ref={hostRef} className={active ? 'h-full min-h-0' : 'hidden'} />
}
+126
View File
@@ -0,0 +1,126 @@
export type BlockType = 'paragraph' | 'h1' | 'h2' | 'h3' | 'bullet' | 'quote' | 'code'
export type Block = {
id: string
type: BlockType
text: string
}
export const BLOCK_KINDS: Array<{
type: BlockType
label: string
chip: string
hint: string
icon: string
}> = [
{ type: 'paragraph', label: 'Абзац', chip: 'Абзац', hint: 'Обычный текст', icon: 'notes' },
{ type: 'h1', label: 'Заголовок 1', chip: 'H1', hint: 'Крупный раздел', icon: 'title' },
{ type: 'h2', label: 'Заголовок 2', chip: 'H2', hint: 'Подраздел', icon: 'format_h2' },
{ type: 'h3', label: 'Заголовок 3', chip: 'H3', hint: 'Пункт', icon: 'format_h3' },
{ type: 'bullet', label: 'Список', chip: 'Список', hint: 'Один пункт', icon: 'format_list_bulleted' },
{ type: 'quote', label: 'Цитата', chip: 'Цитата', hint: 'Выделенная мысль', icon: 'format_quote' },
{ type: 'code', label: 'Код', chip: 'Код', hint: 'Моноширинный блок', icon: 'code' },
]
let sequence = 0
export function createBlock(type: BlockType, text = ''): Block {
sequence += 1
return { id: `block-${sequence}`, type, text }
}
function isFence(line: string): boolean {
return line.startsWith('```')
}
function isAtomic(line: string): boolean {
return (
isFence(line) ||
line.startsWith('# ') ||
line.startsWith('## ') ||
line.startsWith('### ') ||
line.startsWith('- ') ||
line.startsWith('> ')
)
}
export function parseBlocks(markdown: string): Block[] {
const lines = markdown.replace(/\r\n/g, '\n').replace(/\n+$/, '').split('\n')
const blocks: Block[] = []
let index = 0
while (index < lines.length) {
const line = lines[index]
if (line.trim() === '') {
index += 1
continue
}
if (isFence(line)) {
const body: string[] = []
index += 1
while (index < lines.length && !isFence(lines[index])) {
body.push(lines[index])
index += 1
}
if (index < lines.length) index += 1
blocks.push(createBlock('code', body.join('\n')))
continue
}
if (line.startsWith('### ')) blocks.push(createBlock('h3', line.slice(4)))
else if (line.startsWith('## ')) blocks.push(createBlock('h2', line.slice(3)))
else if (line.startsWith('# ')) blocks.push(createBlock('h1', line.slice(2)))
else if (line.startsWith('- ')) blocks.push(createBlock('bullet', line.slice(2)))
else if (line.startsWith('> ')) blocks.push(createBlock('quote', line.slice(2)))
else {
const paragraph = [line]
index += 1
while (index < lines.length && lines[index].trim() !== '' && !isAtomic(lines[index])) {
paragraph.push(lines[index])
index += 1
}
blocks.push(createBlock('paragraph', paragraph.join('\n')))
continue
}
index += 1
}
if (blocks.length === 0) blocks.push(createBlock('paragraph'))
return blocks
}
export function serializeBlocks(blocks: Block[]): string {
return blocks
.map((block) => {
switch (block.type) {
case 'h1':
return `# ${block.text}`
case 'h2':
return `## ${block.text}`
case 'h3':
return `### ${block.text}`
case 'bullet':
return `- ${block.text}`
case 'quote':
return block.text
.split('\n')
.map((line) => `> ${line}`)
.join('\n')
case 'code':
return `\`\`\`\n${block.text}\n\`\`\``
default:
return block.text
}
})
.join('\n\n')
}
export function moveBlock(blocks: Block[], fromId: string, toId: string): Block[] {
if (fromId === toId) return blocks
const from = blocks.findIndex((block) => block.id === fromId)
const to = blocks.findIndex((block) => block.id === toId)
if (from < 0 || to < 0) return blocks
const next = blocks.slice()
const [item] = next.splice(from, 1)
next.splice(to, 0, item)
return next
}
+11
View File
@@ -0,0 +1,11 @@
import type { ChromeApi } from '@shared/chrome'
import type { HostApi } from '@shared/host'
declare global {
interface Window {
host: HostApi
frame: ChromeApi
}
}
export {}
+59
View File
@@ -0,0 +1,59 @@
export type TreeNode = {
id: string
name: string
kind: 'file' | 'folder'
icon: string
tone: string
children?: TreeNode[]
}
export type VisibleRow = {
node: TreeNode
depth: number
}
export type Heading = {
level: number
title: string
line: number
}
export const WORKSPACE_NAME = 'reports'
export const DEFAULT_EXPANDED = ['dir:mirea', 'dir:mirea/schemas', 'dir:mirea/figures']
export function parentMap(
nodes: TreeNode[],
parent: string | null = null,
map: Map<string, string | null> = new Map(),
): Map<string, string | null> {
for (const node of nodes) {
map.set(node.id, parent)
if (node.children) parentMap(node.children, node.id, map)
}
return map
}
export function visibleRows(nodes: TreeNode[], expanded: ReadonlySet<string>, depth = 0): VisibleRow[] {
const rows: VisibleRow[] = []
for (const node of nodes) {
rows.push({ node, depth })
if (node.kind === 'folder' && node.children && expanded.has(node.id)) {
rows.push(...visibleRows(node.children, expanded, depth + 1))
}
}
return rows
}
export function documentHeadings(text: string): Heading[] {
const headings: Heading[] = []
const lines = text.split('\n')
for (let index = 0; index < lines.length; index += 1) {
const match = /^(#{1,6})[ \t]+(.+?)[ \t]*$/.exec(lines[index] ?? '')
if (!match) continue
const title = (match[2] ?? '').replace(/[ \t]+#+$/, '').replace(/^\*\s?/, '').trim()
if (!title) continue
headings.push({ level: match[1]?.length ?? 1, title, line: index + 1 })
}
return headings
}
+38
View File
@@ -0,0 +1,38 @@
export type HelpTopic = {
id: string
title: string
paragraphs: string[]
}
export const HELP_TOPICS: HelpTopic[] = [
{
id: 'syntax',
title: 'Синтаксис отчёта',
paragraphs: [
'Текст отчёта — Markdown. Заголовки начинаются с #. Звёздочка сразу после решёток снимает номер раздела: # *ВВЕДЕНИЕ, # *ЗАКЛЮЧЕНИЕ, # *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ.',
'После # *СОДЕРЖАНИЕ ставится отдельная строка [TOC]. Точку в конце заголовка не ставят.',
'Списки, жирный и курсив работают как в обычном Markdown. Источник в тексте — [1] или [2, 3]. Во введении и заключении таких ссылок быть не должно.',
'Строка --- в этом редакторе видна как разрыв. Сборка DOCX её обработает, когда будет подключено ядро.',
],
},
{
id: 'objects',
title: 'Таблицы, рисунки, формулы',
paragraphs: [
'Таблица: строка %id Название, затем таблица Markdown. В тексте ссылка пишется как @Таблица:id.',
'Рисунок-файл: ![описание](путь "%id Название"). В тексте — @Рисунок:id. Слово «Рисунок» не сокращают.',
'Формула нумеруется, только если на неё есть ссылка. Блок: %id, затем $$ … $$, в тексте (@Формула:id).',
'Листинг: %id Название и следом ограда с языком. Ссылка — @Листинг:id.',
'Кнопки над редактором вставляют эти заготовки в текущую строку.',
],
},
{
id: 'diagrams',
title: 'Схемы',
paragraphs: [
'Исходник схемы хранится в .puml или в ограде ```plantuml прямо в отчёте. Перед оградой — %id Название, тогда схема станет рисунком.',
'Языки ограды: plantuml, uml, mermaid, idef0, dfd. Картинку из схемы соберёт ядро. Пока в окне виден исходник, а файлы .svg открываются картинкой.',
'Готовые рисунки лежат в figures. На них можно сослаться из отчёта обычной картинкой Markdown.',
],
},
]
+22
View File
@@ -0,0 +1,22 @@
import '@fontsource/inter/400.css'
import '@fontsource/inter/500.css'
import '@fontsource/inter/600.css'
import '@fontsource/space-grotesk/500.css'
import '@fontsource/space-grotesk/600.css'
import '@fontsource/jetbrains-mono/400.css'
import '@fontsource/jetbrains-mono/500.css'
import '@fontsource/jetbrains-mono/600.css'
import './styles.css'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
const root = document.getElementById('root')
if (!root) throw new Error('Не найден #root')
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
)
+151
View File
@@ -0,0 +1,151 @@
export type PreviewBlock =
| { type: 'h'; level: number; text: string }
| { type: 'p'; text: string }
| { type: 'ul'; items: string[] }
| { type: 'ol'; items: string[] }
| { type: 'quote'; text: string }
| { type: 'code'; lang: string; text: string }
| { type: 'table'; rows: string[][] }
| { type: 'formula'; text: string }
| { type: 'caption'; text: string }
| { type: 'figure'; alt: string; src: string }
| { type: 'break' }
export function previewBlocks(source: string): PreviewBlock[] {
const lines = source.replace(/\r\n/g, '\n').split('\n')
const blocks: PreviewBlock[] = []
let index = 0
while (index < lines.length) {
const line = lines[index] ?? ''
if (line.trim() === '') {
index += 1
continue
}
const fence = /^(```|~~~)(.*)$/.exec(line)
if (fence) {
const marker = fence[1] ?? '```'
const lang = (fence[2] ?? '').trim().split(/\s+/)[0] ?? ''
const body: string[] = []
index += 1
while (index < lines.length && !(lines[index] ?? '').startsWith(marker)) {
body.push(lines[index] ?? '')
index += 1
}
if (index < lines.length) index += 1
blocks.push({ type: 'code', lang, text: body.join('\n') })
continue
}
const heading = /^(#{1,6})[ \t]+(.+?)\s*$/.exec(line)
if (heading) {
blocks.push({
type: 'h',
level: heading[1]?.length ?? 1,
text: (heading[2] ?? '').replace(/\s+#+$/, '').replace(/^\*\s?/, ''),
})
index += 1
continue
}
if (line.trim() === '[TOC]') {
blocks.push({ type: 'p', text: 'Содержание формируется при сборке документа.' })
index += 1
continue
}
if (line.trim() === '$$') {
const body: string[] = []
index += 1
while (index < lines.length && (lines[index] ?? '').trim() !== '$$') {
body.push(lines[index] ?? '')
index += 1
}
if (index < lines.length) index += 1
blocks.push({ type: 'formula', text: body.join('\n') })
continue
}
if (/^(?:---|\*\*\*|___)\s*$/.test(line)) {
blocks.push({ type: 'break' })
index += 1
continue
}
if (/^%(?!%)\S+/.test(line)) {
blocks.push({ type: 'caption', text: line.replace(/^%\S+[ \t]*/, '').trim() || line.slice(1) })
index += 1
continue
}
const image = /^!\[([^\]]*)]\(\s*<?([^)\s>]+)>?/.exec(line)
if (image) {
blocks.push({ type: 'figure', alt: image[1]?.trim() || 'Рисунок', src: image[2] ?? '' })
index += 1
continue
}
if (line.includes('|')) {
const rows: string[][] = []
while (index < lines.length && (lines[index] ?? '').includes('|')) {
const raw = (lines[index] ?? '').trim()
index += 1
if (/^[\s|:-]+$/.test(raw)) continue
rows.push(
raw
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map((cell) => cell.trim()),
)
}
if (rows.length > 0) blocks.push({ type: 'table', rows })
continue
}
if (/^>\s?/.test(line)) {
const quote: string[] = []
while (index < lines.length && /^>\s?/.test(lines[index] ?? '')) {
quote.push((lines[index] ?? '').replace(/^>\s?/, ''))
index += 1
}
blocks.push({ type: 'quote', text: quote.join(' ') })
continue
}
if (/^[-*]\s+/.test(line)) {
const items: string[] = []
while (index < lines.length && /^[-*]\s+/.test(lines[index] ?? '')) {
items.push((lines[index] ?? '').replace(/^[-*]\s+/, ''))
index += 1
}
blocks.push({ type: 'ul', items })
continue
}
if (/^\d+[.)]\s+/.test(line)) {
const items: string[] = []
while (index < lines.length && /^\d+[.)]\s+/.test(lines[index] ?? '')) {
items.push((lines[index] ?? '').replace(/^\d+[.)]\s+/, ''))
index += 1
}
blocks.push({ type: 'ol', items })
continue
}
const paragraph: string[] = []
while (
index < lines.length &&
(lines[index] ?? '').trim() !== '' &&
!/^(?:#{1,6}[ \t]|>|[-*]\s|\d+[.)]\s|```|~~~|\$\$|%(?!%)|!\[|---|\*\*\*|___)/.test(lines[index] ?? '') &&
!(lines[index] ?? '').includes('|')
) {
paragraph.push(lines[index] ?? '')
index += 1
}
blocks.push({ type: 'p', text: paragraph.join(' ') })
}
return blocks
}
+239
View File
@@ -0,0 +1,239 @@
export type PreviewKind = 'markdown' | 'svg' | 'yaml' | 'bib' | 'diagram' | 'text'
export type YamlRow = { depth: number; key: string; value: string; note: boolean }
export type BibField = { key: string; value: string }
export type BibEntry = { type: string; id: string; fields: BibField[] }
export type DiagramKind = 'person' | 'system' | 'external' | 'entity' | 'actor' | 'participant' | 'database' | 'usecase'
export type DiagramNode = {
id: string
label: string
detail: string
kind: DiagramKind
fields: string[]
}
export type DiagramEdge = { from: string; to: string; label: string }
export type DiagramModel = { title: string; nodes: DiagramNode[]; edges: DiagramEdge[] }
const DIAGRAM_EXT = new Set(['puml', 'plantuml', 'uml', 'wsd', 'iuml'])
export function previewKind(name: string, text: string): PreviewKind {
const ext = name.includes('.') ? (name.split('.').pop()?.toLowerCase() ?? '') : ''
const body = text.trimStart()
if (ext === 'md' || ext === 'markdown') return 'markdown'
if (ext === 'svg' || body.startsWith('<svg')) return 'svg'
if (ext === 'yaml' || ext === 'yml') return 'yaml'
if (ext === 'bib') return 'bib'
if (DIAGRAM_EXT.has(ext) || body.includes('@startuml') || body.includes('@startmindmap')) return 'diagram'
return 'text'
}
export function isDiagramLang(lang: string): boolean {
return DIAGRAM_EXT.has(lang.toLowerCase()) || lang.toLowerCase() === 'mermaid'
}
export function resolveWorkspacePath(fileId: string, href: string): string {
const clean = href.trim().replace(/\\/g, '/')
if (!clean || /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(clean)) return clean
const stack = fileId.split('/').slice(0, -1).filter(Boolean)
for (const part of clean.split('/')) {
if (part === '' || part === '.') continue
if (part === '..') stack.pop()
else stack.push(part)
}
return stack.join('/')
}
export function parseYamlPreview(source: string): YamlRow[] {
const rows: YamlRow[] = []
for (const line of source.replace(/\r\n/g, '\n').split('\n')) {
if (line.trim() === '') continue
const indent = /^\s*/.exec(line)?.[0].length ?? 0
if (/^\s*#/.test(line)) {
rows.push({ depth: indent, key: '', value: line.trim().replace(/^#\s?/, ''), note: true })
continue
}
const item = /^(\s*)-\s+(.*)$/.exec(line)
if (item) {
rows.push({ depth: item[1]?.length ?? 0, key: '—', value: item[2]?.trim() ?? '', note: false })
continue
}
const field = /^(\s*)([^:#]+):(.*)$/.exec(line)
if (field) {
rows.push({
depth: field[1]?.length ?? 0,
key: field[2]?.trim() ?? '',
value: (field[3] ?? '').trim(),
note: false,
})
continue
}
rows.push({ depth: indent, key: '', value: line.trim(), note: false })
}
return rows
}
export function parseBib(source: string): BibEntry[] {
const entries: BibEntry[] = []
const start = /@(\w+)\s*\{\s*([^,\s]+)\s*,/g
let match: RegExpExecArray | null
while ((match = start.exec(source))) {
const from = match.index + match[0].length
let depth = 1
let index = from
while (index < source.length && depth > 0) {
const char = source[index]
if (char === '{') depth += 1
else if (char === '}') depth -= 1
index += 1
}
const body = source.slice(from, Math.max(from, index - 1))
const fields: BibField[] = []
const field = /(\w+)\s*=\s*(?:\{([^{}]*)\}|"([^"]*)"|(\d+))/g
let found: RegExpExecArray | null
while ((found = field.exec(body))) {
fields.push({ key: found[1] ?? '', value: found[2] ?? found[3] ?? found[4] ?? '' })
}
entries.push({ type: match[1] ?? '', id: match[2] ?? '', fields })
}
return entries
}
function splitArgs(body: string): string[] {
const args: string[] = []
let current = ''
let quote = ''
for (const char of body) {
if (quote) {
if (char === quote) quote = ''
else current += char
continue
}
if (char === '"' || char === "'") {
quote = char
continue
}
if (char === ',') {
args.push(current.trim())
current = ''
continue
}
current += char
}
if (current.trim()) args.push(current.trim())
return args
}
export function parseDiagram(source: string): DiagramModel {
const lines = source.replace(/\r\n/g, '\n').split('\n')
const nodes: DiagramNode[] = []
const byKey = new Map<string, DiagramNode>()
const edges: DiagramEdge[] = []
let title = ''
function add(id: string, label: string, kind: DiagramKind, detail = '', fields: string[] = []): DiagramNode {
const key = id || label
const existing = byKey.get(key)
if (existing) {
if (label) existing.label = label
existing.kind = kind
if (detail) existing.detail = detail
if (fields.length) existing.fields = fields
if (label && label !== existing.id) byKey.set(label, existing)
return existing
}
const node: DiagramNode = { id: key, label: label || key, detail, kind, fields }
nodes.push(node)
byKey.set(node.id, node)
if (node.label !== node.id) byKey.set(node.label, node)
return node
}
function canon(token: string): string {
const name = token.replace(/^["']|["']$/g, '').trim()
return byKey.get(name)?.id ?? name
}
for (let index = 0; index < lines.length; index += 1) {
const line = (lines[index] ?? '').trim()
if (!line || line.startsWith("'") || line.startsWith('@') || line.startsWith('!') || /^skinparam\b/i.test(line)) continue
const titleLine = /^title\s+(.+)$/i.exec(line)
if (titleLine) {
title = titleLine[1]?.trim() ?? ''
continue
}
const call = /^(Person|System|System_Ext|SystemDb)\s*\((.*)\)\s*$/.exec(line)
if (call) {
const args = splitArgs(call[2] ?? '')
const kind: DiagramKind = call[1] === 'Person' ? 'person' : call[1] === 'System_Ext' ? 'external' : 'system'
add(args[0] ?? '', args[1] ?? args[0] ?? '', kind, args[2] ?? '')
continue
}
const entity = /^entity\s+(?:"([^"]+)"|(\S+))(?:\s+as\s+(\S+))?\s*(\{)?\s*$/.exec(line)
if (entity) {
const label = entity[1] || entity[2] || ''
const id = entity[3] || label
const fields: string[] = []
if (entity[4]) {
index += 1
while (index < lines.length && !(lines[index] ?? '').trim().startsWith('}')) {
const field = (lines[index] ?? '').trim().replace(/^\*\s*/, '')
if (field && field !== '--') fields.push(field)
index += 1
}
}
add(id, label, 'entity', '', fields)
continue
}
const decl = /^(actor|participant|database)\s+(?:"([^"]+)"|(\S+))(?:\s+as\s+(\S+))?\s*$/.exec(line)
if (decl) {
const label = decl[2] || decl[3] || ''
const id = decl[4] || label
const kind: DiagramKind = decl[1] === 'actor' ? 'actor' : decl[1] === 'database' ? 'database' : 'participant'
add(id, label, kind)
continue
}
const rel = /^Rel(?:_\w+)?\s*\((.*)\)\s*$/.exec(line)
if (rel) {
const args = splitArgs(rel[1] ?? '')
const from = canon(args[0] ?? '')
const to = canon(args[1] ?? '')
if (from && !byKey.has(from)) add(from, from, 'system')
if (to && !byKey.has(to)) add(to, to, 'system')
if (from && to) edges.push({ from, to, label: args[2] ?? '' })
continue
}
const usecase = /^(.+?)\s+-+>\s+\(([^)]+)\)\s*(?::\s*(.*))?$/.exec(line)
if (usecase) {
const from = canon(usecase[1] ?? '')
const to = canon(usecase[2] ?? '')
if (!byKey.has(from)) add(from, from, 'actor')
if (!byKey.has(to)) add(to, to, 'usecase')
edges.push({ from, to, label: (usecase[3] ?? '').trim() })
continue
}
const arrow = /^(.+?)\s+(<?[|ox.*{}-]*-+[|ox.*{}-]*>?)\s+(.+?)(?:\s*:\s*(.*))?$/.exec(line)
if (arrow) {
const structural = (arrow[2] ?? '').includes('|') || (arrow[2] ?? '').includes('{')
const from = canon((arrow[1] ?? '').trim())
const to = canon((arrow[3] ?? '').trim())
const kind: DiagramKind = structural ? 'entity' : 'participant'
if (from && !byKey.has(from)) add(from, from, kind)
if (to && !byKey.has(to)) add(to, to, kind)
if (from && to) edges.push({ from, to, label: (arrow[4] ?? '').trim() })
}
}
return { title, nodes, edges }
}
export function diagramLayout(model: DiagramModel): 'sequence' | 'entities' | 'blocks' {
if (model.nodes.some((node) => node.kind === 'entity')) return 'entities'
const lifelines = model.nodes.filter((node) => node.kind === 'actor' || node.kind === 'participant' || node.kind === 'database')
if (lifelines.length >= 2 && model.edges.length > 0) return 'sequence'
return 'blocks'
}
+211
View File
@@ -0,0 +1,211 @@
import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from 'react'
import {
ACTIVE_FILE_ID,
insertAtCursor,
makeSnippet,
seedFiles,
type InsertKind,
type WorkspaceFile,
} from './documents'
export type EditorMode = 'code' | 'notion'
export type CursorPos = { line: number; col: number }
export type EditorCommand = 'undo' | 'redo' | 'selectAll'
type SessionValue = {
text: string
setText: (text: string) => void
files: Record<string, WorkspaceFile>
folders: string[][]
activeId: string
openFile: (id: string, line?: number) => void
createFile: () => string
createFolder: () => string
insertSnippet: (kind: InsertKind) => void
activity: string[]
mode: EditorMode
setMode: (mode: EditorMode) => void
cursor: CursorPos
setCursor: (cursor: CursorPos) => void
notice: string | null
setNotice: (notice: string | null) => void
focusLine: number
focusToken: number
focusLineAt: (line: number) => void
editorCommand: EditorCommand
editorCommandToken: number
runEditorCommand: (command: EditorCommand) => void
}
const SessionContext = createContext<SessionValue | null>(null)
export function SessionProvider({ children }: { children: ReactNode }) {
const seeded = useRef(seedFiles())
const [files, setFiles] = useState<Record<string, WorkspaceFile>>(seeded.current)
const [folders, setFolders] = useState<string[][]>([])
const [activeId, setActiveId] = useState(ACTIVE_FILE_ID)
const [text, setTextState] = useState(seeded.current[ACTIVE_FILE_ID]?.text ?? '')
const [mode, setMode] = useState<EditorMode>('code')
const [cursor, setCursor] = useState<CursorPos>({ line: 1, col: 1 })
const [notice, setNotice] = useState<string | null>(null)
const [focusLine, setFocusLine] = useState(1)
const [focusToken, setFocusToken] = useState(0)
const [activity, setActivity] = useState<string[]>([])
const [editorCommand, setEditorCommand] = useState<EditorCommand>('undo')
const [editorCommandToken, setEditorCommandToken] = useState(0)
const tokenRef = useRef(0)
const commandRef = useRef(0)
const activeIdRef = useRef(ACTIVE_FILE_ID)
const filesRef = useRef(seeded.current)
const textRef = useRef(text)
const cursorRef = useRef(cursor)
const snippetRef = useRef(0)
const fileCount = useRef(0)
const folderCount = useRef(0)
textRef.current = text
cursorRef.current = cursor
filesRef.current = files
const remember = useCallback((message: string) => {
setActivity((prev) => [message, ...prev].slice(0, 12))
}, [])
const focusLineAt = useCallback((line: number) => {
tokenRef.current += 1
setFocusLine(line)
setFocusToken(tokenRef.current)
}, [])
const runEditorCommand = useCallback((command: EditorCommand) => {
commandRef.current += 1
setEditorCommand(command)
setEditorCommandToken(commandRef.current)
}, [])
const setText = useCallback((next: string) => {
setTextState(next)
const id = activeIdRef.current
setFiles((prev) => {
const file = prev[id]
if (!file || file.text === next) return prev
return { ...prev, [id]: { ...file, text: next } }
})
}, [])
const openFile = useCallback(
(id: string, line = 1) => {
const file = filesRef.current[id]
if (!file) return
if (activeIdRef.current !== id) remember(`Открыт ${file.name}`)
activeIdRef.current = id
setActiveId(id)
setTextState(file.text)
setMode('code')
focusLineAt(line)
},
[focusLineAt, remember],
)
const createFile = useCallback(() => {
fileCount.current += 1
const name = `заметка-${fileCount.current}.md`
const id = `mirea/${name}`
const blank = '# \n\n'
const created: WorkspaceFile = {
id,
name,
dir: ['mirea'],
icon: 'description',
tone: 'text-primary',
text: blank,
initial: blank,
}
setFiles((prev) => ({ ...prev, [id]: created }))
activeIdRef.current = id
setActiveId(id)
setTextState(blank)
setMode('code')
remember(`Создан ${name}`)
focusLineAt(1)
return id
}, [focusLineAt, remember])
const createFolder = useCallback(() => {
folderCount.current += 1
const name = `папка-${folderCount.current}`
setFolders((prev) => [...prev, ['mirea', name]])
remember(`Создана папка ${name}`)
return `dir:mirea/${name}`
}, [remember])
const insertSnippet = useCallback(
(kind: InsertKind) => {
snippetRef.current += 1
const snippet = makeSnippet(kind, snippetRef.current)
const placed = insertAtCursor(textRef.current, cursorRef.current.line, cursorRef.current.col, snippet)
setText(placed.text)
setMode('code')
focusLineAt(placed.line)
remember('Вставлен фрагмент')
},
[focusLineAt, remember, setText],
)
const value = useMemo(
() => ({
text,
setText,
files,
folders,
activeId,
openFile,
createFile,
createFolder,
insertSnippet,
activity,
mode,
setMode,
cursor,
setCursor,
notice,
setNotice,
focusLine,
focusToken,
focusLineAt,
editorCommand,
editorCommandToken,
runEditorCommand,
}),
[
text,
setText,
files,
folders,
activeId,
openFile,
createFile,
createFolder,
insertSnippet,
activity,
mode,
cursor,
notice,
focusLine,
focusToken,
focusLineAt,
editorCommand,
editorCommandToken,
runEditorCommand,
],
)
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>
}
export function useSession(): SessionValue {
const value = useContext(SessionContext)
if (!value) throw new Error('useSession вне SessionProvider')
return value
}
+176
View File
@@ -0,0 +1,176 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'
export const DOC_PROFILES = [
{ id: 'coursework', label: 'Курсовая (coursework)', badge: 'Курсовая работа (МИРЭА)' },
{ id: 'practice', label: 'Практика (practice)', badge: 'Практика (МИРЭА)' },
{ id: 'vkr', label: 'ВКР (vkr)', badge: 'ВКР (МИРЭА)' },
{ id: 'PIS_custom', label: 'ПИС (PIS_custom)', badge: 'ПИС' },
{ id: 'APID_coursework', label: 'АПИД (APID_coursework)', badge: 'АПИД' },
] as const
export type DocProfileId = (typeof DOC_PROFILES)[number]['id']
export type PreviewSpacing = 100 | 115 | 150
export type PreviewMargin = 'narrow' | 'normal' | 'wide'
export type Settings = {
fontSize: number
gutterSize: number
showLineNumbers: boolean
lineWrapping: boolean
highlightLine: boolean
zoom: number
split: boolean
syncScroll: boolean
previewFontSize: number
previewSpacing: PreviewSpacing
previewMargin: PreviewMargin
profile: DocProfileId
student: string
group: string
}
export const DEFAULT_SETTINGS: Settings = {
fontSize: 13,
gutterSize: 10,
showLineNumbers: true,
lineWrapping: true,
highlightLine: true,
zoom: 100,
split: false,
syncScroll: true,
previewFontSize: 14,
previewSpacing: 150,
previewMargin: 'normal',
profile: 'coursework',
student: '',
group: '',
}
const STORAGE_KEY = 'ghosteditor.settings'
const PROFILE_IDS = new Set<string>(DOC_PROFILES.map((item) => item.id))
function oneOf<T extends string | number>(value: unknown, allowed: readonly T[], fallback: T): T {
return allowed.includes(value as T) ? (value as T) : fallback
}
function clamp(value: unknown, min: number, max: number, fallback: number): number {
const number = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(number)) return fallback
return Math.min(max, Math.max(min, Math.round(number)))
}
export function sanitizeSettings(raw: unknown): Settings {
const source = raw && typeof raw === 'object' ? (raw as Partial<Settings>) : {}
const profile =
typeof source.profile === 'string' && PROFILE_IDS.has(source.profile)
? (source.profile as DocProfileId)
: DEFAULT_SETTINGS.profile
return {
fontSize: clamp(source.fontSize, 11, 20, DEFAULT_SETTINGS.fontSize),
gutterSize: clamp(source.gutterSize, 8, 16, DEFAULT_SETTINGS.gutterSize),
showLineNumbers:
typeof source.showLineNumbers === 'boolean' ? source.showLineNumbers : DEFAULT_SETTINGS.showLineNumbers,
lineWrapping: typeof source.lineWrapping === 'boolean' ? source.lineWrapping : DEFAULT_SETTINGS.lineWrapping,
highlightLine: typeof source.highlightLine === 'boolean' ? source.highlightLine : DEFAULT_SETTINGS.highlightLine,
zoom: clamp(source.zoom, 70, 180, DEFAULT_SETTINGS.zoom),
split: typeof source.split === 'boolean' ? source.split : DEFAULT_SETTINGS.split,
syncScroll: typeof source.syncScroll === 'boolean' ? source.syncScroll : DEFAULT_SETTINGS.syncScroll,
previewFontSize: clamp(source.previewFontSize, 12, 20, DEFAULT_SETTINGS.previewFontSize),
previewSpacing: oneOf(source.previewSpacing, [100, 115, 150] as const, DEFAULT_SETTINGS.previewSpacing),
previewMargin: oneOf(source.previewMargin, ['narrow', 'normal', 'wide'] as const, DEFAULT_SETTINGS.previewMargin),
profile,
student: typeof source.student === 'string' ? source.student : '',
group: typeof source.group === 'string' ? source.group : '',
}
}
function readSettings(): Settings {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return DEFAULT_SETTINGS
return sanitizeSettings(JSON.parse(raw) as unknown)
} catch {
return DEFAULT_SETTINGS
}
}
function writeSettings(settings: Settings): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
} catch {
/* останутся в памяти до закрытия окна */
}
}
type SettingsValue = {
settings: Settings
updateSettings: (patch: Partial<Settings>) => void
resetEditorSettings: () => void
resetPreviewSettings: () => void
open: boolean
openSettings: () => void
toggleOpen: () => void
profile: (typeof DOC_PROFILES)[number]
}
const SettingsContext = createContext<SettingsValue | null>(null)
export function SettingsProvider({ children }: { children: ReactNode }) {
const [settings, setSettings] = useState(readSettings)
const [open, setOpen] = useState(false)
const updateSettings = useCallback((patch: Partial<Settings>) => {
setSettings((current) => {
const next = sanitizeSettings({ ...current, ...patch })
writeSettings(next)
return next
})
}, [])
const resetEditorSettings = useCallback(() => {
setSettings((current) => {
const next = sanitizeSettings({
...current,
fontSize: DEFAULT_SETTINGS.fontSize,
gutterSize: DEFAULT_SETTINGS.gutterSize,
showLineNumbers: DEFAULT_SETTINGS.showLineNumbers,
lineWrapping: DEFAULT_SETTINGS.lineWrapping,
highlightLine: DEFAULT_SETTINGS.highlightLine,
zoom: DEFAULT_SETTINGS.zoom,
})
writeSettings(next)
return next
})
}, [])
const resetPreviewSettings = useCallback(() => {
setSettings((current) => {
const next = sanitizeSettings({
...current,
previewFontSize: DEFAULT_SETTINGS.previewFontSize,
previewSpacing: DEFAULT_SETTINGS.previewSpacing,
previewMargin: DEFAULT_SETTINGS.previewMargin,
})
writeSettings(next)
return next
})
}, [])
const openSettings = useCallback(() => setOpen(true), [])
const toggleOpen = useCallback(() => setOpen((value) => !value), [])
const profile = DOC_PROFILES.find((item) => item.id === settings.profile) ?? DOC_PROFILES[0]
const value = useMemo(
() => ({ settings, updateSettings, resetEditorSettings, resetPreviewSettings, open, openSettings, toggleOpen, profile }),
[settings, updateSettings, resetEditorSettings, resetPreviewSettings, open, openSettings, toggleOpen, profile],
)
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>
}
export function useSettings(): SettingsValue {
const value = useContext(SettingsContext)
if (!value) throw new Error('useSettings вне SettingsProvider')
return value
}
+252
View File
@@ -0,0 +1,252 @@
@import 'tailwindcss';
@font-face {
font-family: 'Material Symbols Outlined';
font-style: normal;
font-weight: 100 700;
font-display: block;
src: url('../../../node_modules/material-symbols/material-symbols-outlined.woff2') format('woff2');
}
@theme {
--color-surface: #0b1326;
--color-surface-dim: #0b1326;
--color-surface-bright: #31394d;
--color-surface-container-lowest: #060e20;
--color-surface-container-low: #131b2e;
--color-surface-container: #171f33;
--color-surface-container-high: #222a3d;
--color-surface-container-highest: #2d3449;
--color-on-surface: #dae2fd;
--color-on-surface-variant: #bdc8d1;
--color-inverse-surface: #dae2fd;
--color-inverse-on-surface: #283044;
--color-outline: #87929a;
--color-outline-variant: #3e484f;
--color-surface-tint: #7bd0ff;
--color-primary: #8ed5ff;
--color-on-primary: #00354a;
--color-primary-container: #38bdf8;
--color-on-primary-container: #004965;
--color-inverse-primary: #00668a;
--color-secondary: #c0c1ff;
--color-on-secondary: #1000a9;
--color-secondary-container: #3131c0;
--color-on-secondary-container: #b0b2ff;
--color-tertiary: #56e5a9;
--color-on-tertiary: #003824;
--color-tertiary-container: #30c88f;
--color-on-tertiary-container: #004e34;
--color-error: #ffb4ab;
--color-on-error: #690005;
--color-error-container: #93000a;
--color-on-error-container: #ffdad6;
--color-primary-fixed: #c4e7ff;
--color-primary-fixed-dim: #7bd0ff;
--color-on-primary-fixed: #001e2c;
--color-on-primary-fixed-variant: #004c69;
--color-secondary-fixed: #e1e0ff;
--color-secondary-fixed-dim: #c0c1ff;
--color-on-secondary-fixed: #07006c;
--color-on-secondary-fixed-variant: #2f2ebe;
--color-tertiary-fixed: #6ffbbe;
--color-tertiary-fixed-dim: #4edea3;
--color-on-tertiary-fixed: #002113;
--color-on-tertiary-fixed-variant: #005236;
--color-background: #0b1326;
--color-on-background: #dae2fd;
--color-surface-variant: #2d3449;
--font-headline: 'Space Grotesk', sans-serif;
--font-body: 'Inter', sans-serif;
--font-code: 'JetBrains Mono', ui-monospace, monospace;
--text-headline-lg: 18px;
--text-headline-lg--line-height: 24px;
--text-headline-lg--letter-spacing: -0.01em;
--text-headline-lg--font-weight: 600;
--text-headline-md: 15px;
--text-headline-md--line-height: 20px;
--text-headline-md--letter-spacing: -0.005em;
--text-headline-md--font-weight: 600;
--text-headline-sm: 13px;
--text-headline-sm--line-height: 18px;
--text-headline-sm--font-weight: 500;
--text-body-lg: 13px;
--text-body-lg--line-height: 20px;
--text-body-md: 12px;
--text-body-md--line-height: 18px;
--text-body-sm: 11px;
--text-body-sm--line-height: 16px;
--text-code-editor: 13px;
--text-code-editor--line-height: 22px;
--text-code-gutter: 11px;
--text-code-gutter--line-height: 22px;
--text-label-md: 11px;
--text-label-md--line-height: 14px;
--text-label-md--letter-spacing: 0.02em;
--text-label-md--font-weight: 500;
--text-label-sm: 10px;
--text-label-sm--line-height: 12px;
--text-label-sm--letter-spacing: 0.03em;
--text-label-sm--font-weight: 500;
--text-status-indicator: 10px;
--text-status-indicator--line-height: 12px;
--text-status-indicator--font-weight: 600;
--radius-sm: 0.125rem;
--radius: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
--radius-full: 9999px;
--spacing-gutter-xs: 0.25rem;
--spacing-gutter-sm: 0.5rem;
--spacing-gutter-md: 0.75rem;
--spacing-gutter-lg: 1rem;
--spacing-panel-padding-compact: 0.375rem;
--spacing-panel-padding-default: 0.75rem;
--spacing-window-bar-height: 2.25rem;
--spacing-status-bar-height: 1.625rem;
--spacing-sidebar-min-width: 15rem;
--spacing-sidebar-default-width: 18rem;
--spacing-sidebar-max-width: 26rem;
--spacing-page-canvas-padding: 2rem;
--spacing-toolbar-height: 2.75rem;
}
html,
body,
#root {
height: 100%;
margin: 0;
background: var(--color-surface);
color: var(--color-on-surface);
}
body {
overflow: hidden;
user-select: none;
font-family: var(--font-body);
}
.drag-region {
-webkit-app-region: drag;
}
.no-drag {
-webkit-app-region: no-drag;
}
.material-symbols-outlined {
font-family: 'Material Symbols Outlined';
font-weight: normal;
font-style: normal;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
direction: ltr;
font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
font-variation-settings:
'FILL' 0,
'wght' 400,
'GRAD' 0,
'opsz' 24;
user-select: none;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: #3e484f;
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-track {
background: transparent;
}
.notion-editor {
user-select: text;
background:
linear-gradient(180deg, rgba(56, 189, 248, 0.04), transparent 120px),
var(--color-surface-container-lowest);
}
.block-text {
min-height: 1.6em;
outline: none;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.block-text:empty::before {
content: attr(data-placeholder);
color: var(--color-outline);
pointer-events: none;
}
.block-paragraph {
font-family: var(--font-body);
font-size: 15px;
line-height: 1.65;
color: var(--color-on-surface);
padding: 2px 0;
}
.block-h1,
.block-h2,
.block-h3 {
font-family: var(--font-headline);
font-weight: 600;
letter-spacing: -0.01em;
color: var(--color-on-surface);
}
.block-h1 {
font-size: 28px;
line-height: 1.25;
}
.block-h2 {
font-size: 22px;
line-height: 1.3;
}
.block-h3 {
font-size: 18px;
line-height: 1.35;
}
.block-quote {
border-left: 2px solid var(--color-primary-container);
padding-left: 0.75rem;
color: var(--color-on-surface-variant);
font-family: var(--font-body);
font-size: 15px;
line-height: 1.6;
}
.block-code {
font-family: var(--font-code);
font-size: 13px;
line-height: 22px;
background: var(--color-surface-container);
border: 1px solid var(--color-outline-variant);
border-radius: 8px;
padding: 0.6rem 0.75rem;
}
.cm-editor {
user-select: text;
height: 100%;
}
+44
View File
@@ -0,0 +1,44 @@
import type { TreeNode } from './explorer'
import type { WorkspaceFile } from './documents'
export function buildTree(files: WorkspaceFile[], folders: string[][]): TreeNode[] {
type Dir = { folders: Map<string, Dir>; files: WorkspaceFile[] }
const root: Dir = { folders: new Map(), files: [] }
function ensure(parts: string[]): Dir {
let cursor = root
for (const part of parts) {
let next = cursor.folders.get(part)
if (!next) {
next = { folders: new Map(), files: [] }
cursor.folders.set(part, next)
}
cursor = next
}
return cursor
}
for (const folder of folders) ensure(folder)
for (const file of files) ensure(file.dir).files.push(file)
function toNodes(dir: Dir, prefix: string[]): TreeNode[] {
const folderNodes = [...dir.folders.entries()].map(([name, child]) => ({
id: `dir:${[...prefix, name].join('/')}`,
name,
kind: 'folder' as const,
icon: 'folder',
tone: 'text-primary',
children: toNodes(child, [...prefix, name]),
}))
const fileNodes = dir.files.map((file) => ({
id: file.id,
name: file.name,
kind: 'file' as const,
icon: file.icon,
tone: file.tone,
}))
return [...folderNodes, ...fileNodes]
}
return toNodes(root, [])
}
+8
View File
@@ -0,0 +1,8 @@
/** Кнопки рамки окна. К конвейеру md2gost не относятся. */
export interface ChromeApi {
/** darwin — кнопки слева, остальные системы — справа. */
platform: string
minimize(): Promise<void>
toggleMaximize(): Promise<void>
close(): Promise<void>
}
+15
View File
@@ -0,0 +1,15 @@
/** Результат вызова моста. Сборка ГОСТ по-прежнему без Python, поэтому check и build отвечают «не подключено». */
export type HostResult = { ok: true } | { ok: false; error: string }
export const HOST_NOT_CONNECTED: HostResult = { ok: false, error: 'не подключено' }
export type EditorFile = { id: string; text: string }
/** Черновик Markdown остаётся в окне. Страницу открывает WordCraft. */
export interface HostApi {
open(): Promise<HostResult>
save(text: string): Promise<HostResult>
check(text: string): Promise<HostResult>
build(text: string): Promise<HostResult>
edit(files: EditorFile[], activeId: string): Promise<HostResult>
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.web.json" }
]
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"types": ["node", "electron-vite/node"],
"lib": ["ES2022"]
},
"include": [
"electron.vite.config.ts",
"src/main/**/*.ts",
"src/preload/**/*.ts",
"src/shared/**/*.ts"
]
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["electron-vite/node"],
"paths": {
"@renderer/*": ["./src/renderer/src/*"],
"@shared/*": ["./src/shared/*"]
}
},
"include": ["src/renderer/src/**/*.ts", "src/renderer/src/**/*.tsx", "src/shared/**/*.ts"]
}