NABI NOTE
문서

파일 업로드

설명

upload() 은 파일을 호스트의 저장소로 올리고, 받은 주소를 문서 마크업으로 갈아 끼웁니다. 올리는 코드는 호스트만 아는 것이라 팩토리 함수 하나로 끝나지 않고 빌더로 값을 채워 build() 로 완성합니다.

  • 붙여넣기, 드래그 앤 드롭, 툴바 버튼이 모두 같은 길로 합쳐집니다.
  • 확장자와 용량을 먼저 봅니다. 걸리면 업로드를 시작하지 않고 그 언어의 문구로 알립니다.
  • 캐럿(caret) 자리에 자리표시자(placeholder)를 심고 진행률만큼 채웁니다. 이미지는 미리보기, 그 밖의 파일은 확장자 글자 상자입니다. 올라가는 동안 편집이 잠기고, 시한 안에 응답이 없으면 코어가 잠금을 풉니다. 실패하면 자리표시자를 걷어냅니다.
  • 자리표시자는 코어가 모르는 태그라 필터(filter)에서 벗겨지고, 올라가는 동안 commit() 을 부르지 않아 되돌리기 스냅샷에도 onChange 값에도 남지 않습니다.
  • 문서에 남는 것은 이미지라면 <img src alt>, 그 밖의 파일이라면 <a href data-nabi-file="txt">파일명</a> 입니다. image() 가 함께 켜져 있지 않으면 올린 이미지도 첨부 링크가 되고, link() 가 없으면 첨부가 평문이 됩니다.
  • 검증에서 전부 걸러졌어도 파일은 가져간 것으로 처리합니다. 그러지 않으면 파일이 글자로 붙여넣어집니다.
  • 확장자 배지와 아이콘 모양은 시트(stylesheet)의 몫이라 저장값에 굳지 않습니다.

아래 데모의 업로더는 시늉입니다

이 사이트에는 파일을 올릴 서버가 없습니다. 진행률만 돌리고 브라우저 안의 blob: 주소를 그대로 돌려주므로 allowLocalUrls(true) 가 켜져 있고, 올린 파일은 이 페이지를 벗어나면 사라집니다. 실제 앱에서는 .handler() 자리에 fetchXMLHttpRequest 를 물립니다.

설정

upload() 로 시작해 값을 채우고 .build() 로 끝냅니다. 전부 선택이지만 .handler() 만은 반드시 있어야 합니다 — 없으면 .build() 가 그 자리에서 예외를 던집니다.

ts
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)falseblob:·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 — 가장 흔한 모양

ts
.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 — 진행률까지 받으려면

ts
.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 같은 저장소로 바로 갑니다. 해시를 함께 보내면 같은 파일을 두 번 올리지 않는 길도 열립니다.

ts
.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

ts
.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 }
})

문구를 바꾸거나, 알림을 앱의 것으로 갈아 끼울 수 있습니다.

ts
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 // 내장 알림은 띄우지 않습니다
  })

해시

같은 파일을 알아보거나 서버에서 무결성을 확인할 때 씁니다. 셋 중 하나를 고릅니다.

ts
.hash('SHA-256')                                        // crypto.subtle 이 아는 이름
.hash(async (file) => ({ algorithm: 'blake3', value })) // 무엇으로 계산하든
.hash(false)                                            // 계산 안 함 (큰 파일)

기본값은 'SHA-1' 입니다. 보안 컨텍스트가 아니어서 crypto.subtle 이 없으면 SHA-1 은 자체 구현으로 계산합니다.


데모

wing
HTML에디터를 불러오는 중…
미리보기위의 값을 읽는 사람 화면에 그대로 그린 자리입니다. 편집 화면에서는 안 도는 기능이 여기서 동작합니다 — 표 제목을 눌러 정렬하고, 이미지를 눌러 크게 보세요.
설치
npm install nabi-note
코드
import { nabi } from 'nabi-note'
import 'nabi-note/style.css'

nabi.create('#editor', {
  wings: [],
  locale: 'ko',
  onChange: (html) => 저장(html),
})