notectl

repository·main·Indexed 41 days ago

https://github.com/samyssmile/notectl

A framework-agnostic, plugin-based rich text editor delivered as a Web Component. It supports integration with React, Vue, Angular, Svelte, and Vanilla JS, featuring a state-first architecture with immutable EditorState and a schema-driven HTML serialization system. Includes built-in plugins for tables, code blocks, MathML, and images, as well as a dedicated @notectl/angular wrapper for Angular 21+.

Tokens
157.5K
Snippets
496
Records
692
Agent score
51%

What's inside notectl

  1. What is notectl?

    main

    notectl is a rich text editor delivered as a native Web Component (<notectl-editor>). It is designed for high extensibility and reliability using a strict layered architecture and immutable data structures.

    Key Capabilities

    • Framework Agnostic: As a Web Component, it can be used in plain HTML or integrated into React, Vue, Angular, and Svelte.
    • Plugin-driven: Features are modular; you only include the plugins you need.
    • Immutable State: State changes are handled via invertible transactions, enabling robust undo/redo functionality.
    • Accessibility: Built with ARIA roles, screen reader support, and full keyboard navigation.
    • Advanced Features: Includes support for tables (with cell selection and row/column operations), custom font injection (WOFF2/TTF/OTF), and declarative toolbar configuration.
  2. How NodeSpec extensions work

    main

    An extension is a function of type (spec: NodeSpec) => NodeSpec that transforms a node type owned by another plugin. Extensions can be used to add attributes, wrap toDOM(), or widen content.allow.

    Key behaviors:

    • Order Independence: Extensions can be registered before their target spec exists. They are all materialized together when finalize() is called.
    • Composition: Multiple extensions for the same type compose in the order they were registered.
    • Idempotency: Extensions should be written idempotently (returning the spec unchanged if the modification is already present) because the schema snapshot is rebuilt whenever registrations change.
    • Access: getNodeSpec() returns the finalized, composed spec.
    type NodeSpecExtension = (spec: NodeSpec) => NodeSpec;
  3. Understand notectl keyboard layouts and selection types

    main

    notectl uses physical key position for command shortcuts (e.g., Ctrl+A), ensuring consistent behavior across Latin and non-Latin (like Russian JCUKEN) layouts. Layout-aware characters are prioritized to avoid interfering with IME composition or dead keys.

    There are four selection types used to represent the cursor position:

    • Collapsed: A single cursor position (anchor = head).
    • Range: A span from anchor to head (e.g., via Shift+Arrow).
    • NodeSelection: An entire void block (like an image or horizontal rule) is selected.
    • GapCursorSelection: A virtual cursor at a void-block boundary where no text block exists.
  4. How the notectl data flow cycle works

    main

    Every user interaction in notectl follows a predictable, unidirectional cycle that moves from raw input to a synchronized DOM. This cycle ensures that the editor state remains the single source of truth while allowing plugins and middleware to intercept and modify changes.

    The Cycle Lifecycle:

    1. User Input: Browser events (e.g., beforeinput, keydown) are captured.
    2. Input/Keyboard Handling: InputHandler or KeyboardHandler maps events to specific editing commands.
    3. Transaction Building: A Transaction containing Steps is created via a builder.
    4. Middleware Execution: The transaction passes through a priority-ordered middleware chain.
    5. State Application: EditorState.apply(transaction) produces a new immutable state.
    6. History Recording: The History manager stores the transaction and its inverse for undo/redo.
    7. DOM Reconciliation: The Reconciler performs block-level diffing to patch the DOM efficiently.
    8. Selection Sync: The browser's cursor/selection is synchronized with the new model state.
    9. Plugin Notification: Plugins are notified of the change via onStateChange().
    User Input
        ↓
    InputHandler / KeyboardHandler
        ↓
    TransactionBuilder creates Transaction (Steps)
        ↓
    PluginManager.dispatchWithMiddleware()
        ↓ (priority-ordered middleware chain)
    EditorState.apply(transaction)
        ↓
    New immutable EditorState
        ↓
    Plugin.decorations?(state, tr) collected
        ↓
    Reconciler patches DOM (block-level diff)
        ↓
    SelectionSync updates cursor
        ↓
    Plugins notified via onStateChange()
  5. Configure BlockNode IDs and HTML Anchors

    main

    A BlockNode distinguishes between an internal identity and a document-local anchor:

    • id: The internal editor identity used for selections and transactions. It is always present and rendered as data-block-id.
    • htmlId: An optional string used as a document-local target for links (e.g., href="#installation"). It is rendered as the standard HTML id attribute.

    To set an htmlId, use the fifth argument of createBlockNode: createBlockNode(type, children, id, attrs, htmlId)

    // Heading that can be targeted by a document-local link (`href="#installation"`)
    const anchoredHeading = createBlockNode(
      nodeType('heading'),
      [createTextNode('Installation')],
      undefined,
      { level: 2 },
      'installation',
    );
  6. Related Concepts for Commands

    main

    To use commands effectively, you should understand the following core concepts:

    • Transaction: The Transaction type produced by commands to represent a change in the editor.
    • EditorState: The EditorState type that commands consume and transform.
    • Selection: The selection types used by movement and manipulation commands to define ranges.
    • Input System: The mechanism by which keymaps dispatch events to these commands.
  7. Understand the ListPlugin node structure

    main

    The ListPlugin uses a flat sibling model for efficiency. Instead of a deep tree, every list_item is a sibling that carries its own indent level and listType attribute. This makes indent/outdent operations simple attribute changes.

    Node Attributes:

    • listType: 'bullet' | 'ordered' | 'checklist'
    • indent: number (0-based, up to maxIndent)
    • checked: boolean (only for checklists)

    Multi-block items: A list_item can be a leaf (single paragraph) or a container for multiple blocks (e.g., a paragraph followed by a code block).

    {
      "type": "list_item",
      "attrs": { "listType": "bullet", "indent": 0, "checked": false },
      "children": [
        { "type": "paragraph", "children": [{ "text": "first paragraph" }] },
        { "type": "code_block", "attrs": { "language": "ts" }, "children": [{ "text": "const x = 1;" }] }
      ]
    }
  8. Use the PluginContext API to extend editor capabilities

    main

    The PluginContext object provided during init() is the gateway to all editor extensions. It allows you to interact with the state, register new schema elements, and hook into user input.

    Core Capabilities:

    • State & Dispatch: Use context.getState() to read the current state and context.dispatch(tr) to apply a Transaction.
    • Commands: Register named actions with context.registerCommand(id, handler) and trigger them with context.executeCommand(id).
    • Schema Extension:
      • registerNodeSpec: Add new block types.
      • registerMarkSpec: Add new inline marks.
      • registerInlineNodeSpec: Add atomic inline elements (e.g., emojis).
      • registerNodeSpecExtension: Augment an existing node type owned by another plugin (e.g., allowing a callout inside a table_cell).
    • Input & Keyboard:
      • registerKeymap: Bind keyboard shortcuts (use Mod for Cmd/Ctrl).
      • registerInputRule: Transform text patterns as the user types.
    • UI Components:
      • registerToolbarItem: Add buttons to the toolbar.
      • registerBlockTypePickerEntry: Add entries to the block type dropdown (requires dependencies: ['heading']).
    • Communication & Services:
      • getEventBus(): Emit/Listen to typed events using EventKey.
      • registerService / getService: Expose typed services via ServiceKey.
    • Interception & Middleware:
      • registerMiddleware: Intercept transactions before they are applied.
      • registerPasteInterceptor: Transform pasted content.
    • DOM & Styles:
      • getContainer(): Access the content-editable element.
      • getPluginContainer(area): Access 'top' or 'bottom' plugin areas.
      • registerStyleSheet: Inject CSS into the editor.
    • Other:
      • registerFileHandler: Handle drag-and-drop/paste of files.
      • registerNodeView: Register custom rendering for a block type.
      • announce: Push accessibility announcements to screen readers.
  9. Handle Document-Local Links

    main

    Fragment-only URLs (e.g., #installation) target blocks using their semantic HTML id. These links are treated as document-local and do not receive target="_blank", even if openInNewTab is enabled.

    Key Behaviors:

    • Resolution: An unmodified left click resolves the target within the same editor and scrolls it into view.
    • Targeting: The target is the semantic HTML id. The editor's internal BlockNode.id and data-block-id are not used as anchor names.
    • Exporting:
      • HTML import/export preserves links.
      • PrintPlugin ensures fragment links remain document-local in print/PDF output.
      • Markdown export preserves targets via htmlFallback: true. Disabling this fallback will drop htmlId because standard Markdown lacks block-level ID syntax.
    await editor.setContentHTML(`
      <p><a href="#installation">Go to installation</a></p>
      <h2 id="installation">Installation</h2>
    `);
  10. Understand the notectl theming cascade

    main

    notectl uses a three-tier CSS custom property cascade to resolve colors. This allows you to target specific components without affecting the entire editor.

    1. Component-scoped tokens: Highest priority. Targets a single specific component (e.g., --notectl-table-border).
    2. Global semantic tokens: Medium priority. Targets theme-wide elements (e.g., --notectl-border).
    3. Hard-coded fallback: Lowest priority. A hard-coded color used if no tokens are provided.

    Example: Styling only the table component

    notectl-editor {
      --notectl-table-border: #6366f1;
      --notectl-table-header-bg: #eef2ff;
    }
  11. How the Text Direction Auto middlewares work

    main

    The TextDirectionAutoPlugin operates through three distinct middleware behaviors that manage the dir attribute of blocks:

    1. Auto-Detect: When text is inserted or deleted in a block with dir="auto", the plugin detects the dominant direction from the first strong directional character (using Unicode Script property escapes) and updates the block's dir. It supports over 20 RTL scripts (e.g., Arabic, Hebrew, Syriac).
    2. Inherit Direction: When a new block is inserted (via Enter, paste, or insertNode), the plugin attempts to detect direction from the new content. If detection fails, it inherits the dir from the nearest sibling, ensuring seamless RTL writing.
    3. Preserve Direction: When a block's type is changed (e.g., changing a paragraph to a heading via setBlockType), the plugin copies the existing non-'auto' dir attribute into the new block attributes so the user's direction setting is not lost during the transformation.
  12. Configure Privacy and Security for the Video Plugin

    main

    The Video Plugin is designed with a 'zero-contact' privacy model by default.

    • Facade (Click-to-load): Enabled by default. No requests (including thumbnails) are made to the video provider until the user clicks 'Play'.
    • Thumbnails: To use provider-specific thumbnails instead of the local placeholder, set useProviderThumbnail: true. Note that this breaks the zero-contact guarantee as it triggers a third-party request.
    • Privacy-enhanced URLs: By default, the plugin uses privacy-friendly embed URLs (e.g., youtube-nocookie.com for YouTube and ?dnt=1 for Vimeo). To disable this and use standard URLs, set privacy: false.
    • HTML Export: When exporting to HTML, the plugin produces a progressive-enhancement <figure> element with data-* attributes. This allows a notectl renderer to upgrade it to a video facade, while in other contexts, it degrades to a safe, privacy-preserving link.
    • Security (DOMPurify): The plugin uses a DOMPurify hook to validate <iframe> src attributes. It only allows HTTPS schemes and hostnames that match your configured providers.