noteloom 0.3.2 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -1
- package/dist/index.d.ts +23 -2
- package/dist/noteloom.cjs +91 -8
- package/dist/noteloom.cjs.map +1 -1
- package/dist/noteloom.es.js +4916 -4491
- package/dist/noteloom.es.js.map +1 -1
- package/dist/style.css +83 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -195,6 +195,8 @@ function Editor() {
|
|
|
195
195
|
|
|
196
196
|
`examples/03-custom-field-type/` is a complete runnable version of this pattern.
|
|
197
197
|
|
|
198
|
+
Inserting one from "/" or "@" opens its picker immediately, focused and ready to search — no second click needed to actually pick something right after inserting it. Picking a value then moves focus straight on to the next block, so filling in a form-like document ("Diagnosis: [pick]", then the next line, then the next...) is a smooth insert → pick → keep-typing flow. Reopening an *existing* chip elsewhere to change its value later doesn't also jump away — this only applies right after a fresh insertion.
|
|
199
|
+
|
|
198
200
|
`options` can also be a **function** instead of a plain array — `(query) => Option[] | Promise<Option[]>` — for a real database/API-backed search (React Select's `loadOptions`, essentially):
|
|
199
201
|
|
|
200
202
|
```js
|
|
@@ -217,6 +219,8 @@ A few things worth knowing about the dynamic path:
|
|
|
217
219
|
- Your function is called **fresh on every keystroke**, debounced ~250ms — there's no built-in caching layer, so if you want caching, memoize inside your own function.
|
|
218
220
|
- Only the **resolved pick** — `{ value, label }` (plus `color` for the tag variant) — is ever written onto the document. The live options list itself is never persisted, so a chip never embeds a stale snapshot of your database; re-opening it always calls your function again.
|
|
219
221
|
- `triggers` (default `['slash']`) decides whether the type shows up under `/`, `@` (via `useAtMenuTrigger`), or both. A field that doesn't read naturally after "@" (e.g. "Priority") should usually stay slash-only.
|
|
222
|
+
- A **static** array works just as well when it comes from a JSON file — `import options from './options.json'` (or fetch it once at setup) is already a plain array by the time it reaches `options`, no special handling needed. A hybrid of both ("show a local list, search an API once the user types") is just a function that returns the static list for an empty query and calls your API otherwise — the same debounce applies regardless of what the function does inside.
|
|
223
|
+
- The option list itself is **virtualized** — only the rows currently scrolled into view are ever mounted, so a list of thousands of options (static or a big resolved page) scrolls smoothly, same as a list of ten.
|
|
220
224
|
|
|
221
225
|
### Letting end users create their own field types, in-editor
|
|
222
226
|
|
|
@@ -238,6 +242,8 @@ function NewFieldTypeButton() {
|
|
|
238
242
|
|
|
239
243
|
User-created types are persisted in the document's own `fieldTypes` collection (so they survive reload) and are automatically rehydrated back into your inline registry by `FieldTypeEditorModal` itself — you don't need to call anything extra. Each chip's popover also gets a "Manage options…" entry that reopens this same modal, pre-filled, for renaming/editing/deleting the type it belongs to.
|
|
240
244
|
|
|
245
|
+
Once at least one is created this way, a table column set to "Select" type gets a **"Copy options from…"** dropdown in its own menu (alongside its usual "+ New field type" button) — pick one to seed the column's option list from it in one shot, instead of typing the same options out again by hand. It's a one-time copy, not a live link: renaming/adding/removing options on the column afterward never touches the source field type.
|
|
246
|
+
|
|
241
247
|
## Exporting the document (JSON / HTML / plain text)
|
|
242
248
|
|
|
243
249
|
```js
|
|
@@ -563,9 +569,19 @@ function App() {
|
|
|
563
569
|
|
|
564
570
|
On mount, this loads whatever was last saved under `docId` (if anything) and replaces the store's content with it; every edit after that — typing, structural changes, even changes arriving from a collaborating peer via `CollabSession` — is auto-saved back, debounced (default 500ms of quiet) so a full-document write doesn't fire on every keystroke. Different `docId`s are stored independently, so one browser can hold many separate documents (e.g. keyed by page/route). A runnable example is in `examples/offline-persist/` — run `npm run dev:offline-persist`, type something, then reload the page or close and reopen the tab.
|
|
565
571
|
|
|
572
|
+
Everything already auto-saves, but `usePersistedDocument` also wires up the keyboard shortcut every user reaches for anyway: **Ctrl+S (Windows/Linux) or Cmd+S (Mac)** forces an immediate save (skipping the rest of the debounce window) and blocks the browser's own "Save Page" dialog from popping up instead — pass `{ saveShortcut: false }` to opt out, and `onSave` (fires after every save, shortcut-triggered or manual) to show your own "Saved" feedback. The hook also returns `save()` directly, for a manual Save button:
|
|
573
|
+
|
|
574
|
+
```jsx
|
|
575
|
+
const { isLoaded, save } = usePersistedDocument({
|
|
576
|
+
store: editor.store,
|
|
577
|
+
docId: 'my-document-id',
|
|
578
|
+
onSave: () => showSavedToast(),
|
|
579
|
+
});
|
|
580
|
+
```
|
|
581
|
+
|
|
566
582
|
Lower-level pieces, if `usePersistedDocument`'s all-in-one behavior doesn't fit (a non-React host app, custom load/save timing, etc.):
|
|
567
583
|
- `savePersistedDocument(docId, doc)` / `loadPersistedDocument(docId)` / `deletePersistedDocument(docId)` / `listPersistedDocumentIds()` — the raw IndexedDB operations `usePersistedDocument` is built on.
|
|
568
|
-
- `createAutoPersistence({ store, docId, debounceMs, onError })` — just the debounced auto-save half, if you want to handle the initial load yourself. Returns `{ stop, flush }
|
|
584
|
+
- `createAutoPersistence({ store, docId, debounceMs, onError })` — just the debounced auto-save half, if you want to handle the initial load yourself. Returns `{ stop, flush }` — `flush()` returns a Promise that resolves once the write actually lands (or immediately if there was nothing pending).
|
|
569
585
|
|
|
570
586
|
This is standalone — works with a solo, non-collaborating store just as well as one wired to `CollabSession` (a collaborated-on document also gets saved locally, so it survives even after every peer disconnects). Note this only makes the *editing* work offline; if the app itself is loaded from a dev server or web host, opening it for the very first time (or after clearing cache) still needs that host to be reachable once — that's the separate concern the next section covers.
|
|
571
587
|
|
|
@@ -590,6 +606,52 @@ This is standalone — works with a solo, non-collaborating store just as well a
|
|
|
590
606
|
|
|
591
607
|
Run `npm run dev:offline-persist`, then `npx vite build --config examples/offline-persist/vite.config.js && npx vite preview --config examples/offline-persist/vite.config.js` to try the built (not dev-mode) version — service workers only activate on a real build. Load it once online, then disconnect entirely and reload: the app shell still loads, and editing/persistence both keep working, since IndexedDB has no network dependency of its own.
|
|
592
608
|
|
|
609
|
+
## File & image uploads
|
|
610
|
+
|
|
611
|
+
The image/video/audio/file block (`embed`, reachable via "/image", "/video", etc.) ships with zero configuration needed: a picked or dropped file is read straight into a `data:` URL and stored directly in the document. That keeps everything fully self-contained — works offline, round-trips through copy/paste and undo/redo like any other block — at the cost of bloating the document for large media, since this package has no backend of its own to hand a file to instead.
|
|
612
|
+
|
|
613
|
+
For real upload-to-a-server behavior — local disk, AWS S3, or any other cloud storage — pass `uploadFile` to `<NoteloomEditor>` (or `<EditorProvider>` for the granular API):
|
|
614
|
+
|
|
615
|
+
```jsx
|
|
616
|
+
<NoteloomEditor
|
|
617
|
+
editor={editor}
|
|
618
|
+
uploadFile={async (file, { kind }) => {
|
|
619
|
+
const body = new FormData();
|
|
620
|
+
body.append('file', file);
|
|
621
|
+
const res = await fetch('/api/upload', { method: 'POST', body });
|
|
622
|
+
const { url } = await res.json();
|
|
623
|
+
return { src: url }; // { name?, mimeType? } also accepted, defaulting to the file's own
|
|
624
|
+
}}
|
|
625
|
+
/>
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
A few things worth knowing:
|
|
629
|
+
|
|
630
|
+
- **AWS S3** (or any presigned-URL-style object storage) is the same shape, just two requests instead of one — ask your own backend for a presigned PUT URL, then `PUT` the file straight to it:
|
|
631
|
+
```js
|
|
632
|
+
uploadFile: async (file) => {
|
|
633
|
+
const { uploadUrl, publicUrl } = await fetch('/api/s3-presign', {
|
|
634
|
+
method: 'POST',
|
|
635
|
+
headers: { 'Content-Type': 'application/json' },
|
|
636
|
+
body: JSON.stringify({ filename: file.name, contentType: file.type }),
|
|
637
|
+
}).then((r) => r.json());
|
|
638
|
+
await fetch(uploadUrl, { method: 'PUT', body: file, headers: { 'Content-Type': file.type } });
|
|
639
|
+
return { src: publicUrl };
|
|
640
|
+
}
|
|
641
|
+
```
|
|
642
|
+
Any other cloud storage (Cloudinary, Supabase Storage, R2, GCS, ...) is one of these two shapes — a single API call back with a hosted URL, or a signed-URL handshake — since this package only ever needs the final `{ src }`, not how it got there.
|
|
643
|
+
- **Small/medium/large file handling** is entirely `uploadFile`'s own business, off `file.size` (bytes) — this package deliberately hardcodes no byte thresholds of its own, since what counts as "large" varies wildly by app:
|
|
644
|
+
```js
|
|
645
|
+
uploadFile: async (file) => {
|
|
646
|
+
if (file.size < 200 * 1024) return { src: await inlineAsDataUrl(file) }; // small: keep it simple
|
|
647
|
+
if (file.size < 25 * 1024 * 1024) return uploadToYourServer(file); // medium
|
|
648
|
+
return uploadToS3Multipart(file); // large: chunked/multipart
|
|
649
|
+
}
|
|
650
|
+
```
|
|
651
|
+
- While `uploadFile` is resolving, the block shows an "Uploading…" state; if it rejects, a dismissible error message is shown instead and nothing is written to the document — the file input stays available to try again.
|
|
652
|
+
- `maxFileSize` (bytes) only applies to the **built-in, zero-config `data:` URL fallback** — an oversized file is rejected with a clear error instead of silently bloating the document. It has no effect once `uploadFile` is configured, since the host's own function (or backend) is what decides what it can handle.
|
|
653
|
+
- `useFileUpload()` exposes the same `{ uploadFile, maxFileSize }` to your own components, for building custom upload UI outside the `embed` block that still honors the same configuration.
|
|
654
|
+
|
|
593
655
|
## Live collaboration (experimental)
|
|
594
656
|
|
|
595
657
|
Real-time multi-peer editing, built as a custom **block-tree CRDT** — not a generic text-CRDT library bolted on — so it stays true to the zero-runtime-dependency design. Peers connect directly over WebRTC; you bring your own signaling (a WebSocket relay, Firebase/Supabase realtime, or anything else that can pass small JSON messages between two peers) to bootstrap the connection.
|
package/dist/index.d.ts
CHANGED
|
@@ -321,7 +321,7 @@ export function createAutoPersistence(options: {
|
|
|
321
321
|
docId: string;
|
|
322
322
|
debounceMs?: number;
|
|
323
323
|
onError?: (error: unknown) => void;
|
|
324
|
-
}): { stop: () => void; flush: () => void };
|
|
324
|
+
}): { stop: () => void; flush: () => Promise<void> };
|
|
325
325
|
|
|
326
326
|
// ---------------------------------------------------------------------------
|
|
327
327
|
// templates/ (+ the template half of persistence/)
|
|
@@ -601,6 +601,12 @@ export interface EditorProviderProps {
|
|
|
601
601
|
getBlockClassName?: (block: Block) => string | undefined;
|
|
602
602
|
/** Current user's id for authoring comments through the built-in comment UI -- see useCommentAuthorId. */
|
|
603
603
|
commentAuthorId?: string;
|
|
604
|
+
/** Whether CodeBlock renders its line-number gutter -- see useShowLineNumbers. */
|
|
605
|
+
showLineNumbers?: boolean;
|
|
606
|
+
/** Sends a picked/dropped EmbedBlock file somewhere real (local disk, S3, any other cloud storage) instead of inlining it as a data: URL -- see useFileUpload's own doc comment for the full contract. */
|
|
607
|
+
uploadFile?: (file: File, ctx: { kind: 'image' | 'video' | 'audio' | 'file' }) => Promise<{ src: string; name?: string; mimeType?: string }>;
|
|
608
|
+
/** Byte cap for the in-document data: URL fallback ONLY (no effect once uploadFile is configured) -- an oversized file is rejected with a clear error instead of bloating the document. */
|
|
609
|
+
maxFileSize?: number;
|
|
604
610
|
children?: ReactNode;
|
|
605
611
|
}
|
|
606
612
|
|
|
@@ -614,6 +620,13 @@ export function useSelectedBlock(): [string | null, (id: string | null) => void]
|
|
|
614
620
|
export function usePreviewMode(): [boolean, (value: boolean) => void];
|
|
615
621
|
/** The commentAuthorId passed to EditorProvider/NoteloomEditor, or undefined if not configured -- see NoteloomEditorProps.commentAuthorId. */
|
|
616
622
|
export function useCommentAuthorId(): string | undefined;
|
|
623
|
+
/** Whether CodeBlock should render its line-number gutter -- see EditorProviderProps.showLineNumbers. */
|
|
624
|
+
export function useShowLineNumbers(): boolean;
|
|
625
|
+
/** `{ uploadFile, maxFileSize }` from EditorProvider -- see EditorProviderProps and useFileUpload's own doc comment for the full contract. */
|
|
626
|
+
export function useFileUpload(): {
|
|
627
|
+
uploadFile?: (file: File, ctx: { kind: 'image' | 'video' | 'audio' | 'file' }) => Promise<{ src: string; name?: string; mimeType?: string }>;
|
|
628
|
+
maxFileSize?: number;
|
|
629
|
+
};
|
|
617
630
|
export function useFieldTypeEditor(): {
|
|
618
631
|
editingFieldTypeId: string | null;
|
|
619
632
|
openFieldTypeEditor: (id: string | null) => void;
|
|
@@ -642,7 +655,11 @@ export function usePersistedDocument(options: {
|
|
|
642
655
|
docId: string;
|
|
643
656
|
debounceMs?: number;
|
|
644
657
|
onError?: (error: unknown) => void;
|
|
645
|
-
|
|
658
|
+
/** Wires Ctrl/Cmd+S to save() and blocks the browser's own save-page dialog. Default true. */
|
|
659
|
+
saveShortcut?: boolean;
|
|
660
|
+
/** Fires after every save() completes (shortcut-triggered or manual). */
|
|
661
|
+
onSave?: () => void;
|
|
662
|
+
}): { isLoaded: boolean; save: () => Promise<void> };
|
|
646
663
|
|
|
647
664
|
export function usePresence(session: CollabSession | null | undefined): Map<string, Record<string, unknown>>;
|
|
648
665
|
|
|
@@ -878,6 +895,10 @@ export interface NoteloomEditorProps {
|
|
|
878
895
|
commentAuthorId?: string;
|
|
879
896
|
/** Renders CommentsPanel (right-side, Notion/Google Docs-style thread list) automatically. */
|
|
880
897
|
showCommentsPanel?: boolean;
|
|
898
|
+
/** Sends a picked/dropped EmbedBlock file somewhere real (local disk, S3, any other cloud storage) instead of inlining it as a data: URL -- see useFileUpload's own doc comment for the full contract. */
|
|
899
|
+
uploadFile?: (file: File, ctx: { kind: 'image' | 'video' | 'audio' | 'file' }) => Promise<{ src: string; name?: string; mimeType?: string }>;
|
|
900
|
+
/** Byte cap for the in-document data: URL fallback ONLY (no effect once uploadFile is configured). */
|
|
901
|
+
maxFileSize?: number;
|
|
881
902
|
children?: ReactNode;
|
|
882
903
|
}
|
|
883
904
|
|