Initial public release of MDEditor
This commit is contained in:
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal 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
21
LICENSE
Normal 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
164
README.md
Normal 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
114
docs/USER-GUIDE.md
Normal 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
12
index.html
Normal 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
5818
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
54
package.json
Normal file
54
package.json
Normal 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
41
scripts/Invoke-Tauri.ps1
Normal 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
|
||||
}
|
||||
24
scripts/Sync-PandocSidecar.ps1
Normal file
24
scripts/Sync-PandocSidecar.ps1
Normal 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
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
21
src-tauri/Cargo.toml
Normal 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"
|
||||
5
src-tauri/binaries/README.md
Normal file
5
src-tauri/binaries/README.md
Normal 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
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
20
src-tauri/capabilities/default.json
Normal file
20
src-tauri/capabilities/default.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
33
src-tauri/icons/app-icon.svg
Normal file
33
src-tauri/icons/app-icon.svg
Normal 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
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
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
50
src-tauri/src/lib.rs
Normal 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
5
src-tauri/src/main.rs
Normal 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
34
src-tauri/tauri.conf.json
Normal 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
323
src/App.test.tsx
Normal 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
207
src/App.tsx
Normal 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
88
src/components/Editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
src/components/ExportModal.tsx
Normal file
28
src/components/ExportModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/components/Sidebar.tsx
Normal file
34
src/components/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/components/StatusBar.tsx
Normal file
22
src/components/StatusBar.tsx
Normal 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
212
src/components/Toolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
src/hooks/useFileSystem.ts
Normal file
97
src/hooks/useFileSystem.ts
Normal 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
256
src/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
163
src/lib/documentSession.test.ts
Normal file
163
src/lib/documentSession.test.ts
Normal 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
140
src/lib/documentSession.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
62
src/lib/editorCommands.test.ts
Normal file
62
src/lib/editorCommands.test.ts
Normal 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
90
src/lib/editorCommands.ts
Normal 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
56
src/lib/templates.ts
Normal 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
12
src/main.tsx
Normal 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
1
src/templates/blank.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
11
src/templates/minutes.md
Normal file
11
src/templates/minutes.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 회의록
|
||||
|
||||
- 일시:
|
||||
- 장소:
|
||||
- 참석자:
|
||||
|
||||
## 안건
|
||||
|
||||
## 논의 내용
|
||||
|
||||
## 결정 사항
|
||||
7
src/templates/proposal.md
Normal file
7
src/templates/proposal.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# 기안서 제목
|
||||
|
||||
## 추진 배경
|
||||
|
||||
## 주요 내용
|
||||
|
||||
## 기대 효과
|
||||
7
src/templates/report.md
Normal file
7
src/templates/report.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# 보고서 제목
|
||||
|
||||
## 개요
|
||||
|
||||
## 주요 내용
|
||||
|
||||
## 결론
|
||||
1
src/test/setup.ts
Normal file
1
src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal 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
44
vite.config.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user