Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ jobs:
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm lint
# Unused files / exports / dependencies (knip) and unused CSS classes
- name: Dead code
run: pnpm deadcode
# Build first: the react package and the example type-check against the built core
- run: pnpm build
- run: pnpm typecheck
Expand Down
27 changes: 26 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
GitHub Release; pre-release tags publish under the `next` dist-tag.
- Public sanitization helpers: `sanitizeLinkHref`, `sanitizeImageSrc`, `sanitizeEmbedUrl`, `sanitizeCssColor`,
`sanitizeTextAlign`, `toNonNegativeNumber`, and `generateId`.
- CI workflow (lint, build, type-check, production dependency audit) and Dependabot configuration.
- `findBlockById(doc, id)` helper (early exit, never walks inline content).
- Multi-block selection API is now public: `createMultiBlockSelectionPlugin`, `MULTI_BLOCK_SELECTION_KEY`,
`selectBlock`, `clearBlockSelection`, `getSelectedBlockPositions`, `isMultiBlockSelectionActive` (+ types).
- `textColorMark`, `backgroundColorMark` and the `HardBreakContent` type are exported from the package root.
- CI workflow (lint, dead code, build, type-check, production dependency audit) and Dependabot configuration.
- `pnpm deadcode`: [knip](https://knip.dev) (unused files, exports, dependencies) + unused CSS class check.

### Performance
- **React**: `useOpenBlock` only subscribes to `change` / `selectionChange` while `onUpdate` /
`onSelectionChange` are provided. Previously every keystroke converted the whole document to JSON even when
nobody listened (~0.5 ms per keystroke on a 2,000-block document).
- Drag & drop: the block position cache is rebuilt lazily on hover instead of on every transaction; the side
menu is repositioned once per animation frame after edits (no forced synchronous layout per keystroke);
document-level `mousemove` / `mouseup` listeners are only attached while dragging; block DOM lookup uses
`view.nodeDOM()` instead of an attribute selector.
- Block id assignment and block lookups no longer walk inline content (x1.5 / x5 faster on large documents).
- `TableHandles`: table position resolved via `posAtDOM` (no full-document walk), mouse moves outside tables
are ignored when no handle is shown, no state updates on edits while hidden.
- `MediaMenu`: a single passive capture listener for scroll events (was two, hiding the menu twice).
- npm tarball of `@labbs/openblock-core`: 1.2 MB → 520 kB (standalone source maps are only shipped in the
GitHub Release assets).

### Removed
- Unused code: `styles/index.ts` and `types/index.ts` barrels, the `prosemirror-transform` dependency (never
imported), dead CSS rules (`.ob-text-align-buttons`, `.selectedCell`, table resize handles, `.ob-table-wrapper`,
`.ob-color-picker-indicator`), internal-only exports.

### Changed
- Removed the `uuid` runtime dependency (block IDs use the Web Crypto API).
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,8 +400,9 @@ pnpm install
# Build all packages (ESM + CJS + standalone bundles)
pnpm build

# Lint and type-check
# Lint, dead code (unused files / exports / deps / CSS classes) and type-check
pnpm lint
pnpm deadcode
pnpm typecheck

# Run example in dev mode
Expand Down
11 changes: 11 additions & 0 deletions knip.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
"workspaces": {
"packages/*": {
"project": ["src/**/*.{ts,tsx}"]
},
"examples/*": {
"project": ["src/**/*.{ts,tsx}"]
}
}
}
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@
"lint": "eslint packages/*/src",
"test": "pnpm -r test",
"clean": "pnpm -r clean",
"typecheck": "pnpm -r typecheck"
"typecheck": "pnpm -r typecheck",
"deadcode": "knip && node scripts/check-unused-css.mjs"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"eslint": "^10.14.0",
"globals": "^17.14.0",
"knip": "^6.44.1",
"typescript": "~6.0.3",
"typescript-eslint": "^8.72.1"
},
Expand Down
4 changes: 2 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
"./package.json": "./package.json"
},
"files": [
"dist"
"dist",
"!dist/standalone/*.map"
],
"keywords": [
"editor",
Expand Down Expand Up @@ -59,7 +60,6 @@
"prosemirror-model": "^1.25.12",
"prosemirror-schema-list": "^1.6.0",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.12.5",
"prosemirror-view": "^1.42.9"
},
"devDependencies": {
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/blocks/blockToNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export function blockToNode(schema: Schema, block: Block): PMNode {
* @param content - Array of inline content items
* @returns Array of ProseMirror nodes
*/
export function inlineContentToNodes(schema: Schema, content: InlineContent[]): PMNode[] {
function inlineContentToNodes(schema: Schema, content: InlineContent[]): PMNode[] {
const nodes: PMNode[] = [];

for (const item of content) {
Expand Down Expand Up @@ -143,7 +143,7 @@ export function inlineContentToNodes(schema: Schema, content: InlineContent[]):
* @param styles - The text styles object
* @returns Array of ProseMirror marks
*/
export function stylesToMarks(schema: Schema, styles: TextStyles): readonly Mark[] {
function stylesToMarks(schema: Schema, styles: TextStyles): readonly Mark[] {
const marks: Mark[] = [];

if (styles.bold && schema.marks.bold) {
Expand Down
40 changes: 40 additions & 0 deletions packages/core/src/blocks/findBlock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Block lookup helpers.
*
* @module
*/

import type { Node as PMNode } from 'prosemirror-model';

/**
* Finds a block node by its id.
*
* Block ids only live on block nodes, so inline content (text, hard breaks)
* is never visited, and the search stops at the first match - unlike
* `doc.descendants`, which cannot skip the remaining siblings.
*
* @param doc - The document (or any node) to search in
* @param id - The block id to look for
* @returns The node and its position, or null if not found
*/
export function findBlockById(doc: PMNode, id: string): { node: PMNode; pos: number } | null {
return id ? searchChildren(doc, 0, id) : null;
}

function searchChildren(
parent: PMNode,
start: number,
id: string
): { node: PMNode; pos: number } | null {
let pos = start;
for (let i = 0; i < parent.childCount; i++) {
const child = parent.child(i);
if (child.attrs.id === id) return { node: child, pos };
if (!child.inlineContent && child.childCount > 0) {
const found = searchChildren(child, pos + 1, id);
if (found) return found;
}
pos += child.nodeSize;
}
return null;
}
5 changes: 3 additions & 2 deletions packages/core/src/blocks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@ export type {
} from './types';

// Conversion functions
export { blockToNode, inlineContentToNodes, stylesToMarks, blocksToDoc } from './blockToNode';
export { nodeToBlock, marksToStyles, docToBlocks } from './nodeToBlock';
export { blockToNode, blocksToDoc } from './blockToNode';
export { nodeToBlock, docToBlocks } from './nodeToBlock';
export { findBlockById } from './findBlock';
2 changes: 1 addition & 1 deletion packages/core/src/blocks/nodeToBlock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ function nodeContentToInline(node: PMNode): InlineContent[] {
* @param marks - Array of ProseMirror marks
* @returns A TextStyles object
*/
export function marksToStyles(marks: readonly Mark[]): TextStyles {
function marksToStyles(marks: readonly Mark[]): TextStyles {
const styles: TextStyles = {};

for (const mark of marks) {
Expand Down
33 changes: 11 additions & 22 deletions packages/core/src/editor/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*
* @example
* ```typescript
* import { OpenBlockEditor } from '@openblock/core';
* import { OpenBlockEditor } from '@labbs/openblock-core';
*
* const editor = new OpenBlockEditor({
* element: document.getElementById('editor'),
Expand All @@ -24,7 +24,7 @@

import { EditorView } from 'prosemirror-view';
import { EditorState, Transaction, Plugin, Selection } from 'prosemirror-state';
import { Schema } from 'prosemirror-model';
import { Schema, Node as PMNode } from 'prosemirror-model';
import { history as createHistoryPlugin } from 'prosemirror-history';
import { generateId } from '../utils/generateId';

Expand All @@ -43,6 +43,7 @@ import {
blockToNode,
nodeToBlock,
docToBlocks,
findBlockById,
} from '../blocks';

/**
Expand Down Expand Up @@ -237,23 +238,9 @@ export class OpenBlockEditor {
return found ? nodeToBlock(found.node) : undefined;
}

/**
* Locate a block node by id. Stops traversing as soon as it is found
* (returning false from `descendants` only skips children, so we also
* short-circuit siblings with a flag).
*/
private _findBlockById(
id: string
): { node: import('prosemirror-model').Node; pos: number } | null {
let result: { node: import('prosemirror-model').Node; pos: number } | null = null;
this.pm.doc.descendants((node, pos) => {
if (result) return false;
if (node.attrs.id === id) {
result = { node, pos };
return false;
}
});
return result;
/** Locate a block node by id (see {@link findBlockById}). */
private _findBlockById(id: string): { node: PMNode; pos: number } | null {
return findBlockById(this.pm.doc, id);
}

/**
Expand Down Expand Up @@ -385,6 +372,8 @@ export class OpenBlockEditor {
// and deleting it first would invalidate this ancestor's range.
return false;
}
// Ids only live on block nodes: skip inline content
return !node.inlineContent;
});

// Delete in reverse order to preserve positions during iteration
Expand Down Expand Up @@ -846,7 +835,7 @@ export class OpenBlockEditor {
/**
* Convert a block to a code block.
*/
private _convertToCodeBlock(pos: number, node: import('prosemirror-model').Node): void {
private _convertToCodeBlock(pos: number, node: PMNode): void {
const codeBlockType = this._schema.nodes.codeBlock;
if (!codeBlockType) return;

Expand Down Expand Up @@ -987,7 +976,7 @@ export class OpenBlockEditor {
private _setColumnWidthsEvenly(
tr: Transaction,
columnListPos: number,
columnList: import('prosemirror-model').Node,
columnList: PMNode,
options: { skipPos?: number; columnCount?: number } = {}
): void {
const count = options.columnCount ?? columnList.childCount;
Expand Down Expand Up @@ -1023,7 +1012,7 @@ export class OpenBlockEditor {

// Going down to one (or zero) column: unwrap the columnList and keep content
if (columnList.childCount <= 2) {
const remainingContent: import('prosemirror-model').Node[] = [];
const remainingContent: PMNode[] = [];
columnList.forEach((col, offset) => {
const colPos = columnListPos + 1 + offset;
// Keep the other columns' content — or this column's own content
Expand Down
16 changes: 15 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ export {
strikethroughMark,
codeMark,
linkMark,
textColorMark,
backgroundColorMark,
} from './schema';

// Sanitization helpers - validate user-provided URLs and CSS values before
Expand All @@ -123,7 +125,7 @@ export { generateId } from './utils/generateId';
// BLOCKS
// ===========================================================================

export { blockToNode, nodeToBlock, blocksToDoc, docToBlocks } from './blocks';
export { blockToNode, nodeToBlock, blocksToDoc, docToBlocks, findBlockById } from './blocks';
export type {
Block,
PartialBlock,
Expand All @@ -132,6 +134,7 @@ export type {
TextStyles,
StyledText,
LinkContent,
HardBreakContent,
InlineContent,
} from './blocks';

Expand Down Expand Up @@ -211,6 +214,17 @@ export {
} from './plugins';
export type { BubbleMenuConfig, BubbleMenuState, BlockTypeInfo, TextAlign } from './plugins';

// Multi-block selection
export {
createMultiBlockSelectionPlugin,
MULTI_BLOCK_SELECTION_KEY,
selectBlock,
clearBlockSelection,
getSelectedBlockPositions,
isMultiBlockSelectionActive,
} from './plugins';
export type { MultiBlockSelectionConfig, MultiBlockSelectionState } from './plugins';

// Table plugin
export { createTablePlugin } from './plugins';
export type { TablePluginConfig } from './plugins';
Expand Down
17 changes: 9 additions & 8 deletions packages/core/src/plugins/blockIdPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const BLOCK_ID_PLUGIN_KEY = new PluginKey('blockIds');
*
* @example
* ```typescript
* import { createBlockIdPlugin } from '@openblock/core';
* import { createBlockIdPlugin } from '@labbs/openblock-core';
*
* const plugins = [
* createBlockIdPlugin(),
Expand All @@ -48,14 +48,11 @@ export function createBlockIdPlugin(): Plugin {
const seenIds = new Set<string>();

newState.doc.descendants((node, pos) => {
// Only handle block nodes (plus column nodes, which need IDs too)
if (!node.isBlock && node.type.name !== 'column') {
return;
}

// Only assign ID if the node type has an id attribute defined
// Ids only live on block nodes (columns included): inline nodes are
// never reached since textblock content is skipped below.
// Only assign an ID if the node type defines an id attribute.
if (node.attrs.id === undefined) {
return;
return !node.inlineContent;
}

const id = node.attrs.id as string | null;
Expand All @@ -69,6 +66,10 @@ export function createBlockIdPlugin(): Plugin {
} else {
seenIds.add(id);
}

// Ids only live on block nodes: skip inline content (text nodes are
// the bulk of the document, and this runs on every edit)
return !node.inlineContent;
});

// If no updates needed, return null
Expand Down
Loading
Loading