feat(editor): eslint/tsconfig migration + type fixes
@robonen/editor: migrate to eslint flat config + composite tsconfig; fix convergence test type annotations.
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { heading, paragraph } from '../../blocks';
|
||||
import { bold } from '../../marks';
|
||||
import { createRegistry } from '../registry';
|
||||
|
||||
describe('registry', () => {
|
||||
it('projects a schema from definitions', () => {
|
||||
const registry = createRegistry({ blocks: [paragraph, heading], marks: [bold] });
|
||||
expect(registry.schema.nodeSpec('paragraph')).toBeDefined();
|
||||
expect(registry.schema.markSpec('bold')).toBeDefined();
|
||||
expect(registry.getBlock('heading')?.meta?.title).toBe('Heading');
|
||||
expect(registry.listBlocks().map(block => block.type)).toEqual(['paragraph', 'heading']);
|
||||
});
|
||||
|
||||
it('throws on a duplicate type by default', () => {
|
||||
expect(() => createRegistry({ blocks: [paragraph, paragraph] })).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Component } from 'vue';
|
||||
import type { Attrs, Content, Node } from '../model';
|
||||
import type { NodeSpec } from '../schema';
|
||||
import type { CommandFactory } from '../state/command';
|
||||
import type { InputRuleSpec } from './input-rule';
|
||||
|
||||
/** Props passed to an atom/void block's Vue `component`. */
|
||||
export interface BlockComponentProps {
|
||||
/** The block's model node (read its `attrs`). */
|
||||
node: Node;
|
||||
/** Whether the block is currently node-selected. */
|
||||
selected: boolean;
|
||||
/** Editor-level editable flag. */
|
||||
editable: boolean;
|
||||
/** Merge new attrs into the block (e.g. image src/caption). */
|
||||
update: (attrs: Attrs) => void;
|
||||
}
|
||||
|
||||
/** Presentational/discovery metadata: powers slash menu, conversion, toolbars. */
|
||||
export interface BlockMeta {
|
||||
readonly title: string;
|
||||
readonly icon?: string;
|
||||
readonly keywords?: readonly string[];
|
||||
readonly group?: string;
|
||||
}
|
||||
|
||||
/** Optional block-specific behaviors used by core commands. */
|
||||
export interface BlockBehavior {
|
||||
/** Content for a fresh empty block of this type (defaults to empty inline). */
|
||||
readonly empty?: () => Content;
|
||||
/** Plain-text extraction (defaults to inline text). */
|
||||
readonly toText?: (node: Node) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A block definition: schema contribution + behavior + an opaque Vue component.
|
||||
* Non-view layers treat `component` as an opaque value; only the view resolves
|
||||
* it. The type is `Component` purely for authoring ergonomics (type-only import).
|
||||
*/
|
||||
export interface BlockDefinition {
|
||||
readonly type: string;
|
||||
readonly spec: NodeSpec;
|
||||
readonly component?: Component;
|
||||
readonly meta?: BlockMeta;
|
||||
readonly behavior?: BlockBehavior;
|
||||
readonly commands?: Record<string, CommandFactory>;
|
||||
/** Wrapper element tag for the block (default `'div'`). */
|
||||
readonly as?: string;
|
||||
/** Placeholder text shown when an empty text block has focus. */
|
||||
readonly placeholder?: string;
|
||||
readonly inputRules?: readonly InputRuleSpec[];
|
||||
}
|
||||
|
||||
/** Identity factory that narrows a block definition's literal type (cf. `definePlugin`). */
|
||||
export function defineBlock<const D extends BlockDefinition>(def: D): D {
|
||||
return def;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { MarkSpec } from '../schema';
|
||||
import type { InputRuleSpec } from './input-rule';
|
||||
|
||||
/** Presentational/discovery metadata for a mark (toolbar label, shortcut hint). */
|
||||
export interface MarkMeta {
|
||||
readonly title: string;
|
||||
readonly icon?: string;
|
||||
readonly hotkey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A mark definition: schema contribution (attrs, exclusivity, rank, toDOM,
|
||||
* parseDOM) + metadata. Marks are data-only — the view renders/parses them via
|
||||
* the spec, which is what makes them fully modular through the registry.
|
||||
*/
|
||||
export interface MarkDefinition {
|
||||
readonly type: string;
|
||||
readonly spec: MarkSpec;
|
||||
readonly meta?: MarkMeta;
|
||||
readonly inputRules?: readonly InputRuleSpec[];
|
||||
}
|
||||
|
||||
/** Identity factory that narrows a mark definition's literal type (cf. `definePlugin`). */
|
||||
export function defineMark<const D extends MarkDefinition>(def: D): D {
|
||||
return def;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './input-rule';
|
||||
export * from './define-block';
|
||||
export * from './define-mark';
|
||||
export * from './registry';
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Attrs } from '../model';
|
||||
|
||||
/**
|
||||
* A pattern that transforms the current block or marks when typed (e.g. `'# '`
|
||||
* → heading, `'**x**'` → bold). The matching engine lands in M2; the type is
|
||||
* declared now so block/mark definitions can carry their rules as data.
|
||||
*/
|
||||
export interface InputRuleSpec {
|
||||
/** Pattern tested against the text ending at the caret. */
|
||||
readonly match: RegExp;
|
||||
/** Target block/mark type to apply on match. */
|
||||
readonly type?: string;
|
||||
/** Attrs to apply with the transformation. */
|
||||
readonly attrs?: Attrs;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { MarkSpec, NodeSpec, Schema } from '../schema';
|
||||
import { createSchema } from '../schema';
|
||||
import type { BlockDefinition } from './define-block';
|
||||
import type { MarkDefinition } from './define-mark';
|
||||
|
||||
/** How to resolve two definitions registered under the same type. */
|
||||
export type ConflictPolicy = 'throw' | 'last-wins' | 'first-wins';
|
||||
|
||||
/**
|
||||
* The single source of truth for which block and mark types exist and how they
|
||||
* behave. Immutable: built once via {@link createRegistry}; {@link extendRegistry}
|
||||
* returns a new registry. The {@link Schema} is projected from the definitions.
|
||||
*/
|
||||
export interface Registry {
|
||||
readonly blocks: ReadonlyMap<string, BlockDefinition>;
|
||||
readonly marks: ReadonlyMap<string, MarkDefinition>;
|
||||
readonly schema: Schema;
|
||||
getBlock: (type: string) => BlockDefinition | undefined;
|
||||
getMark: (type: string) => MarkDefinition | undefined;
|
||||
/** Definitions in registration order (drives slash menu / toolbars). */
|
||||
listBlocks: () => readonly BlockDefinition[];
|
||||
listMarks: () => readonly MarkDefinition[];
|
||||
/** Alias of {@link listMarks}, for the inline renderer/parser. */
|
||||
allMarks: () => readonly MarkDefinition[];
|
||||
hasBlock: (type: string) => boolean;
|
||||
hasMark: (type: string) => boolean;
|
||||
}
|
||||
|
||||
export interface CreateRegistryOptions {
|
||||
readonly blocks?: readonly BlockDefinition[];
|
||||
readonly marks?: readonly MarkDefinition[];
|
||||
readonly onConflict?: ConflictPolicy;
|
||||
}
|
||||
|
||||
function buildMap<D extends { readonly type: string }>(
|
||||
items: readonly D[],
|
||||
onConflict: ConflictPolicy,
|
||||
kind: string,
|
||||
): Map<string, D> {
|
||||
const map = new Map<string, D>();
|
||||
|
||||
for (const item of items) {
|
||||
if (map.has(item.type)) {
|
||||
if (onConflict === 'throw')
|
||||
throw new Error(`Editor registry: duplicate ${kind} type '${item.type}'`);
|
||||
|
||||
if (onConflict === 'first-wins')
|
||||
continue;
|
||||
}
|
||||
|
||||
map.set(item.type, item);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Build an immutable {@link Registry} from block and mark definitions. */
|
||||
export function createRegistry(options: CreateRegistryOptions = {}): Registry {
|
||||
const onConflict = options.onConflict ?? 'throw';
|
||||
const blocks = buildMap(options.blocks ?? [], onConflict, 'block');
|
||||
const marks = buildMap(options.marks ?? [], onConflict, 'mark');
|
||||
|
||||
const nodeSpecs = new Map<string, NodeSpec>();
|
||||
for (const [type, def] of blocks)
|
||||
nodeSpecs.set(type, def.spec);
|
||||
|
||||
const markSpecs = new Map<string, MarkSpec>();
|
||||
for (const [type, def] of marks)
|
||||
markSpecs.set(type, def.spec);
|
||||
|
||||
const schema = createSchema({ nodes: nodeSpecs, marks: markSpecs });
|
||||
const blockList = [...blocks.values()];
|
||||
const markList = [...marks.values()];
|
||||
|
||||
return {
|
||||
blocks,
|
||||
marks,
|
||||
schema,
|
||||
getBlock: type => blocks.get(type),
|
||||
getMark: type => marks.get(type),
|
||||
listBlocks: () => blockList,
|
||||
listMarks: () => markList,
|
||||
allMarks: () => markList,
|
||||
hasBlock: type => blocks.has(type),
|
||||
hasMark: type => marks.has(type),
|
||||
};
|
||||
}
|
||||
|
||||
/** Return a new registry extending `base` with extra blocks/marks (override wins). */
|
||||
export function extendRegistry(
|
||||
base: Registry,
|
||||
add: { blocks?: readonly BlockDefinition[]; marks?: readonly MarkDefinition[]; onConflict?: ConflictPolicy },
|
||||
): Registry {
|
||||
return createRegistry({
|
||||
blocks: [...base.listBlocks(), ...(add.blocks ?? [])],
|
||||
marks: [...base.listMarks(), ...(add.marks ?? [])],
|
||||
onConflict: add.onConflict ?? 'last-wins',
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user