Subida de archivos
Conecta la selección de archivos, arrastrar y soltar, y operaciones de pegado que contienen solo archivos a un flujo de subida. La demo de esta página no envía archivos a un servidor; en un servicio real debes conectar un uploader que reciba un archivo y devuelva una URL.
Para insertar resultados de subida como bloques de imagen, necesitas el wing de imagen. Para insertar otros archivos como enlaces de adjunto, necesitas el wing de enlace. Si tu servicio acepta ambos formatos, selecciona ambos wings explícitamente. Mientras la subida está en curso, el editor queda bloqueado, y los archivos correctos se insertan juntos como un solo paso de deshacer.
npm install nabi-noteimport {
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('es')
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')const selected = wings()
.use('img')
.use('a')
.use('upload', { allowLocalUrls: false })
.build()Si seleccionas solo upload, suministra automáticamente la dependencia que falte entre imagen y enlace. La transferencia se conecta con mountUpload(), y la UI de progreso de la pantalla de edición normalmente se conecta con mountUploadView(). Si el servidor devuelve URL HTTPS, la opción de URL local no hace falta.
Dónde se insertan las subidas
Se recuerda la posición donde se abre el selector de archivos o se inicia la subida. Si solo mueves el cursor, la inserción se mantiene en la posición original. Si la subida comienza en una celda, el indicador de progreso y el contenido final permanecen allí. Los resultados de subidas iniciadas en un área flotante también se insertan en ella. No se pueden insertar subidas en una selección que abarque varias celdas.
Editar o reemplazar el documento durante la selección de archivos o el procesamiento asíncrono cancela la inserción y muestra un aviso. Deshacer hasta recuperar el mismo contenido no restaura la inserción en la posición anterior. No evalúes la finalización solo por el éxito de la transferencia: comprueba committed y cancelled, recibidos por onDone. committed es el número de archivos realmente insertados en el documento.
Contrato de API del servidor
NABI NOTE no envía archivos a tu servidor por sí solo. La función uploader envía un archivo al servidor y, si tiene éxito, devuelve solo una URL https: pública o autenticada. El contrato de API más simple se ve así.
POST /api/uploads
Content-Type: multipart/form-data
Field name: file
Success: { "url": "https://cdn.example.com/uploads/8f2c.webp" }
Failure: 4xx or 5xx responseEl servidor no debe confiar solo en el nombre de archivo original, la extensión o el MIME enviados por el navegador. Comprueba primero autenticación y permisos, limita el tamaño del archivo durante el streaming e inspecciona el tipo real del archivo. Crea el nombre almacenado en el servidor. Para imágenes, re-encódalas o crea miniaturas cuando haga falta. Si los archivos subidos no deben poder descargarse por cualquiera, devuelve una ruta de descarga que requiera autenticación en lugar de una URL pública.
| Comprobación en el servidor | Motivo |
|---|---|
| Usuario autenticado y permiso de subida | Evita escribir en el almacenamiento de otro usuario |
| Tamaño por archivo y tamaño total de la petición | Evita agotar memoria y almacenamiento |
| MIME real y extensión permitidos | Bloquea ejecutables con extensiones renombradas |
| Nombre almacenado aleatorio y almacenamiento aislado | Evita manipulación de rutas y sobrescritura de archivos existentes |
| Política de acceso y expiración de la URL de respuesta | Evita exponer archivos privados solo por URL |
extensions y maxFileSize del cliente son solo el primer paso para dar feedback rápido al usuario. Pon los mismos límites también en el servidor.
Conectar el uploader en el navegador
El ejemplo siguiente es la conexión real que espera NABI NOTE. Usa XMLHttpRequest porque el fetch() estándar del navegador no ofrece progreso de subida. Devuelve solo la url de la respuesta del servidor; las imágenes se convierten en bloques de imagen, y los demás archivos en enlaces de adjunto.
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: 'es' },
)
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('Upload request failed.')))
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: 'es',
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: 'es' })
const surface = mountSurface({
nabi,
registry,
root: content,
fileSink: upload.take,
locale: 'es',
})
const toolbar = mountToolbar({
nabi,
registry,
root: document.querySelector<HTMLElement>('#toolbar')!,
surface: content,
onFiles: upload.take,
locale: 'es',
})Conecta fileSink: upload.take para que arrastrar y soltar y pegar solo archivos entren en el flujo de subida. Conecta el botón de selección de archivos mediante onFiles: upload.take de la barra. Durante la subida, el editor queda bloqueado y los archivos correctos se insertan con un único paso de deshacer por lote. upload.cancel() o el botón de cancelar de uploadView interrumpe las solicitudes en curso mediante AbortSignal.
Fallo y limpieza
Si el servidor devuelve una respuesta de error o el uploader devuelve null, ese archivo no se inserta en el documento. Los demás archivos del mismo lote siguen procesándose. Si se supera el límite de tamaño total, el lote completo no empieza. Al cerrar la pantalla, desmonta en el orden inverso al de creación.
function dispose() {
toolbar.unmount()
surface.unmount()
uploadView.unmount()
upload.unmount()
}Solo durante el desarrollo puedes usar URL blob: para una vista previa inmediata. En ese caso, activa allowLocalUrls: true al ensamblar el editor, el wing de imagen y el wing de subida. Si las subidas reales del servidor devuelven URL HTTPS, es más seguro no activar esta opción.
Estilos CSS
Los archivos ordinarios completados se muestran mediante el wing de enlace como a[data-nabi-file]. Usa este selector cuando solo quieras cambiar el aspecto del adjunto en la vista publicada.
.article-body a[data-nabi-file] {
padding: .3em .6em;
border: 1px solid var(--nabi-line);
border-radius: 8px;
background: var(--nabi-soft);
}Los resultados de subida de imagen siguen el CSS del wing de imagen. El progreso de subida aparece solo en la vista de edición, así que el CSS de la vista publicada no necesita crear un estado de progreso.