อัปโหลดไฟล์ ​

เชื่อมการเลือกไฟล์ การลากแล้ววาง และการวางที่มีแต่ไฟล์เข้ากับขั้นตอนการอัปโหลด เดโมในหน้านี้จะไม่ส่งไฟล์ไปยังเซิร์ฟเวอร์ สำหรับบริการจริง คุณต้องเชื่อมตัวอัปโหลดที่รับไฟล์และส่ง URL กลับมาเอง

หากต้องการแทรกผลการอัปโหลดเป็นบล็อกรูปภาพ ต้องมี image wing หากต้องการแทรกไฟล์อื่นเป็นลิงก์ไฟล์แนบ ต้องมี link wing หากบริการของคุณรับทั้งสองรูปแบบ ให้เลือกทั้งสอง wing อย่างชัดเจน ระหว่างอัปโหลด ตัวแก้ไขจะถูกล็อก และไฟล์ที่สำเร็จจะถูกแทรกรวมกันเป็นขั้นตอนเลิกทำเพียงครั้งเดียว

ปีก
แบบอักษรหลักระยะเยื้อง
HTMLกำลังโหลดโปรแกรมแก้ไข…
JSONnabi-treeกำลังโหลดโปรแกรมแก้ไข…
ติดตั้ง
npm install nabi-note
โค้ด
import {
  createLocale, 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 locale = createLocale('th')
const { nabi, registry } = createNabiWith(selected, { locale })

const root = document.querySelector('.nabi')!
const content = document.querySelector('.nabi-content')!

const view = mountUploadView({ nabi, surface: content, locale })
const upload = mountUpload({
  nabi, root: content, locale,
  // 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, fileSink: upload.take })

const settle = watchSettle(document, { surface: content })
const shared = { nabi, registry, surface: content, settle, locale }
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()))

// Call from your language selector. Content and editing history stay intact.
// locale.setLocale('ko')
ts
const selected = wings()
  .use('img')
  .use('a')
  .use('upload', { allowLocalUrls: false })
  .build()

หากเลือกเฉพาะ upload ระบบจะเติม dependency ที่ยังขาดอยู่หนึ่งรายการระหว่างรูปภาพกับลิงก์ให้อัตโนมัติ เชื่อมการส่งด้วย mountUpload() และโดยทั่วไปจะเชื่อม UI แสดงความคืบหน้าบนหน้าจอแก้ไขด้วย mountUploadView() หากเซิร์ฟเวอร์ส่ง HTTPS URL กลับมา ก็ไม่จำเป็นต้องใช้ตัวเลือกอนุญาต URL ภายในเครื่อง

ตำแหน่งแทรกไฟล์ที่อัปโหลด ​

ระบบจดจำตำแหน่งที่เปิดหน้าต่างเลือกไฟล์หรือเริ่มอัปโหลด หากย้ายเพียงเคอร์เซอร์ก็ยังแทรกที่ตำแหน่งเดิม หากเริ่มในเซลล์ตาราง ทั้งสถานะความคืบหน้าและเนื้อหาที่เสร็จแล้วจะอยู่ในเซลล์นั้น ผลการอัปโหลดที่เริ่มในพื้นที่ลอยก็เข้าไปในพื้นที่นั้นเช่นกัน ไม่สามารถแทรกการอัปโหลดลงในส่วนที่เลือกซึ่งข้ามขอบเขตหลายเซลล์ได้

การแก้ไขหรือแทนที่เอกสารระหว่างเลือกไฟล์หรือประมวลผลแบบอะซิงโครนัสจะยกเลิกการแทรกและแจ้งให้ทราบ แม้เลิกทำจนเนื้อหากลับมาเหมือนเดิมก็จะไม่แทรกที่ตำแหน่งเก่า อย่าตัดสินว่าเสร็จจากการส่งไฟล์สำเร็จเพียงอย่างเดียว ให้ตรวจ committed และ cancelled ที่ onDone ได้รับ โดย committed คือจำนวนไฟล์ที่แทรกลงในเอกสารจริง

ข้อตกลง API ของเซิร์ฟเวอร์ ​

NABI NOTE จะไม่ส่งไฟล์ไปยังเซิร์ฟเวอร์ของคุณเอง ฟังก์ชัน uploader จะส่งไฟล์หนึ่งไฟล์ไปยังเซิร์ฟเวอร์ และเมื่อสำเร็จจะส่งกลับเฉพาะ URL แบบ https: ที่เข้าถึงสาธารณะหรือผ่านการยืนยันตัวตน ข้อตกลง API ที่ง่ายที่สุดมีดังนี้

text
POST /api/uploads
Content-Type: multipart/form-data
ชื่อฟิลด์: file

สำเร็จ: { "url": "https://cdn.example.com/uploads/8f2c.webp" }
ล้มเหลว: การตอบกลับ 4xx หรือ 5xx

เซิร์ฟเวอร์ต้องไม่เชื่อเพียงชื่อไฟล์เดิม นามสกุล หรือค่า MIME ที่เบราว์เซอร์ส่งมา ให้ตรวจสอบการยืนยันตัวตนและสิทธิ์ก่อน จำกัดขนาดไฟล์ระหว่างการสตรีม และตรวจชนิดไฟล์จริง ให้เซิร์ฟเวอร์สร้างชื่อที่เก็บใหม่เอง สำหรับรูปภาพ ให้เข้ารหัสใหม่หรือสร้างภาพขนาดย่อเมื่อจำเป็น หากไฟล์ที่อัปโหลดไม่ควรให้ทุกคนดาวน์โหลดได้ ให้ส่งพาธดาวน์โหลดที่ต้องยืนยันตัวตนกลับมาแทน URL สาธารณะ

สิ่งที่ต้องตรวจบนเซิร์ฟเวอร์เหตุผล
ผู้ใช้ที่เข้าสู่ระบบและสิทธิ์อัปโหลดป้องกันการใช้พื้นที่เก็บของผู้ใช้อื่น
ขนาดต่อไฟล์และขนาดรวมของคำขอป้องกันหน่วยความจำและพื้นที่เก็บข้อมูลหมด
ชนิด MIME จริงและนามสกุลที่อนุญาตปิดกั้นไฟล์ปฏิบัติการที่เปลี่ยนนามสกุล
ชื่อที่เก็บแบบสุ่มและพื้นที่เก็บแยกกันป้องกันการแก้ไขพาธและการเขียนทับไฟล์เดิม
สิทธิ์เข้าถึงและนโยบายหมดอายุของ URL ตอบกลับป้องกันไฟล์ส่วนตัวรั่วไหลจาก URL เพียงอย่างเดียว

extensions และ maxFileSize ฝั่งไคลเอนต์เป็นเพียงขั้นแรกเพื่อแจ้งผู้ใช้อย่างรวดเร็วเท่านั้น ต้องกำหนดข้อจำกัดเดียวกันไว้บนเซิร์ฟเวอร์ด้วย

การเชื่อมตัวอัปโหลดในเบราว์เซอร์ ​

ตัวอย่างต่อไปนี้คือการเชื่อมต่อจริงที่ NABI NOTE ต้องการ ใช้ XMLHttpRequest เพราะ fetch() มาตรฐานของเบราว์เซอร์ไม่มีความคืบหน้าการอัปโหลด ส่งกลับเพียง url จากการตอบกลับของเซิร์ฟเวอร์ รูปภาพจะกลายเป็นบล็อกรูปภาพ และไฟล์อื่นจะกลายเป็นลิงก์ไฟล์แนบ

ts
import {
  createNabiWith,
  mountSurface,
  mountToolbar,
  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: 'th' },
)

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: 'th',
  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: 'th' })

