ファイルアップロード
ファイル選択、ドラッグ & ドロップ、ファイルだけを含む貼り付けをアップロードフローにつなぎます。このページのデモはサーバーへ送信しません。実際のサービスでは、ファイルを受け取り URL を返すアップローダーを接続する必要があります。
アップロード結果を画像ブロックとして入れるには画像 wing が必要です。その他のファイルを添付リンクとして入れるにはリンク wing が必要です。両方の形式を受け付けるサービスでは、2 つの wing を明示的に一緒に選びます。アップロード中はエディターがロックされ、成功したファイルは 1 つの undo 手順としてまとめて挿入されます。
npm install nabi-noteimport {
createNabiWith, mountSurface, mountToolbar, mountContextToolbar,
mountHints, watchSettle, boldWing, italicWing, underlineWing,
strikeWing, linkWing, imageWing, uploadWing, clearFormatWing,
mountUpload, mountUploadView, mountViewTools,
} from 'nabi-note'
const selected = [
boldWing,
italicWing,
underlineWing,
strikeWing,
linkWing,
imageWing,
uploadWing,
clearFormatWing,
]
const { nabi, registry } = createNabiWith(selected)
const root = document.querySelector('.nabi')!
const content = document.querySelector('.nabi-content')!
const view = mountUploadView({ nabi, surface: content })
const upload = mountUpload({
nabi, root: content,
// your upload goes here — report progress with task.onProgress(0–100)
uploader: async (task) => ({ uri: 'https://cdn.example/uploaded' }),
extensions: ['png', 'jpg', 'pdf'], maxFileSize: 10 * 1024 * 1024,
onStart: (tasks) => view.start(tasks),
onProgress: (id, percent) => view.progress(id, percent),
onSettle: () => view.settle(),
onDone: () => view.done(),
})
// The locale also sets the direction — Arabic and Urdu run right to left
mountSurface({ nabi, registry, root: content, locale: 'ja', fileSink: upload.take })
const settle = watchSettle(document, { surface: content })
const shared = { nabi, registry, surface: content, settle, locale: 'ja' }
const toolbar = mountToolbar({ ...shared, root: document.querySelector('#toolbar')!, onFiles: upload.take })
const context = mountContextToolbar({ ...shared, root: document.querySelector('#context')! })
mountHints({ toolbar, context, root, surface: content })
// The preview and fullscreen buttons — they stand their own box at the end of the row
mountViewTools({ ...shared, root, container: document.querySelector('#toolbar')! })
// on every change — hook up your own code here
// nabi.onChange(() => user_callback(nabi.getHtml()))const selected = wings()
.use('img')
.use('a')
.use('upload', { allowLocalUrls: false })
.build()upload だけを選ぶと、画像またはリンクのうち不足している依存関係を自動で補います。転送は mountUpload() で接続し、編集画面の進行状態表示は通常 mountUploadView() で接続します。サーバーが HTTPS URL を返すなら、ローカル URL の許可オプションは必要ありません。
サーバー API の約束
NABI NOTE はファイルをサーバーへ自動送信しません。uploader 関数がファイル 1 つをサーバーへ送り、成功したら公開 URL または認証が必要な https: URL だけを返す構造です。もっとも単純な API の約束は次のようになります。
POST /api/uploads
Content-Type: multipart/form-data
Field name: file
Success: { "url": "https://cdn.example.com/uploads/8f2c.webp" }
Failure: 4xx or 5xx responseサーバーは元のファイル名、拡張子、ブラウザーが送った MIME だけを信用してはいけません。まず認証と権限を確認し、ストリーミング段階でファイルサイズを制限し、実際のファイル形式を検査します。保存名はサーバー側で新しく作ります。画像であれば必要に応じて再エンコードしたりサムネイルを作ったりします。アップロードしたファイルを誰でもダウンロードできないサービスなら、公開 URL ではなく認証が必要なダウンロードパスを返してください。
| サーバーで確認すること | 理由 |
|---|---|
| ログイン中のユーザーとアップロード権限 | 他のユーザーの保存領域を使わせないため |
| ファイル 1 つあたりのサイズとリクエスト全体のサイズ | メモリや保存領域の枯渇を防ぐため |
| 許可した実際の MIME と拡張子 | 名前だけを変えた実行ファイルを防ぐため |
| ランダムな保存名と分離された保存先 | パス操作と既存ファイルの上書きを防ぐため |
| 応答 URL のアクセス権限と期限ポリシー | 非公開ファイルが URL だけで露出するのを防ぐため |
クライアント側の extensions と maxFileSize は、ユーザーへ素早く知らせるための最初の段階にすぎません。同じ制限をサーバーにも必ず置いてください。
ブラウザーでアップローダーを接続する
次の例は、NABI NOTE が想定している実際の接続です。XMLHttpRequest を使う理由は、ブラウザー標準の fetch() がアップロード進行率を提供しないためです。サーバーが返した url だけを返せば、画像は画像ブロックに、その他のファイルは添付リンクになります。
import {
createNabiWith,
mountSurface,
mountUpload,
mountUploadView,
wings,
type UploadTask,
} from 'nabi-note'
const content = document.querySelector<HTMLElement>('#content')!
const { nabi, registry } = createNabiWith(
wings().use('img').use('a').use('upload').build(),
{ locale: 'ja' },
)
function sendUpload(task: UploadTask): Promise<{ uri: string } | null> {
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest()
request.open('POST', '/api/uploads')
request.responseType = 'json'
request.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) task.onProgress((event.loaded / event.total) * 100)
})
request.addEventListener('load', () => {
const url = request.response?.url
if (request.status >= 200 && request.status < 300 && typeof url === 'string') {
resolve({ uri: url })
} else {
resolve(null)
}
})
request.addEventListener('error', () => reject(new Error('アップロードリクエストに失敗しました。')))
task.signal.addEventListener('abort', () => request.abort(), { once: true })
const body = new FormData()
body.append('file', task.file as File, task.name)
request.send(body)
})
}
let uploadView: ReturnType<typeof mountUploadView>
const upload = mountUpload({
nabi,
root: content,
uploader: sendUpload,
extensions: ['png', 'jpg', 'jpeg', 'webp', 'pdf'],
maxFileSize: 10 * 1024 * 1024,
maxTotalSize: 20 * 1024 * 1024,
locale: 'ja',
onStart: (tasks) => uploadView.start(tasks),
onProgress: (id, percent) => uploadView.progress(id, percent),
onSettle: () => uploadView.settle(),
onDone: () => uploadView.done(),
})
uploadView = mountUploadView({ nabi, surface: content, upload, locale: 'ja' })
const surface = mountSurface({
nabi,
registry,
root: content,
fileSink: upload.take,
locale: 'ja',
})fileSink: upload.take を接続すると、ドラッグ & ドロップと、ファイルだけを含む貼り付けがアップロードに入ります。ファイル選択ボタンは upload wing の UI が upload.take() へ渡します。アップロード中はエディターがロックされ、成功したファイルは 1 バッチにつき 1 回の undo として挿入されます。upload.cancel() または uploadView のキャンセルボタンは、AbortSignal を通じて進行中のリクエストを中断します。
失敗と画面の解放
サーバーがエラー応答を返したり uploader が null を返したりした場合、そのファイルは文書に入りません。同じバッチの他のファイルは処理を続けます。合計サイズ制限を超えると、バッチ全体が開始されません。画面を閉じるときは、作成した順序の逆に解放します。
function dispose() {
surface.unmount()
uploadView.unmount()
upload.unmount()
}開発中だけ、blob: URL を使って即時プレビューを作れます。この場合は、エディターの組み立て、画像 wing、アップロード wing それぞれで allowLocalUrls: true を有効にする必要があります。実際のサーバーアップロードが HTTPS URL を返すなら、このオプションは有効にしない方が安全です。
CSS スタイル
完了した通常ファイルは、リンク wing によって a[data-nabi-file] として表示されます。公開画面で添付の見た目だけを変えるには、このセレクターを使います。
.article-body a[data-nabi-file] {
padding: .3em .6em;
border: 1px solid var(--nabi-line);
border-radius: 8px;
background: var(--nabi-soft);
}画像アップロードの結果は画像 wing の CSS に従います。アップロード進行表示は編集画面にだけ出るため、公開画面 CSS で進行状態を作る必要はありません。