Compare commits

..
Author SHA1 Message Date
ysj7705andClaude Opus 5.5 2cb0c75092 Make scanner output look like a real scan.
office.sejong.dev/pipeline/head This commit looks good
ysj7705/sejong-office/pipeline/head This commit looks good
- Estimate the true paper aspect from the perspective of the four corners
  (Zhang & He) and snap to A-series/Letter, instead of stretching to the
  longest edges.
- Keep native resolution (1754-3508px long side) and warp with cubic
  interpolation, trimming a thin border.
- Replace the global contrast tweak with OpenCV scan filters: illumination
  flattening (background division) plus ink levels and sharpening for
  color/gray, adaptive threshold for B&W, and an original mode.
- Export color pages as JPEG, size PDF pages to A4 and scale OCR text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:55:12 +09:00
ysj7705andCursor c4ae7495dd Add a markdown viewer for .md files and live typed preview.
office.sejong.dev/pipeline/head This commit looks good
Render GitHub-flavored markdown in the browser with marked and sanitize HTML with DOMPurify.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 13:55:41 +09:00
ysj7705andCursor 545a2802e2 Let image edit set exact pixel size with optional aspect lock.
office.sejong.dev/pipeline/head This commit looks good
Keep width and height editable, and align the side panel so sliders and px fields no longer overflow the right edge.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 13:47:53 +09:00
ysj7705andCursor eadbfb0f86 Support Windows ICO in image edit and background remove.
office.sejong.dev/pipeline/head This commit looks good
Decode the largest BMP or PNG frame for editing, export PNG-in-ICO, and open .ico from the home drop zone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 12:04:56 +09:00
ysj7705andCursor 26c98fe5e8 Add a browser image editor and split scanner Open from Add files.
office.sejong.dev/pipeline/head This commit looks good
Crop, shrink, compress, and change aspect locally, and prompt for warp before showing an uncorrected preview. New pages default to auto paper ratio.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 11:38:49 +09:00
ysj7705andCursor 194eb318d1 Set PATH for the remote deploy command.
office.sejong.dev/pipeline/head This commit looks good
Non-login SSH sessions omit /usr/local/bin, where the NAS keeps docker.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 16:55:06 +09:00
ysj7705andCursor 4bffd65b32 Reuse the agent container mounts for the build step.
office.sejong.dev/pipeline/head There was a failure building this commit
The agent talks to the host daemon over docker.sock, so binding the in-container
workspace path failed on the NAS.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 16:37:42 +09:00
ysj7705andCursor 34d5aa547f Point Publish Over SSH at the SEJONG-NAS host.
office.sejong.dev/pipeline/head There was a failure building this commit
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 16:30:39 +09:00
ysj7705andCursor f4f98704be Replace Gitea Actions with Jenkins CI/CD.
office.sejong.dev/pipeline/head There was a failure building this commit
Build and push images from a Jenkinsfile, then deploy over SSH with compose on main.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 16:17:41 +09:00
ysj7705andCursor b0a6fc326c Drop DinD and use the host Docker socket for image builds.
CI / Type-check & Build (push) Successful in 12m34s
CI / Build & Push image (push) Successful in 16m12s
act_runner does not resolve the docker service hostname here; rely on
CLI plus /var/run/docker.sock from the host runner instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 17:13:22 +09:00
ysj7705andCursor 46acb08b93 Install Docker CLI without sudo in the CI image job.
CI / Type-check & Build (push) Successful in 12m35s
CI / Build & Push image (push) Failing after 2m26s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 16:38:17 +09:00
ysj7705andCursor db1341e40f Use DinD and install Docker CLI in the image job.
CI / Type-check & Build (push) Successful in 12m31s
CI / Build & Push image (push) Failing after 41s
The existing runner job image has no docker binary; provide CLI plus a privileged dind service for login/build/push.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 12:16:58 +09:00
ysj7705andCursor 73e4e73980 Raise Node heap limit in CI to avoid Vite OOM during frontend build.
CI / Type-check & Build (push) Successful in 12m38s
CI / Build & Push image (push) Failing after 13m20s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 11:37:04 +09:00
ysj7705andCursor 816bf636de Add Gitea CI to build and push images to docker.sejong.dev.
CI / Type-check & Build (push) Failing after 8m57s
CI / Build & Push image (push) Skipped
Switch compose to pull sejong-office:latest and document registry secrets and deploy steps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 11:27:09 +09:00
ysj7705 5211b0d7ef 로깅 추가 2026-07-28 15:35:21 +09:00
ysj7705 2967548ff3 .ai, .psd support 2026-07-27 11:30:58 +09:00
ysj7705 f5d880caf5 download 기능 수정 2026-07-23 17:17:24 +09:00
ysj7705 80eae206b6 support no face .ply file 2026-07-23 11:49:29 +09:00
ysj7705 309193a3a7 bg cleaner add 2026-07-23 11:28:43 +09:00
ysj7705 18a19acf55 fix .STEP file load 2026-07-23 10:12:38 +09:00
ysj7705andCursor 00dfb36126 3D 뷰어 로딩 팝업이 100%에서 남지 않도록 수정
loaded 이후에 progress가 loading을 다시 켜던 순서를 바로잡았습니다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 14:49:08 +09:00
ysj7705 b9dfb468e7 ui fix 2026-07-22 14:44:08 +09:00
ysj7705 2beabc610b mobile ui optimize 2026-07-22 13:35:22 +09:00
56 changed files with 7356 additions and 273 deletions

No files matched your search

