@notectl/core 0.0.6 → 0.0.8

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 ADDED
@@ -0,0 +1,246 @@
1
+ <div align="center">
2
+
3
+ # notectl
4
+
5
+ **A modern rich text editor, shipped as a single Web Component.**
6
+
7
+ Built on immutable state, a transaction-based architecture, and a plugin system that powers every feature — from bold text to full table editing.
8
+
9
+ [![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
10
+ [![Web Component](https://img.shields.io/badge/Web_Component-%3Cnotectl--editor%3E-purple)](https://developer.mozilla.org/en-US/docs/Web/API/Web_components)
11
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
12
+ [![npm](https://img.shields.io/npm/v/@notectl/core)](https://www.npmjs.com/package/@notectl/core)
13
+
14
+ <br />
15
+
16
+ <img src="docs-site/src/assets/screenshots/hero-editor-rich.png" alt="notectl editor with rich content" width="720" />
17
+
18
+ </div>
19
+
20
+ <br />
21
+
22
+ ## Why notectl?
23
+
24
+ Most editors bolt formatting on top of `contenteditable` and hope for the best. notectl takes a different approach: every keystroke produces an immutable transaction, every feature is a plugin, and the DOM is a projection of state — never the source of truth.
25
+
26
+ - **Web Component** — drop `<notectl-editor>` into any framework or vanilla HTML
27
+ - **Plugin architecture** — every feature (bold, tables, lists, ...) is a plugin; add only what you need
28
+ - **Immutable state** — predictable updates, time-travel undo/redo, zero mutation bugs
29
+ - **Transaction system** — atomic, invertible steps with middleware support
30
+ - **Zero framework lock-in** — works with React, Vue, Svelte, Angular, or plain JS
31
+ - **Tiny dependency footprint** — single runtime dependency (DOMPurify)
32
+
33
+ <br />
34
+
35
+ ## Quick Start
36
+
37
+ ### Install
38
+
39
+ ```bash
40
+ npm install @notectl/core
41
+ ```
42
+
43
+ ### Use
44
+
45
+ ```ts
46
+ import {
47
+ createEditor,
48
+ TextFormattingPlugin,
49
+ HeadingPlugin,
50
+ ListPlugin,
51
+ LinkPlugin,
52
+ TablePlugin,
53
+ ToolbarPlugin,
54
+ } from '@notectl/core';
55
+
56
+ const editor = await createEditor({
57
+ toolbar: [
58
+ [new TextFormattingPlugin({ bold: true, italic: true, underline: true })],
59
+ [new HeadingPlugin()],
60
+ [new ListPlugin()],
61
+ [new LinkPlugin(), new TablePlugin()],
62
+ ],
63
+ placeholder: 'Start typing...',
64
+ autofocus: true,
65
+ });
66
+
67
+ document.body.appendChild(editor);
68
+ ```
69
+
70
+ That's it. A full-featured editor in 15 lines.
71
+
72
+ <br />
73
+
74
+ ## Plugins
75
+
76
+ Every capability is a plugin. Compose exactly the editor you need.
77
+
78
+ | Plugin | What it does |
79
+ |---|---|
80
+ | **TextFormattingPlugin** | Bold, italic, underline — individually toggleable |
81
+ | **StrikethroughPlugin** | ~~Strikethrough~~ text |
82
+ | **HeadingPlugin** | H1 – H6 headings |
83
+ | **BlockquotePlugin** | Block quotes |
84
+ | **ListPlugin** | Bullet and ordered lists |
85
+ | **LinkPlugin** | Hyperlink insertion and editing |
86
+ | **TablePlugin** | Full table support with row/column controls |
87
+ | **TextColorPlugin** | Text color picker |
88
+ | **TextAlignmentPlugin** | Left, center, right, justify |
89
+ | **FontPlugin** | Font family selection with custom font support |
90
+ | **FontSizePlugin** | Configurable font sizes |
91
+ | **HorizontalRulePlugin** | Horizontal dividers |
92
+ | **SuperSubPlugin** | Superscript and subscript |
93
+ | **HighlightPlugin** | Text highlighting / background color |
94
+ | **ToolbarPlugin** | Visual toolbar with grouped items |
95
+
96
+ ### Tables
97
+
98
+ Full table editing — add/remove rows and columns, navigate with Tab, resize, and select.
99
+
100
+ <div align="center">
101
+ <img src="docs-site/src/assets/screenshots/editor-table-showcase.png" alt="Table editing in notectl" width="720" />
102
+ </div>
103
+
104
+ <br />
105
+
106
+ ## Content API
107
+
108
+ Read and write content in multiple formats:
109
+
110
+ ```ts
111
+ // JSON (immutable Document)
112
+ const doc = editor.getJSON();
113
+ editor.setJSON(doc);
114
+
115
+ // HTML (sanitized via DOMPurify)
116
+ const html = editor.getHTML();
117
+ editor.setHTML('<p>Hello <strong>world</strong></p>');
118
+
119
+ // Plain text
120
+ const text = editor.getText();
121
+
122
+ // State
123
+ editor.isEmpty(); // true | false
124
+ ```
125
+
126
+ ## Command API
127
+
128
+ ```ts
129
+ editor.commands.toggleBold();
130
+ editor.commands.toggleItalic();
131
+ editor.commands.toggleUnderline();
132
+ editor.commands.undo();
133
+ editor.commands.redo();
134
+ editor.commands.selectAll();
135
+ ```
136
+
137
+ ## Events
138
+
139
+ ```ts
140
+ editor.on('stateChange', ({ oldState, newState, transaction }) => { /* ... */ });
141
+ editor.on('selectionChange', ({ selection }) => { /* ... */ });
142
+ editor.on('ready', () => { /* ... */ });
143
+ editor.on('focus', () => { /* ... */ });
144
+ editor.on('blur', () => { /* ... */ });
145
+ ```
146
+
147
+ <br />
148
+
149
+ ## Custom Fonts
150
+
151
+ Bring your own fonts — notectl handles `@font-face` injection automatically.
152
+
153
+ ```ts
154
+ import { FontPlugin, STARTER_FONTS } from '@notectl/core';
155
+
156
+ const Inter = {
157
+ name: 'Inter',
158
+ family: "'Inter', sans-serif",
159
+ category: 'sans-serif',
160
+ fontFaces: [
161
+ {
162
+ src: "url('/fonts/Inter-Variable.ttf') format('truetype')",
163
+ weight: '100 900',
164
+ style: 'normal',
165
+ },
166
+ ],
167
+ };
168
+
169
+ new FontPlugin({ fonts: [...STARTER_FONTS, Inter] });
170
+ ```
171
+
172
+ <br />
173
+
174
+ ## Toolbar Configuration
175
+
176
+ Group plugins into toolbar sections for a clean UI:
177
+
178
+ ```ts
179
+ const editor = await createEditor({
180
+ toolbar: [
181
+ [new FontPlugin(), new FontSizePlugin()],
182
+ [new TextFormattingPlugin(), new StrikethroughPlugin(), new TextColorPlugin()],
183
+ [new HeadingPlugin(), new BlockquotePlugin()],
184
+ [new TextAlignmentPlugin()],
185
+ [new ListPlugin()],
186
+ [new LinkPlugin(), new TablePlugin(), new HorizontalRulePlugin()],
187
+ ],
188
+ });
189
+ ```
190
+
191
+ Each inner array becomes a visually separated group in the toolbar.
192
+
193
+ <br />
194
+
195
+ ## Examples
196
+
197
+ Check out the full working example in [`examples/vanillajs`](examples/vanillajs) — it demonstrates every plugin, custom font loading, toolbar grouping, and the complete content API.
198
+
199
+ ```bash
200
+ git clone https://github.com/Samyssmile/notectl.git
201
+ cd notectl
202
+ pnpm install
203
+ pnpm dev
204
+ ```
205
+
206
+ <br />
207
+
208
+ ## Architecture
209
+
210
+ ```
211
+ Input Event → InputHandler / KeyboardHandler
212
+ → Transaction with atomic Steps
213
+ → Middleware chain (priority-ordered)
214
+ → EditorState.apply(tr) → new immutable EditorState
215
+ → Reconciler patches DOM (block-level diffing)
216
+ → Plugins notified via onStateChange()
217
+ ```
218
+
219
+ | Layer | Responsibility |
220
+ |---|---|
221
+ | `model/` | Immutable data — Document, BlockNode, TextNode, Mark, Selection |
222
+ | `state/` | EditorState, Transaction, StepApplication, History |
223
+ | `view/` | DOM rendering, Reconciler, SelectionSync |
224
+ | `input/` | Keyboard/input handling, paste, input rules |
225
+ | `commands/` | High-level operations (toggleMark, splitBlock, ...) |
226
+ | `plugins/` | All features — every capability is a plugin |
227
+ | `editor/` | `<notectl-editor>` Web Component public API |
228
+
229
+ <br />
230
+
231
+ ## Development
232
+
233
+ ```bash
234
+ pnpm install # install dependencies
235
+ pnpm build # build all packages
236
+ pnpm test # run unit tests (vitest + happy-dom)
237
+ pnpm test:e2e # run e2e tests (playwright)
238
+ pnpm lint # lint (biome)
239
+ pnpm typecheck # type check
240
+ ```
241
+
242
+ <br />
243
+
244
+ ## License
245
+
246
+ [MIT](LICENSE)
package/dist/index.d.ts CHANGED
@@ -142,6 +142,9 @@ export declare function createEditor(config?: NotectlEditorConfig): Promise<Note
142
142
  /** Creates a new {@link InlineNode}. */
143
143
  export declare function createInlineNode(inlineType: InlineTypeName, attrs?: Readonly<Record<string, string | number | boolean>>): InlineNode;
144
144
 
145
+ /** Creates a NodeSelection for the given block. */
146
+ export declare function createNodeSelection(nodeId: BlockId, path: readonly BlockId[]): NodeSelection;
147
+
145
148
  /** Creates a Position, optionally with a path. */
146
149
  export declare function createPosition(blockId: BlockId, offset: number, path?: readonly BlockId[]): Position;
147
150
 
@@ -205,6 +208,12 @@ export declare function deleteBackward(state: EditorState): Transaction | null;
205
208
  /** Handles delete key. */
206
209
  export declare function deleteForward(state: EditorState): Transaction | null;
207
210
 
211
+ /**
212
+ * Deletes the void block targeted by a NodeSelection and places cursor
213
+ * on the adjacent block. If it's the only block, replaces with empty paragraph.
214
+ */
215
+ export declare function deleteNodeSelection(state: EditorState, sel: NodeSelection): Transaction | null;
216
+
208
217
  /** Deletes the current selection. */
209
218
  export declare function deleteSelectionCommand(state: EditorState): Transaction | null;
210
219
 
@@ -232,9 +241,12 @@ export declare interface DropdownConfig {
232
241
  }[];
233
242
  }
234
243
 
244
+ /** Union type representing either a text selection or a node selection. */
245
+ export declare type EditorSelection = Selection_2 | NodeSelection;
246
+
235
247
  export declare class EditorState {
236
248
  readonly doc: Document_2;
237
- readonly selection: Selection_2;
249
+ readonly selection: EditorSelection;
238
250
  readonly storedMarks: readonly Mark[] | null;
239
251
  readonly schema: Schema;
240
252
  private _blockMap;
@@ -243,7 +255,7 @@ export declare class EditorState {
243
255
  /** Creates a new EditorState with default document. */
244
256
  static create(options?: {
245
257
  doc?: Document_2;
246
- selection?: Selection_2;
258
+ selection?: EditorSelection;
247
259
  schema?: Schema;
248
260
  }): EditorState;
249
261
  /** Creates a TransactionBuilder from this state. */
@@ -263,7 +275,7 @@ export declare class EditorState {
263
275
  /** Deserializes a state from JSON. */
264
276
  static fromJSON(json: {
265
277
  doc: Document_2;
266
- selection: Selection_2;
278
+ selection: EditorSelection;
267
279
  }, schema?: Schema): EditorState;
268
280
  }
269
281
 
@@ -291,7 +303,7 @@ export declare class EventKey<T> {
291
303
  declare type EventMap = {
292
304
  stateChange: StateChangeEvent;
293
305
  selectionChange: {
294
- selection: Selection_2;
306
+ selection: EditorSelection;
295
307
  };
296
308
  focus: undefined;
297
309
  blur: undefined;
@@ -304,6 +316,14 @@ export declare interface FeatureConfig {
304
316
  readonly underline: boolean;
305
317
  }
306
318
 
319
+ /** Handler for files pasted or dropped into the editor. */
320
+ export declare type FileHandler = (files: readonly File[], position: Position | null) => boolean | Promise<boolean>;
321
+
322
+ export declare interface FileHandlerEntry {
323
+ readonly pattern: string;
324
+ readonly handler: FileHandler;
325
+ }
326
+
307
327
  /**
308
328
  * Finds a block node by ID anywhere in the document tree (recursive DFS).
309
329
  */
@@ -448,6 +468,12 @@ export declare class FontSizePlugin implements Plugin_2 {
448
468
  private registerCommands;
449
469
  private registerKeymaps;
450
470
  private registerToolbarItem;
471
+ /**
472
+ * Sets the configured default font size on the editor content container
473
+ * so that unformatted text renders at the correct size instead of the
474
+ * browser default (16px).
475
+ */
476
+ private applyDefaultSizeToContainer;
451
477
  private updateComboLabel;
452
478
  private isFontSizeActive;
453
479
  private getActiveSize;
@@ -724,15 +750,16 @@ export declare function invertTransaction(tr: Transaction): Transaction;
724
750
  /** Checks whether a value is a {@link BlockNode}. */
725
751
  export declare function isBlockNode(node: unknown): node is BlockNode;
726
752
 
727
- /** Returns true if the selection is collapsed (cursor with no range). */
728
- export declare function isCollapsed(sel: Selection_2): boolean;
753
+ /** Returns true if the selection is collapsed (cursor with no range). NodeSelection is never collapsed. */
754
+ export declare function isCollapsed(sel: EditorSelection): boolean;
729
755
 
730
756
  /**
731
757
  * Returns true if the selection direction is forward (anchor before head).
732
758
  * When anchor and head are in the same block, compares offsets.
733
759
  * Cross-block ordering uses document order (not determinable here — caller provides block order).
760
+ * For NodeSelection, always returns true.
734
761
  */
735
- export declare function isForward(sel: Selection_2, blockOrder?: readonly BlockId[]): boolean;
762
+ export declare function isForward(sel: EditorSelection, blockOrder?: readonly BlockId[]): boolean;
736
763
 
737
764
  /** Checks whether a value is an {@link InlineNode}. */
738
765
  export declare function isInlineNode(node: unknown): node is InlineNode;
@@ -770,9 +797,18 @@ export declare function isNodeOfType<T extends keyof NodeAttrRegistry>(node: Blo
770
797
  readonly attrs: NodeAttrRegistry[T];
771
798
  };
772
799
 
800
+ /** Type guard: returns true if the selection is a NodeSelection. */
801
+ export declare function isNodeSelection(sel: EditorSelection): sel is NodeSelection;
802
+
773
803
  /** Checks whether a value is a {@link TextNode}. */
774
804
  export declare function isTextNode(node: unknown): node is TextNode;
775
805
 
806
+ /** Type guard: returns true if the selection is a text Selection. */
807
+ export declare function isTextSelection(sel: EditorSelection): sel is Selection_2;
808
+
809
+ /** Returns true if the block with the given ID is a void block (e.g. image, HR). */
810
+ export declare function isVoidBlock(state: EditorState, bid: BlockId): boolean;
811
+
776
812
  export declare interface ItalicMark extends Mark {
777
813
  readonly type: MarkTypeName & 'italic';
778
814
  }
@@ -894,7 +930,10 @@ export declare function markType(name: string): MarkTypeName;
894
930
  /** Semantic name for a mark type (e.g. 'bold', 'link'). */
895
931
  export declare type MarkTypeName = Brand<string, 'MarkTypeName'>;
896
932
 
897
- /** Merges the current block with the previous block, respecting isolating boundaries. */
933
+ /**
934
+ * Merges the current block with the previous block, respecting
935
+ * isolating boundaries and void blocks.
936
+ */
898
937
  export declare function mergeBlockBackward(state: EditorState): Transaction | null;
899
938
 
900
939
  export declare interface MergeBlocksStep {
@@ -907,6 +946,12 @@ export declare interface MergeBlocksStep {
907
946
 
908
947
  export declare type MiddlewareNext = (tr: Transaction) => void;
909
948
 
949
+ /**
950
+ * Navigates arrow keys into/out of void blocks.
951
+ * Returns a transaction if navigation should create a NodeSelection, or null.
952
+ */
953
+ export declare function navigateArrowIntoVoid(state: EditorState, direction: 'left' | 'right' | 'up' | 'down'): Transaction | null;
954
+
910
955
  /** Plugins augment this interface to register typed node attributes. */
911
956
  export declare interface NodeAttrRegistry {
912
957
  paragraph: {
@@ -926,7 +971,7 @@ export declare interface NodeDecoration {
926
971
  /** Creates a node decoration that applies to a whole block element. */
927
972
  export declare function nodeDecoration(blockId: BlockId, attrs: DecorationAttrs): NodeDecoration;
928
973
 
929
- /** A selection that selects an entire node (e.g. for table selection). */
974
+ /** A selection that selects an entire node (e.g. void blocks, table selection). */
930
975
  export declare interface NodeSelection {
931
976
  readonly type: 'node';
932
977
  readonly nodeId: BlockId;
@@ -1130,6 +1175,7 @@ export declare interface PluginContext {
1130
1175
  registerInputRule(rule: InputRule): void;
1131
1176
  registerToolbarItem(item: ToolbarItem): void;
1132
1177
  registerInlineNodeSpec<T extends string>(spec: InlineNodeSpec<T>): void;
1178
+ registerFileHandler(pattern: string, handler: FileHandler): void;
1133
1179
  getSchemaRegistry(): SchemaRegistry;
1134
1180
  }
1135
1181
 
@@ -1273,6 +1319,7 @@ export declare class SchemaRegistry {
1273
1319
  private readonly _inputRules;
1274
1320
  private readonly _toolbarItems;
1275
1321
  private readonly _toolbarItemPluginMap;
1322
+ private readonly _fileHandlers;
1276
1323
  registerNodeSpec<T extends string>(spec: NodeSpec<T>): void;
1277
1324
  getNodeSpec(type: string): NodeSpec | undefined;
1278
1325
  removeNodeSpec(type: string): void;
@@ -1299,6 +1346,10 @@ export declare class SchemaRegistry {
1299
1346
  getToolbarItem(id: string): ToolbarItem | undefined;
1300
1347
  getToolbarItems(): ToolbarItem[];
1301
1348
  removeToolbarItem(id: string): void;
1349
+ registerFileHandler(pattern: string, handler: FileHandler): void;
1350
+ getFileHandlers(): readonly FileHandlerEntry[];
1351
+ matchFileHandlers(mimeType: string): FileHandler[];
1352
+ removeFileHandler(handler: FileHandler): void;
1302
1353
  clear(): void;
1303
1354
  }
1304
1355
 
@@ -1318,9 +1369,13 @@ export declare interface SelectionRange {
1318
1369
 
1319
1370
  /**
1320
1371
  * Returns a normalized range where `from` is always before `to`.
1372
+ * Throws for NodeSelection — use isNodeSelection() guard first.
1321
1373
  */
1322
1374
  export declare function selectionRange(sel: Selection_2, blockOrder?: readonly BlockId[]): SelectionRange;
1323
1375
 
1376
+ /** Compares two EditorSelections for equality. */
1377
+ export declare function selectionsEqual(a: EditorSelection, b: EditorSelection): boolean;
1378
+
1324
1379
  /** Type-safe service key for compile-time type checking. */
1325
1380
  export declare class ServiceKey<T> {
1326
1381
  readonly id: string;
@@ -1712,8 +1767,8 @@ export declare const ToolbarServiceKey: ServiceKey<ToolbarServiceAPI>;
1712
1767
 
1713
1768
  export declare interface Transaction {
1714
1769
  readonly steps: readonly Step[];
1715
- readonly selectionBefore: Selection_2;
1716
- readonly selectionAfter: Selection_2;
1770
+ readonly selectionBefore: EditorSelection;
1771
+ readonly selectionAfter: EditorSelection;
1717
1772
  readonly storedMarksAfter: readonly Mark[] | null;
1718
1773
  readonly metadata: TransactionMetadata;
1719
1774
  }
@@ -1726,7 +1781,7 @@ export declare class TransactionBuilder {
1726
1781
  private readonly selectionBefore;
1727
1782
  private readonly origin;
1728
1783
  private workingDoc;
1729
- constructor(currentSelection: Selection_2, currentStoredMarks: readonly Mark[] | null, origin?: TransactionOrigin, doc?: Document_2);
1784
+ constructor(currentSelection: EditorSelection, currentStoredMarks: readonly Mark[] | null, origin?: TransactionOrigin, doc?: Document_2);
1730
1785
  /** Adds an insert-text step. Updates workingDoc if available. */
1731
1786
  insertText(blockId: BlockId, offset: number, text: string, marks: readonly Mark[], segments?: readonly TextSegment[]): this;
1732
1787
  /** Adds a delete-text step with explicit data. Updates workingDoc if available. */
@@ -1764,7 +1819,9 @@ export declare class TransactionBuilder {
1764
1819
  /** Sets attributes on an InlineNode at the given offset. */
1765
1820
  setInlineNodeAttr(blockId: BlockId, offset: number, attrs: Readonly<Record<string, string | number | boolean>>): this;
1766
1821
  /** Sets the selection for the resulting state. */
1767
- setSelection(selection: Selection_2): this;
1822
+ setSelection(selection: EditorSelection): this;
1823
+ /** Sets a NodeSelection for the resulting state. */
1824
+ setNodeSelection(nodeId: BlockId, path: readonly BlockId[]): this;
1768
1825
  /** Sets stored marks for the resulting state. */
1769
1826
  setStoredMarks(marks: readonly Mark[] | null, previousMarks: readonly Mark[] | null): this;
1770
1827
  /** Builds the final transaction. */