v0.6.0 BigUpdate
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.wordcraft/
|
||||
out/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
@@ -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()],
|
||||
},
|
||||
})
|
||||
Generated
+4333
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
})
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare module '*.css' {}
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
Сравнение вариантов реализации представлено в @Таблица: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 `\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'} />
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Vendored
+11
@@ -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 {}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: 'diagrams',
|
||||
title: 'Схемы',
|
||||
paragraphs: [
|
||||
'Исходник схемы хранится в .puml или в ограде ```plantuml прямо в отчёте. Перед оградой — %id Название, тогда схема станет рисунком.',
|
||||
'Языки ограды: plantuml, uml, mermaid, idef0, dfd. Картинку из схемы соберёт ядро. Пока в окне виден исходник, а файлы .svg открываются картинкой.',
|
||||
'Готовые рисунки лежат в figures. На них можно сослаться из отчёта обычной картинкой Markdown.',
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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, [])
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Кнопки рамки окна. К конвейеру md2gost не относятся. */
|
||||
export interface ChromeApi {
|
||||
/** darwin — кнопки слева, остальные системы — справа. */
|
||||
platform: string
|
||||
minimize(): Promise<void>
|
||||
toggleMaximize(): Promise<void>
|
||||
close(): Promise<void>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.web.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user