noteloom 0.1.5 → 0.1.7
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 +125 -3
- package/dist/noteloom.cjs +483 -8
- package/dist/noteloom.cjs.map +1 -1
- package/dist/noteloom.es.js +6034 -3110
- package/dist/noteloom.es.js.map +1 -1
- package/dist/style.css +475 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,6 +9,17 @@
|
|
|
9
9
|
|
|
10
10
|
A React-first, block-based rich text editor with **zero runtime dependencies** — the only things it expects from your app are `react` and `react-dom`. Everything else (undo/redo, clipboard, slash commands, tables, inline widgets) is built from scratch on top of a small normalized document store.
|
|
11
11
|
|
|
12
|
+
## ✨ Highlights
|
|
13
|
+
|
|
14
|
+
- **12 built-in block types** — paragraph, heading, list (bulleted/numbered/to-do/toggle), table, multi-column layout, divider, callout, blockquote, code, toggle heading, button, embed, and a freehand **canvas** (draw/sketch/shapes/arrows/text — [see below](#drawing--sketching-the-canvas-block)).
|
|
15
|
+
- **Inline widgets mid-sentence** — select dropdowns, dates, checkboxes, and `@mentions`, spliced directly into running text, not forced onto their own line.
|
|
16
|
+
- **A real default theme**, injected automatically, fully retheme-able via CSS custom properties, or opt out entirely and bring your own.
|
|
17
|
+
- **Mobile/touch-first UI** — a bottom action bar, tap-friendly block picker, and touch-aware popovers, not just a desktop UI that technically renders on a phone.
|
|
18
|
+
- **Voice typing** — continuous dictation plus spoken structural commands ("heading one", "bulleted list", "undo") via the browser's own Speech API, no SDK bundled.
|
|
19
|
+
- **RTL & accessibility built in** — automatic per-block text direction, keyboard-operable menus, live-region announcements, and more.
|
|
20
|
+
- **Two JSON export shapes** — the normalized engine format, or a simpler self-contained shape for storage/API/CRUD use — plus HTML and plain-text export, all with a drop-in "View source" button.
|
|
21
|
+
- **Zero runtime dependencies**, a flat/normalized document model that diffs and stores cleanly, and fine-grained React re-rendering (editing one paragraph in a 500-block doc repaints just that block).
|
|
22
|
+
|
|
12
23
|
## Why this exists
|
|
13
24
|
|
|
14
25
|
Most rich-text editors either bring their own large dependency tree, or force every "special" piece of content (a dropdown, a date, a mention) onto its own line. This one is built around two ideas:
|
|
@@ -149,7 +160,94 @@ import { DocumentExportButton } from 'noteloom';
|
|
|
149
160
|
<DocumentExportButton label="View source" />
|
|
150
161
|
```
|
|
151
162
|
|
|
152
|
-
It opens a modal with JSON/HTML/Text tabs (reading live from the store every time it opens) and a Copy button — useful for debugging, or as a starting point for a real "export" feature.
|
|
163
|
+
It opens a modal with JSON/Simple JSON/HTML/Text tabs (reading live from the store every time it opens) and a Copy button — useful for debugging, or as a starting point for a real "export" feature.
|
|
164
|
+
|
|
165
|
+
## A simpler JSON shape for storage/API/CRUD use
|
|
166
|
+
|
|
167
|
+
`exportDocumentJSON()` above returns the *internal engine format* — the same normalized, id-referenced graph `EditorStore` operates on (blocks reference other blocks by id; text lives in a separate `runs` collection, not embedded inline). That shape is what makes per-run reactivity, O(1) structural edits, and real nesting (toggle lists, tables, inline atomic chips) work — it's not going to look like a simple flat document, on purpose.
|
|
168
|
+
|
|
169
|
+
If you just want something simpler to store, send over an API, or hand-edit — self-contained blocks in an array, `children` for nesting, no id-references to resolve — use the second, optional export/import pair instead:
|
|
170
|
+
|
|
171
|
+
```js
|
|
172
|
+
import { exportDocumentSimpleJSON, importDocumentSimpleJSON } from 'noteloom';
|
|
173
|
+
|
|
174
|
+
const json = exportDocumentSimpleJSON(store, registry, inlineRegistry);
|
|
175
|
+
// {
|
|
176
|
+
// "version": 1,
|
|
177
|
+
// "blocks": [
|
|
178
|
+
// { "id": "p1", "type": "paragraph", "data": { "text": "Hello <strong>world</strong>" } },
|
|
179
|
+
// { "id": "h1", "type": "heading", "data": { "text": "Key features", "level": 3 } },
|
|
180
|
+
// {
|
|
181
|
+
// "id": "li1", "type": "listItem",
|
|
182
|
+
// "data": { "text": "Nested item", "ordered": false, "checked": null },
|
|
183
|
+
// "children": [ /* nested listItem blocks, same shape */ ]
|
|
184
|
+
// },
|
|
185
|
+
// {
|
|
186
|
+
// "id": "t1", "type": "table",
|
|
187
|
+
// "data": { "columns": [{ "id": "c1", "label": "Name" }], "rows": [["Cell text"]] }
|
|
188
|
+
// }
|
|
189
|
+
// ]
|
|
190
|
+
// }
|
|
191
|
+
|
|
192
|
+
// ...later, or on a different machine/process:
|
|
193
|
+
const doc = importDocumentSimpleJSON(json, registry, inlineRegistry); // -> { rootId, blocks, runs }
|
|
194
|
+
const store2 = new EditorStore(doc);
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Rich text (`data.text`) is an HTML string — the exact same per-run serialization every block type's own clipboard-copy `toHTML` already produces, so marks (bold/italic/underline/strike/code/sub/superscript/color/highlight/link) and atomic inline chips (checkbox/date/select/mention) round-trip through it the same way copy/paste already does. `table` is flattened specially (`data.columns` + `data.rows`, a 2D array) rather than exposing the internal table/row/cell block chain — the single biggest simplification versus the internal shape. Block/run ids are preserved on both export and import (useful for referencing/updating a specific block from an external system).
|
|
198
|
+
|
|
199
|
+
One existing, by-design limitation carried over from clipboard paste: an atomic inline type's *core* value round-trips (a checkbox's checked state + label, a date's ISO value, a select's chosen value + label) but its full `options` list does not — only the currently-selected option survives, the same as pasting one of these chips into another instance of the editor today.
|
|
200
|
+
|
|
201
|
+
This is purely an additive, alternate *interchange* format — the internal engine format above is unaffected either way, and this is not a replacement for it.
|
|
202
|
+
|
|
203
|
+
## Right-to-left / multi-language text
|
|
204
|
+
|
|
205
|
+
Every block defaults to `dir="auto"` — the browser's own Unicode bidi algorithm detects direction per block from its first strong character, so a document mixing LTR and RTL blocks (an English heading over an Arabic paragraph, say) just works with zero configuration. For the cases `auto` can't infer on its own (most commonly an empty block, which has no text yet to detect a direction from), set an explicit override:
|
|
206
|
+
|
|
207
|
+
```js
|
|
208
|
+
import { operations } from 'noteloom';
|
|
209
|
+
|
|
210
|
+
// Document-wide default:
|
|
211
|
+
store.applyOperation(operations.updateBlockProps(store.getRootId(), { dir: 'rtl' }));
|
|
212
|
+
// Or just one block:
|
|
213
|
+
store.applyOperation(operations.updateBlockProps(blockId, { dir: 'rtl' }));
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
A block's own `dir` wins over the document's; the block gutter menu also has a "Switch to right-to-left"/"left-to-right" item that sets this per-block. Code blocks are always `dir="ltr"` regardless of the surrounding document's default — code syntax (brackets, operators) is structurally LTR no matter what language a comment or string literal happens to be written in.
|
|
217
|
+
|
|
218
|
+
This pass covers the reading/typing/gutter-position direction itself; a full logical-properties (`margin-inline-start` etc.) audit of every pixel value in `style.css` is deliberately out of scope for now — the highest-impact pieces (list/checkbox marker position, blockquote border side, block gutter position) already flip correctly.
|
|
219
|
+
|
|
220
|
+
## Printing & PDF
|
|
221
|
+
|
|
222
|
+
`style.css` includes a built-in `@media print` stylesheet: every piece of editing chrome (the block gutter, all portaled menus, the floating toolbar, resize handles, the mobile action bar, etc.) is hidden automatically, and a block hidden via "Hide in preview" stays hidden in the printout too, regardless of whether the app happens to be toggled into preview mode at the moment you print — printing always behaves like preview mode.
|
|
223
|
+
|
|
224
|
+
There's no bundled PDF-generation library (that would need a real dependency like jsPDF/pdfmake, conflicting with staying zero-runtime-dependency) — the browser's own print-to-PDF is the intended path:
|
|
225
|
+
|
|
226
|
+
```js
|
|
227
|
+
window.print(); // Ctrl+P / Cmd+P works too — "Save as PDF" in the print dialog is your PDF export
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
This only cleans up the *editor's* own chrome. A host app's own outer UI (nav bar, sidebar, its own toolbar) needs its own `@media print` rules the same way — see `examples/basic/src/style.css` for a worked example, since that chrome lives entirely outside this package.
|
|
231
|
+
|
|
232
|
+
## Voice typing
|
|
233
|
+
|
|
234
|
+
`useVoiceTyping()` wraps the browser's native Web Speech API (`SpeechRecognition`) for continuous dictation mixed with spoken structural commands — say "heading one", "new paragraph", "bulleted list", "quote", "undo", etc. while dictating, and the current block converts (or a new one is inserted) instead of those words being typed as text:
|
|
235
|
+
|
|
236
|
+
```jsx
|
|
237
|
+
import { useVoiceTyping } from 'noteloom';
|
|
238
|
+
|
|
239
|
+
function MicButton() {
|
|
240
|
+
const voice = useVoiceTyping();
|
|
241
|
+
if (!voice.isSupported) return null; // e.g. Firefox — no bundled fallback, degrades to nothing
|
|
242
|
+
return (
|
|
243
|
+
<button onClick={() => (voice.isListening ? voice.stop() : voice.start())}>
|
|
244
|
+
{voice.isListening ? 'Stop dictation' : 'Start dictation'}
|
|
245
|
+
</button>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
No speech-to-text SDK is bundled (same zero-runtime-dependency reasoning as PDF export above) — this is built entirely on the browser's own `SpeechRecognition`/`webkitSpeechRecognition`, so `isSupported` is `false` wherever that API doesn't exist. A command is only recognized when an entire *finalized* spoken utterance (a natural pause before/after, as reported by the Speech API itself) matches a known phrase exactly — see `src/voice/voiceCommands.js` for the full table — so a command word merely mentioned mid-sentence while dictating prose is never misread as a command.
|
|
153
251
|
|
|
154
252
|
## Mobile / touch support
|
|
155
253
|
|
|
@@ -179,7 +277,20 @@ Trigger-menu and `Select` popovers reposition above the caret instead of below i
|
|
|
179
277
|
|
|
180
278
|
## Built-in block types
|
|
181
279
|
|
|
182
|
-
`paragraph`, `heading` (h1–h3), `listItem` (bulleted, numbered,
|
|
280
|
+
`paragraph`, `heading` (h1–h3), `listItem` (bulleted, numbered, to-do, and toggle — with Notion-style Tab/Shift+Tab nesting and Enter conventions), `table` (with row/column insert/delete), `layout` (multi-column), `divider`, `callout`, `blockquote`, `code`, `toggleHeading`, `button`, `embed` (image/video/audio/file), and `canvas` (see next section).
|
|
281
|
+
|
|
282
|
+
### Drawing & sketching: the canvas block
|
|
283
|
+
|
|
284
|
+
A freehand drawing surface, insertable via `/canvas` (or the icon in the slash menu) — pen, eraser, resizable text, and rectangle/ellipse/arrow shapes, each with their own color/fill pickers, all in a fixed-size box you can resize like an embed. Exports to a self-contained inline `<svg>` (see `exportSvg.js`) as part of the document's normal HTML export, so it round-trips through copy/paste and `exportDocumentHTML` without a canvas-to-image conversion step.
|
|
285
|
+
|
|
286
|
+
```js
|
|
287
|
+
import { createBlockRegistry, registerBlocks, canvasBlockType } from 'noteloom';
|
|
288
|
+
|
|
289
|
+
const registry = createBlockRegistry();
|
|
290
|
+
registerBlocks(registry, { canvas: canvasBlockType /* , ...other blocks */ });
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Strokes/shapes are authored in a fixed 0–1000 normalized coordinate space, independent of the block's own rendered pixel size — resizing the canvas never has to rescale the drawing data itself. There's no OCR/description generation, so a canvas has no plain-text representation (`toPlainText` returns `''`, matching a divider's decorative nature) and, in this first version, pasted/foreign SVG markup doesn't reverse-parse back into editable stroke data — a canvas block can only be created via its own slash command.
|
|
183
294
|
|
|
184
295
|
## Picking only the blocks you want
|
|
185
296
|
|
|
@@ -299,6 +410,14 @@ registry.register('myBlock', {
|
|
|
299
410
|
});
|
|
300
411
|
```
|
|
301
412
|
|
|
413
|
+
## Accessibility
|
|
414
|
+
|
|
415
|
+
- Every portaled popover that's a genuine standalone action menu (the block gutter's Duplicate/Move/Hide/Delete menu, the block-range action menu, a table column's options menu) is keyboard-operable: opening one moves real focus onto its first item, ArrowUp/ArrowDown move between items (wrapping), Home/End jump to the first/last, and Escape closes it and returns focus to whatever opened it — not just a name-only `role="menu"` that only responds to mouse clicks.
|
|
416
|
+
- `Modal` moves focus into the dialog (its first focusable element) on open and restores it to whatever had focus before on close — not a full focus trap (this package stays zero-dependency, and its dialogs are short, single-purpose forms, not deep navigable UI), just "focus doesn't go missing."
|
|
417
|
+
- Structural actions that don't otherwise move focus anywhere describable (duplicate/move/hide/delete a block, or a whole selected range) announce what happened via a shared, visually-hidden `aria-live="polite"` region — screen-reader users get "Block deleted"/"3 blocks moved up" instead of silence.
|
|
418
|
+
- Embed images have a real, separately-authored `alt` text field (a toolbar button opens a small dialog to set it) — `alt` is never silently filled in from the uploaded file's raw filename or a pasted URL string, since neither is meaningful alt text.
|
|
419
|
+
- Table header cells have `scope="col"`, and the column-resize/embed-resize sliders both expose `aria-valuemin/valuemax/valuenow`.
|
|
420
|
+
|
|
302
421
|
## Development
|
|
303
422
|
|
|
304
423
|
```bash
|
|
@@ -311,6 +430,9 @@ npm run build # library build (dist/, ESM + CJS)
|
|
|
311
430
|
## Known limitations
|
|
312
431
|
|
|
313
432
|
- No accessibility affordance exists for grouping sibling list items under a shared `role="list"` container (each list item is an independent block, not wrapped in one) — adding `role="listitem"` without that ancestor would be worse than no role at all, so it's deliberately left out pending a bigger structural change.
|
|
433
|
+
- The library doesn't render your editor's own root/surface element (that's host-rendered — see `examples/basic/src/App.jsx`'s `EditorSurface`), so it can't add `role="document"`/`aria-label` there itself; the example app demonstrates doing this on your own surface element, which is worth copying into your own app.
|
|
314
434
|
- Cross-block mark toggling (bold/italic/underline over a selection spanning multiple blocks) applies as one store operation per block, not a single atomic undo step.
|
|
315
435
|
- `select`'s option-adding UI and any `createSelectFieldType`-based type's options (e.g. an "Assignee" @-mention) are meant as a starting point — a real app will want to wire its own people/options source.
|
|
316
|
-
-
|
|
436
|
+
- RTL support covers direction resolution (`dir="auto"` + per-block/document override) and the highest-impact visual pieces (list markers, blockquote border, block gutter position) — a full logical-properties rewrite of every hardcoded pixel value in `style.css` is a bigger follow-up, not yet done.
|
|
437
|
+
- Voice typing (`useVoiceTyping`) only acts on *finalized* speech results, not interim/in-progress ones, and command detection requires a spoken command to be its own complete utterance — there's no explicit "command mode" trigger (push-to-command, wake phrase) yet, just pause-based auto-detection.
|
|
438
|
+
- Automated tests run under jsdom; there is no automated real-browser test suite. If you hit an edge case jsdom can't reproduce (anything involving actual native `contentEditable` browser quirks, or the real Web Speech API), please file an issue with the exact browser/OS and steps.
|