Compare commits
24
Commits
453fca6152
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
381ea8f221 | ||
|
|
2cb0c75092 | ||
|
|
c4ae7495dd | ||
|
|
545a2802e2 | ||
|
|
eadbfb0f86 | ||
|
|
26c98fe5e8 | ||
|
|
194eb318d1 | ||
|
|
4bffd65b32 | ||
|
|
34d5aa547f | ||
|
|
f4f98704be | ||
|
|
b0a6fc326c | ||
|
|
46acb08b93 | ||
|
|
db1341e40f | ||
|
|
73e4e73980 | ||
|
|
816bf636de | ||
|
|
5211b0d7ef | ||
|
|
2967548ff3 | ||
|
|
f5d880caf5 | ||
|
|
80eae206b6 | ||
|
|
309193a3a7 | ||
|
|
18a19acf55 | ||
|
|
00dfb36126 | ||
|
|
b9dfb468e7 | ||
|
|
2beabc610b |
No files matched your search
Vendored
+103
@@ -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()
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,132 @@ 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:
|
||||||
|
```
|
||||||
|
|
||||||
|
Document Server를 앱과 같은 도메인의 하위 경로(위 예시의 `/ds`)로 두면 편집기 iframe이 같은 출처가 되어 `X-Frame-Options: SAMEORIGIN`에 막히지 않습니다. 리버스 프록시는 접두어를 떼고 Document Server로 넘기면서 `X-Forwarded-Host`에 경로까지 넣어야 합니다(예: Caddy `handle_path /ds/*` + `header_up X-Forwarded-Host {host}/ds`).
|
||||||
|
|
||||||
|
## 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
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
build: .
|
image: docker.sejong.dev/sejong-office:latest
|
||||||
ports:
|
ports:
|
||||||
- '3000:3000'
|
- '3000:3000'
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
Generated
+35
-5
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,73 @@ 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);
|
||||||
|
// Public URL may be a sub-path of the app (e.g. https://office.example.com/ds):
|
||||||
|
// swap the whole prefix, not just the host, so /ds doesn't leak inward.
|
||||||
|
const publicPath = publicBase.pathname.replace(/\/$/, '');
|
||||||
|
if (
|
||||||
|
publicPath &&
|
||||||
|
target.host === publicBase.host &&
|
||||||
|
target.pathname.startsWith(`${publicPath}/`)
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
config.onlyOfficeInternalUrl +
|
||||||
|
target.pathname.slice(publicPath.length) +
|
||||||
|
target.search
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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 +149,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 +162,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 +173,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);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -35,12 +35,20 @@ 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', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -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]),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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="# 제목 여기에 마크다운을 입력하세요."
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
@@ -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
Reference in new issue
Block a user