const surface = mountSurface({
  nabi,
  registry,
  root: content,
  fileSink: upload.take,
  locale: 'th',
})

const toolbar = mountToolbar({
  nabi,
  registry,
  root: document.querySelector<HTMLElement>('#toolbar')!,
  surface: content,
  onFiles: upload.take,
  locale: 'th',
})

เชื่อม fileSink: upload.take เพื่อให้การลากแล้ววางและการวางเฉพาะไฟล์เข้าไปในกระบวนการอัปโหลด เชื่อมปุ่มเลือกไฟล์ผ่าน onFiles: upload.take ของแถบเครื่องมือ ระหว่างอัปโหลดตัวแก้ไขจะถูกล็อก และไฟล์ที่สำเร็จในแต่ละชุดจะแทรกเป็นขั้นตอนเลิกทำหนึ่งครั้ง upload.cancel() หรือปุ่มยกเลิกของ uploadView จะยุติคำขอที่กำลังดำเนินการผ่าน AbortSignal

ความล้มเหลวและการยกเลิกการแสดงผล ​

หากเซิร์ฟเวอร์ตอบข้อผิดพลาด หรือ uploader ส่งกลับ null จะไม่ใส่ไฟล์นั้นลงในเอกสาร ไฟล์อื่นในชุดเดียวกันจะยังคงประมวลผลต่อไป หากเกินขีดจำกัดขนาดรวม ทั้งชุดจะไม่เริ่มทำงาน เมื่อปิดหน้าจอ ให้ยกเลิกการเชื่อมต่อตามลำดับย้อนกลับจากตอนสร้าง

ts
function dispose() {
  toolbar.unmount()
  surface.unmount()
  uploadView.unmount()
  upload.unmount()
}

เฉพาะระหว่างพัฒนา คุณสามารถใช้ URL blob: เพื่อสร้างตัวอย่างทันท่วงทีได้ ในกรณีนี้ ต้องเปิด allowLocalUrls: true ในการประกอบตัวแก้ไข image wing และ upload wing ตามลำดับ หากการอัปโหลดไปยังเซิร์ฟเวอร์จริงส่ง HTTPS URL กลับมา จะปลอดภัยกว่าหากไม่เปิดตัวเลือกนี้

สไตล์ CSS ​

ไฟล์ทั่วไปที่เสร็จแล้วจะแสดงด้วย a[data-nabi-file] ของ link wing หากต้องการเปลี่ยนเฉพาะลักษณะไฟล์แนบบนหน้าที่เผยแพร่ ให้ใช้ตัวเลือกนี้

css
.article-body a[data-nabi-file] {
  padding: .3em .6em;
  border: 1px solid var(--nabi-line);
  border-radius: 8px;
  background: var(--nabi-soft);
}

ผลการอัปโหลดรูปภาพจะใช้ CSS ของ image wing ตัวแสดงความคืบหน้าการอัปโหลดมีเฉพาะบนหน้าจอแก้ไข จึงไม่จำเป็นต้องสร้างสถานะความคืบหน้าด้วย CSS ของหน้าที่เผยแพร่