Initial public release of MDEditor

This commit is contained in:
sinmb79
2026-03-30 11:45:27 +09:00
commit 7a27714040
43 changed files with 13498 additions and 0 deletions

31
.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
node_modules/
dist/
coverage/
.DS_Store
Thumbs.db
*.log
.vscode/
.idea/
src-tauri/target/
src-tauri/gen/
src-tauri/binaries/*.exe
!src-tauri/binaries/README.md
src-tauri/icons/android/
src-tauri/icons/ios/
src-tauri/icons/*.icns
src-tauri/icons/*Logo.png
src-tauri/icons/32x32.png
src-tauri/icons/64x64.png
src-tauri/icons/128x128.png
src-tauri/icons/128x128@2x.png
src-tauri/icons/Square*.png
src-tauri/icons/StoreLogo.png
!src-tauri/icons/app-icon.svg
!src-tauri/icons/icon.ico
!src-tauri/icons/icon.png
mdeditor_codex_handoff.md

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 22B Labs
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

164
README.md Normal file
View File

@@ -0,0 +1,164 @@
# MDEditor
MDEditor is a Windows-first offline document editor for people who want to write formal documents without dealing with raw Markdown syntax. It combines a Korean WYSIWYG editing experience with simple file handling and PDF export.
## Why This Project Exists
Many teams still want the portability of Markdown files, but the people writing the documents often prefer something that feels closer to a word processor.
This project was created to bridge that gap:
- write in a familiar visual editor
- keep documents as simple `.md` files
- work fully offline
- export polished PDFs when the document is ready
## Project Goal
The goal is not to build a general-purpose publishing platform.
The goal is to make everyday document work easier for non-technical users who need:
- a clear Korean interface
- local file ownership
- a lightweight editing workflow
- repeatable document templates
- a straightforward path from draft to PDF
## Concept At A Glance
```mermaid
flowchart LR
A["User writes visually"] --> B["MDEditor WYSIWYG editor"]
B --> C["Markdown file (.md)"]
C --> D["Save and reopen locally"]
C --> E["Pandoc conversion"]
E --> F["Shareable PDF"]
```
## What Problem It Solves
```mermaid
flowchart TD
A["Need a document tool"] --> B{"Current option?"}
B --> C["Word processor only"]
B --> D["Markdown editor only"]
C --> E["Easy to write, harder to keep plain-text workflow"]
D --> F["Good for developers, harder for non-technical users"]
E --> G["Need a middle ground"]
F --> G
G --> H["MDEditor: visual writing + Markdown files + offline PDF export"]
```
## How The App Works
```mermaid
sequenceDiagram
actor User
participant App as MDEditor
participant File as Local Markdown File
participant Pandoc as Pandoc Sidecar
User->>App: Create or edit a document
App->>File: Save as .md
User->>App: Reopen and continue editing
User->>App: Export as PDF
App->>Pandoc: Convert saved Markdown
Pandoc-->>App: PDF output
App-->>User: Ready-to-share PDF
```
## Key Features
- Korean-first desktop UI
- WYSIWYG editing with TOAST UI Editor
- Native `New / Open / Save / Save As` workflow
- Built-in templates for blank documents, reports, meeting notes, and proposals
- PDF export through Pandoc
- Offline-first local file workflow
- Windows NSIS installer build
## Who It Is For
- office teams writing reports or proposal drafts
- non-developers who do not want to see Markdown syntax while writing
- teams that want local files instead of cloud-only editing
- users who need a simple path from draft to PDF
## Who It Is Not For
- users looking for real-time collaboration
- teams needing cloud sync or online review flows
- advanced publishing workflows with complex layout design
## Repository Layout
- `src/`: React UI, editor wrapper, templates, and file workflow logic
- `src-tauri/`: Tauri desktop app, Windows bundling config, and PDF export command
- `scripts/`: Windows helper scripts for sidecar sync and Tauri build automation
- `docs/`: simple documentation for non-developers and maintainers
## For Non-Developers
If a GitHub Release is published, the easiest path is:
1. Open the repository's `Releases` page.
2. Download the Windows installer.
3. Install MDEditor.
4. Open the app, choose a template if needed, and start writing.
5. Save the document as a Markdown file.
6. Export a PDF when the document is ready.
For a step-by-step explanation, see [docs/USER-GUIDE.md](./docs/USER-GUIDE.md).
## For Developers
### Requirements
- Node.js 20+
- Rust toolchain with `cargo` and `rustc`
- Visual Studio with the x64 C++ toolchain
- Pandoc installed locally on Windows
### Install And Test
```bash
npm install
npm test
npm run build
```
### Windows Desktop Build
```bash
npm run sync:pandoc
npm run tauri:dev:win
npm run tauri:build:win
```
The helper scripts will:
- load the Visual Studio developer shell
- add Cargo to `PATH`
- sync the installed Pandoc binary into the Tauri sidecar location
- run the local Tauri CLI for desktop development or packaging
## Current Status
Phase 0 is implemented and locally verified for:
- frontend tests
- production web build
- Windows Tauri build
- NSIS installer generation
The remaining practical step is final manual smoke testing of the packaged app:
1. launch the installed app
2. create a document
3. save and reopen a `.md` file
4. export a PDF and confirm the output file
## License
This project is released under the MIT License. See [LICENSE](./LICENSE).

114
docs/USER-GUIDE.md Normal file
View File

@@ -0,0 +1,114 @@
# MDEditor User Guide
This guide is written for people who just want to use the app, not study the codebase.
## What MDEditor Is
MDEditor is a desktop writing tool for Windows.
It lets you write documents in a visual editor, save them as Markdown files, and export them as PDFs.
You do not need to understand Markdown syntax to use the basic workflow.
## The Simple Idea
```mermaid
flowchart LR
A["Write like a normal editor"] --> B["Save as Markdown"]
B --> C["Keep the file locally"]
C --> D["Export to PDF when finished"]
```
## What You Can Do
- start a blank document
- begin from a ready-made template
- format text with toolbar buttons
- save your work as a `.md` file
- open the file later and continue editing
- export the finished document as a PDF
## A Typical Document Journey
```mermaid
flowchart TD
A["Open MDEditor"] --> B["Choose blank page or template"]
B --> C["Write and format the document"]
C --> D["Save as .md"]
D --> E["Reopen later if needed"]
E --> F["Export as PDF"]
```
## First-Time Use
1. Open the app.
2. Decide whether to start with a blank page or a template.
3. Write the content in the editor.
4. Use the toolbar for bold text, headings, lists, tables, links, images, or diagrams.
5. Save the document.
6. Export a PDF when the document is complete.
## Templates
MDEditor includes simple starter templates for common document types:
- Blank document
- Report
- Meeting notes
- Proposal
Templates are meant to reduce the time needed to create a first draft.
## Saving Your Work
There are two main save actions:
- `Save`: save the current file
- `Save As`: save a copy with a new file name or location
If you try to replace unsaved content, the app asks for confirmation first.
## Exporting To PDF
When your document is ready:
1. save the current document
2. choose PDF export
3. wait for the app to convert the file
The app uses Pandoc behind the scenes to turn the Markdown file into a PDF.
## What “Offline-First” Means Here
MDEditor is designed so that your main document work happens on your computer.
That means:
- your files are local
- the editor does not depend on a cloud service for normal use
- you can keep your own folder structure
## What To Expect
MDEditor is best for:
- draft writing
- structured reports
- internal meeting notes
- proposals and administrative documents
It is not meant to replace:
- large collaborative document platforms
- advanced design/layout tools
- real-time cloud co-editing tools
## If Something Feels Unsafe
If the app warns you that there are unsaved changes, stop and decide before continuing.
That warning exists to prevent accidental loss of work.
## If You Want The Source Build
If someone on your team needs to build the app from source, the main instructions are in [README.md](../README.md).

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MDEditor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5818
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

54
package.json Normal file
View File

@@ -0,0 +1,54 @@
{
"name": "mdeditor",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"sync:pandoc": "powershell -ExecutionPolicy Bypass -File scripts/Sync-PandocSidecar.ps1",
"test": "vitest run",
"test:watch": "vitest",
"tauri": "tauri",
"tauri:build:win": "powershell -ExecutionPolicy Bypass -File scripts/Invoke-Tauri.ps1 build",
"tauri:dev:win": "powershell -ExecutionPolicy Bypass -File scripts/Invoke-Tauri.ps1 dev"
},
"keywords": [
"markdown",
"editor",
"tauri",
"react"
],
"author": "22B Labs",
"license": "MIT",
"dependencies": {
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-shell": "^2.3.5",
"@toast-ui/editor": "^3.2.2",
"@toast-ui/editor-plugin-chart": "^3.0.1",
"@toast-ui/editor-plugin-color-syntax": "^3.1.0",
"@toast-ui/editor-plugin-table-merged-cell": "^3.1.0",
"@toast-ui/editor-plugin-uml": "^3.0.1",
"@toast-ui/react-editor": "^3.2.3",
"katex": "^0.16.22",
"mermaid": "^11.12.0",
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
"devDependencies": {
"@tauri-apps/cli": "^2.10.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^12.1.5",
"@testing-library/user-event": "^13.5.0",
"@types/react": "^17.0.90",
"@types/react-dom": "^17.0.26",
"@vitejs/plugin-react": "^5.0.4",
"jsdom": "^27.0.0",
"typescript": "^5.9.3",
"vite": "^7.1.12",
"vitest": "^4.0.3"
}
}

41
scripts/Invoke-Tauri.ps1 Normal file
View File

@@ -0,0 +1,41 @@
param(
[Parameter(Position = 0)]
[ValidateSet('build', 'dev')]
[string]$Mode = 'build',
[switch]$SkipPandocSync
)
$ErrorActionPreference = 'Stop'
$repoRoot = Split-Path -Parent $PSScriptRoot
$vsDevShell = 'C:\Program Files\Microsoft Visual Studio\18\Community\Common7\Tools\Launch-VsDevShell.ps1'
$tauriCli = Join-Path $repoRoot 'node_modules\.bin\tauri.cmd'
$pandocSyncScript = Join-Path $PSScriptRoot 'Sync-PandocSidecar.ps1'
if (-not (Test-Path $vsDevShell)) {
throw "Visual Studio Developer Shell was not found at '$vsDevShell'."
}
if (-not (Test-Path $tauriCli)) {
throw "Local Tauri CLI was not found at '$tauriCli'. Run 'npm install' first."
}
& $vsDevShell -Arch amd64 -HostArch amd64 > $null
Set-Location $repoRoot
$env:PATH = "$env:USERPROFILE\.cargo\bin;$env:PATH"
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
throw "Cargo is not available on PATH after loading the developer shell."
}
if (-not $SkipPandocSync) {
& $pandocSyncScript
}
Write-Host "Running Tauri $Mode from '$repoRoot'..."
& $tauriCli $Mode
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}

View File

@@ -0,0 +1,24 @@
param()
$ErrorActionPreference = 'Stop'
$repoRoot = Split-Path -Parent $PSScriptRoot
$destination = Join-Path $repoRoot 'src-tauri\binaries\pandoc-x86_64-pc-windows-msvc.exe'
$candidatePaths = @(
(Join-Path $env:LOCALAPPDATA 'Pandoc\pandoc.exe')
)
$pandocCommand = Get-Command pandoc -ErrorAction SilentlyContinue
if ($pandocCommand) {
$candidatePaths += $pandocCommand.Source
}
$source = $candidatePaths | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
if (-not $source) {
throw "Pandoc was not found. Install it first or place the sidecar manually at '$destination'."
}
Copy-Item -LiteralPath $source -Destination $destination -Force
Write-Host "Synced Pandoc sidecar from '$source' to '$destination'."

5165
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

21
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,21 @@
[package]
name = "mdeditor"
version = "0.1.0"
description = "Windows-first offline WYSIWYG Markdown editor"
authors = ["22B Labs"]
edition = "2021"
[lib]
name = "mdeditor_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
tauri-plugin-shell = "2"

View File

@@ -0,0 +1,5 @@
Place the Pandoc sidecar binary here before packaging.
- Expected base name: `pandoc`
- Tauri will append the platform-specific suffix during bundling.
- Example Windows file: `pandoc-x86_64-pc-windows-msvc.exe`

3
src-tauri/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,20 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main-capability",
"description": "Capability for the main MDEditor window",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:default",
"fs:default",
{
"identifier": "shell:allow-execute",
"allow": [
{
"name": "pandoc",
"sidecar": true
}
]
}
]
}

View File

@@ -0,0 +1,33 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="72" y1="56" x2="432" y2="456" gradientUnits="userSpaceOnUse">
<stop stop-color="#0F766E" />
<stop offset="1" stop-color="#1D4ED8" />
</linearGradient>
<linearGradient id="paper" x1="150" y1="118" x2="360" y2="390" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFFFFF" />
<stop offset="1" stop-color="#DCEAFE" />
</linearGradient>
</defs>
<rect x="32" y="32" width="448" height="448" rx="96" fill="url(#bg)" />
<path
d="M158 110C158 98.9543 166.954 90 178 90H290.686C295.991 90 301.079 92.1071 304.828 95.8579L353.142 144.172C356.893 147.921 359 153.009 359 158.314V334C359 345.046 350.046 354 339 354H178C166.954 354 158 345.046 158 334V110Z"
fill="url(#paper)"
/>
<path
d="M291 90V141C291 152.046 299.954 161 311 161H359"
stroke="#BFDBFE"
stroke-width="22"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M172 386C172 374.954 180.954 366 192 366H337C348.046 366 357 374.954 357 386C357 397.046 348.046 406 337 406H192C180.954 406 172 397.046 172 386Z"
fill="#0B1220"
fill-opacity="0.22"
/>
<path
d="M194 338V179H228L258 242L288 179H322V338H289V238L266 287H250L227 238V338H194Z"
fill="#0F172A"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

50
src-tauri/src/lib.rs Normal file
View File

@@ -0,0 +1,50 @@
use serde::Serialize;
use tauri::Manager;
use tauri_plugin_shell::ShellExt;
#[derive(Debug, Serialize)]
struct ExportResponse {
output_path: String,
}
#[tauri::command]
async fn export_pdf(app: tauri::AppHandle, input_path: String, output_path: String) -> Result<ExportResponse, String> {
let sidecar_command = app
.shell()
.sidecar("pandoc")
.map_err(|error| format!("failed to prepare pandoc sidecar: {error}"))?;
let output = sidecar_command
.args([input_path.as_str(), "-o", output_path.as_str()])
.output()
.await
.map_err(|error| format!("failed to execute pandoc: {error}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
return Err(if stderr.is_empty() {
"pandoc export failed".to_string()
} else {
stderr
});
}
Ok(ExportResponse { output_path })
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![export_pdf])
.setup(|app| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_title("MDEditor");
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

5
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
mdeditor_lib::run();
}

34
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,34 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "MDEditor",
"version": "0.1.0",
"identifier": "com.22blabs.mdeditor",
"build": {
"beforeBuildCommand": "npm run build",
"beforeDevCommand": "npm run dev -- --host 0.0.0.0 --port 1420",
"devUrl": "http://localhost:1420",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "MDEditor",
"width": 1440,
"height": 960,
"resizable": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": ["nsis"],
"copyright": "22B Labs",
"category": "Productivity",
"externalBin": ["binaries/pandoc"],
"shortDescription": "Offline WYSIWYG Markdown editor for Korean document workflows"
}
}

323
src/App.test.tsx Normal file
View File

@@ -0,0 +1,323 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import App from './App';
import type { EditorCommandBridge } from './components/Editor';
import type { DocumentFileService } from './lib/documentSession';
const editorBridgeMock: EditorCommandBridge = {
bold: vi.fn(),
bulletList: vi.fn(),
heading: vi.fn(),
image: vi.fn(),
insertMermaid: vi.fn(),
italic: vi.fn(),
link: vi.fn(),
orderedList: vi.fn(),
table: vi.fn(),
};
vi.mock('./components/Editor', () => ({
EditorSurface: ({
onReady,
value,
onChange,
}: {
onReady?: (bridge: EditorCommandBridge) => void;
value: string;
onChange: (content: string) => void;
}) => {
onReady?.(editorBridgeMock);
return (
<textarea
aria-label="문서 편집기"
value={value}
onChange={(event) => onChange(event.target.value)}
/>
);
},
}));
function createFileServiceMock(overrides: Partial<DocumentFileService> = {}): DocumentFileService {
return {
confirmDiscardChanges: vi.fn().mockResolvedValue(true),
exportDocument: vi.fn().mockResolvedValue(undefined),
openFile: vi.fn().mockResolvedValue(null),
saveFile: vi.fn().mockResolvedValue({ path: 'C:/docs/current.md' }),
...overrides,
};
}
describe('App', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders the Korean shell and applies a template from the sidebar', async () => {
render(<App fileService={createFileServiceMock()} />);
expect(screen.getByText('MDEditor')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '새 문서' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '열기' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '저장' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /보고서/ }));
await waitFor(() =>
expect((screen.getByLabelText('문서 편집기') as HTMLTextAreaElement).value).toContain('# 보고서 제목'),
);
expect(screen.getByText('저장되지 않음')).toBeInTheDocument();
});
it('keeps the current content when template discard is canceled', async () => {
const fileService = createFileServiceMock({
confirmDiscardChanges: vi.fn().mockResolvedValue(false),
});
render(<App fileService={fileService} />);
fireEvent.change(screen.getByLabelText('문서 편집기'), {
target: { value: '지우지 말아야 할 초안' },
});
fireEvent.click(screen.getByRole('button', { name: /보고서/ }));
await waitFor(() =>
expect((screen.getByLabelText('문서 편집기') as HTMLTextAreaElement).value).toBe('지우지 말아야 할 초안'),
);
expect(screen.getByText('작업을 취소했습니다.')).toBeInTheDocument();
});
it('saves the current content through the file service', async () => {
const fileService = createFileServiceMock();
render(<App fileService={fileService} />);
fireEvent.change(screen.getByLabelText('문서 편집기'), {
target: { value: '직접 작성한 문서' },
});
fireEvent.click(screen.getByRole('button', { name: '저장' }));
await waitFor(() =>
expect(fileService.saveFile).toHaveBeenCalledWith('직접 작성한 문서', undefined),
);
expect(screen.getAllByText('C:/docs/current.md')).toHaveLength(2);
});
it('exports pdf after the current document is saved', async () => {
const fileService = createFileServiceMock({
saveFile: vi.fn().mockResolvedValue({ path: 'C:/docs/exportable.md' }),
});
render(<App fileService={fileService} />);
fireEvent.change(screen.getByLabelText('문서 편집기'), {
target: { value: '# 내보내기 테스트' },
});
fireEvent.click(screen.getByRole('button', { name: 'PDF 내보내기' }));
await waitFor(() =>
expect(fileService.exportDocument).toHaveBeenCalledWith('C:/docs/exportable.md', 'pdf'),
);
});
it('opens an existing file into the editor', async () => {
const fileService = createFileServiceMock({
openFile: vi.fn().mockResolvedValue({
content: '# 불러온 문서',
path: 'C:/docs/opened.md',
}),
});
render(<App fileService={fileService} />);
fireEvent.click(screen.getByRole('button', { name: '열기' }));
await waitFor(() =>
expect((screen.getByLabelText('문서 편집기') as HTMLTextAreaElement).value).toContain('# 불러온 문서'),
);
expect(fileService.openFile).toHaveBeenCalledOnce();
});
it('supports save as even after a document already has a path', async () => {
const fileService = createFileServiceMock({
openFile: vi.fn().mockResolvedValue({
content: '# 기존 문서',
path: 'C:/docs/original.md',
}),
saveFile: vi.fn().mockResolvedValue({ path: 'C:/docs/renamed.md' }),
});
render(<App fileService={fileService} />);
fireEvent.click(screen.getByRole('button', { name: '열기' }));
await waitFor(() =>
expect((screen.getByLabelText('문서 편집기') as HTMLTextAreaElement).value).toContain('# 기존 문서'),
);
fireEvent.click(screen.getByRole('button', { name: '다른 이름으로 저장' }));
await waitFor(() => expect(fileService.saveFile).toHaveBeenLastCalledWith('# 기존 문서', undefined));
expect(screen.getAllByText('C:/docs/renamed.md')).toHaveLength(2);
});
it('routes toolbar commands through the editor bridge', async () => {
render(<App fileService={createFileServiceMock()} />);
fireEvent.click(screen.getByRole('button', { name: '굵게' }));
fireEvent.click(screen.getByRole('button', { name: 'H2' }));
fireEvent.click(screen.getByRole('button', { name: '다이어그램' }));
fireEvent.click(screen.getByRole('button', { name: '다이어그램 삽입' }));
expect(editorBridgeMock.bold).toHaveBeenCalledOnce();
expect(editorBridgeMock.heading).toHaveBeenCalledWith(2);
expect(editorBridgeMock.insertMermaid).toHaveBeenCalledWith('flowchart');
});
it('collects table dimensions before inserting a table', async () => {
render(<App fileService={createFileServiceMock()} />);
fireEvent.click(screen.getByRole('button', { name: '표' }));
fireEvent.change(screen.getByLabelText('행 수'), { target: { value: '3' } });
fireEvent.change(screen.getByLabelText('열 수'), { target: { value: '4' } });
fireEvent.click(screen.getByRole('button', { name: '표 삽입' }));
expect(editorBridgeMock.table).toHaveBeenCalledWith(3, 4);
});
it('collects link information before inserting a link', async () => {
render(<App fileService={createFileServiceMock()} />);
fireEvent.click(screen.getByRole('button', { name: '링크' }));
fireEvent.change(screen.getByLabelText('링크 주소'), {
target: { value: 'https://example.com' },
});
fireEvent.change(screen.getByLabelText('표시 텍스트'), {
target: { value: '예시 링크' },
});
fireEvent.click(screen.getByRole('button', { name: '링크 삽입' }));
expect(editorBridgeMock.link).toHaveBeenCalledWith('https://example.com', '예시 링크');
});
it('lets the user choose a mermaid diagram type before inserting', async () => {
render(<App fileService={createFileServiceMock()} />);
fireEvent.click(screen.getByRole('button', { name: '다이어그램' }));
fireEvent.change(screen.getByLabelText('다이어그램 종류'), {
target: { value: 'sequence' },
});
fireEvent.click(screen.getByRole('button', { name: '다이어그램 삽입' }));
expect(editorBridgeMock.insertMermaid).toHaveBeenCalledWith('sequence');
});
it('shows an error message when pdf export fails', async () => {
const fileService = createFileServiceMock({
exportDocument: vi.fn().mockRejectedValue(new Error('Pandoc failed')),
saveFile: vi.fn().mockResolvedValue({ path: 'C:/docs/exportable.md' }),
});
render(<App fileService={fileService} />);
fireEvent.change(screen.getByLabelText('문서 편집기'), {
target: { value: '# 내보내기 테스트' },
});
fireEvent.click(screen.getByRole('button', { name: 'PDF 내보내기' }));
await waitFor(() => expect(screen.getByText('Pandoc failed')).toBeInTheDocument());
});
it('supports common document keyboard shortcuts', async () => {
const fileService = createFileServiceMock({
openFile: vi.fn().mockResolvedValue({
content: '# 단축키로 연 문서',
path: 'C:/docs/shortcut-open.md',
}),
saveFile: vi
.fn()
.mockResolvedValueOnce({ path: 'C:/docs/shortcut-save.md' })
.mockResolvedValueOnce({ path: 'C:/docs/shortcut-save-as.md' }),
});
render(<App fileService={fileService} />);
fireEvent.change(screen.getByLabelText('문서 편집기'), {
target: { value: '# 단축키 테스트' },
});
fireEvent.keyDown(window, { ctrlKey: true, key: 's' });
await waitFor(() =>
expect(fileService.saveFile).toHaveBeenCalledWith('# 단축키 테스트', undefined),
);
fireEvent.keyDown(window, { ctrlKey: true, shiftKey: true, key: 'S' });
await waitFor(() =>
expect(fileService.saveFile).toHaveBeenLastCalledWith('# 단축키 테스트', undefined),
);
fireEvent.keyDown(window, { ctrlKey: true, key: 'o' });
await waitFor(() =>
expect((screen.getByLabelText('문서 편집기') as HTMLTextAreaElement).value).toContain('# 단축키로 연 문서'),
);
fireEvent.keyDown(window, { ctrlKey: true, key: 'n' });
await waitFor(() => expect((screen.getByLabelText('문서 편집기') as HTMLTextAreaElement).value).toBe(''));
});
it('keeps the current document when new-document discard is canceled', async () => {
const fileService = createFileServiceMock({
confirmDiscardChanges: vi.fn().mockResolvedValue(false),
});
render(<App fileService={fileService} />);
fireEvent.change(screen.getByLabelText('문서 편집기'), {
target: { value: '지키고 싶은 내용' },
});
fireEvent.click(screen.getByRole('button', { name: '새 문서' }));
await waitFor(() =>
expect((screen.getByLabelText('문서 편집기') as HTMLTextAreaElement).value).toBe('지키고 싶은 내용'),
);
expect(screen.getByText('작업을 취소했습니다.')).toBeInTheDocument();
});
it('keeps the export modal open when export fails from the modal', async () => {
const fileService = createFileServiceMock({
exportDocument: vi.fn().mockRejectedValue(new Error('Pandoc failed')),
saveFile: vi.fn().mockResolvedValue({ path: 'C:/docs/exportable.md' }),
});
render(<App fileService={fileService} />);
fireEvent.change(screen.getByLabelText('문서 편집기'), {
target: { value: '# 내보내기 테스트' },
});
fireEvent.click(screen.getByRole('button', { name: '내보내기 옵션' }));
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'PDF 내보내기' }));
await waitFor(() => expect(screen.getByText('Pandoc failed')).toBeInTheDocument());
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('warns before the window closes when the document has unsaved changes', () => {
render(<App fileService={createFileServiceMock()} />);
fireEvent.change(screen.getByLabelText('문서 편집기'), {
target: { value: '닫기 전에 경고가 필요한 문서' },
});
const event = new Event('beforeunload', { cancelable: true }) as BeforeUnloadEvent;
Object.defineProperty(event, 'returnValue', {
configurable: true,
writable: true,
value: undefined,
});
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(event.returnValue).toBe('');
});
});

207
src/App.tsx Normal file
View File

@@ -0,0 +1,207 @@
import { useEffect, useMemo, useState } from 'react';
import { EditorSurface, type EditorCommandBridge } from './components/Editor';
import { ExportModal } from './components/ExportModal';
import { Sidebar } from './components/Sidebar';
import { StatusBar } from './components/StatusBar';
import { Toolbar } from './components/Toolbar';
import { createDocumentFileService } from './hooks/useFileSystem';
import {
createDocumentController,
createEmptySession,
type DocumentFileService,
type DocumentSession,
} from './lib/documentSession';
import type { TemplateId } from './lib/templates';
interface AppProps {
fileService?: DocumentFileService;
}
export default function App({ fileService = createDocumentFileService() }: AppProps) {
const [session, setSession] = useState<DocumentSession>(() => createEmptySession());
const [editorBridge, setEditorBridge] = useState<EditorCommandBridge | null>(null);
const [isExportOpen, setIsExportOpen] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const controller = useMemo(() => createDocumentController(fileService), [fileService]);
async function updateSession(
action: Promise<DocumentSession>,
options?: { cancelNotice?: string; successNotice?: string },
): Promise<boolean> {
try {
const nextSession = await action;
setSession(nextSession);
if (nextSession === session && options?.cancelNotice) {
setNotice(options.cancelNotice);
} else {
setNotice(options?.successNotice ?? null);
}
return true;
} catch (error) {
const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
setNotice(message);
return false;
}
}
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (!event.ctrlKey && !event.metaKey) {
return;
}
const key = event.key.toLowerCase();
if (key === 's' && event.shiftKey) {
event.preventDefault();
void updateSession(controller.saveAs(session), {
cancelNotice: '작업을 취소했습니다.',
successNotice: '다른 이름으로 저장했습니다.',
});
return;
}
if (key === 's') {
event.preventDefault();
void updateSession(controller.save(session), {
cancelNotice: '작업을 취소했습니다.',
successNotice: '문서를 저장했습니다.',
});
return;
}
if (key === 'o') {
event.preventDefault();
void updateSession(controller.open(session), {
cancelNotice: '작업을 취소했습니다.',
successNotice: '문서를 불러왔습니다.',
});
return;
}
if (key === 'n') {
event.preventDefault();
void updateSession(controller.createNew(session), {
cancelNotice: '작업을 취소했습니다.',
successNotice: '새 문서를 준비했습니다.',
});
}
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [controller, session]);
useEffect(() => {
function handleBeforeUnload(event: BeforeUnloadEvent) {
if (!session.isDirty) {
return;
}
event.preventDefault();
event.returnValue = '';
}
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [session.isDirty]);
return (
<div className={`app app--${session.theme}`}>
<Toolbar
canSave={session.isDirty || session.currentPath !== null}
editorBridge={editorBridge}
onExportPdf={() => updateSession(controller.exportPdf(session), { successNotice: 'PDF 내보내기 완료' })}
onNew={() =>
updateSession(controller.createNew(session), {
cancelNotice: '작업을 취소했습니다.',
successNotice: '새 문서를 준비했습니다.',
})
}
onOpen={() =>
updateSession(controller.open(session), {
cancelNotice: '작업을 취소했습니다.',
successNotice: '문서를 불러왔습니다.',
})
}
onSave={() =>
updateSession(controller.save(session), {
cancelNotice: '작업을 취소했습니다.',
successNotice: '문서를 저장했습니다.',
})
}
onSaveAs={() =>
updateSession(controller.saveAs(session), {
cancelNotice: '작업을 취소했습니다.',
successNotice: '다른 이름으로 저장했습니다.',
})
}
/>
<main className="app__body">
<Sidebar
currentPath={session.currentPath}
onSelectTemplate={(templateId: TemplateId) =>
updateSession(controller.applyTemplate(session, templateId), {
cancelNotice: '작업을 취소했습니다.',
successNotice: '서식을 적용했습니다.',
})
}
/>
<section className="workspace">
<div className="workspace__header">
<div>
<h1>MDEditor</h1>
<p> WYSIWYG Markdown </p>
</div>
<button onClick={() => setIsExportOpen(true)} type="button">
</button>
</div>
<EditorSurface
onChange={(content) =>
setSession((currentSession) => ({
...currentSession,
content,
isDirty: content !== currentSession.content || currentSession.isDirty,
}))
}
onReady={setEditorBridge}
value={session.content}
/>
</section>
</main>
<StatusBar
currentPath={session.currentPath}
isDirty={session.isDirty}
notice={notice}
onToggleTheme={() =>
setSession((currentSession) => ({
...currentSession,
theme: currentSession.theme === 'light' ? 'dark' : 'light',
}))
}
theme={session.theme}
/>
<ExportModal
isOpen={isExportOpen}
onClose={() => setIsExportOpen(false)}
onExportPdf={async () => {
const succeeded = await updateSession(controller.exportPdf(session), {
successNotice: 'PDF 내보내기 완료',
});
if (succeeded) {
setIsExportOpen(false);
}
}}
/>
</div>
);
}

88
src/components/Editor.tsx Normal file
View File

@@ -0,0 +1,88 @@
import '@toast-ui/editor/dist/i18n/ko-kr';
import '@toast-ui/editor/dist/toastui-editor.css';
import 'katex/dist/katex.min.css';
import { useEffect, useMemo, useRef } from 'react';
import chart from '@toast-ui/editor-plugin-chart';
import colorSyntax from '@toast-ui/editor-plugin-color-syntax';
import tableMergedCell from '@toast-ui/editor-plugin-table-merged-cell';
import uml from '@toast-ui/editor-plugin-uml';
import { Editor as ToastUiReactEditor } from '@toast-ui/react-editor';
import { createEditorCommandBridge, type MermaidDiagramType } from '../lib/editorCommands';
type ToastEditorHandle = InstanceType<typeof ToastUiReactEditor>;
export interface EditorCommandBridge {
bold(): void;
italic(): void;
heading(level: 1 | 2 | 3): void;
bulletList(): void;
orderedList(): void;
table(rowCount: number, columnCount: number): void;
image(imageUrl: string, altText: string): void;
link(linkUrl: string, linkText: string): void;
insertMermaid(type: MermaidDiagramType): void;
}
interface EditorSurfaceProps {
value: string;
onChange(content: string): void;
onReady?(bridge: EditorCommandBridge): void;
}
export function EditorSurface({ value, onChange, onReady }: EditorSurfaceProps) {
const editorRef = useRef<ToastEditorHandle | null>(null);
const lastValueRef = useRef(value);
const plugins = useMemo(() => [chart, colorSyntax, tableMergedCell, uml], []);
useEffect(() => {
const instance = editorRef.current?.getInstance();
if (!instance) {
return;
}
onReady?.(
createEditorCommandBridge({
exec: (command, payload) => instance.exec(command, payload),
focus: () => instance.focus(),
insertText: (text) => instance.insertText(text),
}),
);
}, [onReady]);
useEffect(() => {
const instance = editorRef.current?.getInstance();
if (!instance || value === lastValueRef.current) {
return;
}
instance.setMarkdown(value, false);
lastValueRef.current = value;
}, [value]);
return (
<div className="editor-surface">
<ToastUiReactEditor
autofocus
height="100%"
hideModeSwitch
initialEditType="wysiwyg"
initialValue={value}
language="ko-KR"
plugins={plugins}
previewStyle="vertical"
ref={editorRef}
usageStatistics={false}
onChange={() => {
const nextValue = editorRef.current?.getInstance().getMarkdown() ?? '';
lastValueRef.current = nextValue;
onChange(nextValue);
}}
/>
</div>
);
}

View File

@@ -0,0 +1,28 @@
interface ExportModalProps {
isOpen: boolean;
onClose(): void;
onExportPdf(): void;
}
export function ExportModal({ isOpen, onClose, onExportPdf }: ExportModalProps) {
if (!isOpen) {
return null;
}
return (
<div className="modal-backdrop" role="presentation">
<div aria-modal="true" className="modal" role="dialog">
<h2></h2>
<p>Phase 0 PDF .</p>
<div className="modal__actions">
<button onClick={onExportPdf} type="button">
PDF
</button>
<button onClick={onClose} type="button">
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { DOCUMENT_TEMPLATES, type TemplateId } from '../lib/templates';
interface SidebarProps {
currentPath: string | null;
onSelectTemplate(templateId: TemplateId): void;
}
export function Sidebar({ currentPath, onSelectTemplate }: SidebarProps) {
return (
<aside className="sidebar">
<section className="sidebar__section">
<h2> </h2>
<div className="template-list">
{DOCUMENT_TEMPLATES.map((template) => (
<button
className="template-list__item"
key={template.id}
onClick={() => onSelectTemplate(template.id)}
type="button"
>
<strong>{template.label}</strong>
<span>{template.description}</span>
</button>
))}
</div>
</section>
<section className="sidebar__section">
<h2> </h2>
<p>{currentPath ?? '새 문서'}</p>
</section>
</aside>
);
}

View File

@@ -0,0 +1,22 @@
import type { ThemeMode } from '../lib/documentSession';
interface StatusBarProps {
currentPath: string | null;
isDirty: boolean;
notice: string | null;
onToggleTheme(): void;
theme: ThemeMode;
}
export function StatusBar({ currentPath, isDirty, notice, onToggleTheme, theme }: StatusBarProps) {
return (
<footer className="status-bar">
<span>{currentPath ?? '새 문서'}</span>
<span>{isDirty ? '저장되지 않음' : '저장 완료'}</span>
<span className="status-bar__notice">{notice ?? '준비됨'}</span>
<button onClick={onToggleTheme} type="button">
{theme === 'light' ? '다크 모드' : '라이트 모드'}
</button>
</footer>
);
}

212
src/components/Toolbar.tsx Normal file
View File

@@ -0,0 +1,212 @@
import { useState } from 'react';
import type { EditorCommandBridge } from './Editor';
import type { MermaidDiagramType } from '../lib/editorCommands';
type InsertPanel = 'diagram' | 'image' | 'link' | 'table' | null;
interface ToolbarProps {
canSave: boolean;
onExportPdf(): void;
onNew(): void;
onOpen(): void;
onSave(): void;
onSaveAs(): void;
editorBridge: EditorCommandBridge | null;
}
export function Toolbar({
canSave,
editorBridge,
onExportPdf,
onNew,
onOpen,
onSave,
onSaveAs,
}: ToolbarProps) {
const editorDisabled = !editorBridge;
const [activePanel, setActivePanel] = useState<InsertPanel>(null);
const [tableRows, setTableRows] = useState('2');
const [tableColumns, setTableColumns] = useState('2');
const [linkUrl, setLinkUrl] = useState('');
const [linkText, setLinkText] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [imageAltText, setImageAltText] = useState('');
const [diagramType, setDiagramType] = useState<MermaidDiagramType>('flowchart');
function closePanel() {
setActivePanel(null);
}
return (
<header className="toolbar">
<div className="toolbar__row">
<div className="toolbar__group">
<button onClick={onNew} type="button">
</button>
<button onClick={onOpen} type="button">
</button>
<button disabled={!canSave} onClick={onSave} type="button">
</button>
<button disabled={!canSave} onClick={onSaveAs} type="button">
</button>
<button onClick={onExportPdf} type="button">
PDF
</button>
</div>
<div className="toolbar__group">
<button disabled={editorDisabled} onClick={() => editorBridge?.bold()} type="button">
</button>
<button disabled={editorDisabled} onClick={() => editorBridge?.italic()} type="button">
</button>
<button disabled={editorDisabled} onClick={() => editorBridge?.heading(1)} type="button">
H1
</button>
<button disabled={editorDisabled} onClick={() => editorBridge?.heading(2)} type="button">
H2
</button>
<button disabled={editorDisabled} onClick={() => editorBridge?.heading(3)} type="button">
H3
</button>
<button disabled={editorDisabled} onClick={() => editorBridge?.bulletList()} type="button">
</button>
<button disabled={editorDisabled} onClick={() => editorBridge?.orderedList()} type="button">
</button>
<button disabled={editorDisabled} onClick={() => setActivePanel('table')} type="button">
</button>
<button disabled={editorDisabled} onClick={() => setActivePanel('image')} type="button">
</button>
<button disabled={editorDisabled} onClick={() => setActivePanel('link')} type="button">
</button>
<button disabled={editorDisabled} onClick={() => setActivePanel('diagram')} type="button">
</button>
</div>
</div>
{activePanel === 'diagram' ? (
<div className="toolbar__panel">
<label>
<select onChange={(event) => setDiagramType(event.target.value as MermaidDiagramType)} value={diagramType}>
<option value="flowchart"></option>
<option value="sequence">퀀</option>
<option value="gantt"></option>
<option value="er">ER</option>
<option value="pie"></option>
</select>
</label>
<button
onClick={() => {
editorBridge?.insertMermaid(diagramType);
closePanel();
}}
type="button"
>
</button>
<button onClick={closePanel} type="button">
</button>
</div>
) : null}
{activePanel === 'table' ? (
<div className="toolbar__panel">
<label>
<input
min="1"
onChange={(event) => setTableRows(event.target.value)}
type="number"
value={tableRows}
/>
</label>
<label>
<input
min="1"
onChange={(event) => setTableColumns(event.target.value)}
type="number"
value={tableColumns}
/>
</label>
<button
onClick={() => {
editorBridge?.table(Number(tableRows), Number(tableColumns));
closePanel();
}}
type="button"
>
</button>
<button onClick={closePanel} type="button">
</button>
</div>
) : null}
{activePanel === 'link' ? (
<div className="toolbar__panel">
<label>
<input onChange={(event) => setLinkUrl(event.target.value)} type="url" value={linkUrl} />
</label>
<label>
<input onChange={(event) => setLinkText(event.target.value)} type="text" value={linkText} />
</label>
<button
onClick={() => {
editorBridge?.link(linkUrl, linkText);
closePanel();
}}
type="button"
>
</button>
<button onClick={closePanel} type="button">
</button>
</div>
) : null}
{activePanel === 'image' ? (
<div className="toolbar__panel">
<label>
<input onChange={(event) => setImageUrl(event.target.value)} type="url" value={imageUrl} />
</label>
<label>
<input onChange={(event) => setImageAltText(event.target.value)} type="text" value={imageAltText} />
</label>
<button
onClick={() => {
editorBridge?.image(imageUrl, imageAltText || 'image');
closePanel();
}}
type="button"
>
</button>
<button onClick={closePanel} type="button">
</button>
</div>
) : null}
</header>
);
}

View File

@@ -0,0 +1,97 @@
import { invoke, isTauri } from '@tauri-apps/api/core';
import { confirm, message, open, save } from '@tauri-apps/plugin-dialog';
import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs';
import type { DocumentFileService, OpenResult, SaveResult } from '../lib/documentSession';
function canUseTauriApis(): boolean {
return typeof window !== 'undefined' && isTauri();
}
async function browserSaveFallback(content: string, suggestedName = 'document.md'): Promise<SaveResult> {
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
const href = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = href;
anchor.download = suggestedName;
anchor.click();
URL.revokeObjectURL(href);
return { path: suggestedName };
}
export function createDocumentFileService(): DocumentFileService {
return {
async confirmDiscardChanges(): Promise<boolean> {
if (!canUseTauriApis()) {
return window.confirm('저장하지 않은 변경 사항이 있습니다. 계속 진행할까요?');
}
return confirm('저장하지 않은 변경 사항이 있습니다. 계속 진행할까요?', {
title: 'MDEditor',
kind: 'warning',
});
},
async openFile(): Promise<OpenResult | null> {
if (!canUseTauriApis()) {
window.alert('브라우저 미리보기에서는 파일 열기를 지원하지 않습니다.');
return null;
}
const selected = await open({
directory: false,
filters: [{ name: 'Markdown', extensions: ['md', 'markdown'] }],
multiple: false,
});
if (!selected || Array.isArray(selected)) {
return null;
}
const content = await readTextFile(selected);
return { path: String(selected), content };
},
async saveFile(content: string, path?: string): Promise<SaveResult | null> {
if (!canUseTauriApis()) {
return browserSaveFallback(content, path ?? 'document.md');
}
const targetPath =
path ??
(await save({
defaultPath: 'document.md',
filters: [{ name: 'Markdown', extensions: ['md'] }],
}));
if (!targetPath) {
return null;
}
await writeTextFile(targetPath, content);
return { path: String(targetPath) };
},
async exportDocument(inputPath: string, format: 'pdf' | 'docx' | 'hwp'): Promise<void> {
if (format !== 'pdf') {
throw new Error('Phase 0 only supports PDF export.');
}
const outputPath = inputPath.replace(/\.md$/i, '.pdf');
if (!canUseTauriApis()) {
throw new Error('PDF export is only available in the Tauri desktop app.');
}
await invoke('export_pdf', { inputPath, outputPath });
await message(`PDF로 내보냈습니다.\n${outputPath}`, {
title: 'MDEditor',
kind: 'info',
});
},
};
}

256
src/index.css Normal file
View File

@@ -0,0 +1,256 @@
:root {
color: #172033;
font-family: 'Segoe UI', 'Malgun Gothic', sans-serif;
line-height: 1.5;
background:
radial-gradient(circle at top left, rgba(143, 182, 255, 0.28), transparent 32%),
linear-gradient(180deg, #f2f5fb 0%, #e7edf8 100%);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 1200px;
min-height: 100vh;
}
button {
border: 1px solid rgba(26, 46, 87, 0.12);
border-radius: 12px;
background: #ffffff;
color: inherit;
cursor: pointer;
font: inherit;
padding: 0.65rem 0.9rem;
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
#root {
min-height: 100vh;
}
.app {
display: flex;
min-height: 100vh;
flex-direction: column;
}
.app--dark {
color: #f4f6fb;
background:
radial-gradient(circle at top left, rgba(56, 80, 122, 0.42), transparent 32%),
linear-gradient(180deg, #0d1525 0%, #121d31 100%);
}
.toolbar,
.status-bar,
.sidebar,
.workspace,
.modal {
backdrop-filter: blur(14px);
background: rgba(255, 255, 255, 0.78);
}
.app--dark .toolbar,
.app--dark .status-bar,
.app--dark .sidebar,
.app--dark .workspace,
.app--dark .modal {
background: rgba(12, 21, 37, 0.82);
}
.toolbar {
display: flex;
flex-direction: column;
gap: 0.85rem;
padding: 1rem 1.5rem;
border-bottom: 1px solid rgba(26, 46, 87, 0.08);
}
.toolbar__row {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.toolbar__group {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.toolbar__panel {
display: flex;
align-items: end;
flex-wrap: wrap;
gap: 0.8rem;
border-top: 1px solid rgba(26, 46, 87, 0.08);
padding-top: 0.85rem;
}
.toolbar__panel label {
display: flex;
min-width: 160px;
flex-direction: column;
gap: 0.35rem;
font-size: 0.92rem;
}
.toolbar__panel input {
border: 1px solid rgba(26, 46, 87, 0.12);
border-radius: 12px;
background: rgba(255, 255, 255, 0.92);
color: inherit;
font: inherit;
padding: 0.65rem 0.75rem;
}
.toolbar__panel select {
border: 1px solid rgba(26, 46, 87, 0.12);
border-radius: 12px;
background: rgba(255, 255, 255, 0.92);
color: inherit;
font: inherit;
padding: 0.65rem 0.75rem;
}
.app--dark .toolbar__panel input {
background: rgba(10, 17, 29, 0.95);
border-color: rgba(255, 255, 255, 0.1);
}
.app--dark .toolbar__panel select {
background: rgba(10, 17, 29, 0.95);
border-color: rgba(255, 255, 255, 0.1);
}
.app__body {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 1rem;
flex: 1;
padding: 1rem;
}
.sidebar,
.workspace {
border: 1px solid rgba(26, 46, 87, 0.08);
border-radius: 24px;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1.2rem;
}
.sidebar__section h2,
.workspace__header h1 {
margin: 0 0 0.75rem;
}
.template-list {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.template-list__item {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
padding: 0.9rem;
}
.template-list__item span {
font-size: 0.9rem;
opacity: 0.78;
}
.workspace {
display: flex;
min-height: 0;
flex-direction: column;
padding: 1.2rem;
}
.workspace__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.workspace__header p {
margin: 0;
opacity: 0.72;
}
.editor-surface {
min-height: 0;
flex: 1;
overflow: hidden;
border: 1px solid rgba(26, 46, 87, 0.08);
border-radius: 18px;
}
.status-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.9rem 1.4rem;
border-top: 1px solid rgba(26, 46, 87, 0.08);
}
.status-bar__notice {
flex: 1;
text-align: center;
opacity: 0.78;
}
.modal-backdrop {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(7, 12, 22, 0.4);
}
.modal {
width: min(420px, calc(100vw - 2rem));
border: 1px solid rgba(26, 46, 87, 0.08);
border-radius: 20px;
padding: 1.4rem;
}
.modal__actions {
display: flex;
justify-content: flex-end;
gap: 0.8rem;
}
@media (max-width: 980px) {
body {
min-width: 0;
}
.toolbar__row {
flex-direction: column;
}
.app__body {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,163 @@
import { describe, expect, it, vi } from 'vitest';
import {
createDocumentController,
createEmptySession,
type DocumentFileService,
type DocumentSession,
} from './documentSession';
import { DEFAULT_TEMPLATE_ID, getTemplateById } from './templates';
function createServiceMock(overrides: Partial<DocumentFileService> = {}): DocumentFileService {
return {
confirmDiscardChanges: vi.fn().mockResolvedValue(true),
exportDocument: vi.fn().mockResolvedValue(undefined),
openFile: vi.fn().mockResolvedValue(null),
saveFile: vi.fn().mockResolvedValue({ path: 'C:/docs/saved.md' }),
...overrides,
};
}
describe('document session', () => {
it('creates an empty untitled session', () => {
expect(createEmptySession()).toEqual<DocumentSession>({
content: '',
currentPath: null,
isDirty: false,
theme: 'light',
});
});
it('applies the default template and marks the document as dirty', async () => {
const service = createServiceMock();
const controller = createDocumentController(service);
const nextSession = await controller.applyTemplate(createEmptySession(), DEFAULT_TEMPLATE_ID);
expect(nextSession.content).toBe(getTemplateById(DEFAULT_TEMPLATE_ID).content);
expect(nextSession.currentPath).toBeNull();
expect(nextSession.isDirty).toBe(true);
});
it('asks before discarding unsaved changes when applying a template', async () => {
const service = createServiceMock({
confirmDiscardChanges: vi.fn().mockResolvedValue(false),
});
const controller = createDocumentController(service);
const session: DocumentSession = {
content: 'Unsaved draft',
currentPath: null,
isDirty: true,
theme: 'light',
};
const nextSession = await controller.applyTemplate(session, DEFAULT_TEMPLATE_ID);
expect(service.confirmDiscardChanges).toHaveBeenCalledOnce();
expect(nextSession).toBe(session);
});
it('opens a file and resets the dirty state', async () => {
const service = createServiceMock({
openFile: vi.fn().mockResolvedValue({
content: '# Existing file',
path: 'C:/docs/existing.md',
}),
});
const controller = createDocumentController(service);
const nextSession = await controller.open(createEmptySession());
expect(service.openFile).toHaveBeenCalledOnce();
expect(nextSession).toEqual<DocumentSession>({
content: '# Existing file',
currentPath: 'C:/docs/existing.md',
isDirty: false,
theme: 'light',
});
});
it('saves to the current path without changing the clean content', async () => {
const service = createServiceMock({
saveFile: vi.fn().mockResolvedValue({ path: 'C:/docs/draft.md' }),
});
const controller = createDocumentController(service);
const session: DocumentSession = {
content: '# Draft',
currentPath: 'C:/docs/draft.md',
isDirty: true,
theme: 'dark',
};
const nextSession = await controller.save(session);
expect(service.saveFile).toHaveBeenCalledWith('# Draft', 'C:/docs/draft.md');
expect(nextSession).toEqual<DocumentSession>({
content: '# Draft',
currentPath: 'C:/docs/draft.md',
isDirty: false,
theme: 'dark',
});
});
it('supports save as by ignoring the existing path', async () => {
const service = createServiceMock({
saveFile: vi.fn().mockResolvedValue({ path: 'C:/docs/renamed.md' }),
});
const controller = createDocumentController(service);
const session: DocumentSession = {
content: '# Draft',
currentPath: 'C:/docs/original.md',
isDirty: true,
theme: 'dark',
};
const nextSession = await controller.saveAs(session);
expect(service.saveFile).toHaveBeenCalledWith('# Draft', undefined);
expect(nextSession).toEqual<DocumentSession>({
content: '# Draft',
currentPath: 'C:/docs/renamed.md',
isDirty: false,
theme: 'dark',
});
});
it('asks before discarding unsaved changes when creating a new file', async () => {
const service = createServiceMock({
confirmDiscardChanges: vi.fn().mockResolvedValue(false),
});
const controller = createDocumentController(service);
const session: DocumentSession = {
content: 'Unsaved',
currentPath: null,
isDirty: true,
theme: 'light',
};
const nextSession = await controller.createNew(session);
expect(service.confirmDiscardChanges).toHaveBeenCalledOnce();
expect(nextSession).toBe(session);
});
it('exports pdf by saving untitled content first', async () => {
const service = createServiceMock({
saveFile: vi.fn().mockResolvedValue({ path: 'C:/docs/export-target.md' }),
});
const controller = createDocumentController(service);
const session: DocumentSession = {
content: '# Export me',
currentPath: null,
isDirty: true,
theme: 'light',
};
const nextSession = await controller.exportPdf(session);
expect(service.saveFile).toHaveBeenCalledWith('# Export me', undefined);
expect(service.exportDocument).toHaveBeenCalledWith('C:/docs/export-target.md', 'pdf');
expect(nextSession.currentPath).toBe('C:/docs/export-target.md');
expect(nextSession.isDirty).toBe(false);
});
});

140
src/lib/documentSession.ts Normal file
View File

@@ -0,0 +1,140 @@
import { DEFAULT_TEMPLATE_ID, getTemplateById, type TemplateId } from './templates';
export type ThemeMode = 'light' | 'dark';
export interface DocumentSession {
content: string;
currentPath: string | null;
isDirty: boolean;
theme: ThemeMode;
}
export interface SaveResult {
path: string;
}
export interface OpenResult {
path: string;
content: string;
}
export interface DocumentFileService {
confirmDiscardChanges(): Promise<boolean>;
openFile(): Promise<OpenResult | null>;
saveFile(content: string, path?: string): Promise<SaveResult | null>;
exportDocument(inputPath: string, format: 'pdf' | 'docx' | 'hwp'): Promise<void>;
}
export function createEmptySession(theme: ThemeMode = 'light'): DocumentSession {
return {
content: '',
currentPath: null,
isDirty: false,
theme,
};
}
function markSaved(session: DocumentSession, path: string): DocumentSession {
return {
...session,
currentPath: path,
isDirty: false,
};
}
async function ensureDiscardAllowed(
service: DocumentFileService,
session: DocumentSession,
): Promise<boolean> {
if (!session.isDirty) {
return true;
}
return service.confirmDiscardChanges();
}
export function createDocumentController(service: DocumentFileService) {
return {
async applyTemplate(
session: DocumentSession,
templateId: TemplateId = DEFAULT_TEMPLATE_ID,
): Promise<DocumentSession> {
const canDiscard = await ensureDiscardAllowed(service, session);
if (!canDiscard) {
return session;
}
const template = getTemplateById(templateId);
return {
...session,
content: template.content,
currentPath: null,
isDirty: template.content.length > 0,
};
},
async createNew(session: DocumentSession): Promise<DocumentSession> {
const canDiscard = await ensureDiscardAllowed(service, session);
if (!canDiscard) {
return session;
}
return createEmptySession(session.theme);
},
async open(session: DocumentSession): Promise<DocumentSession> {
const canDiscard = await ensureDiscardAllowed(service, session);
if (!canDiscard) {
return session;
}
const nextFile = await service.openFile();
if (!nextFile) {
return session;
}
return {
content: nextFile.content,
currentPath: nextFile.path,
isDirty: false,
theme: session.theme,
};
},
async save(session: DocumentSession): Promise<DocumentSession> {
const result = await service.saveFile(session.content, session.currentPath ?? undefined);
if (!result) {
return session;
}
return markSaved(session, result.path);
},
async saveAs(session: DocumentSession): Promise<DocumentSession> {
const result = await service.saveFile(session.content, undefined);
if (!result) {
return session;
}
return markSaved(session, result.path);
},
async exportPdf(session: DocumentSession): Promise<DocumentSession> {
const savedSession = session.currentPath ? await this.save(session) : await this.save(session);
if (!savedSession.currentPath) {
return session;
}
await service.exportDocument(savedSession.currentPath, 'pdf');
return savedSession;
},
};
}

View File

@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from 'vitest';
import { createEditorCommandBridge, type EditorRuntime } from './editorCommands';
function createRuntime(): EditorRuntime {
return {
exec: vi.fn(),
focus: vi.fn(),
insertText: vi.fn(),
};
}
describe('editor command bridge', () => {
it('maps formatting actions to TOAST UI commands', () => {
const runtime = createRuntime();
const bridge = createEditorCommandBridge(runtime);
bridge.bold();
bridge.italic();
bridge.heading(2);
bridge.bulletList();
bridge.orderedList();
expect(runtime.exec).toHaveBeenNthCalledWith(1, 'bold');
expect(runtime.exec).toHaveBeenNthCalledWith(2, 'italic');
expect(runtime.exec).toHaveBeenNthCalledWith(3, 'heading', { level: 2 });
expect(runtime.exec).toHaveBeenNthCalledWith(4, 'bulletList');
expect(runtime.exec).toHaveBeenNthCalledWith(5, 'orderedList');
});
it('inserts template text for mermaid diagrams', () => {
const runtime = createRuntime();
const bridge = createEditorCommandBridge(runtime);
bridge.insertMermaid('flowchart');
expect(runtime.insertText).toHaveBeenCalledWith(expect.stringContaining('flowchart TD'));
expect(runtime.focus).toHaveBeenCalledOnce();
});
it('passes structured payloads for table, link, and image insertion', () => {
const runtime = createRuntime();
const bridge = createEditorCommandBridge(runtime);
bridge.table(3, 4);
bridge.link('https://example.com', '예시 링크');
bridge.image('https://example.com/image.png', '예시 이미지');
expect(runtime.exec).toHaveBeenNthCalledWith(1, 'addTable', {
columnCount: 4,
rowCount: 3,
});
expect(runtime.exec).toHaveBeenNthCalledWith(2, 'addLink', {
linkText: '예시 링크',
linkUrl: 'https://example.com',
});
expect(runtime.exec).toHaveBeenNthCalledWith(3, 'addImage', {
altText: '예시 이미지',
imageUrl: 'https://example.com/image.png',
});
});
});

90
src/lib/editorCommands.ts Normal file
View File

@@ -0,0 +1,90 @@
export type MermaidDiagramType = 'flowchart' | 'sequence' | 'gantt' | 'er' | 'pie';
export interface EditorRuntime {
exec(command: string, payload?: Record<string, unknown>): void;
focus(): void;
insertText(text: string): void;
}
function mermaidTemplate(type: MermaidDiagramType): string {
switch (type) {
case 'sequence':
return ['```mermaid', 'sequenceDiagram', 'Alice->>Bob: 안녕하세요', '```', ''].join('\n');
case 'gantt':
return [
'```mermaid',
'gantt',
'title 일정표',
'dateFormat YYYY-MM-DD',
'section 작업',
'초안 작성 :done, a1, 2026-03-30, 2d',
'검토 :a2, after a1, 2d',
'```',
'',
].join('\n');
case 'er':
return [
'```mermaid',
'erDiagram',
'DOCUMENT ||--o{ TEMPLATE : uses',
'DOCUMENT {',
' string title',
'}',
'```',
'',
].join('\n');
case 'pie':
return [
'```mermaid',
'pie title 문서 구성',
'"본문" : 70',
'"표" : 20',
'"그림" : 10',
'```',
'',
].join('\n');
case 'flowchart':
default:
return [
'```mermaid',
'flowchart TD',
'A[작성 시작] --> B[검토]',
'B --> C[배포]',
'```',
'',
].join('\n');
}
}
export function createEditorCommandBridge(runtime: EditorRuntime) {
return {
bold() {
runtime.exec('bold');
},
italic() {
runtime.exec('italic');
},
heading(level: 1 | 2 | 3) {
runtime.exec('heading', { level });
},
bulletList() {
runtime.exec('bulletList');
},
orderedList() {
runtime.exec('orderedList');
},
table(rowCount: number, columnCount: number) {
runtime.exec('addTable', { rowCount, columnCount });
},
image(imageUrl: string, altText: string) {
runtime.exec('addImage', { imageUrl, altText });
},
link(linkUrl: string, linkText: string) {
runtime.exec('addLink', { linkUrl, linkText });
},
insertMermaid(type: MermaidDiagramType) {
runtime.insertText(mermaidTemplate(type));
runtime.focus();
},
};
}

56
src/lib/templates.ts Normal file
View File

@@ -0,0 +1,56 @@
import blankTemplate from '../templates/blank.md?raw';
import minutesTemplate from '../templates/minutes.md?raw';
import proposalTemplate from '../templates/proposal.md?raw';
import reportTemplate from '../templates/report.md?raw';
export type TemplateId = 'blank' | 'report' | 'minutes' | 'proposal';
export interface DocumentTemplate {
id: TemplateId;
label: string;
description: string;
content: string;
}
export const DEFAULT_TEMPLATE_ID: TemplateId = 'report';
function normalizeTemplateContent(content: string): string {
return content.replace(/\r\n/g, '\n').trim();
}
export const DOCUMENT_TEMPLATES: DocumentTemplate[] = [
{
id: 'blank',
label: '빈 문서',
description: '아무 내용 없는 새 문서로 시작합니다.',
content: normalizeTemplateContent(blankTemplate),
},
{
id: 'report',
label: '보고서',
description: '기본 보고서 제목과 개요가 포함됩니다.',
content: normalizeTemplateContent(reportTemplate),
},
{
id: 'minutes',
label: '회의록',
description: '회의 일시와 안건을 빠르게 정리합니다.',
content: normalizeTemplateContent(minutesTemplate),
},
{
id: 'proposal',
label: '기안서',
description: '검토 배경과 기대 효과 중심의 기안서 양식입니다.',
content: normalizeTemplateContent(proposalTemplate),
},
];
export function getTemplateById(id: TemplateId): DocumentTemplate {
const template = DOCUMENT_TEMPLATES.find((item) => item.id === id);
if (!template) {
throw new Error(`Unknown template: ${id}`);
}
return template;
}

12
src/main.tsx Normal file
View File

@@ -0,0 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import './index.css';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root'),
);

1
src/templates/blank.md Normal file
View File

@@ -0,0 +1 @@

11
src/templates/minutes.md Normal file
View File

@@ -0,0 +1,11 @@
# 회의록
- 일시:
- 장소:
- 참석자:
## 안건
## 논의 내용
## 결정 사항

View File

@@ -0,0 +1,7 @@
# 기안서 제목
## 추진 배경
## 주요 내용
## 기대 효과

7
src/templates/report.md Normal file
View File

@@ -0,0 +1,7 @@
# 보고서 제목
## 개요
## 주요 내용
## 결론

1
src/test/setup.ts Normal file
View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom';

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

21
tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src", "vite.config.ts"]
}

44
vite.config.ts Normal file
View File

@@ -0,0 +1,44 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes('node_modules')) {
return undefined;
}
if (id.includes('@toast-ui/editor-plugin-') || id.includes('tui-color-picker')) {
return 'toastui-plugins';
}
if (id.includes('@toast-ui') || id.includes('@toast-ui/toastmark')) {
return 'toastui-core';
}
if (id.includes('prosemirror') || id.includes('codemirror')) {
return 'editor-runtime';
}
if (id.includes('katex')) {
return 'katex-vendor';
}
if (id.includes('react') || id.includes('scheduler')) {
return 'react-vendor';
}
return 'vendor';
},
},
},
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test/setup.ts',
},
});