Vendored
+103
View File
@@ -0,0 +1,103 @@
pipeline {
agent any
environment {
NODE_OPTIONS = '--max-old-space-size=8192'
NODE_IMAGE = 'node:22-bookworm'
REGISTRY = 'docker.sejong.dev'
IMAGE = 'docker.sejong.dev/sejong-office'
SSH_CONFIG_NAME = 'SEJONG-NAS'
DEPLOY_DIR = '/volume1/docker/sejong-office'
}
options {
timestamps()
buildDiscarder(logRotator(numToKeepStr: '20'))
}
stages {
stage('Type-check & Build') {
steps {
sh '''
set -eu
# DooD: docker.sock talks to the host daemon, so -v "$PWD" looks for
# that path on the NAS and fails. Reuse the agent container mounts.
if docker inspect "$(hostname)" >/dev/null 2>&1; then
set -- --volumes-from "$(hostname)" -w "$PWD"
else
set -- -v "$PWD":/app -w /app
fi
docker run --rm \
-u "$(id -u):$(id -g)" \
-e HOME=/tmp \
-e NODE_OPTIONS \
"$@" \
"$NODE_IMAGE" \
bash -lc 'npm ci && npm run build'
'''
}
}
stage('Release') {
when {
allOf {
not { changeRequest() }
anyOf {
branch 'main'
expression {
def branch = env.GIT_BRANCH ?: env.BRANCH_NAME ?: ''
branch == 'main' || branch == 'origin/main' || branch == 'refs/heads/main'
}
}
}
}
stages {
stage('Build & Push image') {
steps {
withCredentials([usernamePassword(
credentialsId: 'docker-sejong-dev',
usernameVariable: 'REGISTRY_USERNAME',
passwordVariable: 'REGISTRY_PASSWORD'
)]) {
sh '''
set -eu
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USERNAME" --password-stdin
docker build -t "$IMAGE:latest" -t "$IMAGE:sha-${GIT_COMMIT}" .
docker push "$IMAGE:latest"
docker push "$IMAGE:sha-${GIT_COMMIT}"
'''
}
}
post {
always {
sh 'docker logout "$REGISTRY" || true'
}
}
}
stage('Deploy') {
steps {
sshPublisher(
failOnError: true,
publishers: [
sshPublisherDesc(
configName: env.SSH_CONFIG_NAME,
verbose: true,
transfers: [
sshTransfer(
execTimeout: 1800000,
usePty: true,
execCommand: """
sudo -n su -c 'set -eu; export PATH=/usr/local/bin:/usr/bin:/bin; cd "${env.DEPLOY_DIR}"; docker compose pull; docker compose up -d'
""".stripIndent().trim()
)
]
)
]
)
}
}
}
}
}
}
+136 -1
View File
@@ -24,7 +24,18 @@
cp .env.example .env cp .env.example .env
# JWT_SECRET 을 긴 임의 문자열로 바꾸세요 # JWT_SECRET 을 긴 임의 문자열로 바꾸세요
docker compose up --build # 로컬에서 이미지 빌드 (레지스트리 없이 테스트할 때)
docker build -t docker.sejong.dev/sejong-office:latest .
docker compose up -d
```
프로덕션(레지스트리 pull):
```bash
# 이미지: docker.sejong.dev/sejong-office:latest (호스트 고정)
docker compose pull
docker compose up -d
``` ```
- 앱: http://localhost:3000 - 앱: http://localhost:3000
@@ -32,6 +43,130 @@ docker compose up --build
첫 Document Server 기동은 수 분 걸릴 수 있습니다. 프론트 빌드 시 Paddle OCR 모델·ORT WASM을 내려받으므로 네트워크와 디스크 여유가 필요합니다. 첫 Document Server 기동은 수 분 걸릴 수 있습니다. 프론트 빌드 시 Paddle OCR 모델·ORT WASM을 내려받으므로 네트워크와 디스크 여유가 필요합니다.
### docker-compose 예시
[`docker-compose.yml`](docker-compose.yml) — backend는 `docker.sejong.dev/sejong-office:latest`를 pull합니다. (아래는 값 고정 예시)
```yaml
services:
backend:
# 고정: 레지스트리 호스트 docker.sejong.dev, 이미지 sejong-office:latest
image: docker.sejong.dev/sejong-office:latest
ports:
# 변경: 호스트 포트 (왼쪽). 컨테이너는 3000 고정
- '3000:3000'
environment:
# 변경(필수): backend ↔ Document Server 공유 JWT. 긴 임의 문자열
JWT_SECRET: change-me-to-a-long-random-secret
# 변경: 브라우저가 접근하는 앱 공개 URL
PUBLIC_URL: https://office.example.com
# 보통 유지: compose 네트워크 안에서 backend 컨테이너 주소
INTERNAL_URL: http://backend:3000
# 변경: 브라우저가 접근하는 Document Server 공개 URL
ONLYOFFICE_URL: https://office.example.com/ds
# 보통 유지: compose 네트워크 안 Document Server 주소
ONLYOFFICE_INTERNAL_URL: http://documentserver
# 변경 가능: 세션 TTL(ms). 기본 15분
SESSION_TTL_MS: '900000'
# 유지: 컨테이너 내 임시 문서 경로 (아래 tmpfs와 맞출 것)
TEMP_DIR: /tmp/sejong
# 유지: 컨테이너 리스닝 포트
PORT: 3000
# 변경 가능: 업로드 최대 크기(MB)
MAX_UPLOAD_MB: '50'
tmpfs:
# 변경 가능: 임시 저장 용량. TEMP_DIR 과 경로 일치
- /tmp/sejong:size=512m,mode=1777
depends_on:
- documentserver
restart: unless-stopped
documentserver:
# 유지 권장: 버전 태그 pin
image: ghcr.io/euro-office/documentserver:v9.3.2
ports:
# 변경: 호스트 포트 (왼쪽). 프록시만 쓸 거면 공개 안 해도 됨
- '8080:80'
environment:
JWT_ENABLED: 'true'
# 변경(필수): backend 의 JWT_SECRET 과 반드시 동일
JWT_SECRET: change-me-to-a-long-random-secret
JWT_HEADER: Authorization
JWT_IN_BODY: 'true'
volumes:
- documentserver_data:/var/www/onlyoffice/Data
restart: unless-stopped
volumes:
documentserver_data:
```
## CI/CD (Jenkins)
[`Jenkinsfile`](Jenkinsfile) 이 `main` PR/브랜치에서 `npm ci` + `npm run build`를 돌리고, **`main` 브랜치 빌드**(PR 제외)에서만 이미지를 push한 뒤 서버에 SSH로 배포합니다.
에이전트에 Docker가 있어야 합니다. Node 22는 `node:22-bookworm` 컨테이너로 돌리며, Vite 힙 한도는 `NODE_OPTIONS=--max-old-space-size=8192`입니다.
푸시 태그: `docker.sejong.dev/sejong-office:latest`, `docker.sejong.dev/sejong-office:sha-<commit>`
배포는 [Publish Over SSH](https://plugins.jenkins.io/publish-over-ssh/)로 파일을 올리지 않고, 서버에 이미 있는 `docker-compose.yml` 디렉터리에서 `docker compose pull && docker compose up -d`만 실행합니다.
### Jenkins Credentials
Username/password 자격 증명 ID: `docker-sejong-dev`
| 필드 | 설명 |
|------|------|
| Username | push 전용 계정 |
| Password | push 전용 토큰 (write) |
레지스트리 호스트는 `docker.sejong.dev`로 고정입니다. PR 빌드는 이 자격 증명을 쓰지 않습니다. 에이전트에 상시 `docker login` 하지 마세요.
Multibranch Pipeline(또는 Gitea/Git 브랜치 소스)로 이 레포를 연결하면 PR과 `main`이 각각 잡으로 뜹니다.
### Publish Over SSH
1. Jenkins에 **Publish Over SSH** 플러그인 설치
2. **Manage Jenkins → System → Publish over SSH** 에 배포 서버 등록 (호스트, 사용자, 키 또는 비밀번호)
3. 등록한 **Name**이 Jenkinsfile의 `SSH_CONFIG_NAME`과 같아야 함 (`SEJONG-NAS`)
4. 배포 명령은 SSH 접속 후 `sudo -n su -c '...'` 로 root에서 실행함. 대화형 `sudo su`와 비밀번호 입력은 CI에서 불가
5. Publish Over SSH에 넣은 **그 계정**으로 서버에 로그인한 뒤, 한 번만 NOPASSWD를 설정:
```bash
sudo visudo -f /etc/sudoers.d/sejong-office-deploy
```
파일 내용 (SSH 사용자 이름으로 바꿀 것):
```
youruser ALL=(ALL) NOPASSWD: /bin/su, /usr/bin/su
```
저장 후 비밀번호 없이 root가 되는지 확인:
```bash
sudo -n su -c 'whoami'
# root
```
`sudo -n`은 비밀번호를 묻지 않고, 설정이 안 되어 있으면 바로 실패합니다. Jenkinsfile에 비밀번호를 넣지 마세요.
비로그인 SSH의 PATH에는 `/usr/local/bin`이 없어서 시놀로지의 `docker`를 못 찾습니다. 그래서 배포 명령이 PATH를 직접 지정합니다. 다음이 같은 결과여야 합니다:
```bash
ssh -T youruser@nas "sudo -n su -c 'export PATH=/usr/local/bin:/usr/bin:/bin; command -v docker'"
# /usr/local/bin/docker
```
서버 compose 경로는 Jenkinsfile `DEPLOY_DIR` (`/volume1/docker/sejong-office`)입니다. 이름이나 경로가 다르면 Jenkinsfile을 수정하면 됩니다.
### 서버 배포 체크리스트
1. 배포 디렉터리에 `docker-compose.yml`과 `.env`를 두고, **pull 전용** 토큰으로 `docker login docker.sejong.dev` (CI push 토큰과 분리)
2. `.env`에 `JWT_SECRET`, URL 설정
3. `main` 배포 잡이 이미지 push 후 그 디렉터리에서 compose pull/up 실행
4. `JWT_SECRET`은 Git·이미지·Jenkins Credential에 넣지 않음 (서버 `.env`만)
## 로컬 개발 ## 로컬 개발
```bash ```bash
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
backend: backend:
build: . image: docker.sejong.dev/sejong-office:latest
ports: ports:
- '3000:3000' - '3000:3000'
environment: environment:
+35 -5
View File
@@ -3775,6 +3775,16 @@
"@types/d3-selection": "*" "@types/d3-selection": "*"
} }
}, },
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/trusted-types": "*"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -3973,8 +3983,8 @@
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT", "devOptional": true,
"optional": true "license": "MIT"
}, },
"node_modules/@types/web-bluetooth": { "node_modules/@types/web-bluetooth": {
"version": "0.0.20", "version": "0.0.20",
@@ -4309,6 +4319,22 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/ag-psd": {
"version": "31.0.2",
"resolved": "https://registry.npmjs.org/ag-psd/-/ag-psd-31.0.2.tgz",
"integrity": "sha512-5s5PvqbomPIIJ9YjL6robz55rT4RYPiQkww4brisyZEb5jzlHB1EKh47IJg7gIFj0RUrD0cMgdntM907qaZjXA==",
"license": "MIT",
"dependencies": {
"base64-js": "1.5.1",
"pako": "2.1.0"
}
},
"node_modules/ag-psd/node_modules/pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
"license": "(MIT AND Zlib)"
},
"node_modules/ai": { "node_modules/ai": {
"version": "5.0.217", "version": "5.0.217",
"resolved": "https://registry.npmjs.org/ai/-/ai-5.0.217.tgz", "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.217.tgz",
@@ -5529,9 +5555,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.4.12", "version": "3.4.15",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz",
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==",
"license": "(MPL-2.0 OR Apache-2.0)", "license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": { "optionalDependencies": {
"@types/trusted-types": "^2.0.7" "@types/trusted-types": "^2.0.7"
@@ -9349,11 +9375,14 @@
"@pdf-lib/fontkit": "^1.1.1", "@pdf-lib/fontkit": "^1.1.1",
"@rhwp/editor": "^0.7.18", "@rhwp/editor": "^0.7.18",
"@vueuse/core": "^11.3.0", "@vueuse/core": "^11.3.0",
"ag-psd": "^31.0.2",
"dompurify": "^3.4.15",
"draco3d": "^1.5.7", "draco3d": "^1.5.7",
"element-plus": "^2.14.3", "element-plus": "^2.14.3",
"fflate": "^0.8.2", "fflate": "^0.8.2",
"jscanify": "^1.4.3", "jscanify": "^1.4.3",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"marked": "^16.4.2",
"mermaid": "^11.16.0", "mermaid": "^11.16.0",
"occt-import-js": "^0.0.22", "occt-import-js": "^0.0.22",
"online-3d-viewer": "^0.18.0", "online-3d-viewer": "^0.18.0",
@@ -9374,6 +9403,7 @@
"@fontsource-variable/noto-sans-kr": "^5.3.0", "@fontsource-variable/noto-sans-kr": "^5.3.0",
"@fontsource/noto-sans-kr": "^5.3.0", "@fontsource/noto-sans-kr": "^5.3.0",
"@noto-pdf-ts/fonts-kr": "^0.0.1-alpha.0", "@noto-pdf-ts/fonts-kr": "^0.0.1-alpha.0",
"@types/dompurify": "^3.0.5",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/three": "^0.172.0", "@types/three": "^0.172.0",
+13 -4
View File
@@ -4,6 +4,7 @@ import cookieParser from 'cookie-parser';
import path from 'node:path'; import path from 'node:path';
import fs from 'node:fs'; import fs from 'node:fs';
import { config } from './config.js'; import { config } from './config.js';
import { logger, requestLogger } from './logger.js';
import { sessionsRouter } from './routes/sessions.js'; import { sessionsRouter } from './routes/sessions.js';
import { documentsRouter } from './routes/documents.js'; import { documentsRouter } from './routes/documents.js';
import { import {
@@ -18,6 +19,7 @@ async function main() {
const app = express(); const app = express();
app.use(requestLogger);
app.use( app.use(
cors({ cors({
origin: true, origin: true,
@@ -61,16 +63,23 @@ async function main() {
}); });
} }
app.use((err: unknown, req: express.Request, res: express.Response, _next: express.NextFunction) => {
logger.error('Unhandled request error', { err, path: req.path });
if (!res.headersSent) {
res.status(500).json({ error: 'internal_server_error' });
}
});
startSweeper(); startSweeper();
app.listen(config.port, () => { app.listen(config.port, () => {
console.log(`[sejong-office] listening on :${config.port}`); logger.info(`Backend listening on :${config.port}`);
console.log(`[sejong-office] tempDir=${config.tempDir}`); logger.info(`Temp directory: ${config.tempDir}`);
console.log(`[sejong-office] publicUrl=${config.publicUrl}`); logger.info(`Public URL: ${config.publicUrl}`);
}); });
} }
main().catch((err) => { main().catch((err) => {
console.error(err); logger.error('Backend failed to start', { err });
process.exit(1); process.exit(1);
}); });
+92
View File
@@ -0,0 +1,92 @@
import type { RequestHandler } from 'express';
type LogLevel = 'INFO' | 'WARN' | 'ERROR';
type LogDetails = Record<string, unknown>;
function formatDetails(details?: LogDetails): string {
if (!details) return '';
const normalized = Object.fromEntries(
Object.entries(details).map(([key, value]) => [
key,
value instanceof Error ? value.stack ?? value.message : value,
]),
);
return ` ${JSON.stringify(normalized)}`;
}
function write(level: LogLevel, message: string, details?: LogDetails) {
const line = `[${new Date().toISOString()}] [${level}] ${message}${formatDetails(details)}`;
if (level === 'ERROR') {
console.error(line);
} else if (level === 'WARN') {
console.warn(line);
} else {
console.log(line);
}
}
export const logger = {
info: (message: string, details?: LogDetails) =>
write('INFO', message, details),
warn: (message: string, details?: LogDetails) =>
write('WARN', message, details),
error: (message: string, details?: LogDetails) =>
write('ERROR', message, details),
};
function browserSummary(userAgent = ''): string {
const browsers: [RegExp, string][] = [
[/Edg\/([\d.]+)/, 'Edge'],
[/OPR\/([\d.]+)/, 'Opera'],
[/Chrome\/([\d.]+)/, 'Chrome'],
[/Firefox\/([\d.]+)/, 'Firefox'],
[/Version\/([\d.]+).*Safari\//, 'Safari'],
[/curl\/([\d.]+)/, 'curl'],
];
const browser = browsers
.map(([pattern, name]) => {
const match = userAgent.match(pattern);
return match ? `${name} ${match[1]}` : null;
})
.find(Boolean) ?? 'Unknown';
const os = /Windows NT/.test(userAgent)
? 'Windows'
: /Android/.test(userAgent)
? 'Android'
: /iPhone|iPad/.test(userAgent)
? 'iOS'
: /Mac OS X/.test(userAgent)
? 'macOS'
: /Linux/.test(userAgent)
? 'Linux'
: 'Unknown';
return `${browser} / ${os}`;
}
function requestIp(forwardedFor: string | string[] | undefined, remote?: string) {
const forwarded = Array.isArray(forwardedFor)
? forwardedFor[0]
: forwardedFor?.split(',')[0]?.trim();
return (forwarded || remote || '-').replace(/^::ffff:/, '');
}
export const requestLogger: RequestHandler = (req, res, next) => {
const startedAt = performance.now();
res.on('finish', () => {
const durationMs = Math.round(performance.now() - startedAt);
const ip = requestIp(
req.headers['x-forwarded-for'],
req.socket.remoteAddress,
);
const browser = browserSummary(req.headers['user-agent']);
const message =
`${req.method} ${req.originalUrl} ${res.statusCode} ${durationMs}ms` +
` ip=${ip} browser="${browser}"`;
if (res.statusCode >= 500) logger.error(message);
else if (res.statusCode >= 400) logger.warn(message);
else logger.info(message);
});
next();
};
+10 -33
View File
@@ -2,16 +2,16 @@ import { Router } from 'express';
import multer from 'multer'; import multer from 'multer';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { config, OFFICE_EXTENSIONS } from '../config.js'; import { config, OFFICE_EXTENSIONS } from '../config.js';
import { logger } from '../logger.js';
import { requireSession } from '../middleware/session.js'; import { requireSession } from '../middleware/session.js';
import { import {
addDocument, addDocument,
deleteDocument, deleteDocument,
getDocument, getDocument,
overwriteDocument,
} from '../services/sessionStore.js'; } from '../services/sessionStore.js';
import { import {
buildEditorConfig, buildEditorConfig,
convertToOriginalFormat, persistDocumentFromUrl,
} from '../services/onlyOffice.js'; } from '../services/onlyOffice.js';
import { import {
createBlankDocument, createBlankDocument,
@@ -51,7 +51,7 @@ documentsRouter.post('/new', requireSession, async (req, res) => {
token: doc.token, token: doc.token,
}); });
} catch (err) { } catch (err) {
console.error(err); logger.error('Failed to create blank document', { err });
res.status(500).json({ error: 'create_failed' }); res.status(500).json({ error: 'create_failed' });
} }
}); });
@@ -97,7 +97,7 @@ documentsRouter.post(
documentType: doc.documentType, documentType: doc.documentType,
}); });
} catch (err) { } catch (err) {
console.error(err); logger.error('Failed to upload document', { err });
res.status(500).json({ error: 'upload_failed' }); res.status(500).json({ error: 'upload_failed' });
} }
}, },
@@ -148,7 +148,7 @@ documentsRouter.post('/:docId/callback', async (req, res) => {
verifyOnlyOfficeToken(token); verifyOnlyOfficeToken(token);
} }
} catch (err) { } catch (err) {
console.error('[callback] jwt invalid', err); logger.warn('Invalid callback JWT', { err, docId: req.params.docId });
res.status(403).json({ error: 1 }); res.status(403).json({ error: 1 });
return; return;
} }
@@ -187,35 +187,12 @@ documentsRouter.post('/:docId/callback', async (req, res) => {
} }
try { try {
const downloaded = await fetch(fileUrl); await persistDocumentFromUrl(doc, fileUrl, filetype);
if (!downloaded.ok) {
console.error('[callback] download failed', downloaded.status);
res.json({ error: 1 });
return;
}
let buffer: Buffer = Buffer.from(await downloaded.arrayBuffer());
const returnedType = (filetype ?? doc.fileType).toLowerCase();
if (returnedType !== doc.fileType) {
const converted = await convertToOriginalFormat(
fileUrl,
returnedType,
doc.fileType,
);
if (!converted) {
console.error(
`[callback] refused save: got ${returnedType}, need ${doc.fileType}`,
);
res.json({ error: 1 });
return;
}
buffer = Buffer.from(converted);
}
await overwriteDocument(doc, buffer, doc.fileType);
} catch (err) { } catch (err) {
console.error('[callback] save error', err); logger.error('Failed to save callback document', {
err,
docId: req.params.docId,
});
res.json({ error: 1 }); res.json({ error: 1 });
return; return;
} }
+63 -3
View File
@@ -1,5 +1,7 @@
import { config } from '../config.js'; import { config } from '../config.js';
import { logger } from '../logger.js';
import type { SessionDoc } from '../types/session.js'; import type { SessionDoc } from '../types/session.js';
import { overwriteDocument } from './sessionStore.js';
import { signOnlyOfficePayload } from '../utils/jwt.js'; import { signOnlyOfficePayload } from '../utils/jwt.js';
export function buildEditorConfig(doc: SessionDoc, userId: string) { export function buildEditorConfig(doc: SessionDoc, userId: string) {
@@ -49,6 +51,59 @@ export function buildEditorConfig(doc: SessionDoc, userId: string) {
}; };
} }
/** Prefer the internal Document Server base when the backend fetches the file. */
export function toInternalOnlyOfficeUrl(raw: string): string {
try {
const target = new URL(raw);
const publicBase = new URL(config.onlyOfficeUrl);
const internalBase = new URL(config.onlyOfficeInternalUrl);
if (
target.host === publicBase.host ||
target.host === internalBase.host
) {
target.protocol = internalBase.protocol;
target.host = internalBase.host;
}
return target.toString();
} catch {
return raw;
}
}
/**
* Download an OnlyOffice-produced file URL and overwrite the session document
* (converting back to the original extension when needed).
*/
export async function persistDocumentFromUrl(
doc: SessionDoc,
fileUrl: string,
filetype?: string,
): Promise<Buffer> {
const fetchUrl = toInternalOnlyOfficeUrl(fileUrl);
const downloaded = await fetch(fetchUrl);
if (!downloaded.ok) {
throw new Error(`download_failed_${downloaded.status}`);
}
let buffer: Buffer = Buffer.from(await downloaded.arrayBuffer());
const returnedType = (filetype ?? doc.fileType).toLowerCase();
if (returnedType !== doc.fileType) {
const converted = await convertToOriginalFormat(
fetchUrl,
returnedType,
doc.fileType,
);
if (!converted) {
throw new Error(`convert_failed:${returnedType}->${doc.fileType}`);
}
buffer = Buffer.from(converted);
}
await overwriteDocument(doc, buffer, doc.fileType);
return buffer;
}
export async function convertToOriginalFormat( export async function convertToOriginalFormat(
sourceUrl: string, sourceUrl: string,
fromExt: string, fromExt: string,
@@ -80,7 +135,9 @@ export async function convertToOriginalFormat(
}); });
if (!res.ok) { if (!res.ok) {
console.error('[convert] HTTP', res.status); logger.error('OnlyOffice conversion HTTP error', {
statusCode: res.status,
});
return null; return null;
} }
@@ -91,7 +148,10 @@ export async function convertToOriginalFormat(
}; };
if (data.error || !data.fileUrl) { if (data.error || !data.fileUrl) {
console.error('[convert] failed', data); logger.error('OnlyOffice conversion failed', {
errorCode: data.error,
hasFileUrl: Boolean(data.fileUrl),
});
return null; return null;
} }
@@ -99,7 +159,7 @@ export async function convertToOriginalFormat(
if (!fileRes.ok) return null; if (!fileRes.ok) return null;
return Buffer.from(await fileRes.arrayBuffer()); return Buffer.from(await fileRes.arrayBuffer());
} catch (err) { } catch (err) {
console.error('[convert] error', err); logger.error('OnlyOffice conversion error', { err });
return null; return null;
} }
} }
@@ -3,6 +3,7 @@ import path from 'node:path';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { config } from '../config.js'; import { config } from '../config.js';
import { logger } from '../logger.js';
import type { Session, SessionDoc } from '../types/session.js'; import type { Session, SessionDoc } from '../types/session.js';
import { documentTypeFor, mimeFor } from '../utils/formats.js'; import { documentTypeFor, mimeFor } from '../utils/formats.js';
@@ -151,10 +152,14 @@ export async function sweepExpired(): Promise<number> {
export function startSweeper(intervalMs = 60_000): NodeJS.Timeout { export function startSweeper(intervalMs = 60_000): NodeJS.Timeout {
return setInterval(() => { return setInterval(() => {
void sweepExpired().then((n) => { void sweepExpired()
.then((n) => {
if (n > 0) { if (n > 0) {
console.log(`[session] swept ${n} expired session(s)`); logger.info(`Swept ${n} expired session(s)`);
} }
})
.catch((err) => {
logger.error('Failed to sweep expired sessions', { err });
}); });
}, intervalMs); }, intervalMs);
} }
+3 -3
View File
@@ -10,11 +10,11 @@
type="image/svg+xml" type="image/svg+xml"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%230f6cbd' d='M9,13H15V19H18V10L12,5.5L6,10V19H9V13M4,21V9L12,3L20,9V21H4Z'/%3E%3C/svg%3E" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%230f6cbd' d='M9,13H15V19H18V10L12,5.5L6,10V19H9V13M4,21V9L12,3L20,9V21H4Z'/%3E%3C/svg%3E"
/> />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+KR:wght@400;500;600;700&display=swap"
rel="stylesheet" rel="stylesheet"
as="style"
crossorigin
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css"
/> />
</head> </head>
<body> <body>
+4
View File
@@ -26,11 +26,14 @@
"@pdf-lib/fontkit": "^1.1.1", "@pdf-lib/fontkit": "^1.1.1",
"@rhwp/editor": "^0.7.18", "@rhwp/editor": "^0.7.18",
"@vueuse/core": "^11.3.0", "@vueuse/core": "^11.3.0",
"ag-psd": "^31.0.2",
"dompurify": "^3.4.15",
"draco3d": "^1.5.7", "draco3d": "^1.5.7",
"element-plus": "^2.14.3", "element-plus": "^2.14.3",
"fflate": "^0.8.2", "fflate": "^0.8.2",
"jscanify": "^1.4.3", "jscanify": "^1.4.3",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"marked": "^16.4.2",
"mermaid": "^11.16.0", "mermaid": "^11.16.0",
"occt-import-js": "^0.0.22", "occt-import-js": "^0.0.22",
"online-3d-viewer": "^0.18.0", "online-3d-viewer": "^0.18.0",
@@ -51,6 +54,7 @@
"@fontsource-variable/noto-sans-kr": "^5.3.0", "@fontsource-variable/noto-sans-kr": "^5.3.0",
"@fontsource/noto-sans-kr": "^5.3.0", "@fontsource/noto-sans-kr": "^5.3.0",
"@noto-pdf-ts/fonts-kr": "^0.0.1-alpha.0", "@noto-pdf-ts/fonts-kr": "^0.0.1-alpha.0",
"@types/dompurify": "^3.0.5",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/three": "^0.172.0", "@types/three": "^0.172.0",
@@ -356,6 +356,46 @@ const replacements = [
], ],
]; ];
/**
* CDN builds load OCCT via a blob Worker + importScripts. That fails for
* self-hosted absolute paths in some browsers. Use a same-origin Worker URL.
*/
function patchCreateOcctWorker(source) {
const moduleRe =
/function CreateOcctWorker\s*\([^)]*\)\s*\{[\s\S]*?URL\.createObjectURL\s*\(\s*blob\s*\)[\s\S]*?\}\s*(?=\s*function LoadExternalLibrary)/;
const moduleNext = `function CreateOcctWorker (worker)
{
return new Promise ((resolve, reject) => {
try {
if (occtWorkerUrl === null) {
occtWorkerUrl = '${O3DV_BASE}occt-import-js-worker.js';
}
resolve (new Worker (occtWorkerUrl));
} catch (error) {
reject (error);
}
});
}
`;
const minRe =
/function Wo\(r\)\{return new Promise\(\(e,t\)=>\{if\(vu!==null\)\{e\(new Worker\(vu\)\);return\}[\s\S]*?URL\.createObjectURL\(s\),e\(new Worker\(vu\)\)\}\)\.catch\(t\)\}\)\}/;
const minNext = `function Wo(r){return new Promise((e,t)=>{try{vu===null&&(vu="${O3DV_BASE}occt-import-js-worker.js"),e(new Worker(vu))}catch(n){t(n)}})}`;
let next = source;
let changed = false;
if (moduleRe.test(next)) {
next = next.replace(moduleRe, moduleNext);
changed = true;
}
if (minRe.test(next)) {
next = next.replace(minRe, minNext);
changed = true;
}
return { source: next, changed };
}
async function patchO3dvFile(relativePath) { async function patchO3dvFile(relativePath) {
for (const candidate of resolvePackage(relativePath)) { for (const candidate of resolvePackage(relativePath)) {
try { try {
@@ -367,17 +407,46 @@ async function patchO3dvFile(relativePath) {
changed = true; changed = true;
} }
} }
const occt = patchCreateOcctWorker(source);
source = occt.source;
changed = changed || occt.changed;
if (changed) { if (changed) {
await writeFile(candidate, source); await writeFile(candidate, source);
console.log(`patched ${candidate}`); console.log(`patched ${candidate}`);
} else if (!source.includes(`${O3DV_BASE}occt-import-js-worker.js`)) {
throw new Error(
`OCCT worker patch did not apply for ${candidate}`,
);
} }
return; return;
} catch { } catch (error) {
if (error && /OCCT worker patch/.test(String(error.message))) throw error;
// try next path // try next path
} }
} }
throw new Error(`Could not patch ${relativePath}`); throw new Error(`Could not patch ${relativePath}`);
} }
await writeFile(
path.join(publicDir, 'viewer-assets/o3dv/occt-import-js-worker.js'),
[
`importScripts('${O3DV_BASE}occt-import-js.js');`,
'',
'onmessage = async function (ev)',
'{',
'\tlet modulOverrides = {',
'\t\tlocateFile: function (path) {',
`\t\t\treturn '${O3DV_BASE}' + String(path).split('/').pop();`,
'\t\t}',
'\t};',
'\tlet occt = await occtimportjs (modulOverrides);',
'\tlet result = occt.ReadFile (ev.data.format, ev.data.buffer, ev.data.params);',
'\tpostMessage (result);',
'};',
'',
].join('\n'),
);
console.log('wrote self-hosted occt-import-js-worker.js');
await patchO3dvFile('online-3d-viewer/build/engine/o3dv.module.js'); await patchO3dvFile('online-3d-viewer/build/engine/o3dv.module.js');
await patchO3dvFile('online-3d-viewer/build/engine/o3dv.min.js'); await patchO3dvFile('online-3d-viewer/build/engine/o3dv.min.js');
+17 -4
View File
@@ -1,7 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { bindSessionPageHideOnce } from '@/composables/useSessionLifecycle'; import {
bindSessionPageHideOnce,
hwpOpenToken,
} from '@/composables/useSessionLifecycle';
bindSessionPageHideOnce(); bindSessionPageHideOnce();
@@ -12,8 +15,9 @@ const viewKey = computed(() => {
if (route.name === 'onlyoffice-editor') { if (route.name === 'onlyoffice-editor') {
return `onlyoffice-${route.params.docId}`; return `onlyoffice-${route.params.docId}`;
} }
// Token changes on every staged HWP open so the view remounts with the new file.
if (route.name === 'rhwp-editor') { if (route.name === 'rhwp-editor') {
return 'rhwp-editor'; return `rhwp-${hwpOpenToken()}`;
} }
if ( if (
route.name === 'excalidraw-editor' || route.name === 'excalidraw-editor' ||
@@ -21,7 +25,12 @@ const viewKey = computed(() => {
route.name === 'drawio-editor' || route.name === 'drawio-editor' ||
route.name === 'cad-viewer' || route.name === 'cad-viewer' ||
route.name === 'model3d-viewer' || route.name === 'model3d-viewer' ||
route.name === 'tools-scanner' route.name === 'illustrator-viewer' ||
route.name === 'photoshop-viewer' ||
route.name === 'markdown-viewer' ||
route.name === 'tools-scanner' ||
route.name === 'tools-bg-remove' ||
route.name === 'tools-image-edit'
) { ) {
return String(route.name); return String(route.name);
} }
@@ -34,13 +43,17 @@ const viewKey = computed(() => {
<KeepAlive <KeepAlive
:include="[ :include="[
'OnlyOfficeEditorView', 'OnlyOfficeEditorView',
'RhwpEditorView',
'ExcalidrawEditorView', 'ExcalidrawEditorView',
'MermaidEditorView', 'MermaidEditorView',
'DrawioEditorView', 'DrawioEditorView',
'CadViewerView', 'CadViewerView',
'Model3DViewerView', 'Model3DViewerView',
'IllustratorViewerView',
'PhotoshopViewerView',
'MarkdownViewerView',
'ToolsScannerView', 'ToolsScannerView',
'ToolsBgRemoveView',
'ToolsImageEditView',
]" ]"
:max="10" :max="10"
> >
+9 -5
View File
@@ -35,11 +35,19 @@ export async function createSession(): Promise<{
} }
export async function heartbeat(): Promise<void> { export async function heartbeat(): Promise<void> {
await fetch('/api/sessions/heartbeat', { const res = await fetch('/api/sessions/heartbeat', {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
}); });
if (!res.ok) {
throw new ApiError(
res.status,
'session_required',
'세션이 만료되었거나 없습니다.',
);
} }
}
export async function endSession(): Promise<void> { export async function endSession(): Promise<void> {
await fetch('/api/sessions', { await fetch('/api/sessions', {
@@ -116,7 +124,3 @@ export async function fetchEditorConfig(
}), }),
); );
} }
export function downloadDocumentUrl(docId: string): string {
return `/api/documents/${docId}/download`;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

@@ -0,0 +1,156 @@
<script setup lang="ts">
import { onMounted, onUnmounted, watch } from 'vue';
const props = withDefaults(
defineProps<{
open?: boolean;
title?: string;
lead?: string;
steps?: string[];
pathHint?: string;
}>(),
{
open: false,
title: '다운로드 방법',
lead: '',
steps: () => [],
pathHint: '',
},
);
const emit = defineEmits<{
close: [];
}>();
function onKeydown(e: KeyboardEvent) {
if (!props.open) return;
if (e.key === 'Escape') emit('close');
}
onMounted(() => {
window.addEventListener('keydown', onKeydown);
});
onUnmounted(() => {
window.removeEventListener('keydown', onKeydown);
if (typeof document !== 'undefined') {
document.body.style.overflow = '';
}
});
watch(
() => props.open,
(open) => {
if (typeof document === 'undefined') return;
document.body.style.overflow = open ? 'hidden' : '';
},
);
</script>
<template>
<Teleport to="body">
<div
v-if="open"
class="guide"
role="dialog"
aria-modal="true"
aria-labelledby="download-guide-title"
@click.self="emit('close')"
>
<div class="guide__panel">
<h2 id="download-guide-title" class="guide__title">{{ title }}</h2>
<p v-if="lead" class="guide__lead">{{ lead }}</p>
<ol v-if="steps.length" class="guide__steps">
<li v-for="(step, i) in steps" :key="i">{{ step }}</li>
</ol>
<p v-if="pathHint" class="guide__path" aria-hidden="true">
{{ pathHint }}
</p>
<div class="guide__actions">
<button type="button" class="guide__btn" @click="emit('close')">
확인
</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.guide {
position: fixed;
inset: 0;
z-index: 40;
display: grid;
place-items: center;
padding: 1.25rem;
background: rgb(0 0 0 / 45%);
}
.guide__panel {
width: min(420px, 100%);
padding: 1.25rem 1.35rem 1.1rem;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--ink);
box-shadow: 0 12px 40px rgb(0 0 0 / 18%);
}
.guide__title {
margin: 0 0 0.5rem;
font-size: 1.1rem;
font-weight: 650;
}
.guide__lead {
margin: 0 0 0.85rem;
font-size: 0.9rem;
color: var(--ink-soft);
line-height: 1.45;
}
.guide__steps {
margin: 0 0 0.85rem;
padding-left: 1.2rem;
font-size: 0.9rem;
line-height: 1.55;
color: var(--ink);
}
.guide__steps li + li {
margin-top: 0.35rem;
}
.guide__path {
margin: 0 0 1.1rem;
padding: 0.55rem 0.7rem;
border-radius: 6px;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.82rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.guide__actions {
display: flex;
justify-content: flex-end;
}
.guide__btn {
font: inherit;
font-size: 0.88rem;
font-weight: 600;
padding: 0.45rem 1rem;
border-radius: 6px;
border: none;
background: var(--accent);
color: #fff;
cursor: pointer;
}
.guide__btn:hover {
filter: brightness(1.05);
}
</style>
@@ -9,7 +9,7 @@ withDefaults(
busy?: boolean; busy?: boolean;
}>(), }>(),
{ {
saveLabel: '저장', saveLabel: '다운로드',
showSave: true, showSave: true,
showExportActions: false, showExportActions: false,
showFilename: true, showFilename: true,
@@ -163,10 +163,21 @@ defineEmits<{
@media (max-width: 640px) { @media (max-width: 640px) {
.toolbar { .toolbar {
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
min-height: 48px;
padding: 0.4rem 0.55rem;
} }
.toolbar__title { .toolbar__title {
display: none; display: none;
} }
.toolbar__link {
display: none;
}
.toolbar__btn {
min-height: 2.5rem;
padding: 0.4rem 0.75rem;
}
} }
</style> </style>
@@ -1,7 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { import {
HWP_ICON_URL,
ICON_COLORS, ICON_COLORS,
ICON_MARK_PATHS,
ICON_PATHS, ICON_PATHS,
iconKindForExtension, iconKindForExtension,
} from '@/config/favicons'; } from '@/config/favicons';
@@ -11,28 +13,65 @@ const props = withDefaults(
extension: string; extension: string;
size?: number; size?: number;
}>(), }>(),
{ size: 40 }, { size: 36 },
); );
const kind = computed(() => iconKindForExtension(props.extension.toLowerCase())); const kind = computed(() => iconKindForExtension(props.extension.toLowerCase()));
const mark = computed(() => ICON_MARK_PATHS[kind.value]);
const boxStyle = computed(() => ({
width: `${props.size}px`,
height: `${props.size}px`,
}));
</script> </script>
<template> <template>
<span class="ft-icon" :style="boxStyle" aria-hidden="true">
<img
v-if="kind === 'hwp'"
class="ft-icon__img"
:src="HWP_ICON_URL"
alt=""
draggable="false"
/>
<svg <svg
class="ft-icon" v-else
class="ft-icon__svg"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
:width="size"
:height="size"
aria-hidden="true"
> >
<path :fill="ICON_COLORS[kind]" :d="ICON_PATHS[kind]" /> <path :fill="ICON_COLORS[kind]" :d="ICON_PATHS[kind]" />
<path
v-if="mark"
fill="#ffffff"
fill-rule="evenodd"
:d="mark"
/>
</svg> </svg>
</span>
</template> </template>
<style scoped> <style scoped>
.ft-icon { .ft-icon {
flex-shrink: 0; flex-shrink: 0;
display: inline-grid;
place-items: center;
box-sizing: border-box;
overflow: hidden;
border-radius: 4px;
line-height: 0;
}
.ft-icon__svg,
.ft-icon__img {
display: block; display: block;
width: 100%;
height: 100%;
}
/* Raster HWP logo is full-bleed; match SVG document inset (~3/24). */
.ft-icon__img {
object-fit: contain;
padding: 12.5%;
box-sizing: border-box;
} }
</style> </style>
@@ -0,0 +1,285 @@
<script setup lang="ts">
import { computed, onBeforeUnmount } from 'vue';
import {
clampCrop,
type PixelRect,
} from '@/integrations/imageedit/process';
defineOptions({ name: 'CropEditor' });
const props = defineProps<{
src: string;
crop: PixelRect;
imageWidth: number;
imageHeight: number;
aspect: number | null;
}>();
const emit = defineEmits<{
'update:crop': [PixelRect];
}>();
type Handle = 'move' | 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
const handles = computed<Handle[]>(() =>
props.aspect
? ['nw', 'ne', 'se', 'sw']
: ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'],
);
const boxStyle = computed(() => {
const w = props.imageWidth || 1;
const h = props.imageHeight || 1;
return {
left: `${(props.crop.x / w) * 100}%`,
top: `${(props.crop.y / h) * 100}%`,
width: `${(props.crop.width / w) * 100}%`,
height: `${(props.crop.height / h) * 100}%`,
};
});
let drag: {
handle: Handle;
pointerId: number;
startX: number;
startY: number;
start: PixelRect;
displayScale: number;
} | null = null;
function displayScaleFromEvent(event: PointerEvent) {
const stage = (event.currentTarget as HTMLElement).closest('.crop');
const img = stage?.querySelector('img');
if (!img || !props.imageWidth) return 1;
return img.clientWidth / props.imageWidth;
}
function bindWindow(on: boolean) {
if (on) {
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', endDrag);
window.addEventListener('pointercancel', endDrag);
} else {
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', endDrag);
window.removeEventListener('pointercancel', endDrag);
}
}
function beginDrag(handle: Handle, event: PointerEvent) {
event.preventDefault();
event.stopPropagation();
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
bindWindow(false);
drag = {
handle,
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
start: { ...props.crop },
displayScale: displayScaleFromEvent(event),
};
bindWindow(true);
}
function applyAspectFromFixedCorner(
next: PixelRect,
fixed: { x: number; y: number },
preferWidth: boolean,
) {
const aspect = props.aspect;
if (!aspect) return next;
if (preferWidth) {
next.height = next.width / aspect;
} else {
next.width = next.height * aspect;
}
next.x = fixed.x < next.x + next.width ? fixed.x : fixed.x - next.width;
next.y = fixed.y < next.y + next.height ? fixed.y : fixed.y - next.height;
if (next.width < 0) {
next.x += next.width;
next.width = Math.abs(next.width);
}
if (next.height < 0) {
next.y += next.height;
next.height = Math.abs(next.height);
}
return next;
}
function onPointerMove(event: PointerEvent) {
if (!drag || event.pointerId !== drag.pointerId) return;
const scale = drag.displayScale || 1;
const dx = (event.clientX - drag.startX) / scale;
const dy = (event.clientY - drag.startY) / scale;
const start = drag.start;
let next: PixelRect = { ...start };
const handle = drag.handle;
const aspect = props.aspect;
if (handle === 'move') {
next.x = start.x + dx;
next.y = start.y + dy;
emit(
'update:crop',
clampCrop(next, props.imageWidth, props.imageHeight),
);
return;
}
const se = { x: start.x + start.width, y: start.y + start.height };
const nw = { x: start.x, y: start.y };
if (handle.includes('e') || handle === 'e') next.width = start.width + dx;
if (handle.includes('s') || handle === 's') next.height = start.height + dy;
if (handle.includes('w') || handle === 'w') {
next.x = start.x + dx;
next.width = start.width - dx;
}
if (handle.includes('n') || handle === 'n') {
next.y = start.y + dy;
next.height = start.height - dy;
}
if (aspect) {
if (handle === 'se') {
next = applyAspectFromFixedCorner(next, nw, Math.abs(dx) >= Math.abs(dy));
next.x = nw.x;
next.y = nw.y;
} else if (handle === 'nw') {
next = applyAspectFromFixedCorner(next, se, Math.abs(dx) >= Math.abs(dy));
next.x = se.x - next.width;
next.y = se.y - next.height;
} else if (handle === 'ne') {
const fixed = { x: start.x, y: se.y };
next = applyAspectFromFixedCorner(next, fixed, Math.abs(dx) >= Math.abs(dy));
next.x = start.x;
next.y = se.y - next.height;
} else if (handle === 'sw') {
const fixed = { x: se.x, y: start.y };
next = applyAspectFromFixedCorner(next, fixed, Math.abs(dx) >= Math.abs(dy));
next.x = se.x - next.width;
next.y = start.y;
}
}
emit('update:crop', clampCrop(next, props.imageWidth, props.imageHeight));
}
function endDrag(event: PointerEvent) {
if (!drag || event.pointerId !== drag.pointerId) return;
drag = null;
bindWindow(false);
}
onBeforeUnmount(() => bindWindow(false));
</script>
<template>
<div class="crop">
<img :src="src" alt="편집할 이미지" draggable="false" />
<div class="crop__shade" aria-hidden="true" />
<div
class="crop__box"
:style="boxStyle"
@pointerdown="beginDrag('move', $event)"
>
<span
v-for="handle in handles"
:key="handle"
class="crop__handle"
:class="`crop__handle--${handle}`"
@pointerdown="beginDrag(handle, $event)"
/>
</div>
</div>
</template>
<style scoped>
.crop {
position: relative;
display: inline-block;
max-width: 100%;
line-height: 0;
overflow: hidden;
touch-action: none;
user-select: none;
}
.crop img {
display: block;
max-width: 100%;
max-height: min(70vh, 44rem);
width: auto;
height: auto;
}
.crop__shade {
pointer-events: none;
position: absolute;
inset: 0;
}
.crop__box {
position: absolute;
box-sizing: border-box;
border: 2px solid var(--accent);
box-shadow: 0 0 0 9999px rgb(0 0 0 / 42%);
cursor: move;
}
.crop__handle {
position: absolute;
width: 0.8rem;
height: 0.8rem;
background: var(--surface);
border: 2px solid var(--accent);
border-radius: 1px;
box-sizing: border-box;
}
.crop__handle--nw {
left: -0.4rem;
top: -0.4rem;
cursor: nwse-resize;
}
.crop__handle--ne {
right: -0.4rem;
top: -0.4rem;
cursor: nesw-resize;
}
.crop__handle--se {
right: -0.4rem;
bottom: -0.4rem;
cursor: nwse-resize;
}
.crop__handle--sw {
left: -0.4rem;
bottom: -0.4rem;
cursor: nesw-resize;
}
.crop__handle--n {
left: 50%;
top: -0.4rem;
transform: translateX(-50%);
cursor: ns-resize;
}
.crop__handle--s {
left: 50%;
bottom: -0.4rem;
transform: translateX(-50%);
cursor: ns-resize;
}
.crop__handle--e {
right: -0.4rem;
top: 50%;
transform: translateY(-50%);
cursor: ew-resize;
}
.crop__handle--w {
left: -0.4rem;
top: 50%;
transform: translateY(-50%);
cursor: ew-resize;
}
</style>
@@ -35,6 +35,11 @@ import {
meshInstancePropertyGroups, meshInstancePropertyGroups,
modelPropertyGroups, modelPropertyGroups,
} from '@/integrations/model3d/o3dv'; } from '@/integrations/model3d/o3dv';
import {
createPointCloudFromPly,
isNoFacesImportError,
isPlyFileName,
} from '@/integrations/model3d/pointCloudPly';
const props = defineProps<{ const props = defineProps<{
files: readonly File[]; files: readonly File[];
@@ -67,6 +72,8 @@ const host = ref<HTMLElement | null>(null);
let viewer: OV.Viewer | null = null; let viewer: OV.Viewer | null = null;
let loader: OV.ThreeModelLoader | null = null; let loader: OV.ThreeModelLoader | null = null;
let model: OV.Model | null = null; let model: OV.Model | null = null;
let pointCloudMode = false;
let pointCloudGroups: ModelPropertyGroup[] = [];
let canvas: HTMLCanvasElement | null = null; let canvas: HTMLCanvasElement | null = null;
let resizeObserver: ResizeObserver | null = null; let resizeObserver: ResizeObserver | null = null;
let loadSequence = 0; let loadSequence = 0;
@@ -98,7 +105,7 @@ function applyEnvironment() {
} }
function applyEdges(enabled: boolean) { function applyEdges(enabled: boolean) {
if (!viewer) return; if (!viewer || pointCloudMode) return;
viewer.SetEdgeSettings( viewer.SetEdgeSettings(
new OV.EdgeSettings(enabled, new OV.RGBColor(0, 0, 0), 15), new OV.EdgeSettings(enabled, new OV.RGBColor(0, 0, 0), 15),
); );
@@ -119,7 +126,7 @@ function applyUp(axis: 'y' | 'z') {
} }
function fitCamera() { function fitCamera() {
if (!viewer || !model) return; if (!viewer || (!model && !pointCloudMode)) return;
const sphere = viewer.GetBoundingSphere(() => true); const sphere = viewer.GetBoundingSphere(() => true);
if (!sphere) return; if (!sphere) return;
viewer.AdjustClippingPlanesToSphere(sphere); viewer.AdjustClippingPlanesToSphere(sphere);
@@ -159,7 +166,7 @@ function addMeasureLine(a: Vector3, b: Vector3) {
} }
function applyVisibility() { function applyVisibility() {
if (!viewer) return; if (!viewer || pointCloudMode) return;
viewer.SetMeshesVisibility((userData: { viewer.SetMeshesVisibility((userData: {
originalMeshInstance?: OV.MeshInstance; originalMeshInstance?: OV.MeshInstance;
}) => { }) => {
@@ -170,7 +177,7 @@ function applyVisibility() {
} }
function applyHighlight() { function applyHighlight() {
if (!viewer) return; if (!viewer || pointCloudMode) return;
viewer.SetMeshesHighlight(new OV.RGBColor(255, 200, 0), (userData: { viewer.SetMeshesHighlight(new OV.RGBColor(255, 200, 0), (userData: {
originalMeshInstance?: OV.MeshInstance; originalMeshInstance?: OV.MeshInstance;
}) => { }) => {
@@ -180,6 +187,10 @@ function applyHighlight() {
} }
function selectByKey(key: string | null) { function selectByKey(key: string | null) {
if (pointCloudMode) {
emit('select', null, pointCloudGroups);
return;
}
if (!model) { if (!model) {
emit('select', null, []); emit('select', null, []);
return; return;
@@ -191,9 +202,10 @@ function selectByKey(key: string | null) {
} }
function onMouseClick(_button: number, mouseCoords: { x: number; y: number }) { function onMouseClick(_button: number, mouseCoords: { x: number; y: number }) {
if (!viewer || !model) return; if (!viewer || (!model && !pointCloudMode)) return;
if (props.measureMode) { if (props.measureMode) {
if (pointCloudMode) return;
const hit = viewer.GetMeshIntersectionUnderMouse( const hit = viewer.GetMeshIntersectionUnderMouse(
OV.IntersectionMode.MeshOnly, OV.IntersectionMode.MeshOnly,
mouseCoords, mouseCoords,
@@ -224,6 +236,11 @@ function onMouseClick(_button: number, mouseCoords: { x: number; y: number }) {
return; return;
} }
if (pointCloudMode) {
selectByKey(null);
return;
}
const userData = viewer.GetMeshUserDataUnderMouse( const userData = viewer.GetMeshUserDataUnderMouse(
OV.IntersectionMode.MeshOnly, OV.IntersectionMode.MeshOnly,
mouseCoords, mouseCoords,
@@ -232,11 +249,33 @@ function onMouseClick(_button: number, mouseCoords: { x: number; y: number }) {
selectByKey(key); selectByKey(key);
} }
async function loadPointCloudPly(file: File, sequence: number) {
emit('progress', 0.4, '포인트 클라우드 읽는 중');
const result = await createPointCloudFromPly(file);
if (sequence !== loadSequence || !viewer) return;
pointCloudMode = true;
pointCloudGroups = result.groups;
model = null;
viewer.Clear();
viewer.SetMainObject(result.object);
applyProjection(props.projection);
applyUp(props.upAxis);
void nextTick(() => {
if (sequence !== loadSequence) return;
fitCamera();
emit('progress', 1, '포인트 클라우드 준비 완료');
emit('loaded', result.stats, result.tree, result.groups, []);
selectByKey(null);
});
}
async function load(files: readonly File[]) { async function load(files: readonly File[]) {
const sequence = ++loadSequence; const sequence = ++loadSequence;
if (!viewer || !loader) return; if (!viewer || !loader) return;
clearMeasure(); clearMeasure();
model = null; model = null;
pointCloudMode = false;
pointCloudGroups = [];
viewer.Clear(); viewer.Clear();
if (!files.length) return; if (!files.length) return;
@@ -263,6 +302,8 @@ async function load(files: readonly File[]) {
onTextureLoaded: () => viewer?.Render(), onTextureLoaded: () => viewer?.Render(),
onModelFinished: (importResult: OV.ImportResult, threeObject: unknown) => { onModelFinished: (importResult: OV.ImportResult, threeObject: unknown) => {
if (sequence !== loadSequence) return; if (sequence !== loadSequence) return;
pointCloudMode = false;
pointCloudGroups = [];
viewer?.SetMainObject(threeObject); viewer?.SetMainObject(threeObject);
model = importResult.model; model = importResult.model;
applyVisibility(); applyVisibility();
@@ -272,6 +313,7 @@ async function load(files: readonly File[]) {
applyUp(props.upAxis); applyUp(props.upAxis);
void nextTick(() => { void nextTick(() => {
fitCamera(); fitCamera();
emit('progress', 1, '모델 준비 완료');
emit( emit(
'loaded', 'loaded',
summarizeModel(importResult.model), summarizeModel(importResult.model),
@@ -279,13 +321,26 @@ async function load(files: readonly File[]) {
modelPropertyGroups(importResult.model), modelPropertyGroups(importResult.model),
importResult.missingFiles ?? [], importResult.missingFiles ?? [],
); );
emit('progress', 1, '모델 준비 완료');
selectByKey(null); selectByKey(null);
}); });
}, },
onLoadError: (importError: OV.ImportError) => { onLoadError: (importError: OV.ImportError) => {
if (sequence !== loadSequence) return; if (sequence !== loadSequence) return;
emit('error', importErrorMessage(importError)); const message = importErrorMessage(importError);
const ply = files.find((file) => isPlyFileName(file.name));
if (ply && isNoFacesImportError(message)) {
void loadPointCloudPly(ply, sequence).catch((reason) => {
if (sequence !== loadSequence) return;
emit(
'error',
reason instanceof Error
? reason.message
: '포인트 클라우드 PLY를 열지 못했습니다.',
);
});
return;
}
emit('error', message);
}, },
}); });
} }
@@ -12,10 +12,24 @@ const emit = defineEmits<{
'update:corners': [value: CornerPoints]; 'update:corners': [value: CornerPoints];
}>(); }>();
const LOUPE_SIZE = 128;
const LOUPE_ZOOM = 2.8;
/** 드래그 중인 코너 반대편에 루페를 두어 손가락에 가리지 않게 함 */
const LOUPE_CORNER: Record<
keyof CornerPoints,
{ x: 'left' | 'right'; y: 'top' | 'bottom' }
> = {
topLeftCorner: { x: 'right', y: 'bottom' },
topRightCorner: { x: 'left', y: 'bottom' },
bottomRightCorner: { x: 'left', y: 'top' },
bottomLeftCorner: { x: 'right', y: 'top' },
};
const host = ref<HTMLElement | null>(null); const host = ref<HTMLElement | null>(null);
const imageEl = ref<HTMLImageElement | null>(null); const imageEl = ref<HTMLImageElement | null>(null);
const display = ref({ width: 0, height: 0, naturalWidth: 1, naturalHeight: 1 }); const display = ref({ width: 0, height: 0, naturalWidth: 1, naturalHeight: 1 });
let dragging: keyof CornerPoints | null = null; const dragging = ref<keyof CornerPoints | null>(null);
const scaleX = computed( const scaleX = computed(
() => display.value.width / Math.max(1, display.value.naturalWidth), () => display.value.width / Math.max(1, display.value.naturalWidth),
@@ -50,6 +64,30 @@ const polygonPoints = computed(() => {
.join(' '); .join(' ');
}); });
const loupeStyle = computed(() => {
const key = dragging.value;
if (!key || display.value.width < 8) return null;
const point = props.corners[key];
const anchor = LOUPE_CORNER[key];
const margin = 10;
const left =
anchor.x === 'left' ? margin : display.value.width - LOUPE_SIZE - margin;
const top =
anchor.y === 'top' ? margin : display.value.height - LOUPE_SIZE - margin;
return {
width: `${LOUPE_SIZE}px`,
height: `${LOUPE_SIZE}px`,
left: `${left}px`,
top: `${top}px`,
backgroundImage: `url(${props.imageUrl})`,
backgroundRepeat: 'no-repeat',
backgroundSize: `${display.value.naturalWidth * LOUPE_ZOOM}px ${display.value.naturalHeight * LOUPE_ZOOM}px`,
backgroundPosition: `${LOUPE_SIZE / 2 - point.x * LOUPE_ZOOM}px ${LOUPE_SIZE / 2 - point.y * LOUPE_ZOOM}px`,
};
});
function measure() { function measure() {
if (!host.value || !imageEl.value) return; if (!host.value || !imageEl.value) return;
const rect = imageEl.value.getBoundingClientRect(); const rect = imageEl.value.getBoundingClientRect();
@@ -70,13 +108,13 @@ function clamp(point: Point2D): Point2D {
function onPointerDown(key: keyof CornerPoints, event: PointerEvent) { function onPointerDown(key: keyof CornerPoints, event: PointerEvent) {
if (props.disabled) return; if (props.disabled) return;
dragging = key; dragging.value = key;
(event.target as HTMLElement).setPointerCapture?.(event.pointerId); (event.target as HTMLElement).setPointerCapture?.(event.pointerId);
event.preventDefault(); event.preventDefault();
} }
function onPointerMove(event: PointerEvent) { function onPointerMove(event: PointerEvent) {
if (!dragging || !imageEl.value) return; if (!dragging.value || !imageEl.value) return;
const rect = imageEl.value.getBoundingClientRect(); const rect = imageEl.value.getBoundingClientRect();
const nextPoint = clamp({ const nextPoint = clamp({
x: (event.clientX - rect.left) / scaleX.value, x: (event.clientX - rect.left) / scaleX.value,
@@ -84,12 +122,12 @@ function onPointerMove(event: PointerEvent) {
}); });
emit('update:corners', { emit('update:corners', {
...props.corners, ...props.corners,
[dragging]: nextPoint, [dragging.value]: nextPoint,
}); });
} }
function onPointerUp() { function onPointerUp() {
dragging = null; dragging.value = null;
} }
onMounted(() => { onMounted(() => {
@@ -132,11 +170,20 @@ watch(
:key="handle.key" :key="handle.key"
type="button" type="button"
class="corner-editor__handle" class="corner-editor__handle"
:class="{ 'is-active': dragging === handle.key }"
:style="{ left: `${handle.left}px`, top: `${handle.top}px` }" :style="{ left: `${handle.left}px`, top: `${handle.top}px` }"
:disabled="disabled" :disabled="disabled"
:aria-label="handle.key" :aria-label="handle.key"
@pointerdown="onPointerDown(handle.key, $event)" @pointerdown="onPointerDown(handle.key, $event)"
/> />
<div
v-if="loupeStyle"
class="corner-editor__loupe"
:style="loupeStyle"
aria-hidden="true"
>
<span class="corner-editor__loupe-cross" />
</div>
</div> </div>
</template> </template>
@@ -185,7 +232,8 @@ watch(
touch-action: none; touch-action: none;
} }
.corner-editor__handle:active { .corner-editor__handle.is-active {
transform: scale(1.15);
cursor: grabbing; cursor: grabbing;
} }
@@ -193,4 +241,60 @@ watch(
opacity: 0.5; opacity: 0.5;
cursor: default; cursor: default;
} }
.corner-editor__loupe {
position: absolute;
z-index: 3;
box-sizing: border-box;
border: 2px solid white;
border-radius: 50%;
box-shadow:
0 0 0 1px color-mix(in srgb, black 35%, transparent),
0 10px 28px color-mix(in srgb, black 28%, transparent);
pointer-events: none;
overflow: hidden;
background-color: color-mix(in srgb, var(--bg, #111) 80%, black);
}
.corner-editor__loupe-cross {
position: absolute;
inset: 0;
pointer-events: none;
}
.corner-editor__loupe-cross::before,
.corner-editor__loupe-cross::after {
content: '';
position: absolute;
background: color-mix(in srgb, white 88%, transparent);
box-shadow: 0 0 0 0.5px color-mix(in srgb, black 40%, transparent);
}
.corner-editor__loupe-cross::before {
left: 50%;
top: 18%;
bottom: 18%;
width: 1.5px;
transform: translateX(-50%);
}
.corner-editor__loupe-cross::after {
top: 50%;
left: 18%;
right: 18%;
height: 1.5px;
transform: translateY(-50%);
}
@media (max-width: 900px) {
.corner-editor__image {
max-height: min(52vh, 520px);
}
.corner-editor__handle {
width: 28px;
height: 28px;
margin: -14px 0 0 -14px;
}
}
</style> </style>
@@ -0,0 +1,11 @@
let pending: File | null = null;
export function stageImageEditFile(file: File) {
pending = file;
}
export function consumeImageEditFile(): File | null {
const file = pending;
pending = null;
return file;
}
@@ -0,0 +1,41 @@
import { ref } from 'vue';
/** Nested dragenter/leave safe file drop handlers for a page root. */
export function useFileDropZone(onFiles: (files: File[]) => void) {
const dropActive = ref(false);
let depth = 0;
function onDragEnter(event: DragEvent) {
event.preventDefault();
depth += 1;
dropActive.value = true;
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
}
function onDragOver(event: DragEvent) {
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
}
function onDragLeave(event: DragEvent) {
event.preventDefault();
depth = Math.max(0, depth - 1);
if (depth === 0) dropActive.value = false;
}
function onDrop(event: DragEvent) {
event.preventDefault();
depth = 0;
dropActive.value = false;
const files = Array.from(event.dataTransfer?.files ?? []);
if (files.length) onFiles(files);
}
return {
dropActive,
onDragEnter,
onDragOver,
onDragLeave,
onDrop,
};
}
@@ -19,6 +19,7 @@ import {
storeHwpFile, storeHwpFile,
} from '@/composables/useSessionLifecycle'; } from '@/composables/useSessionLifecycle';
import { stageDiagramFile } from '@/composables/diagramSession'; import { stageDiagramFile } from '@/composables/diagramSession';
import { stageImageEditFile } from '@/composables/imageEditSession';
import { stageScannerFiles } from '@/composables/scannerSession'; import { stageScannerFiles } from '@/composables/scannerSession';
import { stageViewerFiles } from '@/composables/viewerSession'; import { stageViewerFiles } from '@/composables/viewerSession';
@@ -63,6 +64,11 @@ export function useOpenDocument() {
await router.push({ name: 'tools-scanner' }); await router.push({ name: 'tools-scanner' });
return true; return true;
} }
if (toolKind === 'imageedit') {
stageImageEditFile(file);
await router.push({ name: 'tools-image-edit' });
return true;
}
const diagramKind = diagramKindFor(file.name); const diagramKind = diagramKindFor(file.name);
if (diagramKind) { if (diagramKind) {
@@ -6,27 +6,44 @@ import {
type MaybeRefOrGetter, type MaybeRefOrGetter,
} from 'vue'; } from 'vue';
import { APP_DISPLAY_NAME } from '@/config/app'; import { APP_DISPLAY_NAME } from '@/config/app';
import { faviconHrefFor, setFavicon } from '@/config/favicons'; import {
faviconHrefFor,
faviconHrefForKind,
setFavicon,
type FaviconKind,
} from '@/config/favicons';
export function formatPageTitle(filename?: string | null): string { export function formatPageTitle(filename?: string | null): string {
const name = filename?.trim(); const name = filename?.trim();
return name ? `${name} - ${APP_DISPLAY_NAME}` : APP_DISPLAY_NAME; return name ? `${name} - ${APP_DISPLAY_NAME}` : APP_DISPLAY_NAME;
} }
export type UsePageTitleOptions = {
/** Force favicon kind (tools whose file extensions collide with other tools). */
faviconKind?: MaybeRefOrGetter<FaviconKind | null | undefined>;
};
/** /**
* Sets `document.title` to `filename - Sejong Office` (or just the app name) * Sets `document.title` to `filename - Sejong Office` (or just the app name)
* and swaps the favicon by file extension (home icon when no file). * and swaps the favicon by file extension (home icon when no file).
* Pass `faviconKind` when the route should keep a fixed tool/viewer icon.
*/ */
export function usePageTitle( export function usePageTitle(
filename: MaybeRefOrGetter<string | null | undefined> = null, filename: MaybeRefOrGetter<string | null | undefined> = null,
options?: UsePageTitleOptions,
) { ) {
function apply() { function apply() {
const name = toValue(filename); const name = toValue(filename);
document.title = formatPageTitle(name); document.title = formatPageTitle(name);
setFavicon(faviconHrefFor(name)); const kind = options?.faviconKind != null ? toValue(options.faviconKind) : null;
setFavicon(kind ? faviconHrefForKind(kind) : faviconHrefFor(name));
} }
watch(() => toValue(filename), apply, { immediate: true }); watch(
() => [toValue(filename), toValue(options?.faviconKind)] as const,
apply,
{ immediate: true },
);
onMounted(apply); onMounted(apply);
onActivated(apply); onActivated(apply);
} }
@@ -2,6 +2,7 @@ import { onMounted, onUnmounted } from 'vue';
import { createSession, endSession, heartbeat } from '@/api/client'; import { createSession, endSession, heartbeat } from '@/api/client';
const HWP_FILE_KEY = 'sejong_hwp_file'; const HWP_FILE_KEY = 'sejong_hwp_file';
const HWP_OPEN_TOKEN_KEY = 'sejong_hwp_open_token';
let pageHideBound = false; let pageHideBound = false;
@@ -31,6 +32,16 @@ export function storeHwpFile(file: File): Promise<void> {
}); });
} }
/** Bumps whenever a HWP/HWPX is staged for the editor (forces a fresh view). */
export function hwpOpenToken(): string {
if (typeof sessionStorage === 'undefined') return '0';
return sessionStorage.getItem(HWP_OPEN_TOKEN_KEY) ?? '0';
}
function bumpHwpOpenToken(): void {
sessionStorage.setItem(HWP_OPEN_TOKEN_KEY, `${Date.now()}`);
}
export function storeHwpBuffer(name: string, buffer: ArrayBuffer): void { export function storeHwpBuffer(name: string, buffer: ArrayBuffer): void {
const bytes = new Uint8Array(buffer); const bytes = new Uint8Array(buffer);
let binary = ''; let binary = '';
@@ -45,6 +56,7 @@ export function storeHwpBuffer(name: string, buffer: ArrayBuffer): void {
base64: btoa(binary), base64: btoa(binary),
}), }),
); );
bumpHwpOpenToken();
} }
export function loadHwpFile(): { name: string; buffer: ArrayBuffer } | null { export function loadHwpFile(): { name: string; buffer: ArrayBuffer } | null {
@@ -68,6 +80,7 @@ export function loadHwpFile(): { name: string; buffer: ArrayBuffer } | null {
export function clearHwpFile(): void { export function clearHwpFile(): void {
sessionStorage.removeItem(HWP_FILE_KEY); sessionStorage.removeItem(HWP_FILE_KEY);
sessionStorage.removeItem(HWP_OPEN_TOKEN_KEY);
} }
export function useSessionLifecycle(options?: { enableHeartbeat?: boolean }) { export function useSessionLifecycle(options?: { enableHeartbeat?: boolean }) {
+8 -2
View File
@@ -6,8 +6,8 @@ export const APP_NAME = "sejong-office";
export const APP_DISPLAY_NAME = "Sejong Office"; export const APP_DISPLAY_NAME = "Sejong Office";
export type DiagramKind = "excalidraw" | "mermaid" | "drawio"; export type DiagramKind = "excalidraw" | "mermaid" | "drawio";
export type ViewerKind = "cad" | "model3d"; export type ViewerKind = "cad" | "model3d" | "illustrator" | "photoshop" | "markdown";
export type ToolKind = "scanner"; export type ToolKind = "scanner" | "imageedit";
const DIAGRAM_EXTENSION_KIND: Record<string, DiagramKind> = { const DIAGRAM_EXTENSION_KIND: Record<string, DiagramKind> = {
excalidraw: "excalidraw", excalidraw: "excalidraw",
@@ -34,6 +34,7 @@ const TOOL_EXTENSION_KIND: Record<string, ToolKind> = {
webp: "scanner", webp: "scanner",
gif: "scanner", gif: "scanner",
bmp: "scanner", bmp: "scanner",
ico: "imageedit",
}; };
/** Online3DViewer import formats (+ zip container). */ /** Online3DViewer import formats (+ zip container). */
@@ -66,6 +67,11 @@ export const MODEL3D_EXTENSIONS = [
const VIEWER_EXTENSION_KIND: Record<string, ViewerKind> = { const VIEWER_EXTENSION_KIND: Record<string, ViewerKind> = {
dxf: "cad", dxf: "cad",
dwg: "cad", dwg: "cad",
ai: "illustrator",
psd: "photoshop",
md: "markdown",
markdown: "markdown",
mdown: "markdown",
...Object.fromEntries( ...Object.fromEntries(
MODEL3D_EXTENSIONS.map((ext) => [ext, "model3d" as const]), MODEL3D_EXTENSIONS.map((ext) => [ext, "model3d" as const]),
), ),
+90 -16
View File
@@ -4,6 +4,7 @@
*/ */
import { extensionOf } from '@/config/app'; import { extensionOf } from '@/config/app';
import hwpIconUrl from '@/assets/icons/hwp.png';
export type FaviconKind = export type FaviconKind =
| 'home' | 'home'
@@ -17,44 +18,89 @@ export type FaviconKind =
| 'diagram' | 'diagram'
| 'cad' | 'cad'
| 'model3d' | 'model3d'
| 'illustrator'
| 'photoshop'
| 'scanner' | 'scanner'
| 'bgremove'
| 'imgedit'
| 'markdown'
| 'text' | 'text'
| 'csv' | 'csv'
| 'file'; | 'file';
/** Shared filled document plate for letter-mark office icons. */
export const DOC_SHELL_PATH =
'M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3Z';
export const ICON_PATHS: Record<FaviconKind, string> = { export const ICON_PATHS: Record<FaviconKind, string> = {
home: 'M9,13H15V19H18V10L12,5.5L6,10V19H9V13M4,21V9L12,3L20,9V21H4Z', home: 'M9,13H15V19H18V10L12,5.5L6,10V19H9V13M4,21V9L12,3L20,9V21H4Z',
pdf: 'M19 3H5C3.9 3 3 3.9 3 5V19C3 20.1 3.9 21 5 21H19C20.1 21 21 20.1 21 19V5C21 3.9 20.1 3 19 3M9.5 11.5C9.5 12.3 8.8 13 8 13H7V15H5.5V9H8C8.8 9 9.5 9.7 9.5 10.5V11.5M14.5 13.5C14.5 14.3 13.8 15 13 15H10.5V9H13C13.8 9 14.5 9.7 14.5 10.5V13.5M18.5 10.5H17V11.5H18.5V13H17V15H15.5V9H18.5V10.5M12 10.5H13V13.5H12V10.5M7 10.5H8V11.5H7V10.5Z', pdf: DOC_SHELL_PATH,
word: 'M15.5,17H14L12,9.5L10,17H8.5L6.1,7H7.8L9.34,14.5L11.3,7H12.7L14.67,14.5L16.2,7H17.9M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3Z', word: DOC_SHELL_PATH,
excel: excel: DOC_SHELL_PATH,
'M16.2,17H14.2L12,13.2L9.8,17H7.8L11,12L7.8,7H9.8L12,10.8L14.2,7H16.2L13,12M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3Z', powerpoint: DOC_SHELL_PATH,
powerpoint: hwp: DOC_SHELL_PATH,
'M9.8,13.4H12.3C13.8,13.4 14.46,13.12 15.1,12.58C15.74,12.03 16,11.25 16,10.23C16,9.26 15.75,8.5 15.1,7.88C14.45,7.29 13.83,7 12.3,7H8V17H9.8V13.4M19,3A2,2 0 0,1 21,5V19A2,2 0 0,1 19,21H5A2,2 0 0,1 3,19V5C3,3.89 3.9,3 5,3H19M9.8,12V8.4H12.1C12.76,8.4 13.27,8.65 13.6,9C13.93,9.35 14.1,9.72 14.1,10.24C14.1,10.8 13.92,11.19 13.6,11.5C13.28,11.81 12.9,12 12.22,12H9.8Z',
hwp: 'M17,4V10L15,8L13,10V4H9V20H19V4H17M3,7V5H5V4C5,2.89 5.9,2 7,2H19C20.05,2 21,2.95 21,4V20C21,21.05 20.05,22 19,22H7C5.95,22 5,21.05 5,20V19H3V17H5V13H3V11H5V7H3M5,5V7H7V5H5M5,19H7V17H5V19M5,13H7V11H5V13Z',
sketch: 'M3 17.25V21H6.75L17.81 9.94L14.06 6.19L3 17.25M20.71 7.04C21.1 6.65 21.1 6 20.71 5.63L18.37 3.29C18 2.9 17.35 2.9 16.96 3.29L15.13 5.12L18.88 8.87L20.71 7.04Z', sketch: 'M3 17.25V21H6.75L17.81 9.94L14.06 6.19L3 17.25M20.71 7.04C21.1 6.65 21.1 6 20.71 5.63L18.37 3.29C18 2.9 17.35 2.9 16.96 3.29L15.13 5.12L18.88 8.87L20.71 7.04Z',
code: 'M8 3L6.59 4.41L10.17 8L6.59 11.59L8 13L13 8L8 3M14 11V13H22V11H14M5 19H19V17H5V19Z', code: 'M8 3L6.59 4.41L10.17 8L6.59 11.59L8 13L13 8L8 3M14 11V13H22V11H14M5 19H19V17H5V19Z',
diagram: 'M4 3H10V9H8V11H16V9H14V3H20V9H18V13H13V15H16V21H8V15H11V13H6V9H4V3M6 5V7H8V5H6M16 5V7H18V5H16M10 17V19H14V17H10Z', diagram: 'M4 3H10V9H8V11H16V9H14V3H20V9H18V13H13V15H16V21H8V15H11V13H6V9H4V3M6 5V7H8V5H6M16 5V7H18V5H16M10 17V19H14V17H10Z',
cad: 'M3 3H21V21H3V3M5 5V19H19V5H5M7 17V15H9V13H11V11H13V9H15V7H17V17H7Z', cad: 'M3 3H21V21H3V3M5 5V19H19V5H5M7 17V15H9V13H11V11H13V9H15V7H17V17H7Z',
model3d: 'M12 2L21 7V17L12 22L3 17V7L12 2M12 4.3L5.4 8L12 11.7L18.6 8L12 4.3M5 9.7V15.8L11 19.1V13.1L5 9.7M13 19.1L19 15.8V9.7L13 13.1V19.1Z', model3d: 'M12 2L21 7V17L12 22L3 17V7L12 2M12 4.3L5.4 8L12 11.7L18.6 8L12 4.3M5 9.7V15.8L11 19.1V13.1L5 9.7M13 19.1L19 15.8V9.7L13 13.1V19.1Z',
/** mdi:vector-polyline — Illustrator-style mark */
illustrator:
'M2,9V15H6.5L9.5,19H14.5L17.5,15H22V9H17.5L14.5,5H9.5L6.5,9H2M9.5,7.5L12,11L14.5,7.5H9.5M6.5,10.5L8.5,13.5H5V10.5H6.5M18.5,13.5L16.5,10.5H19V13.5H18.5M12,12.5L9.5,16.5H14.5L12,12.5Z',
/** mdi:image — Photoshop-style mark */
photoshop:
'M19,19H5V5H19M19,3H5A2,2 0 0,0 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5A2,2 0 0,0 19,3M13.96,12.29L11.21,15.83L9.25,13.47L6.5,17H17.5L13.96,12.29Z',
scanner: 'M4 4H10V6H6V10H4V4M14 4H20V10H18V6H14V4M4 14H6V18H10V20H4V14M18 14H20V20H14V18H18V14M8 8H16V16H8V8Z', scanner: 'M4 4H10V6H6V10H4V4M14 4H20V10H18V6H14V4M4 14H6V18H10V20H4V14M18 14H20V20H14V18H18V14M8 8H16V16H8V8Z',
bgremove:
'M19.36,2.72L20.78,4.14L15.06,9.85C16.13,11.39 16.28,13.24 15.38,14.44L9.06,8.12C10.26,7.22 12.11,7.37 13.65,8.44L19.36,2.72M5.93,17.57C3.92,15.56 2.69,13.16 2.35,10.92L7.23,8.83L14.67,16.27L12.58,21.15C10.34,20.81 7.94,19.58 5.93,17.57Z',
/** mdi:crop */
imgedit:
'M7,17V1H5V5H1V7H5V17A2,2 0 0,0 7,19H17V23H19V19H23V17H19V7H7V17M17,17H7V7H17V17Z',
/** mdi:language-markdown */
markdown:
'M20.56 18H3.44C2.65 18 2 17.37 2 16.59V7.41C2 6.63 2.65 6 3.44 6H20.56C21.35 6 22 6.63 22 7.41V16.59C22 17.37 21.35 18 20.56 18M6.81 15.19V11.53L8.73 13.88L10.66 11.53V15.19H12.31V8.81H10.66L8.73 11.16L6.81 8.81H5.16V15.19H6.81M15.19 15.19H19.5V13.84H16.84V12.5H19.5V11.16H16.84V9.81H19.5V8.81H15.19V15.19Z',
text: 'M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z', text: 'M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z',
csv: 'M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2M18 20H6V4H13V9H18V20M10 19L12 15H9V10H15V15L13 19H10', csv: 'M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2M18 20H6V4H13V9H18V20M10 19L12 15H9V10H15V15L13 19H10',
file: 'M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z', file: 'M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z',
}; };
/**
* White glyphs drawn on `DOC_SHELL_PATH` (evenodd for letter counters).
* Paths adapted from Material Design Icons (@mdi/svg), Apache-2.0.
*/
export const ICON_MARK_PATHS: Partial<Record<FaviconKind, string>> = {
pdf:
'M9.5,11.5C9.5,12.3 8.8,13 8,13H7V15H5.5V9H8C8.8,9 9.5,9.7 9.5,10.5V11.5M7,10.5H8V11.5H7V10.5Z' +
'M14.5,13.5C14.5,14.3 13.8,15 13,15H10.5V9H13C13.8,9 14.5,9.7 14.5,10.5V13.5M12,10.5H13V13.5H12V10.5Z' +
'M18.5,10.5H17V11.5H18.5V13H17V15H15.5V9H18.5V10.5Z',
word: 'M15.5,17H14L12,9.5L10,17H8.5L6.1,7H7.8L9.34,14.5L11.3,7H12.7L14.67,14.5L16.2,7H17.9Z',
excel:
'M16.2,17H14.2L12,13.2L9.8,17H7.8L11,12L7.8,7H9.8L12,10.8L14.2,7H16.2L13,12Z',
powerpoint:
'M9.8,13.4H12.3C13.8,13.4 14.46,13.12 15.1,12.58C15.74,12.03 16,11.25 16,10.23C16,9.26 15.75,8.5 15.1,7.88C14.45,7.29 13.83,7 12.3,7H8V17H9.8V13.4M9.8,12V8.4H12.1C12.76,8.4 13.27,8.65 13.6,9C13.93,9.35 14.1,9.72 14.1,10.24C14.1,10.8 13.92,11.19 13.6,11.5C13.28,11.81 12.9,12 12.22,12H9.8Z',
};
/** Raster logo for Hangul (HWP) documents. */
export const HWP_ICON_URL = hwpIconUrl;
export const ICON_COLORS: Record<FaviconKind, string> = { export const ICON_COLORS: Record<FaviconKind, string> = {
home: '#0f6cbd', home: '#0f6cbd',
pdf: '#e53935', pdf: '#e53935',
word: '#1e88e5', word: '#1e88e5',
excel: '#43a047', excel: '#43a047',
powerpoint: '#fb8c00', powerpoint: '#fb8c00',
hwp: '#00897b', hwp: '#34aee7',
sketch: '#7b61a8', sketch: '#7b61a8',
code: '#d97706', code: '#d97706',
diagram: '#2e7d8a', diagram: '#2e7d8a',
cad: '#5c6bc0', cad: '#5c6bc0',
model3d: '#6d4c41', model3d: '#6d4c41',
illustrator: '#f57c00',
photoshop: '#31a8ff',
scanner: '#00897b', scanner: '#00897b',
bgremove: '#5c6bc0',
imgedit: '#c2185b',
markdown: '#6f42c1',
text: '#607d8b', text: '#607d8b',
csv: '#00838f', csv: '#00838f',
file: '#546e7a', file: '#546e7a',
@@ -85,6 +131,8 @@ const EXT_KIND: Record<string, FaviconKind> = {
xml: 'diagram', xml: 'diagram',
dxf: 'cad', dxf: 'cad',
dwg: 'cad', dwg: 'cad',
ai: 'illustrator',
psd: 'photoshop',
'3dm': 'model3d', '3dm': 'model3d',
'3ds': 'model3d', '3ds': 'model3d',
'3mf': 'model3d', '3mf': 'model3d',
@@ -114,11 +162,29 @@ const EXT_KIND: Record<string, FaviconKind> = {
webp: 'scanner', webp: 'scanner',
gif: 'scanner', gif: 'scanner',
bmp: 'scanner', bmp: 'scanner',
bgremove: 'bgremove',
imgedit: 'imgedit',
ico: 'imgedit',
md: 'markdown',
markdown: 'markdown',
mdown: 'markdown',
txt: 'text', txt: 'text',
}; };
export function iconSvgInner(kind: FaviconKind): string {
const color = ICON_COLORS[kind];
const mark = ICON_MARK_PATHS[kind];
if (mark) {
return (
`<path fill="${color}" d="${ICON_PATHS[kind]}"/>` +
`<path fill="#ffffff" fill-rule="evenodd" d="${mark}"/>`
);
}
return `<path fill="${color}" d="${ICON_PATHS[kind]}"/>`;
}
function toDataUri(kind: FaviconKind): string { function toDataUri(kind: FaviconKind): string {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="${ICON_COLORS[kind]}" d="${ICON_PATHS[kind]}"/></svg>`; const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">${iconSvgInner(kind)}</svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`; return `data:image/svg+xml,${encodeURIComponent(svg)}`;
} }
@@ -128,6 +194,16 @@ export function iconKindForExtension(ext: string): FaviconKind {
return EXT_KIND[ext] ?? 'file'; return EXT_KIND[ext] ?? 'file';
} }
export function faviconHrefForKind(kind: FaviconKind): string {
if (kind === 'hwp') return HWP_ICON_URL;
let href = CACHE.get(kind);
if (!href) {
href = toDataUri(kind);
CACHE.set(kind, href);
}
return href;
}
export function faviconHrefFor(filename?: string | null): string { export function faviconHrefFor(filename?: string | null): string {
const name = filename?.trim(); const name = filename?.trim();
let kind: FaviconKind = 'home'; let kind: FaviconKind = 'home';
@@ -135,12 +211,7 @@ export function faviconHrefFor(filename?: string | null): string {
const ext = extensionOf(name); const ext = extensionOf(name);
kind = ext ? (EXT_KIND[ext] ?? 'file') : 'home'; kind = ext ? (EXT_KIND[ext] ?? 'file') : 'home';
} }
let href = CACHE.get(kind); return faviconHrefForKind(kind);
if (!href) {
href = toDataUri(kind);
CACHE.set(kind, href);
}
return href;
} }
export function setFavicon(href: string) { export function setFavicon(href: string) {
@@ -150,7 +221,10 @@ export function setFavicon(href: string) {
link.rel = 'icon'; link.rel = 'icon';
document.head.appendChild(link); document.head.appendChild(link);
} }
link.type = 'image/svg+xml'; link.type =
/\.png(?:\?|$)/i.test(href) || href.startsWith('data:image/png')
? 'image/png'
: 'image/svg+xml';
// Bust stubborn tab caches when switching between data URIs // Bust stubborn tab caches when switching between data URIs
if (link.href !== href) { if (link.href !== href) {
link.href = href; link.href = href;
@@ -0,0 +1,254 @@
export type BgRemoveMode = 'threshold' | 'flood';
export interface BgRemoveOptions {
/** 0–100: how far from sampled background still counts as background */
tolerance: number;
/** 0–100: soft edge width beyond hard threshold */
feather: number;
mode: BgRemoveMode;
}
export interface Rgb {
r: number;
g: number;
b: number;
}
export const DEFAULT_BG_REMOVE_OPTIONS: BgRemoveOptions = {
tolerance: 28,
feather: 12,
mode: 'threshold',
};
function clampByte(value: number) {
return Math.min(255, Math.max(0, Math.round(value)));
}
function colorDistance(a: Rgb, r: number, g: number, b: number) {
return Math.max(Math.abs(r - a.r), Math.abs(g - a.g), Math.abs(b - a.b));
}
function median(values: number[]) {
if (!values.length) return 0;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2
? sorted[mid]!
: Math.round((sorted[mid - 1]! + sorted[mid]!) / 2);
}
/** Sample corner neighborhoods and take per-channel median as background. */
export function sampleCornerBackground(
data: Uint8ClampedArray,
width: number,
height: number,
radius = 2,
): Rgb {
const rs: number[] = [];
const gs: number[] = [];
const bs: number[] = [];
const corners: Array<[number, number]> = [
[0, 0],
[width - 1, 0],
[0, height - 1],
[width - 1, height - 1],
];
for (const [cx, cy] of corners) {
for (let dy = -radius; dy <= radius; dy += 1) {
for (let dx = -radius; dx <= radius; dx += 1) {
const x = Math.min(width - 1, Math.max(0, cx + dx));
const y = Math.min(height - 1, Math.max(0, cy + dy));
const i = (y * width + x) * 4;
rs.push(data[i]!);
gs.push(data[i + 1]!);
bs.push(data[i + 2]!);
}
}
}
return { r: median(rs), g: median(gs), b: median(bs) };
}
function hardLimit(tolerance: number) {
return Math.round((Math.min(100, Math.max(0, tolerance)) / 100) * 180);
}
function featherWidth(feather: number) {
return Math.round((Math.min(100, Math.max(0, feather)) / 100) * 80);
}
function alphaForDistance(
distance: number,
hard: number,
soft: number,
originalAlpha: number,
) {
if (distance <= hard) return 0;
if (soft <= 0 || distance >= hard + soft) return originalAlpha;
const t = (distance - hard) / soft;
return clampByte(originalAlpha * t);
}
function loadImage(source: string | Blob | HTMLImageElement | HTMLCanvasElement) {
if (source instanceof HTMLCanvasElement) {
return Promise.resolve(source);
}
if (source instanceof HTMLImageElement) {
if (source.complete && source.naturalWidth) return Promise.resolve(source);
return new Promise<HTMLImageElement>((resolve, reject) => {
source.onload = () => resolve(source);
source.onerror = () =>
reject(new Error('이미지를 불러오지 못했습니다.'));
});
}
const objectUrl =
typeof source === 'string' ? null : URL.createObjectURL(source);
const src = typeof source === 'string' ? source : objectUrl!;
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => {
if (objectUrl) URL.revokeObjectURL(objectUrl);
resolve(image);
};
image.onerror = () => {
if (objectUrl) URL.revokeObjectURL(objectUrl);
reject(new Error('이미지를 불러오지 못했습니다.'));
};
image.src = src;
});
}
function drawToCanvas(
image: HTMLImageElement | HTMLCanvasElement,
): HTMLCanvasElement {
if (image instanceof HTMLCanvasElement) {
const copy = document.createElement('canvas');
copy.width = image.width;
copy.height = image.height;
const ctx = copy.getContext('2d', { willReadFrequently: true });
if (!ctx) throw new Error('Canvas를 초기화하지 못했습니다.');
ctx.drawImage(image, 0, 0);
return copy;
}
const canvas = document.createElement('canvas');
canvas.width = image.naturalWidth || image.width;
canvas.height = image.naturalHeight || image.height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) throw new Error('Canvas를 초기화하지 못했습니다.');
ctx.drawImage(image, 0, 0);
return canvas;
}
function applyThreshold(
data: Uint8ClampedArray,
bg: Rgb,
hard: number,
soft: number,
) {
for (let i = 0; i < data.length; i += 4) {
const d = colorDistance(bg, data[i]!, data[i + 1]!, data[i + 2]!);
data[i + 3] = alphaForDistance(d, hard, soft, data[i + 3]!);
}
}
function applyFlood(
data: Uint8ClampedArray,
width: number,
height: number,
bg: Rgb,
hard: number,
soft: number,
) {
const total = width * height;
const match = new Uint8Array(total);
const limit = hard + soft;
for (let p = 0; p < total; p += 1) {
const i = p * 4;
const d = colorDistance(bg, data[i]!, data[i + 1]!, data[i + 2]!);
if (d <= limit) match[p] = 1;
}
const visited = new Uint8Array(total);
const queue = new Int32Array(total);
let head = 0;
let tail = 0;
const seed = (x: number, y: number) => {
const p = y * width + x;
if (!match[p] || visited[p]) return;
visited[p] = 1;
queue[tail++] = p;
};
seed(0, 0);
seed(width - 1, 0);
seed(0, height - 1);
seed(width - 1, height - 1);
while (head < tail) {
const p = queue[head++]!;
const x = p % width;
const y = (p / width) | 0;
if (x > 0) seed(x - 1, y);
if (x + 1 < width) seed(x + 1, y);
if (y > 0) seed(x, y - 1);
if (y + 1 < height) seed(x, y + 1);
}
for (let p = 0; p < total; p += 1) {
if (!visited[p]) continue;
const i = p * 4;
const d = colorDistance(bg, data[i]!, data[i + 1]!, data[i + 2]!);
data[i + 3] = alphaForDistance(d, hard, soft, data[i + 3]!);
}
}
export async function removeBackgroundBySample(
source: string | Blob | HTMLImageElement | HTMLCanvasElement,
options: Partial<BgRemoveOptions> = {},
): Promise<{
blob: Blob;
url: string;
width: number;
height: number;
background: Rgb;
}> {
const opts = { ...DEFAULT_BG_REMOVE_OPTIONS, ...options };
const image = await loadImage(source);
const canvas = drawToCanvas(image);
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) throw new Error('Canvas를 초기화하지 못했습니다.');
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const { data, width, height } = imageData;
const background = sampleCornerBackground(data, width, height);
const hard = hardLimit(opts.tolerance);
const soft = featherWidth(opts.feather);
if (opts.mode === 'flood') {
applyFlood(data, width, height, background, hard, soft);
} else {
applyThreshold(data, background, hard, soft);
}
ctx.putImageData(imageData, 0, 0);
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(value) =>
value
? resolve(value)
: reject(new Error('투명 PNG를 만들지 못했습니다.')),
'image/png',
);
});
return {
blob,
url: URL.createObjectURL(blob),
width: canvas.width,
height: canvas.height,
background,
};
}
@@ -0,0 +1,117 @@
import {
GlobalWorkerOptions,
getDocument,
RenderingCancelledException,
type PDFDocumentProxy,
type RenderTask,
} from 'pdfjs-dist';
const WORKER_SRC = '/viewer-assets/scanner/pdfjs/pdf.worker.min.mjs';
let workerConfigured = false;
let activeRender: RenderTask | null = null;
/** Serialize canvas renders so pdf.js never sees overlapping render() calls. */
let renderGate: Promise<void> = Promise.resolve();
function ensureWorker() {
if (workerConfigured) return;
GlobalWorkerOptions.workerSrc = WORKER_SRC;
workerConfigured = true;
}
/** PDF-compatible .ai embeds a PDF stream (often starts with %PDF). */
export function looksPdfCompatibleAi(data: ArrayBuffer): boolean {
const n = Math.min(data.byteLength, 8192);
if (n < 5) return false;
const text = new TextDecoder('latin1').decode(data.slice(0, n));
return text.includes('%PDF');
}
export const AI_INCOMPATIBLE_MESSAGE =
'PDF 호환으로 저장된 .ai만 열 수 있습니다. Illustrator에서 「PDF 호환 파일 만들기」로 저장한 뒤 다시 열어 주세요.';
export function isAiRenderCancelled(reason: unknown): boolean {
return (
reason instanceof RenderingCancelledException ||
(typeof reason === 'object' &&
reason !== null &&
'name' in reason &&
(reason as { name: string }).name === 'RenderingCancelledException')
);
}
export async function cancelAiRender(): Promise<void> {
const task = activeRender;
if (!task) return;
activeRender = null;
task.cancel();
try {
await task.promise;
} catch {
/* cancelled or failed after cancel */
}
}
export async function openAiAsPdf(data: ArrayBuffer): Promise<PDFDocumentProxy> {
ensureWorker();
if (!looksPdfCompatibleAi(data)) {
throw new Error(AI_INCOMPATIBLE_MESSAGE);
}
try {
return await getDocument({ data: data.slice(0) }).promise;
} catch {
throw new Error(AI_INCOMPATIBLE_MESSAGE);
}
}
export async function renderAiPage(
pdf: PDFDocumentProxy,
pageNumber: number,
canvas: HTMLCanvasElement,
scale: number,
): Promise<{ width: number; height: number }> {
// Interrupt in-flight work so the gate holder can exit quickly.
void cancelAiRender();
let release!: () => void;
const previous = renderGate;
renderGate = new Promise<void>((resolve) => {
release = resolve;
});
await previous;
try {
await cancelAiRender();
const page = await pdf.getPage(pageNumber);
try {
const viewport = page.getViewport({ scale });
canvas.width = Math.max(1, Math.floor(viewport.width));
canvas.height = Math.max(1, Math.floor(viewport.height));
const context = canvas.getContext('2d', { alpha: false });
if (!context) {
throw new Error('캔버스를 초기화하지 못했습니다.');
}
context.fillStyle = '#ffffff';
context.fillRect(0, 0, canvas.width, canvas.height);
const task = page.render({ canvasContext: context, viewport, canvas });
activeRender = task;
try {
await task.promise;
} finally {
if (activeRender === task) activeRender = null;
}
return { width: canvas.width, height: canvas.height };
} finally {
page.cleanup();
}
} finally {
release();
}
}
export function isAiFile(file: File): boolean {
return /\.ai$/i.test(file.name) || file.type === 'application/postscript';
}
@@ -0,0 +1,227 @@
const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
export function isIcoFile(file: File) {
return (
/image\/(x-icon|vnd\.microsoft\.icon|icon)/i.test(file.type) ||
/\.ico$/i.test(file.name)
);
}
function isPng(bytes: Uint8Array) {
return (
bytes.length >= 8 && PNG_SIG.every((value, index) => bytes[index] === value)
);
}
function rowStride(width: number, bits: number) {
return (((width * bits + 31) >> 5) << 2);
}
function decodeDib(bytes: Uint8Array): ImageData {
if (bytes.length < 40) {
throw new Error('ICO 비트맵 헤더가 올바르지 않습니다.');
}
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const headerSize = view.getUint32(0, true);
if (headerSize < 40) {
throw new Error('지원하지 않는 ICO 비트맵입니다.');
}
const width = Math.abs(view.getInt32(4, true));
const rawHeight = view.getInt32(8, true);
const bitCount = view.getUint16(14, true);
const compression = view.getUint32(16, true);
if (!(width > 0) || compression !== 0) {
throw new Error('압축된 ICO 비트맵은 열 수 없습니다.');
}
const storedHeight = Math.abs(rawHeight) || 1;
const bottomUp = rawHeight >= 0;
const paletteColors =
bitCount <= 8
? view.getUint32(32, true) || 1 << bitCount
: 0;
let offset = headerSize + paletteColors * 4;
const xorStride = rowStride(width, bitCount);
const remaining = Math.max(0, bytes.length - offset);
let height = storedHeight;
if (storedHeight % 2 === 0) {
const half = storedHeight / 2;
const xorHalf = xorStride * half;
const andHalf = rowStride(width, 1) * half;
if (xorHalf + andHalf <= remaining || xorHalf <= remaining) {
height = half;
}
}
const xorSize = xorStride * height;
if (!(height > 0) || offset + xorSize > bytes.length) {
throw new Error('ICO 비트맵 데이터가 잘렸습니다.');
}
const palette: Array<[number, number, number, number]> = [];
for (let i = 0; i < paletteColors; i += 1) {
const p = headerSize + i * 4;
palette.push([bytes[p + 2]!, bytes[p + 1]!, bytes[p]!, 255]);
}
const pixels = new Uint8ClampedArray(width * height * 4);
const andOffset = offset + xorSize;
const andStride = rowStride(width, 1);
const hasAnd = andOffset + andStride * height <= bytes.length;
function xorPixel(x: number, y: number): [number, number, number, number] {
const row = bottomUp ? height - 1 - y : y;
const rowStart = offset + row * xorStride;
if (bitCount === 32) {
const i = rowStart + x * 4;
return [bytes[i + 2]!, bytes[i + 1]!, bytes[i]!, bytes[i + 3]!];
}
if (bitCount === 24) {
const i = rowStart + x * 3;
return [bytes[i + 2]!, bytes[i + 1]!, bytes[i]!, 255];
}
if (bitCount === 8) {
return palette[bytes[rowStart + x]!] ?? [0, 0, 0, 255];
}
if (bitCount === 4) {
const byte = bytes[rowStart + (x >> 1)]!;
const index = x & 1 ? byte & 0x0f : byte >> 4;
return palette[index] ?? [0, 0, 0, 255];
}
if (bitCount === 1) {
const byte = bytes[rowStart + (x >> 3)]!;
const index = (byte >> (7 - (x & 7))) & 1;
return palette[index] ?? [0, 0, 0, 255];
}
throw new Error(`지원하지 않는 ICO 색 깊이입니다 (${bitCount}bit).`);
}
function andTransparent(x: number, y: number) {
if (!hasAnd) return false;
const row = bottomUp ? height - 1 - y : y;
const byte = bytes[andOffset + row * andStride + (x >> 3)]!;
return ((byte >> (7 - (x & 7))) & 1) === 1;
}
let anyAlpha = false;
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const [r, g, b, a] = xorPixel(x, y);
if (a > 0 && a < 255) anyAlpha = true;
if (a === 255) anyAlpha = anyAlpha || bitCount === 32;
const i = (y * width + x) * 4;
pixels[i] = r;
pixels[i + 1] = g;
pixels[i + 2] = b;
pixels[i + 3] = a;
}
}
const useAnd = bitCount < 32 || !anyAlpha;
if (useAnd) {
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
if (andTransparent(x, y)) {
pixels[(y * width + x) * 4 + 3] = 0;
}
}
}
}
return new ImageData(pixels, width, height);
}
function canvasFromImageData(data: ImageData) {
const canvas = document.createElement('canvas');
canvas.width = data.width;
canvas.height = data.height;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('캔버스를 만들 수 없습니다.');
ctx.putImageData(data, 0, 0);
return canvas;
}
function pngBlobFromCanvas(canvas: HTMLCanvasElement) {
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(blob) =>
blob ? resolve(blob) : reject(new Error('ICO를 PNG로 바꾸지 못했습니다.')),
'image/png',
);
});
}
export function wrapPngAsIco(png: ArrayBuffer, width: number, height: number) {
const header = 22;
const out = new Uint8Array(header + png.byteLength);
const view = new DataView(out.buffer);
view.setUint16(0, 0, true);
view.setUint16(2, 1, true);
view.setUint16(4, 1, true);
out[6] = width >= 256 ? 0 : width;
out[7] = height >= 256 ? 0 : height;
view.setUint16(10, 1, true);
view.setUint16(12, 32, true);
view.setUint32(14, png.byteLength, true);
view.setUint32(18, header, true);
out.set(new Uint8Array(png), header);
return new Blob([out], { type: 'image/x-icon' });
}
async function decodeEntry(bytes: Uint8Array) {
if (isPng(bytes)) {
const blob = new Blob([bytes.slice()], { type: 'image/png' });
const url = URL.createObjectURL(blob);
try {
const image = new Image();
image.decoding = 'async';
image.src = url;
await image.decode();
return {
blob,
width: image.naturalWidth || image.width,
height: image.naturalHeight || image.height,
};
} finally {
URL.revokeObjectURL(url);
}
}
const canvas = canvasFromImageData(decodeDib(bytes));
const blob = await pngBlobFromCanvas(canvas);
return { blob, width: canvas.width, height: canvas.height };
}
/** Decode an ICO and return the largest image as a PNG blob. */
export async function decodeIcoToPng(buffer: ArrayBuffer) {
const view = new DataView(buffer);
if (view.byteLength < 22) {
throw new Error('ICO 파일이 너무 짧습니다.');
}
const reserved = view.getUint16(0, true);
const type = view.getUint16(2, true);
const count = view.getUint16(4, true);
if (reserved !== 0 || (type !== 1 && type !== 2) || count < 1) {
throw new Error('올바른 ICO 파일이 아닙니다.');
}
const bytes = new Uint8Array(buffer);
let best: { score: number; offset: number; size: number } | null = null;
for (let i = 0; i < count; i += 1) {
const entry = 6 + i * 16;
const width = view.getUint8(entry) || 256;
const height = view.getUint8(entry + 1) || 256;
const size = view.getUint32(entry + 8, true);
const offset = view.getUint32(entry + 12, true);
if (offset + size > buffer.byteLength) continue;
const score = width * height + size;
if (!best || score > best.score) {
best = { score, offset, size };
}
}
if (!best) {
throw new Error('ICO에서 이미지를 찾지 못했습니다.');
}
const slice = bytes.subarray(best.offset, best.offset + best.size);
const decoded = await decodeEntry(slice);
return { ...decoded, imageCount: count };
}
@@ -0,0 +1,307 @@
import { wrapPngAsIco } from './ico';
export type PixelRect = {
x: number;
y: number;
width: number;
height: number;
};
export type ExportFormat = 'image/jpeg' | 'image/webp' | 'image/png' | 'image/x-icon';
export type RatioFit = 'crop' | 'pad';
const MIN_CROP = 8;
export function loadImageFromUrl(url: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.decoding = 'async';
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('이미지를 불러오지 못했습니다.'));
image.src = url;
});
}
export function clampCrop(
crop: PixelRect,
imageWidth: number,
imageHeight: number,
): PixelRect {
const maxW = Math.max(1, Math.floor(imageWidth));
const maxH = Math.max(1, Math.floor(imageHeight));
const width = Math.min(Math.max(MIN_CROP, Math.round(crop.width)), maxW);
const height = Math.min(Math.max(MIN_CROP, Math.round(crop.height)), maxH);
const x = Math.min(Math.max(0, Math.round(crop.x)), maxW - width);
const y = Math.min(Math.max(0, Math.round(crop.y)), maxH - height);
return { x, y, width, height };
}
export function fullImageCrop(imageWidth: number, imageHeight: number): PixelRect {
return clampCrop(
{ x: 0, y: 0, width: imageWidth, height: imageHeight },
imageWidth,
imageHeight,
);
}
/** Largest rectangle of `aspect` (width/height) that fits in the image. */
export function maxCropForAspect(
imageWidth: number,
imageHeight: number,
aspect: number,
center?: { x: number; y: number },
): PixelRect {
if (!(aspect > 0) || !Number.isFinite(aspect)) {
return fullImageCrop(imageWidth, imageHeight);
}
let width = imageWidth;
let height = width / aspect;
if (height > imageHeight) {
height = imageHeight;
width = height * aspect;
}
const cx = center?.x ?? imageWidth / 2;
const cy = center?.y ?? imageHeight / 2;
return clampCrop(
{ x: cx - width / 2, y: cy - height / 2, width, height },
imageWidth,
imageHeight,
);
}
export function formatBytes(bytes: number) {
if (!Number.isFinite(bytes) || bytes < 0) return '—';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
export function extensionForFormat(format: ExportFormat) {
if (format === 'image/jpeg') return 'jpg';
if (format === 'image/webp') return 'webp';
if (format === 'image/x-icon') return 'ico';
return 'png';
}
export function clampPixelSize(value: number) {
return Math.min(8192, Math.max(1, Math.round(Number(value)) || 1));
}
function outputSize(
srcWidth: number,
srcHeight: number,
scale: number,
maxEdge: number | null,
padAspect: number | null,
targetWidth?: number | null,
targetHeight?: number | null,
) {
let width = Math.max(1, srcWidth);
let height = Math.max(1, srcHeight);
if (padAspect && padAspect > 0) {
const current = width / height;
if (current < padAspect) {
width = height * padAspect;
} else if (current > padAspect) {
height = width / padAspect;
}
}
if (
targetWidth != null &&
targetHeight != null &&
targetWidth > 0 &&
targetHeight > 0
) {
width = targetWidth;
height = targetHeight;
} else {
const factor = Math.min(8, Math.max(0.01, scale));
width *= factor;
height *= factor;
}
if (maxEdge && maxEdge > 0) {
const cap = Math.min(1, maxEdge / width, maxEdge / height);
width *= cap;
height *= cap;
}
return {
width: clampPixelSize(width),
height: clampPixelSize(height),
};
}
export function previewOutputSize(options: {
crop: PixelRect;
scale: number;
maxEdge: number | null;
padAspect: number | null;
fit: RatioFit;
targetWidth?: number | null;
targetHeight?: number | null;
}) {
const padAspect = options.fit === 'pad' ? options.padAspect : null;
return outputSize(
options.crop.width,
options.crop.height,
options.scale,
options.maxEdge,
padAspect,
options.targetWidth,
options.targetHeight,
);
}
function canvasToBlob(
canvas: HTMLCanvasElement,
format: ExportFormat,
quality: number,
) {
const mime = format === 'image/x-icon' ? 'image/png' : format;
return new Promise<Blob>((resolve, reject) => {
const q =
mime === 'image/png' ? undefined : Math.min(1, Math.max(0.01, quality));
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('이미지를 인코딩하지 못했습니다.'));
return;
}
resolve(blob);
},
mime,
q,
);
});
}
function parseCssColor(color: string): { r: number; g: number; b: number } {
const hex = color.trim();
const short = /^#([0-9a-f]{3})$/i.exec(hex);
if (short) {
const n = short[1];
return {
r: Number.parseInt(n[0] + n[0], 16),
g: Number.parseInt(n[1] + n[1], 16),
b: Number.parseInt(n[2] + n[2], 16),
};
}
const full = /^#([0-9a-f]{6})$/i.exec(hex);
if (full) {
const n = full[1];
return {
r: Number.parseInt(n.slice(0, 2), 16),
g: Number.parseInt(n.slice(2, 4), 16),
b: Number.parseInt(n.slice(4, 6), 16),
};
}
return { r: 255, g: 255, b: 255 };
}
export async function exportEditedImage(options: {
image: HTMLImageElement;
crop: PixelRect;
scale: number;
maxEdge: number | null;
format: ExportFormat;
quality: number;
fit: RatioFit;
padAspect: number | null;
padColor: string;
targetWidth?: number | null;
targetHeight?: number | null;
lockAspect?: boolean;
}): Promise<{ blob: Blob; width: number; height: number }> {
const crop = clampCrop(
options.crop,
options.image.naturalWidth || options.image.width,
options.image.naturalHeight || options.image.height,
);
const padAspect =
options.fit === 'pad' && options.padAspect && options.padAspect > 0
? options.padAspect
: null;
const size = outputSize(
crop.width,
crop.height,
options.scale,
options.maxEdge,
padAspect,
options.targetWidth,
options.targetHeight,
);
const canvas = document.createElement('canvas');
canvas.width = size.width;
canvas.height = size.height;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('캔버스를 만들 수 없습니다.');
if (options.format === 'image/jpeg' || padAspect) {
const { r, g, b } = parseCssColor(options.padColor);
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
ctx.fillRect(0, 0, size.width, size.height);
} else {
ctx.clearRect(0, 0, size.width, size.height);
}
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
const letterbox = Boolean(padAspect) && options.lockAspect !== false;
if (letterbox) {
const fitted = containSize(crop.width, crop.height, size.width, size.height);
const dx = Math.round((size.width - fitted.width) / 2);
const dy = Math.round((size.height - fitted.height) / 2);
ctx.drawImage(
options.image,
crop.x,
crop.y,
crop.width,
crop.height,
dx,
dy,
fitted.width,
fitted.height,
);
} else {
ctx.drawImage(
options.image,
crop.x,
crop.y,
crop.width,
crop.height,
0,
0,
size.width,
size.height,
);
}
const blob = await canvasToBlob(canvas, options.format, options.quality);
if (options.format === 'image/x-icon') {
return {
blob: wrapPngAsIco(await blob.arrayBuffer(), size.width, size.height),
width: size.width,
height: size.height,
};
}
return { blob, width: size.width, height: size.height };
}
function containSize(
srcW: number,
srcH: number,
boxW: number,
boxH: number,
) {
const scale = Math.min(boxW / srcW, boxH / srcH);
return {
width: Math.max(1, Math.round(srcW * scale)),
height: Math.max(1, Math.round(srcH * scale)),
};
}
@@ -0,0 +1,29 @@
import { marked } from 'marked';
import DOMPurify from 'dompurify';
marked.setOptions({
gfm: true,
breaks: true,
});
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node instanceof HTMLAnchorElement) {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
});
export function renderMarkdown(source: string): string {
const html = marked.parse(source ?? '', { async: false });
return DOMPurify.sanitize(typeof html === 'string' ? html : '', {
USE_PROFILES: { html: true },
});
}
export function isMarkdownFile(file: File) {
return (
file.type === 'text/markdown' ||
file.type === 'text/x-markdown' ||
/\.(?:md|markdown|mdown)$/i.test(file.name)
);
}
@@ -0,0 +1,84 @@
import {
BufferGeometry,
Color,
Group,
Points,
PointsMaterial,
} from 'three';
import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader.js';
import type { ModelPropertyGroup, ModelStats, ModelTreeNode } from './types';
export function isPlyFileName(name: string) {
return /\.ply$/i.test(name);
}
export function isNoFacesImportError(message: string) {
return /no faces/i.test(message);
}
export async function createPointCloudFromPly(file: File): Promise<{
object: Group;
pointCount: number;
hasColors: boolean;
stats: ModelStats;
tree: ModelTreeNode;
groups: ModelPropertyGroup[];
}> {
const buffer = await file.arrayBuffer();
const geometry = new PLYLoader().parse(buffer) as BufferGeometry;
const position = geometry.getAttribute('position');
if (!position || position.count < 1) {
throw new Error('PLY에 표시할 포인트가 없습니다.');
}
geometry.computeBoundingSphere();
const hasColors = Boolean(geometry.getAttribute('color'));
const radius = geometry.boundingSphere?.radius || 1;
const material = new PointsMaterial({
size: Math.max(radius / 250, 0.0005),
sizeAttenuation: true,
vertexColors: hasColors,
color: hasColors ? new Color(0xffffff) : new Color(0x4fc3f7),
});
const points = new Points(geometry, material);
points.name = file.name;
const object = new Group();
object.name = file.name;
object.add(points);
const pointCount = position.count;
const stats: ModelStats = {
nodes: 1,
meshes: 0,
meshInstances: 0,
vertices: pointCount,
triangles: 0,
materials: 1,
};
const tree: ModelTreeNode = {
id: 'pointcloud-root',
name: file.name,
type: '포인트 클라우드',
children: [
{
id: 'pointcloud-points',
name: `포인트 ${pointCount.toLocaleString()}개`,
type: 'points',
children: [],
},
],
};
const groups: ModelPropertyGroup[] = [
{
name: '포인트 클라우드',
properties: [
{ name: '파일', value: file.name },
{ name: '포인트', value: pointCount.toLocaleString() },
{ name: '정점 색', value: hasColors ? '있음' : '없음' },
],
},
];
return { object, pointCount, hasColors, stats, tree, groups };
}
@@ -0,0 +1,57 @@
import { readPsd, type Psd } from 'ag-psd';
export const PSD_INCOMPATIBLE_MESSAGE =
'이 브라우저에서는 RGB·Grayscale·Bitmap / 8bit .psd만 안정적으로 볼 수 있습니다. CMYK·16bit·PSB는 지원하지 않습니다.';
export type OpenedPsd = {
width: number;
height: number;
/** Composite image produced by ag-psd */
canvas: HTMLCanvasElement;
};
export function isPsdFile(file: File): boolean {
return (
/\.psd$/i.test(file.name) || file.type === 'image/vnd.adobe.photoshop'
);
}
export function openPsd(data: ArrayBuffer): OpenedPsd {
let psd: Psd;
try {
psd = readPsd(data);
} catch {
throw new Error(PSD_INCOMPATIBLE_MESSAGE);
}
const source = psd.canvas;
if (!source || source.width < 1 || source.height < 1) {
throw new Error(PSD_INCOMPATIBLE_MESSAGE);
}
return {
width: psd.width || source.width,
height: psd.height || source.height,
canvas: source as HTMLCanvasElement,
};
}
export function drawPsdToCanvas(
source: HTMLCanvasElement,
target: HTMLCanvasElement,
scale: number,
): { width: number; height: number } {
const width = Math.max(1, Math.floor(source.width * scale));
const height = Math.max(1, Math.floor(source.height * scale));
target.width = width;
target.height = height;
const context = target.getContext('2d', { alpha: true });
if (!context) {
throw new Error('캔버스를 초기화하지 못했습니다.');
}
context.clearRect(0, 0, width, height);
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.drawImage(source, 0, 0, width, height);
return { width, height };
}
@@ -21,6 +21,14 @@ async function loadCjkFont(pdf: PDFDocument) {
return pdf.embedFont(bytes, { subset: true }); return pdf.embedFont(bytes, { subset: true });
} }
/** 스캔 페이지의 긴 변을 A4 긴 변(297mm = 841.89pt)으로 맞춘다 — 픽셀 수를 그대로 pt로 쓰면 종이가 수십 cm가 된다 */
const PAGE_LONG_PT = 841.89;
const isJpeg = (bytes: ArrayBuffer) => {
const b = new Uint8Array(bytes, 0, 2);
return b[0] === 0xff && b[1] === 0xd8;
};
export async function buildSearchablePdf(pages: PdfPageInput[]) { export async function buildSearchablePdf(pages: PdfPageInput[]) {
if (!pages.length) { if (!pages.length) {
throw new Error('PDF로 내보낼 페이지가 없습니다.'); throw new Error('PDF로 내보낼 페이지가 없습니다.');
@@ -42,13 +50,18 @@ export async function buildSearchablePdf(pages: PdfPageInput[]) {
} }
for (const page of pages) { for (const page of pages) {
const image = await pdf.embedPng(page.imageBytes); const image = isJpeg(page.imageBytes)
const pdfPage = pdf.addPage([page.width, page.height]); ? await pdf.embedJpg(page.imageBytes)
: await pdf.embedPng(page.imageBytes);
const k = PAGE_LONG_PT / Math.max(page.width, page.height);
const pageWidth = page.width * k;
const pageHeight = page.height * k;
const pdfPage = pdf.addPage([pageWidth, pageHeight]);
pdfPage.drawImage(image, { pdfPage.drawImage(image, {
x: 0, x: 0,
y: 0, y: 0,
width: page.width, width: pageWidth,
height: page.height, height: pageHeight,
}); });
const font = cjkFont ?? latinFont; const font = cjkFont ?? latinFont;
@@ -57,11 +70,12 @@ export async function buildSearchablePdf(pages: PdfPageInput[]) {
const text = word.text.trim(); const text = word.text.trim();
if (!text) continue; if (!text) continue;
if (!cjkFont && /[^\u0000-\u007f]/.test(text)) continue; if (!cjkFont && /[^\u0000-\u007f]/.test(text)) continue;
const boxWidth = Math.max(1, word.bbox.x1 - word.bbox.x0); // OCR 좌표는 스캔 이미지 픽셀 기준 → pt로 환산
const boxHeight = Math.max(1, word.bbox.y1 - word.bbox.y0); const boxWidth = Math.max(1, (word.bbox.x1 - word.bbox.x0) * k);
const fontSize = Math.max(4, Math.min(boxHeight * 0.9, boxWidth)); const boxHeight = Math.max(1, (word.bbox.y1 - word.bbox.y0) * k);
const x = word.bbox.x0; const fontSize = Math.max(2, Math.min(boxHeight * 0.9, boxWidth));
const y = page.height - word.bbox.y1; const x = word.bbox.x0 * k;
const y = pageHeight - word.bbox.y1 * k;
try { try {
pdfPage.drawText(text, { pdfPage.drawText(text, {
x, x,
@@ -1,11 +1,12 @@
import * as ort from 'onnxruntime-web'; import * as ort from 'onnxruntime-web';
import ortWasmUrl from 'onnxruntime-web/ort-wasm-simd-threaded.jsep.wasm?url';
import ortMjsUrl from 'onnxruntime-web/ort-wasm-simd-threaded.jsep.mjs?url';
import { import {
PaddleOcrService, PaddleOcrService,
V5_KOREAN_MOBILE_MODEL, V5_KOREAN_MOBILE_MODEL,
} from 'ppu-paddle-ocr/web'; } from 'ppu-paddle-ocr/web';
import type { OcrResult, OcrWord } from './types'; import type { OcrResult, OcrWord } from './types';
const ORT_WASM_PATH = '/viewer-assets/scanner/ort/';
const PADDLE_BASE = '/viewer-assets/scanner/paddle'; const PADDLE_BASE = '/viewer-assets/scanner/paddle';
let servicePromise: Promise<PaddleOcrService> | null = null; let servicePromise: Promise<PaddleOcrService> | null = null;
@@ -44,7 +45,12 @@ async function getService(
if (!servicePromise) { if (!servicePromise) {
servicePromise = (async () => { servicePromise = (async () => {
onProgress?.(0.05, 'OCR 모델 준비 중'); onProgress?.(0.05, 'OCR 모델 준비 중');
ort.env.wasm.wasmPaths = ORT_WASM_PATH; // Vite는 /public .mjs 를 import() 하면 실패하므로 node_modules 에셋 URL 사용
ort.env.wasm.numThreads = 1;
ort.env.wasm.wasmPaths = {
wasm: ortWasmUrl,
mjs: ortMjsUrl,
};
const service = new PaddleOcrService({ const service = new PaddleOcrService({
model: { model: {
...V5_KOREAN_MOBILE_MODEL, ...V5_KOREAN_MOBILE_MODEL,
@@ -12,6 +12,9 @@ export interface CornerPoints {
export type PaperAspect = 'a4-portrait' | 'a4-landscape' | 'square' | 'auto'; export type PaperAspect = 'a4-portrait' | 'a4-landscape' | 'square' | 'auto';
/** 원본: 원근 보정만 · 컬러/회색조 문서: 조명 평탄화·글자 보정 · 흑백 문서: 이진화 */
export type ScanMode = 'original' | 'color' | 'gray' | 'bw';
export interface OcrWord { export interface OcrWord {
text: string; text: string;
/** Pixel bbox on the warped image: x0,y0,x1,y1 */ /** Pixel bbox on the warped image: x0,y0,x1,y1 */
@@ -32,6 +35,9 @@ export interface ScannerPage {
sourceHeight: number; sourceHeight: number;
corners: CornerPoints; corners: CornerPoints;
aspect: PaperAspect; aspect: PaperAspect;
scanMode: ScanMode;
/** 글자 진하기·선명도 0–100 (흑백은 이진화 문턱) */
scanIntensity: number;
warpedUrl: string | null; warpedUrl: string | null;
warpedWidth: number; warpedWidth: number;
warpedHeight: number; warpedHeight: number;
@@ -1,40 +1,251 @@
import { import { ensureOpenCv, loadImageFromUrl } from './detect';
createScanner, import type { CornerPoints, PaperAspect, ScanMode } from './types';
ensureJscanify,
loadImageFromUrl,
} from './detect';
import type { CornerPoints, PaperAspect } from './types';
/** 0 = 은은하게 · 100 = 글자를 아주 진하고 선명하게 (조명 평탄화는 강도와 무관하게 항상) */
export const DEFAULT_SCAN_INTENSITY = 60;
export const DEFAULT_SCAN_MODE: ScanMode = 'color';
const A_RATIO = Math.SQRT2; // A 계열 용지 (A4 등)
const LETTER_RATIO = 11 / 8.5;
/** 원본이 이보다 크면 줄여서 처리 (48MP 사진 등에서 WASM 메모리 부족 방지) */
const MAX_SOURCE_SIDE = 4096;
// jscanify에 들어 있는 OpenCV.js(4.7)에는 타입 정의가 없다
type Cv = any;
type Mat = any;
const dist = (a: { x: number; y: number }, b: { x: number; y: number }) =>
Math.hypot(a.x - b.x, a.y - b.y);
const odd = (n: number) => Math.round(n) | 1;
function edgeAspect(c: CornerPoints) {
const w =
(dist(c.topLeftCorner, c.topRightCorner) +
dist(c.bottomLeftCorner, c.bottomRightCorner)) /
2;
const h =
(dist(c.topLeftCorner, c.bottomLeftCorner) +
dist(c.topRightCorner, c.bottomRightCorner)) /
2;
return w / Math.max(h, 1);
}
/**
* 원근을 고려한 실제 용지 가로/세로 비.
* 비스듬히 찍으면 먼 쪽 변이 짧아져 변 길이만으로는 비율이 크게 틀어진다(늘어난 듯한 결과).
* Zhang & He, "Whiteboard scanning and image enhancement"(2007) — 주점은 사진 중앙으로 가정.
* 정면에 가깝거나 값이 불안정하면 변 길이 평균으로 대신한다.
*/
export function estimateAspect(
c: CornerPoints,
imageWidth: number,
imageHeight: number,
) {
type V3 = [number, number, number];
const u0 = imageWidth / 2;
const v0 = imageHeight / 2;
const p = (pt: { x: number; y: number }): V3 => [pt.x - u0, pt.y - v0, 1];
const cross = (a: V3, b: V3): V3 => [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
];
const dot = (a: V3, b: V3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const m1 = p(c.topLeftCorner);
const m2 = p(c.topRightCorner);
const m3 = p(c.bottomLeftCorner);
const m4 = p(c.bottomRightCorner);
const c14 = cross(m1, m4);
const k2 = dot(c14, m3) / dot(cross(m2, m4), m3);
const k3 = dot(c14, m2) / dot(cross(m3, m4), m2);
const n2 = m2.map((v, i) => k2 * v - m1[i]!) as V3;
const n3 = m3.map((v, i) => k3 * v - m1[i]!) as V3;
const denom = n2[2] * n3[2];
if (!Number.isFinite(k2) || !Number.isFinite(k3) || Math.abs(denom) < 1e-9) {
return edgeAspect(c);
}
const f2 = -(n2[0] * n3[0] + n2[1] * n3[1]) / denom;
const maxDim = Math.max(imageWidth, imageHeight);
if (!(f2 > 0) || Math.sqrt(f2) < maxDim * 0.3 || Math.sqrt(f2) > maxDim * 6) {
return edgeAspect(c);
}
const ratio = Math.sqrt(
(n2[0] ** 2 + n2[1] ** 2 + n2[2] ** 2 * f2) /
(n3[0] ** 2 + n3[1] ** 2 + n3[2] ** 2 * f2),
);
return Number.isFinite(ratio) && ratio > 0.15 && ratio < 7
? ratio
: edgeAspect(c);
}
/** 흔한 용지 비율(A 계열, Letter)과 4% 안쪽이면 그 비율로 맞춘다 */
function snapAspect(ratio: number) {
for (const target of [A_RATIO, LETTER_RATIO]) {
for (const t of [target, 1 / target]) {
if (Math.abs(ratio / t - 1) < 0.04) return t;
}
}
return ratio;
}
/** 결과 크기: 비율은 용지 설정(자동이면 원근 추정), 긴 변은 원본에서 문서가 차지한 픽셀 수 기준
* (1754–3508px = A4 150–300dpi, 원본보다 크게 늘리지 않음) */
export function outputSizeForAspect( export function outputSizeForAspect(
aspect: PaperAspect, aspect: PaperAspect,
corners: CornerPoints, corners: CornerPoints,
imageWidth: number,
imageHeight: number,
) { ) {
if (aspect === 'a4-portrait') return { width: 1240, height: 1754 }; let ratio: number;
if (aspect === 'a4-landscape') return { width: 1754, height: 1240 }; if (aspect === 'a4-portrait') ratio = 1 / A_RATIO;
if (aspect === 'square') return { width: 1400, height: 1400 }; else if (aspect === 'a4-landscape') ratio = A_RATIO;
else if (aspect === 'square') ratio = 1;
else ratio = snapAspect(estimateAspect(corners, imageWidth, imageHeight));
const sourceLong = Math.max(
dist(corners.topLeftCorner, corners.topRightCorner),
dist(corners.bottomLeftCorner, corners.bottomRightCorner),
dist(corners.topLeftCorner, corners.bottomLeftCorner),
dist(corners.topRightCorner, corners.bottomRightCorner),
);
const long = Math.round(Math.min(3508, Math.max(1754, sourceLong * 1.1)));
return ratio >= 1
? { width: long, height: Math.max(1, Math.round(long / ratio)) }
: { width: Math.max(1, Math.round(long * ratio)), height: long };
}
const widthTop = Math.hypot( /** 원근 보정 (RGB → RGB). 가장자리는 늘려 채워 검은 테두리가 생기지 않게 한다 */
corners.topRightCorner.x - corners.topLeftCorner.x, function warpMat(
corners.topRightCorner.y - corners.topLeftCorner.y, cv: Cv,
src: Mat,
c: CornerPoints,
size: { width: number; height: number },
) {
const from = cv.matFromArray(4, 1, cv.CV_32FC2, [
c.topLeftCorner.x, c.topLeftCorner.y,
c.topRightCorner.x, c.topRightCorner.y,
c.bottomRightCorner.x, c.bottomRightCorner.y,
c.bottomLeftCorner.x, c.bottomLeftCorner.y,
]);
// 가장자리를 0.4% 안쪽으로 잘라 코너 오차로 섞여 든 책상·그림자 테두리를 없앤다
const m = Math.round(Math.max(size.width, size.height) * 0.004);
const to = cv.matFromArray(4, 1, cv.CV_32FC2, [
-m, -m, size.width + m, -m, size.width + m, size.height + m, -m, size.height + m,
]);
const transform = cv.getPerspectiveTransform(from, to);
const out = new cv.Mat();
cv.warpPerspective(
src, out, transform, new cv.Size(size.width, size.height),
cv.INTER_CUBIC, cv.BORDER_REPLICATE,
); );
const widthBottom = Math.hypot( from.delete();
corners.bottomRightCorner.x - corners.bottomLeftCorner.x, to.delete();
corners.bottomRightCorner.y - corners.bottomLeftCorner.y, transform.delete();
return out;
}
/**
* 조명 평탄화: 글자를 지운 종이 배경(밝은 쪽 팽창 + 메디안 블러)을 추정해 원본을 나눈다.
* 그림자·밝기 차이·누런 조명이 사라지고 종이는 흰색, 도장·서명 같은 색은 남는다.
*/
function flatten(cv: Cv, src: Mat) {
const scale = Math.min(1, 640 / Math.max(src.cols, src.rows));
const small = new cv.Mat();
cv.resize(
src, small,
new cv.Size(Math.round(src.cols * scale), Math.round(src.rows * scale)),
0, 0, cv.INTER_AREA,
); );
const heightLeft = Math.hypot( const kernel = cv.getStructuringElement(cv.MORPH_ELLIPSE, new cv.Size(9, 9));
corners.bottomLeftCorner.x - corners.topLeftCorner.x, cv.dilate(small, small, kernel);
corners.bottomLeftCorner.y - corners.topLeftCorner.y, cv.medianBlur(small, small, 31);
cv.GaussianBlur(small, small, new cv.Size(0, 0), 3);
const background = new cv.Mat();
cv.resize(small, background, new cv.Size(src.cols, src.rows), 0, 0, cv.INTER_LINEAR);
const out = new cv.Mat();
cv.divide(src, background, out, 255);
small.delete();
kernel.delete();
background.delete();
return out;
}
/** 레벨 보정: 잉크 쪽 하위 blackPct를 0으로 당기고 감마로 글자를 진하게 (이 빌드에는 cv.LUT가 없다) */
function levels(mat: Mat, blackPct: number, gamma: number, whiteAt = 245) {
const data = mat.data as Uint8Array;
const hist = new Uint32Array(256);
for (let i = 0; i < data.length; i += 1) hist[data[i]!] += 1;
const target = data.length * blackPct;
let acc = 0;
let black = 0;
for (let v = 0; v < 256; v += 1) {
acc += hist[v]!;
if (acc >= target) {
black = v;
break;
}
}
black = Math.min(black, whiteAt - 40);
const lut = new Uint8Array(256);
for (let v = 0; v < 256; v += 1) {
const t = Math.min(1, Math.max(0, (v - black) / (whiteAt - black)));
lut[v] = Math.round(255 * t ** gamma);
}
for (let i = 0; i < data.length; i += 1) data[i] = lut[data[i]!]!;
}
function sharpen(cv: Cv, mat: Mat, amount: number) {
const blur = new cv.Mat();
cv.GaussianBlur(mat, blur, new cv.Size(0, 0), 1.1);
cv.addWeighted(mat, 1 + amount, blur, -amount, 0, mat);
blur.delete();
}
/** 스캔 필터. 원본: 원근 보정만 · 컬러/회색조: 조명 평탄화 + 글자 진하게 + 선명 · 흑백: 적응형 이진화 */
function enhance(cv: Cv, src: Mat, mode: ScanMode, intensity: number) {
const a = Math.min(100, Math.max(0, intensity)) / 100;
if (mode === 'original') return src.clone();
let base = src;
if (mode === 'gray' || mode === 'bw') {
base = new cv.Mat();
cv.cvtColor(src, base, cv.COLOR_RGB2GRAY);
}
const flat = flatten(cv, base);
if (base !== src) base.delete();
if (mode === 'bw') {
const out = new cv.Mat();
cv.adaptiveThreshold(
flat, out, 255, cv.ADAPTIVE_THRESH_GAUSSIAN_C, cv.THRESH_BINARY,
odd(Math.max(flat.cols, flat.rows) / 70), 6 + 14 * a,
); );
const heightRight = Math.hypot( flat.delete();
corners.bottomRightCorner.x - corners.topRightCorner.x, return out;
corners.bottomRightCorner.y - corners.topRightCorner.y, }
); levels(flat, 0.002 + 0.02 * a, 1 + 0.6 * a);
const width = Math.max(widthTop, widthBottom, 1); sharpen(cv, flat, 0.3 + 0.5 * a);
const height = Math.max(heightLeft, heightRight, 1); return flat;
const scale = Math.min(1800 / width, 1800 / height, 1); }
/** 원본을 캔버스에 그린다. 너무 크면 줄이고 그 배율을 돌려준다 */
function sourceCanvas(image: HTMLImageElement) {
const w = image.naturalWidth || image.width;
const h = image.naturalHeight || image.height;
const scale = Math.min(1, MAX_SOURCE_SIDE / Math.max(w, h));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(w * scale));
canvas.height = Math.max(1, Math.round(h * scale));
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) throw new Error('Canvas를 초기화하지 못했습니다.');
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
return { canvas, scale };
}
function scaleCorners(c: CornerPoints, s: number): CornerPoints {
const f = (p: { x: number; y: number }) => ({ x: p.x * s, y: p.y * s });
return { return {
width: Math.max(1, Math.round(width * scale)), topLeftCorner: f(c.topLeftCorner),
height: Math.max(1, Math.round(height * scale)), topRightCorner: f(c.topRightCorner),
bottomRightCorner: f(c.bottomRightCorner),
bottomLeftCorner: f(c.bottomLeftCorner),
}; };
} }
@@ -42,27 +253,47 @@ export async function warpImage(
sourceUrl: string, sourceUrl: string,
corners: CornerPoints, corners: CornerPoints,
aspect: PaperAspect, aspect: PaperAspect,
scanIntensity: number = DEFAULT_SCAN_INTENSITY,
scanMode: ScanMode = DEFAULT_SCAN_MODE,
) { ) {
await ensureJscanify(); await ensureOpenCv();
const cv: Cv = window.cv;
const image = await loadImageFromUrl(sourceUrl); const image = await loadImageFromUrl(sourceUrl);
const size = outputSizeForAspect(aspect, corners); const { canvas: source, scale } = sourceCanvas(image);
const scanner = createScanner(); const c = scaleCorners(corners, scale);
const canvas = scanner.extractPaper( const size = outputSizeForAspect(aspect, c, source.width, source.height);
image,
size.width, const rgba = cv.imread(source);
size.height, const rgb = new cv.Mat();
corners, cv.cvtColor(rgba, rgb, cv.COLOR_RGBA2RGB);
rgba.delete();
let warped: Mat = null;
let result: Mat = null;
const canvas = document.createElement('canvas');
try {
warped = warpMat(cv, rgb, c, size);
result = enhance(cv, warped, scanMode, scanIntensity);
cv.imshow(canvas, result);
} catch (reason) {
throw new Error(
`문서 보정에 실패했습니다: ${reason instanceof Error ? reason.message : String(reason)}`,
); );
if (!canvas) { } finally {
throw new Error('문서 영역을 추출하지 못했습니다.'); rgb.delete();
warped?.delete();
result?.delete();
} }
// 흑백은 PNG가 작고 선명하다. 컬러·회색조는 JPEG(PNG면 페이지당 10MB 이상)
const type = scanMode === 'bw' ? 'image/png' : 'image/jpeg';
const blob = await new Promise<Blob>((resolve, reject) => { const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob( canvas.toBlob(
(value) => (value) =>
value value
? resolve(value) ? resolve(value)
: reject(new Error('보정 이미지를 만들지 못했습니다.')), : reject(new Error('보정 이미지를 만들지 못했습니다.')),
'image/png', type,
0.92,
); );
}); });
return { return {
+25
View File
@@ -44,11 +44,36 @@ const router = createRouter({
name: 'model3d-viewer', name: 'model3d-viewer',
component: () => import('@/views/Model3DViewerView.vue'), component: () => import('@/views/Model3DViewerView.vue'),
}, },
{
path: '/viewer/illustrator',
name: 'illustrator-viewer',
component: () => import('@/views/IllustratorViewerView.vue'),
},
{
path: '/viewer/photoshop',
name: 'photoshop-viewer',
component: () => import('@/views/PhotoshopViewerView.vue'),
},
{
path: '/viewer/markdown',
name: 'markdown-viewer',
component: () => import('@/views/MarkdownViewerView.vue'),
},
{ {
path: '/tools/scanner', path: '/tools/scanner',
name: 'tools-scanner', name: 'tools-scanner',
component: () => import('@/views/ToolsScannerView.vue'), component: () => import('@/views/ToolsScannerView.vue'),
}, },
{
path: '/tools/bg-remove',
name: 'tools-bg-remove',
component: () => import('@/views/ToolsBgRemoveView.vue'),
},
{
path: '/tools/image-edit',
name: 'tools-image-edit',
component: () => import('@/views/ToolsImageEditView.vue'),
},
{ {
path: '/licenses', path: '/licenses',
name: 'licenses', name: 'licenses',
+4 -2
View File
@@ -10,8 +10,10 @@
--card: #ffffff; --card: #ffffff;
--card-hover: #f5f5f5; --card-hover: #f5f5f5;
--danger: #c62828; --danger: #c62828;
--font-sans: 'IBM Plex Sans KR', 'Apple SD Gothic Neo', sans-serif; --font-sans: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont,
--font-display: 'IBM Plex Sans KR', 'Apple SD Gothic Neo', sans-serif; system-ui, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
--font-display: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont,
system-ui, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
color-scheme: light; color-scheme: light;
} }
+45 -8
View File
@@ -14,6 +14,7 @@ import {
} from '@mlightcad/cad-simple-viewer'; } from '@mlightcad/cad-simple-viewer';
import AppShell from '@/components/AppShell.vue'; import AppShell from '@/components/AppShell.vue';
import EditorToolbar from '@/components/EditorToolbar.vue'; import EditorToolbar from '@/components/EditorToolbar.vue';
import { useFileDropZone } from '@/composables/useFileDropZone';
import { consumeViewerFiles } from '@/composables/viewerSession'; import { consumeViewerFiles } from '@/composables/viewerSession';
import { usePageTitle } from '@/composables/usePageTitle'; import { usePageTitle } from '@/composables/usePageTitle';
import { useSystemTheme } from '@/composables/useSystemTheme'; import { useSystemTheme } from '@/composables/useSystemTheme';
@@ -38,7 +39,7 @@ const error = ref('');
const notice = ref(''); const notice = ref('');
const theme = useSystemTheme(); const theme = useSystemTheme();
usePageTitle(filename); usePageTitle(filename, { faviconKind: 'cad' });
const settings = AcApSettingManager.instance; const settings = AcApSettingManager.instance;
settings.set('isShowCommandLine', false); settings.set('isShowCommandLine', false);
@@ -52,7 +53,12 @@ settings.set('isShowToolbar', true);
function applyFiles(files: File[]) { function applyFiles(files: File[]) {
const file = files.find((item) => /\.(?:dxf|dwg)$/i.test(item.name)); const file = files.find((item) => /\.(?:dxf|dwg)$/i.test(item.name));
if (!file) return; if (!file) {
if (files.length) {
error.value = '.dxf 또는 .dwg 파일을 선택해 주세요.';
}
return;
}
pendingFile.value = file; pendingFile.value = file;
if (fontReady.value) localFile.value = file; if (fontReady.value) localFile.value = file;
filename.value = file.name; filename.value = file.name;
@@ -63,6 +69,14 @@ function applyFiles(files: File[]) {
: ''; : '';
} }
const {
dropActive,
onDragEnter,
onDragOver,
onDragLeave,
onDrop,
} = useFileDropZone(applyFiles);
async function initialize() { async function initialize() {
applyFiles(consumeViewerFiles('cad')); applyFiles(consumeViewerFiles('cad'));
try { try {
@@ -98,10 +112,6 @@ function onFileSelected(event: Event) {
const files = Array.from(input.files ?? []); const files = Array.from(input.files ?? []);
input.value = ''; input.value = '';
if (!files.length) return; if (!files.length) return;
if (!files.some((file) => /\.(?:dxf|dwg)$/i.test(file.name))) {
error.value = '.dxf 또는 .dwg 파일을 선택해 주세요.';
return;
}
applyFiles(files); applyFiles(files);
void nextTick(); void nextTick();
} }
@@ -126,7 +136,14 @@ function onClose() {
<template> <template>
<AppShell :editor="true"> <AppShell :editor="true">
<div class="cad-page"> <div
class="cad-page"
:class="{ 'is-drop-active': dropActive }"
@dragenter="onDragEnter"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
>
<EditorToolbar <EditorToolbar
:filename="filename" :filename="filename"
:show-save="false" :show-save="false"
@@ -160,9 +177,15 @@ function onClose() {
/> />
<div v-if="ready && !localFile && viewerCreated" class="empty-state"> <div v-if="ready && !localFile && viewerCreated" class="empty-state">
<strong>CAD 도면을 선택해 주세요.</strong> <strong>CAD 도면을 선택해 주세요.</strong>
<span>DXF와 DWG 파일은 브라우저 안에서만 처리됩니다.</span> <span>
DXF와 DWG 파일을 열거나 이 영역으로 끌어다 놓으세요. 처리는
브라우저 안에서만 이뤄집니다.
</span>
<button type="button" @click="onOpen">파일 열기</button> <button type="button" @click="onOpen">파일 열기</button>
</div> </div>
<div v-if="dropActive" class="drop-overlay" aria-hidden="true">
DXF · DWG 파일을 놓으세요
</div>
</div> </div>
</div> </div>
</AppShell> </AppShell>
@@ -231,6 +254,20 @@ function onClose() {
cursor: pointer; cursor: pointer;
} }
.drop-overlay {
position: absolute;
inset: 0.75rem;
z-index: 30;
display: grid;
place-items: center;
border: 2px dashed var(--accent);
border-radius: 8px;
background: color-mix(in srgb, var(--accent-soft) 88%, transparent);
color: var(--ink);
font-weight: 650;
pointer-events: none;
}
.sr-only { .sr-only {
position: absolute; position: absolute;
width: 1px; width: 1px;
@@ -154,7 +154,6 @@ function onClose() {
<div class="editor-page"> <div class="editor-page">
<EditorToolbar <EditorToolbar
:filename="filename" :filename="filename"
save-label="원본 저장"
:show-export-actions="true" :show-export-actions="true"
:show-filename="false" :show-filename="false"
:busy="busy || !ready" :busy="busy || !ready"
@@ -135,7 +135,6 @@ function onClose() {
<div class="editor-page"> <div class="editor-page">
<EditorToolbar <EditorToolbar
:filename="filename" :filename="filename"
save-label="원본 저장"
:show-export-actions="true" :show-export-actions="true"
:busy="busy" :busy="busy"
@open="onOpen" @open="onOpen"
+42 -6
View File
@@ -60,6 +60,24 @@ const viewerTypes = [
title: '3D 모델', title: '3D 모델',
subtitle: 'glTF · OBJ · STEP · IFC · STL · 3DM 등', subtitle: 'glTF · OBJ · STEP · IFC · STL · 3DM 등',
}, },
{
route: 'illustrator-viewer',
extension: 'ai',
title: '일러스트',
subtitle: 'Adobe Illustrator (.ai, PDF 호환)',
},
{
route: 'photoshop-viewer',
extension: 'psd',
title: '포토샵',
subtitle: 'Adobe Photoshop (.psd, RGB 8bit)',
},
{
route: 'markdown-viewer',
extension: 'md',
title: '마크다운',
subtitle: '.md · 입력 미리보기',
},
] as const; ] as const;
const toolTypes = [ const toolTypes = [
@@ -69,6 +87,18 @@ const toolTypes = [
title: '문서 스캐너', title: '문서 스캐너',
subtitle: '코너 보정 · OCR · PDF', subtitle: '코너 보정 · OCR · PDF',
}, },
{
route: 'tools-bg-remove',
extension: 'bgremove',
title: '배경 제거',
subtitle: '로고 단색 배경 · 투명 PNG',
},
{
route: 'tools-image-edit',
extension: 'imgedit',
title: '이미지 편집',
subtitle: '크롭 · 축소 · 경량화 · 비율',
},
] as const; ] as const;
function onDrop(e: DragEvent) { function onDrop(e: DragEvent) {
@@ -116,7 +146,7 @@ function onOpenTool(route: string) {
<input <input
class="drop__input" class="drop__input"
type="file" type="file"
accept=".docx,.xlsx,.pptx,.pdf,.hwp,.hwpx,.odt,.ods,.odp,.doc,.xls,.ppt,.rtf,.txt,.csv,.excalidraw,.mmd,.mermaid,.drawio,.xml,.dxf,.dwg,.3dm,.3ds,.3mf,.amf,.bim,.brp,.brep,.dae,.fbx,.fcstd,.gltf,.glb,.ifc,.igs,.iges,.stp,.step,.stl,.obj,.off,.ply,.wrl,.zip,.bin,.mtl,.png,.jpg,.jpeg,.webp" accept=".docx,.xlsx,.pptx,.pdf,.hwp,.hwpx,.odt,.ods,.odp,.doc,.xls,.ppt,.rtf,.txt,.csv,.excalidraw,.mmd,.mermaid,.drawio,.xml,.dxf,.dwg,.ai,.psd,.md,.markdown,.3dm,.3ds,.3mf,.amf,.bim,.brp,.brep,.dae,.fbx,.fcstd,.gltf,.glb,.ifc,.igs,.iges,.stp,.step,.stl,.obj,.off,.ply,.wrl,.zip,.bin,.mtl,.png,.jpg,.jpeg,.webp,.ico"
multiple multiple
:disabled="busy" :disabled="busy"
@change="onInput" @change="onInput"
@@ -125,7 +155,7 @@ function onOpenTool(route: string) {
{{ busy ? '여는 중…' : '파일을 끌어다 놓거나 클릭하여 선택' }} {{ busy ? '여는 중…' : '파일을 끌어다 놓거나 클릭하여 선택' }}
</span> </span>
<span class="drop__hint"> <span class="drop__hint">
오피스·PDF · HWP/HWPX · 다이어그램 · CAD · 3D · 이미지(스캐너) 오피스·PDF · HWP/HWPX · 다이어그램 · CAD · 3D · 일러스트(.ai) · 포토샵(.psd) · 마크다운(.md) · 이미지(스캐너) · 아이콘(.ico)
</span> </span>
</label> </label>
@@ -179,10 +209,11 @@ function onOpenTool(route: string) {
</section> </section>
<section class="section"> <section class="section">
<h2 class="section__title">CAD·3D 모델</h2> <h2 class="section__title">뷰어</h2>
<p class="section__lead"> <p class="section__lead">
서버 업로드 없이 도면과 모델을 조회합니다. 텍스처·MTL·BIN 등 보조 서버 업로드 없이 도면·모델·일러스트·포토샵·마크다운을 조회합니다. 텍스처·MTL·BIN
파일이나 ZIP은 원본과 함께 선택하세요. 등 보조 파일이나 ZIP은 원본과 함께 선택하세요. .ai는 PDF 호환, .psd는
RGB·8bit만 안정적으로 열립니다.
</p> </p>
<div class="grid"> <div class="grid">
@@ -206,7 +237,7 @@ function onOpenTool(route: string) {
<section class="section"> <section class="section">
<h2 class="section__title">도구</h2> <h2 class="section__title">도구</h2>
<p class="section__lead"> <p class="section__lead">
브라우저에서 문서를 스캔·보정하고 OCR이 포함된 PDF로 저장합니다. 브라우저에서 문서를 스캔하고, 배경을 제거하며, 이미지를 크롭·축소·경량화합니다.
</p> </p>
<div class="grid"> <div class="grid">
@@ -358,6 +389,11 @@ function onOpenTool(route: string) {
border-color 0.15s ease; border-color 0.15s ease;
} }
.tile :deep(.ft-icon) {
width: 36px;
height: 36px;
}
.tile:hover:not(:disabled) { .tile:hover:not(:disabled) {
background: var(--card-hover); background: var(--card-hover);
border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
@@ -0,0 +1,476 @@
<script setup lang="ts">
import {
computed,
nextTick,
onActivated,
onBeforeUnmount,
onMounted,
ref,
watch,
} from 'vue';
import { useRouter } from 'vue-router';
import type { PDFDocumentProxy } from 'pdfjs-dist';
import AppShell from '@/components/AppShell.vue';
import EditorToolbar from '@/components/EditorToolbar.vue';
import { useFileDropZone } from '@/composables/useFileDropZone';
import { consumeViewerFiles } from '@/composables/viewerSession';
import { usePageTitle } from '@/composables/usePageTitle';
import {
AI_INCOMPATIBLE_MESSAGE,
cancelAiRender,
isAiFile,
isAiRenderCancelled,
openAiAsPdf,
renderAiPage,
} from '@/integrations/illustrator/loadAi';
defineOptions({ name: 'IllustratorViewerView' });
const router = useRouter();
const fileInput = ref<HTMLInputElement | null>(null);
const canvasRef = ref<HTMLCanvasElement | null>(null);
const stageRef = ref<HTMLElement | null>(null);
const filename = ref('일러스트');
const error = ref('');
const notice = ref('');
const busy = ref(false);
const page = ref(1);
const pageCount = ref(0);
const zoom = ref(1);
const fitZoom = ref(1);
let pdfDoc: PDFDocumentProxy | null = null;
let renderSeq = 0;
usePageTitle(filename, { faviconKind: 'illustrator' });
const pageLabel = computed(() =>
pageCount.value > 0 ? `${page.value} / ${pageCount.value}` : '—',
);
const zoomLabel = computed(() => `${Math.round(zoom.value * 100)}%`);
async function disposePdf() {
renderSeq += 1;
await cancelAiRender();
if (pdfDoc) {
try {
await pdfDoc.cleanup();
} catch {
/* ignore */
}
pdfDoc = null;
}
pageCount.value = 0;
page.value = 1;
}
async function paint() {
const canvas = canvasRef.value;
if (!pdfDoc || !canvas || pageCount.value < 1) return;
const seq = ++renderSeq;
// Coalesce rapid page/zoom updates onto the latest request.
await Promise.resolve();
if (seq !== renderSeq) return;
busy.value = true;
try {
await renderAiPage(pdfDoc, page.value, canvas, zoom.value);
if (seq !== renderSeq) return;
error.value = '';
} catch (reason) {
if (seq !== renderSeq || isAiRenderCancelled(reason)) return;
error.value =
reason instanceof Error ? reason.message : '페이지를 그리지 못했습니다.';
} finally {
if (seq === renderSeq) busy.value = false;
}
}
async function updateFitZoom() {
if (!pdfDoc || !stageRef.value) return;
try {
const p = await pdfDoc.getPage(page.value);
const viewport = p.getViewport({ scale: 1 });
p.cleanup();
const pad = 32;
const availW = Math.max(120, stageRef.value.clientWidth - pad);
const availH = Math.max(120, stageRef.value.clientHeight - pad);
const sx = availW / viewport.width;
const sy = availH / viewport.height;
fitZoom.value = Math.max(0.1, Math.min(sx, sy, 4));
} catch {
fitZoom.value = 1;
}
}
async function loadFile(file: File) {
if (!isAiFile(file)) {
error.value = '.ai 파일을 선택해 주세요.';
return;
}
busy.value = true;
error.value = '';
notice.value = '';
filename.value = file.name;
await disposePdf();
try {
const data = await file.arrayBuffer();
pdfDoc = await openAiAsPdf(data);
pageCount.value = pdfDoc.numPages;
page.value = 1;
await nextTick();
await updateFitZoom();
zoom.value = fitZoom.value;
await paint();
if (pdfDoc.numPages > 1) {
notice.value = `아트보드(페이지) ${pdfDoc.numPages}개`;
}
} catch (reason) {
error.value =
reason instanceof Error ? reason.message : AI_INCOMPATIBLE_MESSAGE;
await disposePdf();
} finally {
busy.value = false;
}
}
function applyFiles(files: File[]) {
const file = files.find((item) => isAiFile(item));
if (!file) {
if (files.length) error.value = '.ai 파일을 선택해 주세요.';
return;
}
void loadFile(file);
}
const {
dropActive,
onDragEnter,
onDragOver,
onDragLeave,
onDrop,
} = useFileDropZone(applyFiles);
onMounted(() => {
applyFiles(consumeViewerFiles('illustrator'));
});
onActivated(() => {
const files = consumeViewerFiles('illustrator');
if (files.length) applyFiles(files);
});
onBeforeUnmount(() => {
void disposePdf();
});
watch([page, zoom], () => {
if (!pdfDoc || pageCount.value < 1) return;
void paint();
});
function onOpen() {
fileInput.value?.click();
}
function onFileSelected(event: Event) {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
input.value = '';
if (files.length) applyFiles(files);
}
function onClose() {
void router.push({ name: 'home' });
}
function zoomBy(factor: number) {
zoom.value = Math.min(4, Math.max(0.1, Math.round(zoom.value * factor * 100) / 100));
}
async function zoomFit() {
await updateFitZoom();
zoom.value = fitZoom.value;
}
function zoomReset() {
zoom.value = 1;
}
function prevPage() {
if (page.value > 1) page.value -= 1;
}
function nextPage() {
if (page.value < pageCount.value) page.value += 1;
}
</script>
<template>
<AppShell :editor="true">
<div
class="ai-page"
:class="{ 'is-drop-active': dropActive }"
@dragenter="onDragEnter"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
>
<EditorToolbar
:filename="filename"
:show-save="false"
:busy="busy"
@open="onOpen"
@close="onClose"
/>
<input
ref="fileInput"
class="sr-only"
type="file"
accept=".ai,application/postscript"
@change="onFileSelected"
/>
<div v-if="pageCount > 0" class="ai-controls">
<div class="ai-controls__group">
<button
type="button"
class="ai-btn"
:disabled="page <= 1 || busy"
@click="prevPage"
>
이전
</button>
<span class="ai-controls__label">{{ pageLabel }}</span>
<button
type="button"
class="ai-btn"
:disabled="page >= pageCount || busy"
@click="nextPage"
>
다음
</button>
</div>
<div class="ai-controls__group">
<button type="button" class="ai-btn" :disabled="busy" @click="zoomBy(1 / 1.25)">
−
</button>
<span class="ai-controls__label">{{ zoomLabel }}</span>
<button type="button" class="ai-btn" :disabled="busy" @click="zoomBy(1.25)">
+
</button>
<button type="button" class="ai-btn" :disabled="busy" @click="zoomFit">
맞춤
</button>
<button type="button" class="ai-btn" :disabled="busy" @click="zoomReset">
100%
</button>
</div>
</div>
<p v-if="error" class="viewer-message viewer-message--error" role="alert">
{{ error }}
</p>
<p v-else-if="notice" class="viewer-message">{{ notice }}</p>
<div ref="stageRef" class="ai-stage">
<canvas
v-show="pageCount > 0 && !error"
ref="canvasRef"
class="ai-canvas"
/>
<div v-if="!pageCount && !error && !busy" class="empty-state">
<strong>Illustrator (.ai) 파일을 선택해 주세요.</strong>
<span>
PDF 호환으로 저장된 .ai만 브라우저에서 볼 수 있습니다. 파일을 열거나
이 영역으로 끌어다 놓으세요.
</span>
<button type="button" @click="onOpen">파일 열기</button>
</div>
<div v-if="dropActive" class="drop-overlay" aria-hidden="true">
.ai 파일을 놓으세요
</div>
</div>
</div>
</AppShell>
</template>
<style scoped>
.ai-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
background: var(--canvas);
position: relative;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.ai-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
padding: 0.45rem 0.85rem;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.ai-controls__group {
display: flex;
align-items: center;
gap: 0.4rem;
}
.ai-controls__label {
min-width: 3.5rem;
text-align: center;
font-size: 0.82rem;
font-variant-numeric: tabular-nums;
color: var(--ink-soft);
}
.ai-btn {
font: inherit;
font-size: 0.8rem;
font-weight: 600;
padding: 0.28rem 0.65rem;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--card);
color: var(--ink);
cursor: pointer;
}
.ai-btn:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
background: var(--card-hover);
}
.ai-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.viewer-message {
margin: 0;
padding: 0.45rem 0.85rem;
font-size: 0.82rem;
color: var(--ink-soft);
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.viewer-message--error {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 10%, var(--surface));
}
.ai-stage {
flex: 1;
min-height: 0;
position: relative;
overflow: auto;
display: grid;
place-items: center;
padding: 1rem;
background:
linear-gradient(45deg, #ececec 25%, transparent 25%),
linear-gradient(-45deg, #ececec 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ececec 75%),
linear-gradient(-45deg, transparent 75%, #ececec 75%);
background-size: 16px 16px;
background-position:
0 0,
0 8px,
8px -8px,
-8px 0;
background-color: #f7f7f7;
}
@media (prefers-color-scheme: dark) {
.ai-stage {
background-color: #1a1a1a;
background-image:
linear-gradient(45deg, #242424 25%, transparent 25%),
linear-gradient(-45deg, #242424 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #242424 75%),
linear-gradient(-45deg, transparent 75%, #242424 75%);
}
}
.ai-canvas {
display: block;
max-width: none;
box-shadow: 0 4px 24px rgb(0 0 0 / 18%);
background: #fff;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.55rem;
max-width: 28rem;
padding: 1.25rem 1.35rem;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--ink);
}
.empty-state strong {
font-size: 1rem;
}
.empty-state span {
font-size: 0.88rem;
color: var(--muted);
line-height: 1.45;
}
.empty-state button {
font: inherit;
font-size: 0.86rem;
font-weight: 600;
margin-top: 0.25rem;
padding: 0.4rem 0.85rem;
border: none;
border-radius: 6px;
background: var(--accent);
color: #fff;
cursor: pointer;
}
.drop-overlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: color-mix(in srgb, var(--accent) 22%, transparent);
color: var(--ink);
font-weight: 650;
font-size: 1.05rem;
pointer-events: none;
z-index: 2;
}
.ai-page.is-drop-active .ai-stage {
outline: 2px dashed var(--accent);
outline-offset: -8px;
}
</style>
@@ -20,6 +20,16 @@ const thirdParty = [
license: 'MIT', license: 'MIT',
url: 'https://github.com/mermaid-js/mermaid', url: 'https://github.com/mermaid-js/mermaid',
}, },
{
name: 'marked',
license: 'MIT',
url: 'https://github.com/markedjs/marked',
},
{
name: 'DOMPurify',
license: 'MPL-2.0 OR Apache-2.0',
url: 'https://github.com/cure53/DOMPurify',
},
{ name: 'Three.js', license: 'MIT', url: 'https://github.com/mrdoob/three.js' }, { name: 'Three.js', license: 'MIT', url: 'https://github.com/mrdoob/three.js' },
{ {
name: 'Online 3D Viewer', name: 'Online 3D Viewer',
@@ -86,6 +96,11 @@ const thirdParty = [
license: 'Apache-2.0', license: 'Apache-2.0',
url: 'https://github.com/mozilla/pdf.js', url: 'https://github.com/mozilla/pdf.js',
}, },
{
name: 'Pretendard',
license: 'SIL OFL 1.1',
url: 'https://github.com/orioncactus/pretendard',
},
{ {
name: 'Noto Sans KR', name: 'Noto Sans KR',
license: 'SIL OFL 1.1', license: 'SIL OFL 1.1',
@@ -0,0 +1,357 @@
<script setup lang="ts">
import { computed, onActivated, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import AppShell from '@/components/AppShell.vue';
import EditorToolbar from '@/components/EditorToolbar.vue';
import { useFileDropZone } from '@/composables/useFileDropZone';
import { consumeViewerFiles } from '@/composables/viewerSession';
import { usePageTitle } from '@/composables/usePageTitle';
import {
isMarkdownFile,
renderMarkdown,
} from '@/integrations/markdown/render';
import { downloadText, readTextFile } from '@/utils/downloadBlob';
defineOptions({ name: 'MarkdownViewerView' });
const DEFAULT_SOURCE = `# 마크다운
왼쪽 칸에 입력하거나 \`.md\` 파일을 열면 미리보기가 표시됩니다.
- **굵게** · *기울임* · \`코드\`
- [링크](https://git.sejong.dev)
\`\`\`ts
console.log('hello');
\`\`\`
`;
const router = useRouter();
const fileInput = ref<HTMLInputElement | null>(null);
const filename = ref('미리보기.md');
const source = ref(DEFAULT_SOURCE);
const error = ref('');
const busy = ref(false);
usePageTitle(filename, { faviconKind: 'markdown' });
const html = computed(() => renderMarkdown(source.value));
function onClose() {
void router.push({ name: 'home' });
}
function onOpen() {
fileInput.value?.click();
}
function onSave() {
downloadText(source.value, filename.value, 'text/markdown;charset=utf-8');
}
async function loadFile(file: File) {
if (!isMarkdownFile(file)) {
error.value = '.md 또는 .markdown 파일을 선택해 주세요.';
return;
}
busy.value = true;
error.value = '';
try {
source.value = await readTextFile(file);
filename.value = file.name.replace(/\.markdown$/i, '.md');
} catch (reason) {
error.value =
reason instanceof Error ? reason.message : '파일을 열지 못했습니다.';
} finally {
busy.value = false;
}
}
function onFileSelected(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (file) void loadFile(file);
}
function consumePending() {
const [file] = consumeViewerFiles('markdown');
if (file) void loadFile(file);
}
onMounted(consumePending);
onActivated(consumePending);
const {
dropActive,
onDragEnter,
onDragOver,
onDragLeave,
onDrop: onZoneDrop,
} = useFileDropZone((files) => {
const file = files[0];
if (file) void loadFile(file);
});
watch(source, () => {
if (error.value && source.value) error.value = '';
});
</script>
<template>
<AppShell :editor="true">
<div
class="md-page"
:class="{ 'is-drop-active': dropActive }"
@dragenter="onDragEnter"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onZoneDrop"
>
<EditorToolbar
:filename="filename"
:busy="busy"
save-label="다운로드"
@open="onOpen"
@save="onSave"
@close="onClose"
/>
<input
ref="fileInput"
class="sr-only"
type="file"
accept=".md,.markdown,.mdown,text/markdown,text/plain"
@change="onFileSelected"
/>
<p v-if="error" class="viewer-message viewer-message--error" role="alert">
{{ error }}
</p>
<div class="md-layout">
<section class="source-pane" aria-label="마크다운 입력">
<textarea
v-model="source"
class="source"
spellcheck="false"
:disabled="busy"
aria-label="마크다운 원문"
placeholder="# 제목&#10;&#10;여기에 마크다운을 입력하세요."
/>
</section>
<section class="preview-pane" aria-label="마크다운 미리보기">
<article class="preview" v-html="html" />
</section>
</div>
<div v-if="dropActive" class="drop-overlay" aria-hidden="true">
마크다운 파일을 놓아 열기
</div>
</div>
</AppShell>
</template>
<style scoped>
.md-page {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
background: var(--canvas);
}
.md-layout {
flex: 1 1 auto;
min-height: 0;
display: grid;
grid-template-columns: minmax(16rem, 1fr) minmax(0, 1.15fr);
gap: 1px;
background: var(--border);
}
.source-pane,
.preview-pane {
min-width: 0;
min-height: 0;
background: var(--surface);
}
.source {
display: block;
width: 100%;
height: 100%;
resize: none;
border: 0;
outline: 0;
padding: 1rem;
background: var(--surface);
color: var(--ink);
font: 0.9rem/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
tab-size: 2;
}
.preview-pane {
overflow: auto;
padding: 1.25rem 1.4rem 2rem;
}
.preview {
max-width: 46rem;
color: var(--ink);
font-size: 0.95rem;
line-height: 1.65;
word-break: break-word;
}
.preview :deep(:first-child) {
margin-top: 0;
}
.preview :deep(h1),
.preview :deep(h2),
.preview :deep(h3),
.preview :deep(h4) {
margin: 1.35em 0 0.5em;
line-height: 1.3;
font-weight: 650;
}
.preview :deep(h1) {
font-size: 1.7rem;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--border);
}
.preview :deep(h2) {
font-size: 1.3rem;
padding-bottom: 0.25em;
border-bottom: 1px solid var(--border);
}
.preview :deep(h3) {
font-size: 1.1rem;
}
.preview :deep(p),
.preview :deep(ul),
.preview :deep(ol),
.preview :deep(blockquote),
.preview :deep(pre),
.preview :deep(table) {
margin: 0 0 1em;
}
.preview :deep(ul),
.preview :deep(ol) {
padding-left: 1.4em;
}
.preview :deep(a) {
color: var(--accent);
}
.preview :deep(code) {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.86em;
padding: 0.12em 0.35em;
border-radius: 4px;
background: var(--canvas);
}
.preview :deep(pre) {
overflow: auto;
padding: 0.85rem 1rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--canvas);
}
.preview :deep(pre code) {
padding: 0;
background: none;
}
.preview :deep(blockquote) {
padding: 0 0.85rem;
border-left: 3px solid var(--accent);
color: var(--muted);
}
.preview :deep(hr) {
border: 0;
border-top: 1px solid var(--border);
margin: 1.4em 0;
}
.preview :deep(img) {
max-width: 100%;
height: auto;
}
.preview :deep(table) {
display: block;
width: max-content;
max-width: 100%;
overflow: auto;
border-collapse: collapse;
}
.preview :deep(th),
.preview :deep(td) {
padding: 0.4rem 0.65rem;
border: 1px solid var(--border);
}
.preview :deep(th) {
background: var(--canvas);
text-align: left;
}
.drop-overlay {
position: absolute;
inset: 2.75rem 0.5rem 0.5rem;
z-index: 20;
display: grid;
place-items: center;
border: 2px dashed var(--accent);
border-radius: 8px;
background: color-mix(in srgb, var(--accent-soft) 88%, transparent);
color: var(--ink);
font-weight: 650;
pointer-events: none;
}
.viewer-message {
margin: 0;
padding: 0.45rem 0.85rem;
border-bottom: 1px solid var(--border);
background: var(--surface);
color: var(--ink);
font-size: 0.86rem;
}
.viewer-message--error {
color: #b42318;
background: color-mix(in srgb, #b42318 10%, var(--surface));
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 760px) {
.md-layout {
grid-template-columns: 1fr;
grid-template-rows: minmax(12rem, 0.85fr) minmax(14rem, 1fr);
}
}
</style>
@@ -58,7 +58,7 @@ async function renderDiagram() {
startOnLoad: false, startOnLoad: false,
securityLevel: 'strict', securityLevel: 'strict',
theme: theme.value === 'dark' ? 'dark' : 'default', theme: theme.value === 'dark' ? 'dark' : 'default',
fontFamily: 'IBM Plex Sans KR, sans-serif', fontFamily: 'Pretendard Variable, Pretendard, sans-serif',
htmlLabels: false, htmlLabels: false,
}); });
await mermaid.parse(code.value); await mermaid.parse(code.value);
@@ -176,7 +176,6 @@ function onClose() {
<div class="editor-page"> <div class="editor-page">
<EditorToolbar <EditorToolbar
:filename="filename" :filename="filename"
save-label="원본 저장"
:show-export-actions="true" :show-export-actions="true"
:busy="busy" :busy="busy"
@open="onOpen" @open="onOpen"
@@ -13,6 +13,7 @@ import Model3DCanvas from '@/components/model3d/Model3DCanvas.vue';
import ModelTreeItem from '@/components/model3d/ModelTreeItem.vue'; import ModelTreeItem from '@/components/model3d/ModelTreeItem.vue';
import { MODEL3D_EXTENSIONS } from '@/config/app'; import { MODEL3D_EXTENSIONS } from '@/config/app';
import { consumeViewerFiles } from '@/composables/viewerSession'; import { consumeViewerFiles } from '@/composables/viewerSession';
import { useFileDropZone } from '@/composables/useFileDropZone';
import { usePageTitle } from '@/composables/usePageTitle'; import { usePageTitle } from '@/composables/usePageTitle';
import { useSystemTheme } from '@/composables/useSystemTheme'; import { useSystemTheme } from '@/composables/useSystemTheme';
import { import {
@@ -84,7 +85,7 @@ const propertyGroups = computed(() =>
selectedKey.value ? selectionGroups.value : modelGroups.value, selectedKey.value ? selectionGroups.value : modelGroups.value,
); );
usePageTitle(filename); usePageTitle(filename, { faviconKind: 'model3d' });
function applyFiles(nextFiles: File[]) { function applyFiles(nextFiles: File[]) {
const primary = findPrimaryModelFile(nextFiles); const primary = findPrimaryModelFile(nextFiles);
@@ -135,10 +136,18 @@ function onFilesSelected(event: Event) {
if (selected.length) applyFiles(selected); if (selected.length) applyFiles(selected);
} }
const {
dropActive,
onDragEnter,
onDragOver,
onDragLeave,
onDrop,
} = useFileDropZone(applyFiles);
function onProgress(value: number, label: string) { function onProgress(value: number, label: string) {
loading.value = true;
progress.value = value; progress.value = value;
progressLabel.value = label; progressLabel.value = label;
if (value < 1) loading.value = true;
} }
function onLoaded( function onLoaded(
@@ -238,10 +247,16 @@ function onClose() {
<template> <template>
<AppShell :editor="true"> <AppShell :editor="true">
<div class="model-page"> <div
class="model-page"
:class="{ 'is-drop-active': dropActive }"
@dragenter="onDragEnter"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
>
<EditorToolbar <EditorToolbar
:filename="filename" :filename="filename"
save-label="내보내기"
:show-save="Boolean(stats)" :show-save="Boolean(stats)"
:busy="loading || exportBusy" :busy="loading || exportBusy"
@open="onOpen" @open="onOpen"
@@ -314,8 +329,9 @@ function onClose() {
<div v-else class="empty-state"> <div v-else class="empty-state">
<strong>3D 모델을 선택해 주세요.</strong> <strong>3D 모델을 선택해 주세요.</strong>
<span> <span>
Online3DViewer가 지원하는 형식과 ZIP, 보조 텍스처를 함께 열 수 Online3DViewer가 지원하는 형식과 ZIP, 보조 텍스처를 함께 열거나
있습니다. 이 영역으로 끌어다 놓을 수 있습니다. 면 없는 PLY는 포인트
클라우드로 표시됩니다.
</span> </span>
<button type="button" @click="onOpen">파일 열기</button> <button type="button" @click="onOpen">파일 열기</button>
</div> </div>
@@ -369,6 +385,10 @@ function onClose() {
<progress :value="progressPercent" max="100" /> <progress :value="progressPercent" max="100" />
<small>{{ progressPercent }}%</small> <small>{{ progressPercent }}%</small>
</div> </div>
<div v-if="dropActive" class="drop-overlay" aria-hidden="true">
3D 모델 파일을 놓으세요
</div>
</main> </main>
<aside class="inspector"> <aside class="inspector">
@@ -632,6 +652,20 @@ function onClose() {
text-align: right; text-align: right;
} }
.drop-overlay {
position: absolute;
inset: 0.75rem;
z-index: 25;
display: grid;
place-items: center;
border: 2px dashed var(--accent);
border-radius: 8px;
background: color-mix(in srgb, var(--accent-soft) 88%, transparent);
color: var(--ink);
font-weight: 650;
pointer-events: none;
}
.export-bar { .export-bar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -2,9 +2,10 @@
import { nextTick, onActivated, onMounted, onUnmounted, ref } from 'vue'; import { nextTick, onActivated, onMounted, onUnmounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import AppShell from '@/components/AppShell.vue'; import AppShell from '@/components/AppShell.vue';
import DownloadGuideModal from '@/components/DownloadGuideModal.vue';
import EditorErrorState from '@/components/EditorErrorState.vue'; import EditorErrorState from '@/components/EditorErrorState.vue';
import EditorToolbar from '@/components/EditorToolbar.vue'; import EditorToolbar from '@/components/EditorToolbar.vue';
import { downloadDocumentUrl, fetchEditorConfig } from '@/api/client'; import { fetchEditorConfig } from '@/api/client';
import { useOpenDocument } from '@/composables/useOpenDocument'; import { useOpenDocument } from '@/composables/useOpenDocument';
import { usePageTitle } from '@/composables/usePageTitle'; import { usePageTitle } from '@/composables/usePageTitle';
import { useSessionLifecycle } from '@/composables/useSessionLifecycle'; import { useSessionLifecycle } from '@/composables/useSessionLifecycle';
@@ -15,6 +16,7 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const error = ref<unknown>(null); const error = ref<unknown>(null);
const filename = ref('문서'); const filename = ref('문서');
const guideOpen = ref(false);
const frameHost = ref<HTMLElement | null>(null); const frameHost = ref<HTMLElement | null>(null);
const fileInput = ref<HTMLInputElement | null>(null); const fileInput = ref<HTMLInputElement | null>(null);
let editor: { destroyEditor?: () => void } | null = null; let editor: { destroyEditor?: () => void } | null = null;
@@ -27,6 +29,12 @@ const { openDocument } = useOpenDocument();
usePageTitle(filename); usePageTitle(filename);
useSessionLifecycle({ enableHeartbeat: true }); useSessionLifecycle({ enableHeartbeat: true });
const downloadGuideSteps = [
'편집기 좌측 상단의 파일을 누릅니다.',
'다운로드 방법을 선택합니다.',
'원하는 확장자를 고르면 파일이 저장됩니다.',
];
function editorHeightPx(): number { function editorHeightPx(): number {
const host = frameHost.value; const host = frameHost.value;
if (host) { if (host) {
@@ -123,15 +131,8 @@ function onFileSelected(e: Event) {
input.value = ''; input.value = '';
} }
function onSave() { function onDownload() {
const docId = route.params.docId as string; guideOpen.value = true;
const a = document.createElement('a');
a.href = downloadDocumentUrl(docId);
a.download = filename.value;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
} }
function onClose() { function onClose() {
@@ -147,7 +148,7 @@ function onClose() {
:filename="filename" :filename="filename"
:show-filename="false" :show-filename="false"
@open="onOpen" @open="onOpen"
@save="onSave" @save="onDownload"
@close="onClose" @close="onClose"
/> />
<input <input
@@ -162,6 +163,14 @@ function onClose() {
<div v-show="!error" ref="frameHost" class="editor-frame"> <div v-show="!error" ref="frameHost" class="editor-frame">
<div id="onlyoffice-placeholder" class="editor-frame__inner" /> <div id="onlyoffice-placeholder" class="editor-frame__inner" />
</div> </div>
<DownloadGuideModal
:open="guideOpen"
lead="수정한 파일은 EuroOffice 편집기 메뉴에서 받을 수 있습니다."
:steps="downloadGuideSteps"
path-hint="파일 → 다운로드 방법 → 확장자 선택"
@close="guideOpen = false"
/>
</div> </div>
</AppShell> </AppShell>
</template> </template>
@@ -0,0 +1,421 @@
<script setup lang="ts">
import {
computed,
nextTick,
onActivated,
onBeforeUnmount,
onMounted,
ref,
watch,
} from 'vue';
import { useRouter } from 'vue-router';
import AppShell from '@/components/AppShell.vue';
import EditorToolbar from '@/components/EditorToolbar.vue';
import { useFileDropZone } from '@/composables/useFileDropZone';
import { consumeViewerFiles } from '@/composables/viewerSession';
import { usePageTitle } from '@/composables/usePageTitle';
import {
drawPsdToCanvas,
isPsdFile,
openPsd,
PSD_INCOMPATIBLE_MESSAGE,
type OpenedPsd,
} from '@/integrations/photoshop/loadPsd';
defineOptions({ name: 'PhotoshopViewerView' });
const router = useRouter();
const fileInput = ref<HTMLInputElement | null>(null);
const canvasRef = ref<HTMLCanvasElement | null>(null);
const stageRef = ref<HTMLElement | null>(null);
const filename = ref('포토샵');
const error = ref('');
const notice = ref('');
const busy = ref(false);
const hasImage = ref(false);
const zoom = ref(1);
const fitZoom = ref(1);
let opened: OpenedPsd | null = null;
let renderSeq = 0;
usePageTitle(filename, { faviconKind: 'photoshop' });
const zoomLabel = computed(() => `${Math.round(zoom.value * 100)}%`);
function disposePsd() {
renderSeq += 1;
opened = null;
hasImage.value = false;
}
function paint() {
const canvas = canvasRef.value;
if (!opened || !canvas) return;
const seq = ++renderSeq;
try {
drawPsdToCanvas(opened.canvas, canvas, zoom.value);
if (seq !== renderSeq) return;
error.value = '';
} catch (reason) {
if (seq !== renderSeq) return;
error.value =
reason instanceof Error ? reason.message : '이미지를 그리지 못했습니다.';
}
}
function updateFitZoom() {
if (!opened || !stageRef.value) {
fitZoom.value = 1;
return;
}
const pad = 32;
const availW = Math.max(120, stageRef.value.clientWidth - pad);
const availH = Math.max(120, stageRef.value.clientHeight - pad);
const sx = availW / opened.width;
const sy = availH / opened.height;
fitZoom.value = Math.max(0.1, Math.min(sx, sy, 4));
}
async function loadFile(file: File) {
if (!isPsdFile(file)) {
error.value = '.psd 파일을 선택해 주세요.';
return;
}
busy.value = true;
error.value = '';
notice.value = '';
filename.value = file.name;
disposePsd();
try {
const data = await file.arrayBuffer();
opened = openPsd(data);
hasImage.value = true;
await nextTick();
updateFitZoom();
zoom.value = fitZoom.value;
paint();
notice.value = `${opened.width} × ${opened.height}px`;
} catch (reason) {
error.value =
reason instanceof Error ? reason.message : PSD_INCOMPATIBLE_MESSAGE;
disposePsd();
} finally {
busy.value = false;
}
}
function applyFiles(files: File[]) {
const file = files.find((item) => isPsdFile(item));
if (!file) {
if (files.length) error.value = '.psd 파일을 선택해 주세요.';
return;
}
void loadFile(file);
}
const {
dropActive,
onDragEnter,
onDragOver,
onDragLeave,
onDrop,
} = useFileDropZone(applyFiles);
onMounted(() => {
applyFiles(consumeViewerFiles('photoshop'));
});
onActivated(() => {
const files = consumeViewerFiles('photoshop');
if (files.length) applyFiles(files);
});
onBeforeUnmount(() => {
disposePsd();
});
watch(zoom, () => {
if (!opened) return;
paint();
});
function onOpen() {
fileInput.value?.click();
}
function onFileSelected(event: Event) {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
input.value = '';
if (files.length) applyFiles(files);
}
function onClose() {
void router.push({ name: 'home' });
}
function zoomBy(factor: number) {
zoom.value = Math.min(
4,
Math.max(0.1, Math.round(zoom.value * factor * 100) / 100),
);
}
function zoomFit() {
updateFitZoom();
zoom.value = fitZoom.value;
}
function zoomReset() {
zoom.value = 1;
}
</script>
<template>
<AppShell :editor="true">
<div
class="psd-page"
:class="{ 'is-drop-active': dropActive }"
@dragenter="onDragEnter"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
>
<EditorToolbar
:filename="filename"
:show-save="false"
:busy="busy"
@open="onOpen"
@close="onClose"
/>
<input
ref="fileInput"
class="sr-only"
type="file"
accept=".psd,image/vnd.adobe.photoshop"
@change="onFileSelected"
/>
<div v-if="hasImage" class="psd-controls">
<div class="psd-controls__group">
<button type="button" class="psd-btn" :disabled="busy" @click="zoomBy(1 / 1.25)">
−
</button>
<span class="psd-controls__label">{{ zoomLabel }}</span>
<button type="button" class="psd-btn" :disabled="busy" @click="zoomBy(1.25)">
+
</button>
<button type="button" class="psd-btn" :disabled="busy" @click="zoomFit">
맞춤
</button>
<button type="button" class="psd-btn" :disabled="busy" @click="zoomReset">
100%
</button>
</div>
</div>
<p v-if="error" class="viewer-message viewer-message--error" role="alert">
{{ error }}
</p>
<p v-else-if="notice" class="viewer-message">{{ notice }}</p>
<div ref="stageRef" class="psd-stage">
<canvas
v-show="hasImage && !error"
ref="canvasRef"
class="psd-canvas"
/>
<div v-if="!hasImage && !error && !busy" class="empty-state">
<strong>Photoshop (.psd) 파일을 선택해 주세요.</strong>
<span>
RGB·Grayscale·Bitmap / 8bit .psd만 브라우저에서 볼 수 있습니다.
파일을 열거나 이 영역으로 끌어다 놓으세요.
</span>
<button type="button" @click="onOpen">파일 열기</button>
</div>
<div v-if="dropActive" class="drop-overlay" aria-hidden="true">
.psd 파일을 놓으세요
</div>
</div>
</div>
</AppShell>
</template>
<style scoped>
.psd-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
background: var(--canvas);
position: relative;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.psd-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.65rem;
padding: 0.45rem 0.85rem;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.psd-controls__group {
display: flex;
align-items: center;
gap: 0.4rem;
}
.psd-controls__label {
min-width: 3.5rem;
text-align: center;
font-size: 0.82rem;
font-variant-numeric: tabular-nums;
color: var(--ink-soft);
}
.psd-btn {
font: inherit;
font-size: 0.8rem;
font-weight: 600;
padding: 0.28rem 0.65rem;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--card);
color: var(--ink);
cursor: pointer;
}
.psd-btn:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
background: var(--card-hover);
}
.psd-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.viewer-message {
margin: 0;
padding: 0.45rem 0.85rem;
font-size: 0.82rem;
color: var(--ink-soft);
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.viewer-message--error {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 10%, var(--surface));
}
.psd-stage {
flex: 1;
min-height: 0;
position: relative;
overflow: auto;
display: grid;
place-items: center;
padding: 1rem;
background:
linear-gradient(45deg, #ececec 25%, transparent 25%),
linear-gradient(-45deg, #ececec 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ececec 75%),
linear-gradient(-45deg, transparent 75%, #ececec 75%);
background-size: 16px 16px;
background-position:
0 0,
0 8px,
8px -8px,
-8px 0;
background-color: #f7f7f7;
}
@media (prefers-color-scheme: dark) {
.psd-stage {
background-color: #1a1a1a;
background-image:
linear-gradient(45deg, #242424 25%, transparent 25%),
linear-gradient(-45deg, #242424 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #242424 75%),
linear-gradient(-45deg, transparent 75%, #242424 75%);
}
}
.psd-canvas {
display: block;
max-width: none;
box-shadow: 0 4px 24px rgb(0 0 0 / 18%);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.55rem;
max-width: 28rem;
padding: 1.25rem 1.35rem;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--ink);
}
.empty-state strong {
font-size: 1rem;
}
.empty-state span {
font-size: 0.88rem;
color: var(--muted);
line-height: 1.45;
}
.empty-state button {
font: inherit;
font-size: 0.86rem;
font-weight: 600;
margin-top: 0.25rem;
padding: 0.4rem 0.85rem;
border: none;
border-radius: 6px;
background: var(--accent);
color: #fff;
cursor: pointer;
}
.drop-overlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: color-mix(in srgb, var(--accent) 22%, transparent);
color: var(--ink);
font-weight: 650;
font-size: 1.05rem;
pointer-events: none;
z-index: 2;
}
.psd-page.is-drop-active .psd-stage {
outline: 2px dashed var(--accent);
outline-offset: -8px;
}
</style>
+59 -29
View File
@@ -1,37 +1,47 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'; import { nextTick, onActivated, onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { createEditor, type RhwpEditor } from '@rhwp/editor'; import { createEditor, type RhwpEditor } from '@rhwp/editor';
import AppShell from '@/components/AppShell.vue'; import AppShell from '@/components/AppShell.vue';
import DownloadGuideModal from '@/components/DownloadGuideModal.vue';
import EditorErrorState from '@/components/EditorErrorState.vue'; import EditorErrorState from '@/components/EditorErrorState.vue';
import EditorToolbar from '@/components/EditorToolbar.vue'; import EditorToolbar from '@/components/EditorToolbar.vue';
import { ApiError } from '@/api/client'; import { ApiError } from '@/api/client';
import { extensionOf, isHwp } from '@/config/app'; import { isHwp } from '@/config/app';
import { usePageTitle } from '@/composables/usePageTitle'; import { usePageTitle } from '@/composables/usePageTitle';
import { import {
clearHwpFile, clearHwpFile,
hwpOpenToken,
loadHwpFile, loadHwpFile,
storeHwpFile, storeHwpFile,
useSessionLifecycle, useSessionLifecycle,
} from '@/composables/useSessionLifecycle'; } from '@/composables/useSessionLifecycle';
import { downloadBlob } from '@/utils/downloadBlob';
defineOptions({ name: 'RhwpEditorView' }); defineOptions({ name: 'RhwpEditorView' });
const router = useRouter(); const router = useRouter();
const error = ref<unknown>(null); const error = ref<unknown>(null);
const filename = ref('문서.hwp'); const filename = ref('문서.hwp');
const guideOpen = ref(false);
const fileInput = ref<HTMLInputElement | null>(null); const fileInput = ref<HTMLInputElement | null>(null);
let editor: RhwpEditor | null = null; let editor: RhwpEditor | null = null;
let initialized = false; let loadedToken = '';
let loadSeq = 0;
usePageTitle(filename); usePageTitle(filename);
useSessionLifecycle({ enableHeartbeat: true }); useSessionLifecycle({ enableHeartbeat: true });
onMounted(async () => { const downloadGuideSteps = [
if (initialized) return; '편집기 메뉴에서 파일을 누릅니다.',
'저장 또는 다른 이름으로 저장을 선택합니다.',
'원하는 위치와 이름으로 저장합니다.',
];
async function bootstrapFromStore() {
const seq = ++loadSeq;
const token = hwpOpenToken();
const stored = loadHwpFile(); const stored = loadHwpFile();
if (!stored) { if (!stored) {
error.value = new ApiError( error.value = new ApiError(
404, 404,
@@ -41,25 +51,51 @@ onMounted(async () => {
return; return;
} }
// Same staged file already shown (e.g. mounted + activated).
if (editor && loadedToken === token && loadedToken !== '0') {
filename.value = stored.name;
error.value = null;
return;
}
filename.value = stored.name; filename.value = stored.name;
error.value = null; error.value = null;
try { try {
await nextTick();
if (seq !== loadSeq) return;
if (!editor) {
editor = await createEditor('#rhwp-host', { editor = await createEditor('#rhwp-host', {
width: '100%', width: '100%',
height: '100%', height: '100%',
}); });
}
if (seq !== loadSeq) return;
await editor.loadFile(stored.buffer, stored.name); await editor.loadFile(stored.buffer, stored.name);
initialized = true; if (seq !== loadSeq) return;
loadedToken = token;
} catch (err) { } catch (err) {
if (seq !== loadSeq) return;
error.value = err; error.value = err;
} }
}
onMounted(() => {
void bootstrapFromStore();
});
onActivated(() => {
void bootstrapFromStore();
}); });
onUnmounted(() => { onUnmounted(() => {
loadSeq += 1;
editor?.destroy(); editor?.destroy();
editor = null; editor = null;
initialized = false; loadedToken = '';
}); });
function onOpen() { function onOpen() {
@@ -70,7 +106,7 @@ async function onFileSelected(e: Event) {
const input = e.target as HTMLInputElement; const input = e.target as HTMLInputElement;
const file = input.files?.[0]; const file = input.files?.[0];
input.value = ''; input.value = '';
if (!file || !editor) return; if (!file) return;
if (!isHwp(file.name)) { if (!isHwp(file.name)) {
error.value = new ApiError( error.value = new ApiError(
@@ -83,30 +119,16 @@ async function onFileSelected(e: Event) {
try { try {
await storeHwpFile(file); await storeHwpFile(file);
const stored = loadHwpFile(); // Token bump remounts via App viewKey; also reload in place if not remounted.
if (!stored) throw new Error('파일을 읽을 수 없습니다.'); loadedToken = '';
await editor.loadFile(stored.buffer, stored.name); await bootstrapFromStore();
filename.value = stored.name;
error.value = null;
} catch (err) { } catch (err) {
error.value = err; error.value = err;
} }
} }
async function onSave() { async function onDownload() {
if (!editor) return; guideOpen.value = true;
try {
const ext = extensionOf(filename.value);
const bytes =
ext === 'hwpx' ? await editor.exportHwpx() : await editor.exportHwp();
const name =
ext === 'hwpx' || ext === 'hwp'
? filename.value
: `${filename.value}.hwp`;
downloadBlob(bytes, name);
} catch (err) {
error.value = err;
}
} }
function onClose() { function onClose() {
@@ -122,7 +144,7 @@ function onClose() {
v-if="!error" v-if="!error"
:filename="filename" :filename="filename"
@open="onOpen" @open="onOpen"
@save="onSave" @save="onDownload"
@close="onClose" @close="onClose"
/> />
<input <input
@@ -135,6 +157,14 @@ function onClose() {
<EditorErrorState v-if="error" :error="error" context="rhwp" /> <EditorErrorState v-if="error" :error="error" context="rhwp" />
<div v-show="!error" id="rhwp-host" class="editor-frame" /> <div v-show="!error" id="rhwp-host" class="editor-frame" />
<DownloadGuideModal
:open="guideOpen"
lead="수정한 한글 문서는 rhwp 편집기 메뉴에서 받을 수 있습니다."
:steps="downloadGuideSteps"
path-hint="파일 → 저장(다른 이름으로 저장)"
@close="guideOpen = false"
/>
</div> </div>
</AppShell> </AppShell>
</template> </template>
@@ -0,0 +1,654 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import AppShell from '@/components/AppShell.vue';
import EditorToolbar from '@/components/EditorToolbar.vue';
import { useFileDropZone } from '@/composables/useFileDropZone';
import { usePageTitle } from '@/composables/usePageTitle';
import {
DEFAULT_BG_REMOVE_OPTIONS,
removeBackgroundBySample,
type BgRemoveMode,
type Rgb,
} from '@/integrations/bgremove/removeBg';
import { decodeIcoToPng, isIcoFile } from '@/integrations/imageedit/ico';
import { downloadBlob } from '@/utils/downloadBlob';
defineOptions({ name: 'ToolsBgRemoveView' });
const router = useRouter();
const fileInput = ref<HTMLInputElement | null>(null);
const filename = ref('배경 제거');
const sourceUrl = ref<string | null>(null);
const resultUrl = ref<string | null>(null);
const background = ref<Rgb | null>(null);
const busy = ref(false);
const error = ref('');
const notice = ref('');
const tolerance = ref(DEFAULT_BG_REMOVE_OPTIONS.tolerance);
const feather = ref(DEFAULT_BG_REMOVE_OPTIONS.feather);
const mode = ref<BgRemoveMode>(DEFAULT_BG_REMOVE_OPTIONS.mode);
const imageUrlInput = ref('');
usePageTitle(filename, { faviconKind: 'bgremove' });
const hasImage = computed(() => Boolean(sourceUrl.value));
const bgCss = computed(() => {
if (!background.value) return '';
const { r, g, b } = background.value;
return `rgb(${r}, ${g}, ${b})`;
});
function revoke(url: string | null) {
if (url?.startsWith('blob:')) URL.revokeObjectURL(url);
}
function baseName(name = filename.value) {
return name.replace(/\.[^.]+$/, '') || 'logo';
}
function onClose() {
void router.push({ name: 'home' });
}
function onOpen() {
fileInput.value?.click();
}
function isImageFile(file: File) {
if (isIcoFile(file)) return true;
if (file.type.startsWith('image/')) return true;
return /\.(png|jpe?g|webp|gif|bmp|svg)$/i.test(file.name);
}
async function loadFile(file: File) {
if (!isImageFile(file)) {
error.value = 'PNG, JPEG, WebP, GIF, BMP, SVG, ICO 이미지를 선택해 주세요.';
return;
}
error.value = '';
notice.value = '';
revoke(sourceUrl.value);
revoke(resultUrl.value);
resultUrl.value = null;
background.value = null;
filename.value = file.name;
if (isIcoFile(file)) {
try {
const decoded = await decodeIcoToPng(await file.arrayBuffer());
sourceUrl.value = URL.createObjectURL(decoded.blob);
} catch (reason) {
error.value =
reason instanceof Error ? reason.message : 'ICO를 열지 못했습니다.';
return;
}
} else {
sourceUrl.value = URL.createObjectURL(file);
}
await processImage();
}
async function loadFromUrl(raw = imageUrlInput.value) {
const trimmed = raw.trim();
if (!trimmed) {
error.value = '이미지 URL을 입력해 주세요.';
return;
}
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
error.value = '올바른 이미지 URL을 입력해 주세요.';
return;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
error.value = 'http(s) URL만 사용할 수 있습니다.';
return;
}
busy.value = true;
error.value = '';
notice.value = '';
try {
const response = await fetch(parsed.href, { mode: 'cors' });
if (!response.ok) {
throw new Error(`이미지를 가져오지 못했습니다 (${response.status}).`);
}
const blob = await response.blob();
const looksImage =
blob.type.startsWith('image/') ||
/\.(png|jpe?g|webp|gif|bmp|svg|ico)(\?|$)/i.test(parsed.pathname);
if (!looksImage) {
throw new Error('응답이 이미지가 아닙니다.');
}
const leaf = decodeURIComponent(
parsed.pathname.split('/').filter(Boolean).pop() || 'image',
);
const name = /\.[a-z0-9]+$/i.test(leaf) ? leaf : `${leaf}.png`;
const file = new File([blob], name, {
type: blob.type || 'image/png',
});
imageUrlInput.value = parsed.href;
await loadFile(file);
} catch (reason) {
const message =
reason instanceof Error ? reason.message : 'URL에서 이미지를 불러오지 못했습니다.';
error.value = /Failed to fetch|NetworkError|CORS/i.test(message)
? '이미지를 가져오지 못했습니다. CORS를 허용하는 URL인지 확인해 주세요.'
: message;
} finally {
busy.value = false;
}
}
function onFilesSelected(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (file) void loadFile(file);
}
const {
dropActive,
onDragEnter,
onDragOver,
onDragLeave,
onDrop: onZoneDrop,
} = useFileDropZone((files) => {
const file = files[0];
if (file) void loadFile(file);
});
async function processImage() {
if (!sourceUrl.value) return;
busy.value = true;
error.value = '';
try {
const result = await removeBackgroundBySample(sourceUrl.value, {
tolerance: tolerance.value,
feather: feather.value,
mode: mode.value,
});
revoke(resultUrl.value);
resultUrl.value = result.url;
background.value = result.background;
notice.value = '배경을 제거했습니다.';
} catch (reason) {
error.value =
reason instanceof Error ? reason.message : '배경 제거에 실패했습니다.';
} finally {
busy.value = false;
}
}
function exportPng() {
if (!resultUrl.value) return;
void fetch(resultUrl.value)
.then((response) => response.blob())
.then((blob) => downloadBlob(blob, `${baseName()}-transparent.png`));
}
watch(mode, () => {
if (sourceUrl.value) void processImage();
});
onBeforeUnmount(() => {
revoke(sourceUrl.value);
revoke(resultUrl.value);
});
</script>
<template>
<AppShell :editor="true">
<div
class="bg-page"
:class="{ 'is-drop-active': dropActive }"
@dragenter="onDragEnter"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onZoneDrop"
>
<EditorToolbar
:filename="filename"
:show-save="Boolean(resultUrl)"
:busy="busy"
@open="onOpen"
@save="exportPng"
@close="onClose"
/>
<input
ref="fileInput"
class="sr-only"
type="file"
accept="image/png,image/jpeg,image/webp,image/gif,image/bmp,image/svg+xml,image/x-icon,image/vnd.microsoft.icon,.png,.jpg,.jpeg,.webp,.gif,.bmp,.svg,.ico"
@change="onFilesSelected"
/>
<p v-if="error" class="viewer-message viewer-message--error" role="alert">
{{ error }}
</p>
<p v-else-if="notice" class="viewer-message">{{ notice }}</p>
<main class="workspace">
<div
v-if="!hasImage"
class="empty"
:class="{ 'is-drop-active': dropActive }"
>
<strong>배경 제거</strong>
<p>
단색 배경 로고 이미지를 열거나 끌어다 놓으면 모서리 색을 기준으로
배경을 투명하게 만듭니다. 흰 배경·검은 배경 모두 처리됩니다.
</p>
<button
type="button"
class="btn-primary"
:disabled="busy"
@click="onOpen"
>
이미지 열기
</button>
<form class="url-form" @submit.prevent="void loadFromUrl()">
<input
v-model="imageUrlInput"
type="url"
inputmode="url"
placeholder="https://example.com/logo.png"
:disabled="busy"
aria-label="이미지 URL"
/>
<button type="submit" :disabled="busy">URL 불러오기</button>
</form>
</div>
<template v-else>
<div class="controls">
<form class="url-form url-form--inline" @submit.prevent="void loadFromUrl()">
<input
v-model="imageUrlInput"
type="url"
inputmode="url"
placeholder="이미지 URL"
:disabled="busy"
aria-label="이미지 URL"
/>
<button type="submit" :disabled="busy">URL</button>
</form>
<label class="field field--mode">
<span>모드</span>
<select v-model="mode" :disabled="busy">
<option value="threshold">전체 유사색 (글자 구멍 포함)</option>
<option value="flood">바깥만 (모서리 연결)</option>
</select>
</label>
<label class="field">
<span class="field__row">
허용치
<em>{{ tolerance }}</em>
</span>
<input
type="range"
min="0"
max="100"
step="1"
:value="tolerance"
:disabled="busy"
@input="
tolerance = Number(($event.target as HTMLInputElement).value)
"
@change="void processImage()"
/>
</label>
<label class="field">
<span class="field__row">
가장자리
<em>{{ feather }}</em>
</span>
<input
type="range"
min="0"
max="100"
step="1"
:value="feather"
:disabled="busy"
@input="
feather = Number(($event.target as HTMLInputElement).value)
"
@change="void processImage()"
/>
</label>
<div v-if="background" class="swatch" title="추정 배경색">
<span class="swatch__chip" :style="{ background: bgCss }" />
<span>배경 {{ bgCss }}</span>
</div>
<button
type="button"
class="btn-primary"
:disabled="busy || !resultUrl"
@click="exportPng"
>
PNG 저장
</button>
</div>
<div class="panes">
<section class="pane">
<h2>원본</h2>
<div class="frame">
<img :src="sourceUrl!" alt="원본" />
</div>
</section>
<section class="pane">
<h2>결과</h2>
<div class="frame frame--checker">
<img v-if="resultUrl" :src="resultUrl" alt="배경 제거 결과" />
<p v-else class="muted">처리 중…</p>
</div>
</section>
</div>
</template>
<div
v-if="dropActive && hasImage"
class="drop-overlay"
aria-hidden="true"
>
이미지를 놓아 바꾸기
</div>
</main>
</div>
</AppShell>
</template>
<style scoped>
.bg-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
background: var(--canvas);
}
.workspace {
position: relative;
flex: 1;
min-height: 0;
overflow: auto;
padding: 0.85rem;
}
.empty {
height: 100%;
min-height: min(60vh, 28rem);
display: grid;
place-content: center;
justify-items: center;
gap: 0.75rem;
text-align: center;
color: var(--ink);
border: 1px dashed transparent;
border-radius: 6px;
padding: 1.25rem;
}
.empty.is-drop-active {
border-color: var(--accent);
background: var(--accent-soft);
}
.empty p {
margin: 0;
max-width: 28rem;
color: var(--muted);
font-size: 0.92rem;
line-height: 1.45;
}
.btn-primary {
min-height: 2.5rem;
border: 1px solid var(--accent);
border-radius: 4px;
padding: 0.4rem 0.9rem;
background: var(--accent-soft);
color: var(--ink);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
.url-form {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
width: min(100%, 28rem);
margin-top: 0.25rem;
}
.url-form input {
flex: 1 1 12rem;
min-height: 2.5rem;
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.35rem 0.55rem;
background: var(--canvas);
color: var(--ink);
font: inherit;
}
.url-form button {
min-height: 2.5rem;
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.35rem 0.75rem;
background: var(--surface);
color: var(--ink);
font: inherit;
cursor: pointer;
}
.url-form button:disabled {
opacity: 0.5;
cursor: default;
}
.url-form--inline {
width: auto;
flex: 1 1 16rem;
margin-top: 0;
align-items: end;
}
.url-form--inline input {
min-height: 2.25rem;
}
.url-form--inline button {
min-height: 2.25rem;
}
.controls {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 0.75rem 1rem;
margin-bottom: 0.85rem;
padding: 0.65rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
}
.field {
display: grid;
gap: 0.3rem;
min-width: 10rem;
flex: 1 1 12rem;
font-size: 0.86rem;
}
.field--mode {
flex: 0 1 16rem;
}
.field__row {
display: flex;
justify-content: space-between;
gap: 0.5rem;
}
.field__row em {
font-style: normal;
font-variant-numeric: tabular-nums;
color: var(--muted);
}
.field select,
.field input[type='range'] {
width: 100%;
accent-color: var(--accent);
}
.field select {
min-height: 2.25rem;
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.3rem 0.45rem;
background: var(--canvas);
color: var(--ink);
font: inherit;
}
.swatch {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 2.25rem;
font-size: 0.8rem;
color: var(--muted);
}
.swatch__chip {
width: 1.1rem;
height: 1.1rem;
border-radius: 3px;
border: 1px solid var(--border);
}
.panes {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
align-items: start;
}
.pane {
min-width: 0;
overflow: auto;
}
.pane h2 {
margin: 0 0 0.45rem;
font-size: 0.9rem;
}
.frame {
display: inline-block;
width: fit-content;
max-width: 100%;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
overflow: auto;
vertical-align: top;
line-height: 0;
}
.frame--checker {
background-color: #c8c8c8;
background-image:
linear-gradient(45deg, #efefef 25%, transparent 25%),
linear-gradient(-45deg, #efefef 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #efefef 75%),
linear-gradient(-45deg, transparent 75%, #efefef 75%);
background-size: 20px 20px;
background-position:
0 0,
0 10px,
10px -10px,
-10px 0;
}
.frame img {
display: block;
width: auto;
height: auto;
max-width: none;
max-height: none;
}
.muted {
margin: 0;
color: var(--muted);
}
.drop-overlay {
position: absolute;
inset: 0.75rem;
z-index: 20;
display: grid;
place-items: center;
border: 2px dashed var(--accent);
border-radius: 8px;
background: color-mix(in srgb, var(--accent-soft) 88%, transparent);
color: var(--ink);
font-weight: 650;
pointer-events: none;
}
.viewer-message {
margin: 0;
padding: 0.45rem 0.85rem;
border-bottom: 1px solid var(--border);
background: var(--surface);
color: var(--ink);
font-size: 0.86rem;
}
.viewer-message--error {
color: #b42318;
background: color-mix(in srgb, #b42318 10%, var(--surface));
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 900px) {
.panes {
grid-template-columns: 1fr;
}
.controls {
align-items: stretch;
}
.controls .btn-primary {
width: 100%;
}
}
</style>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff