파일 업로드
설명
upload() 은 파일을 호스트의 저장소로 올리고, 받은 주소를 문서 마크업으로 갈아 끼웁니다. 올리는 코드는 호스트만 아는 것이라 팩토리 함수 하나로 끝나지 않고 빌더로 값을 채워 build() 로 완성합니다.
- 붙여넣기, 드래그 앤 드롭, 툴바 버튼이 모두 같은 길로 합쳐집니다.
- 확장자와 용량을 먼저 봅니다. 걸리면 업로드를 시작하지 않고 그 언어의 문구로 알립니다.
- 캐럿(caret) 자리에 자리표시자(placeholder)를 심고 진행률만큼 채웁니다. 이미지는 미리보기, 그 밖의 파일은 확장자 글자 상자입니다. 올라가는 동안 편집이 잠기고, 시한 안에 응답이 없으면 코어가 잠금을 풉니다. 실패하면 자리표시자를 걷어냅니다.
- 자리표시자는 코어가 모르는 태그라 필터(filter)에서 벗겨지고, 올라가는 동안
commit()을 부르지 않아 되돌리기 스냅샷에도onChange값에도 남지 않습니다. - 문서에 남는 것은 이미지라면
<img src alt>, 그 밖의 파일이라면<a href data-nabi-file="txt">파일명</a>입니다.image()가 함께 켜져 있지 않으면 올린 이미지도 첨부 링크가 되고,link()가 없으면 첨부가 평문이 됩니다. - 검증에서 전부 걸러졌어도 파일은 가져간 것으로 처리합니다. 그러지 않으면 파일이 글자로 붙여넣어집니다.
- 확장자 배지와 아이콘 모양은 시트(stylesheet)의 몫이라 저장값에 굳지 않습니다.
아래 데모의 업로더는 시늉입니다
이 사이트에는 파일을 올릴 서버가 없습니다. 진행률만 돌리고 브라우저 안의 blob: 주소를 그대로 돌려주므로 allowLocalUrls(true) 가 켜져 있고, 올린 파일은 이 페이지를 벗어나면 사라집니다. 실제 앱에서는 .handler() 자리에 fetch 나 XMLHttpRequest 를 물립니다.
설정
upload() 로 시작해 값을 채우고 .build() 로 끝냅니다. 전부 선택이지만 .handler() 만은 반드시 있어야 합니다 — 없으면 .build() 가 그 자리에서 예외를 던집니다.
import { nabi, upload, defaultWings } from 'nabi-note'
const uploader = upload()
.extensions(['png', 'jpg', 'jpeg', 'gif', 'webp', 'pdf'])
.maxFileSize(10 * 1024 * 1024)
.maxTotalSize(20 * 1024 * 1024)
.concurrency(3)
.handler(async ({ file }) => ({ code: 'ok', uri: await 올리기(file) }))
.build()
// 첨부는 link 의 <a>, 이미지는 image 의 <img> 로 떨어집니다 — 둘 다 함께 켜 두세요.
nabi.create('#editor', { wings: [uploader, ...defaultWings()] })| 설정 | 기본값 | 하는 일 |
|---|---|---|
.extensions(list) | 전부 받음 | 받을 확장자입니다. 대소문자를 가리지 않습니다. 확장자가 없는 파일을 받으려면 빈 문자열 '' 을 넣습니다 |
.maxFileSize(bytes) | 10MB | 파일 하나의 최대 용량입니다. 0 이면 제한이 없습니다 |
.maxTotalSize(bytes) | 10MB | 한 번에 넣은 묶음의 합계 최대 용량입니다 |
.concurrency(count) | 1 | 동시에 올릴 개수입니다. 기본은 하나씩 순서대로입니다 |
.timeout(ms) | 60000 | 무응답으로 볼 시간입니다. 넘으면 잠금이 풀리고 되돌립니다 |
.hash(spec) | 'SHA-1' | 파일 해시를 어떻게 낼지 정합니다 (아래 참조) |
.allowLocalUrls(bool) | false | blob:·data: 주소도 값에 남깁니다. 서버에 올리는 구성에서는 켤 이유가 없습니다 |
.messages(dict) | 기본 문구 | 오류 문구를 코드 단위로 덮어씁니다 |
.onError(fn) | 없음 | 오류를 가로챕니다. true 를 돌려주면 내장 알림이 뜨지 않습니다 |
.handler(fn) | 필수 | 실제로 파일을 올리는 함수입니다 |
업로드 함수
.handler() 가 받는 인자는 객체 하나입니다. 나중에 필드가 늘어나도 이미 쓰고 있는 함수가 그대로 돕니다.
| 필드 | |
|---|---|
id · file · name · extension · size · type | 파일 자체입니다. extension 은 소문자이고, 없으면 빈 문자열입니다 |
width · height | 이미지 원본 크기입니다. 미리보기가 뜬 뒤부터 채워집니다(그 전에는 0) |
hash · hashAlgorithm | 계산한 값(소문자 hex)과 알고리즘 이름입니다. 계산하지 않았으면 둘 다 빈 문자열입니다 |
onProgress(percent) | 0~100 을 알립니다. 부를 때마다 무응답 시계가 되감깁니다 |
signal | 에디터가 destroy() 되면 abort 됩니다 |
돌려줄 것은 { code: 'ok', uri } 하나뿐입니다. 그 밖의 코드는 전부 실패로 보고 자리표시자를 걷어냅니다.
fetch — 가장 흔한 모양
.handler(async ({ file, signal }) => {
const body = new FormData()
body.append('file', file)
const response = await fetch('/api/files', { method: 'POST', body, signal })
if (!response.ok) return { code: 'upload_failed' }
const { url } = await response.json()
return { code: 'ok', uri: url }
})fetch 로는 진행률을 알 수 없습니다
fetch 가 알려 주는 것은 내려받는 진행률뿐이고, 올려보내는 쪽은 알 수 없습니다. 진행 막대가 채워지지 않아도 업로드 자체는 되지만, onProgress 를 부르지 않으면 무응답 시계가 되감기지 않아 큰 파일이 .timeout() 에 걸려 끊길 수 있습니다. 진행률이 필요하면 아래를 쓰세요.
XMLHttpRequest — 진행률까지 받으려면
.handler(({ file, onProgress, signal }) =>
new Promise((resolve) => {
const request = new XMLHttpRequest()
const body = new FormData()
body.append('file', file)
request.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) onProgress((event.loaded / event.total) * 100)
})
request.addEventListener('load', () => {
const ok = request.status >= 200 && request.status < 300
resolve(ok ? { code: 'ok', uri: JSON.parse(request.responseText).url } : { code: 'upload_failed' })
})
request.addEventListener('error', () => resolve({ code: 'upload_failed' }))
request.addEventListener('abort', () => resolve({ code: 'aborted' }))
// 에디터가 사라지면 올리던 것도 함께 끊습니다.
signal.addEventListener('abort', () => request.abort())
request.open('POST', '/api/files')
request.send(body)
}),
)서명된 주소로 저장소에 바로 올리기
파일이 서버를 거치지 않고 S3 같은 저장소로 바로 갑니다. 해시를 함께 보내면 같은 파일을 두 번 올리지 않는 길도 열립니다.
.handler(async ({ file, name, type, hash, signal }) => {
// ① 서명된 주소를 받습니다.
const ticket = await fetch('/api/upload-url', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name, type, hash }),
signal,
}).then((response) => response.json())
// ② 이미 있는 파일이면 그 주소를 그대로 씁니다.
if (ticket.duplicated) return { code: 'ok', uri: ticket.uri }
// ③ 저장소로 바로 올립니다.
const put = await fetch(ticket.upload, { method: 'PUT', body: file, signal })
if (!put.ok) return { code: 'upload_failed' }
return { code: 'ok', uri: ticket.uri }
})실패를 다루는 법
돌려준 코드가 아는 이름이면 그 문구로, 모르는 이름이면 "거부되었습니다 (코드)" 로 뜹니다. 아는 이름은 이것들입니다.
unsupported_type · file_too_large · total_too_large · empty_file · hash_failed · upload_failed · timeout · rejected
.handler(async ({ file }) => {
const response = await fetch('/api/files', { method: 'POST', body: file })
if (response.status === 413) return { code: 'file_too_large' }
if (response.status === 415) return { code: 'unsupported_type' }
// 모르는 코드는 "거부되었습니다 (http_500)" 처럼 코드를 그대로 실어 보여 줍니다.
if (!response.ok) return { code: `http_${response.status}` }
return { code: 'ok', uri: (await response.json()).url }
})문구를 바꾸거나, 알림을 앱의 것으로 갈아 끼울 수 있습니다.
upload()
.messages({
// {name} · {max} · {ext} · {code} 자리에 값이 들어갑니다.
file_too_large: {
ko: '{name} 은 너무 큽니다 (최대 {max})',
en: '{name} is too large (max {max})',
},
upload_failed: { ko: '{name} · 잠시 뒤 다시 시도해 주세요', en: '{name} · Please try again' },
})
.onError((error) => {
// error.code(정해진 이름) · error.raw(원래 코드) · error.message · error.params · error.file
내토스트(error.message)
return true // 내장 알림은 띄우지 않습니다
})해시
같은 파일을 알아보거나 서버에서 무결성을 확인할 때 씁니다. 셋 중 하나를 고릅니다.
.hash('SHA-256') // crypto.subtle 이 아는 이름
.hash(async (file) => ({ algorithm: 'blake3', value })) // 무엇으로 계산하든
.hash(false) // 계산 안 함 (큰 파일)기본값은 'SHA-1' 입니다. 보안 컨텍스트가 아니어서 crypto.subtle 이 없으면 SHA-1 은 자체 구현으로 계산합니다.
데모
npm install nabi-noteimport { nabi } from 'nabi-note'
import 'nabi-note/style.css'
nabi.create('#editor', {
wings: [],
locale: 'ko',
onChange: (html) => 저장(html),
})