diff --git a/src/core/Editor.ts b/src/core/Editor.ts index a7f44344..524ba2eb 100644 --- a/src/core/Editor.ts +++ b/src/core/Editor.ts @@ -7,6 +7,7 @@ import type {CommonEditor, ContentHandler, MarkupString} from '../common'; import type {ActionsManager} from './ActionsManager'; import {WysiwygContentHandler} from './ContentHandler'; import {ExtensionsManager} from './ExtensionsManager'; +import {MarkupManagerOptions} from './markdown/MarkupManager'; import {TransformFn} from './markdown/ProseMirrorTransformer'; import type {ActionStorage} from './types/actions'; import type {Extension} from './types/extension'; @@ -38,6 +39,17 @@ export type WysiwygEditorOptions = { onChange?: OnChange; /** Call only if document change */ onDocChange?: OnChange; + /** + * Configures MarkupManager to control storing and processing + * raw markup, including tracked tokens, nodes, and attributes. + */ + markupManagerOpts?: MarkupManagerOptions; +}; + +const defaultMarkupManagerOpts: MarkupManagerOptions = { + allowDynamicAttributesForTrackedEntities: true, + trackedTokensTypes: ['yfm_table_open'], + trackedNodesTypes: ['yfm_table'], }; export class WysiwygEditor implements CommonEditor, ActionStorage { @@ -77,6 +89,7 @@ export class WysiwygEditor implements CommonEditor, ActionStorage { mdPreset, linkify, pmTransformers, + markupManagerOpts = defaultMarkupManagerOpts, linkifyTlds, escapeConfig, onChange, @@ -96,6 +109,7 @@ export class WysiwygEditor implements CommonEditor, ActionStorage { mdOpts: {html: allowHTML, linkify, breaks: true, preset: mdPreset}, linkifyTlds, pmTransformers, + markupManagerOpts, }); const state = EditorState.create({ diff --git a/src/core/ExtensionsManager.ts b/src/core/ExtensionsManager.ts index 7ce5de49..933778ca 100644 --- a/src/core/ExtensionsManager.ts +++ b/src/core/ExtensionsManager.ts @@ -6,6 +6,7 @@ import {ExtensionBuilder} from './ExtensionBuilder'; import {ParserTokensRegistry} from './ParserTokensRegistry'; import {SchemaSpecRegistry} from './SchemaSpecRegistry'; import {SerializerTokensRegistry} from './SerializerTokensRegistry'; +import {MarkupManager, MarkupManagerOptions} from './markdown/MarkupManager'; import {TransformFn} from './markdown/ProseMirrorTransformer'; import type {ActionSpec} from './types/actions'; import type { @@ -26,6 +27,7 @@ type ExtensionsManagerOptions = { mdOpts?: MarkdownIt.Options & {preset?: PresetName}; linkifyTlds?: string | string[]; pmTransformers?: TransformFn[]; + markupManagerOpts?: MarkupManagerOptions; }; export class ExtensionsManager { @@ -53,6 +55,7 @@ export class ExtensionsManager { #actions: Record = {}; #nodeViews: Record = {}; #markViews: Record = {}; + #markupManager: MarkupManager; constructor({extensions, options = {}}: ExtensionsManagerParams) { this.#extensions = extensions; @@ -72,6 +75,8 @@ export class ExtensionsManager { // TODO: add prefilled context this.#builder = new ExtensionBuilder(); + + this.#markupManager = new MarkupManager(options.markupManagerOpts); } build() { @@ -105,6 +110,16 @@ export class ExtensionsManager { private processNode = (name: string, {spec, fromMd, toMd: toMd, view}: ExtensionNodeSpec) => { this.#schemaRegistry.addNode(name, spec); + + // Inject nodeId attr for tracked nodes types + if ( + this.#markupManager.isAllowDynamicAttributesForTrackedEntities() && + this.#markupManager.isTrackedNodeType(name) + ) { + spec.attrs = spec.attrs || {}; + spec.attrs.nodeId = {default: null}; + } + this.#parserRegistry.addToken(fromMd.tokenName || name, fromMd.tokenSpec); this.#serializerRegistry.addNode(name, toMd); if (view) { @@ -122,24 +137,31 @@ export class ExtensionsManager { }; private createDeps() { + const actions = new ActionsManager(); + const schema = this.#schemaRegistry.createSchema(); + const markupParser = this.#parserRegistry.createParser( + schema, + this.#mdForMarkup, + this.#pmTransformers, + this.#markupManager, + ); + const textParser = this.#parserRegistry.createParser( + schema, + this.#mdForText, + this.#pmTransformers, + this.#markupManager, + ); + const serializer = this.#serializerRegistry.createSerializer(this.#markupManager); + this.#deps = { schema, - actions: new ActionsManager(), - markupParser: this.#parserRegistry.createParser( - schema, - this.#mdForMarkup, - this.#pmTransformers, - ), - textParser: this.#parserRegistry.createParser( - schema, - this.#mdForText, - this.#pmTransformers, - ), - serializer: this.#serializerRegistry.createSerializer(), + actions, + markupParser, + textParser, + serializer, }; } - private createDerived() { this.#plugins = this.#spec.plugins(this.#deps); Object.assign(this.#actions, this.#spec.actions(this.#deps)); diff --git a/src/core/ParserTokensRegistry.ts b/src/core/ParserTokensRegistry.ts index a8256363..f5bbdf32 100644 --- a/src/core/ParserTokensRegistry.ts +++ b/src/core/ParserTokensRegistry.ts @@ -2,6 +2,7 @@ import type MarkdownIt from 'markdown-it'; import type {Schema} from 'prosemirror-model'; import {MarkdownParser} from './markdown/MarkdownParser'; +import {MarkupManager} from './markdown/MarkupManager'; import {TransformFn} from './markdown/ProseMirrorTransformer'; import type {Parser, ParserToken} from './types/parser'; @@ -13,7 +14,12 @@ export class ParserTokensRegistry { return this; } - createParser(schema: Schema, tokenizer: MarkdownIt, pmTransformers: TransformFn[]): Parser { - return new MarkdownParser(schema, tokenizer, this.#tokens, pmTransformers); + createParser( + schema: Schema, + tokenizer: MarkdownIt, + pmTransformers: TransformFn[], + markupManager: MarkupManager, + ): Parser { + return new MarkdownParser(schema, tokenizer, this.#tokens, pmTransformers, markupManager); } } diff --git a/src/core/SerializerTokensRegistry.test.ts b/src/core/SerializerTokensRegistry.test.ts index 82b36b3c..71bebdba 100644 --- a/src/core/SerializerTokensRegistry.test.ts +++ b/src/core/SerializerTokensRegistry.test.ts @@ -1,28 +1,32 @@ import {SerializerTokensRegistry} from './SerializerTokensRegistry'; +import {MarkupManager} from './markdown/MarkupManager'; import type {SerializerTests} from './types/serializer'; describe('SerializerTokensRegistry', () => { it('should create empty serializer', () => { - const serializer = new SerializerTokensRegistry().createSerializer(); + const markupManager = new MarkupManager(); + const serializer = new SerializerTokensRegistry().createSerializer(markupManager); expect(typeof serializer.serialize).toBe('function'); }); it('should add nodes', () => { const nodeName = 'example_node'; + const markupManager = new MarkupManager(); const serializer = new SerializerTokensRegistry() .addNode(nodeName, () => {}) - .createSerializer() as SerializerTests; + .createSerializer(markupManager) as SerializerTests; expect(serializer.containsNode(nodeName)).toBe(true); }); it('should add marks', () => { const markName = 'example_mark'; + const markupManager = new MarkupManager(); const serializer = new SerializerTokensRegistry() .addMark(markName, {open: '1', close: '2'}) - .createSerializer() as SerializerTests; + .createSerializer(markupManager) as SerializerTests; expect(serializer.containsMark(markName)).toBe(true); }); diff --git a/src/core/SerializerTokensRegistry.ts b/src/core/SerializerTokensRegistry.ts index dabd0156..787da688 100644 --- a/src/core/SerializerTokensRegistry.ts +++ b/src/core/SerializerTokensRegistry.ts @@ -1,4 +1,5 @@ import {MarkdownSerializer} from './markdown/MarkdownSerializer'; +import {MarkupManager} from './markdown/MarkupManager'; import type {Serializer, SerializerMarkToken, SerializerNodeToken} from './types/serializer'; export class SerializerTokensRegistry { @@ -15,7 +16,7 @@ export class SerializerTokensRegistry { return this; } - createSerializer(): Serializer { - return new MarkdownSerializer(this.#nodes, this.#marks); + createSerializer(markupManager: MarkupManager): Serializer { + return new MarkdownSerializer(this.#nodes, this.#marks, markupManager); } } diff --git a/src/core/markdown/Markdown.test.ts b/src/core/markdown/Markdown.test.ts index 82996bde..05f52469 100644 --- a/src/core/markdown/Markdown.test.ts +++ b/src/core/markdown/Markdown.test.ts @@ -11,9 +11,12 @@ import type {SerializerNodeToken} from '../types/serializer'; import {MarkdownParser} from './MarkdownParser'; import {MarkdownSerializer} from './MarkdownSerializer'; +import {MarkupManager} from './MarkupManager'; const {schema} = builder; schema.nodes['hard_break'].spec.isBreak = true; + +const markupManager = new MarkupManager(); const parser: Parser = new MarkdownParser( schema, new MarkdownIt('commonmark'), @@ -35,6 +38,7 @@ const parser: Parser = new MarkdownParser( code_inline: {type: 'mark', name: 'code', noCloseToken: true}, }, [], + markupManager, ); const serializer = new MarkdownSerializer( { @@ -85,6 +89,7 @@ const serializer = new MarkdownSerializer( strong: {open: '**', close: '**', mixable: true, expelEnclosingWhitespace: true}, code: {open: '`', close: '`', escape: false, expelEnclosingWhitespace: true}, }, + markupManager, ); const {doc, p, h1, h2, li, ul, ol, br, pre} = builder; diff --git a/src/core/markdown/MarkdownParser.test.ts b/src/core/markdown/MarkdownParser.test.ts index 0085275d..d93dbb33 100644 --- a/src/core/markdown/MarkdownParser.test.ts +++ b/src/core/markdown/MarkdownParser.test.ts @@ -5,8 +5,11 @@ import {doc, br as hardBreak, p, schema} from 'prosemirror-test-builder'; import type {Parser} from '../types/parser'; import {MarkdownParser} from './MarkdownParser'; +import {MarkupManager} from './MarkupManager'; const md = MarkdownIt('commonmark', {html: false, breaks: true}); +const markupManager = new MarkupManager(); + const testParser: Parser = new MarkdownParser( schema, md, @@ -16,6 +19,7 @@ const testParser: Parser = new MarkdownParser( softbreak: {type: 'node', name: 'hard_break'}, }, [], + markupManager, ); function parseWith(parser: Parser) { diff --git a/src/core/markdown/MarkdownParser.ts b/src/core/markdown/MarkdownParser.ts index d0344a71..d0999f0a 100644 --- a/src/core/markdown/MarkdownParser.ts +++ b/src/core/markdown/MarkdownParser.ts @@ -6,6 +6,7 @@ import {Mark, MarkType, Node, NodeType, Schema} from 'prosemirror-model'; import {logger} from '../../logger'; import type {Parser, ParserToken} from '../types/parser'; +import {MarkupManager} from './MarkupManager'; import {ProseMirrorTransformer, TransformFn} from './ProseMirrorTransformer'; type TokenAttrs = {[name: string]: unknown}; @@ -18,6 +19,18 @@ enum TokenType { default = 'default', } +/** + * Generate a unique token ID + */ +export function createUniqueId(prefix: string): string { + const randomLetters = Array.from( + {length: 5}, + () => String.fromCharCode(97 + Math.floor(Math.random() * 26)), // a-z + ).join(''); + + return `${prefix}-${randomLetters}${Date.now()}`; +} + export class MarkdownParser implements Parser { schema: Schema; stack: Array<{type: NodeType; attrs?: TokenAttrs; content: Array}> = []; @@ -25,18 +38,21 @@ export class MarkdownParser implements Parser { tokens: Record; tokenizer: MarkdownIt; pmTransformers: TransformFn[]; + markupManager: MarkupManager; constructor( schema: Schema, tokenizer: MarkdownIt, tokens: Record, pmTransformers: TransformFn[], + markupManager: MarkupManager, ) { this.schema = schema; this.marks = Mark.none; this.tokens = tokens; this.tokenizer = tokenizer; this.pmTransformers = pmTransformers; + this.markupManager = markupManager; } validateLink(url: string): boolean { @@ -57,12 +73,23 @@ export class MarkdownParser implements Parser { parse(text: string) { const time = Date.now(); + this.markupManager.setMarkup(text); + try { this.stack = [{type: this.schema.topNodeType, content: []}]; let mdItTokens; try { mdItTokens = this.tokenizer.parse(text, {}); + if (this.markupManager.isAllowDynamicAttributesForTrackedEntities()) { + mdItTokens.forEach((token) => { + if (this.markupManager.isTrackedTokenType(token.type) && token.map) { + const tokenId = createUniqueId(token.type); + token.attrSet('tokenId', tokenId); + this.markupManager.setPos(tokenId, token.map); + } + }); + } } catch (err) { const e = err as Error; e.message = 'Unable to parse your markup. Please check for errors. ' + e.message; @@ -102,10 +129,25 @@ export class MarkdownParser implements Parser { tokenStream: Token[], i: number, ): TokenAttrs | undefined { - if (tokenSpec.getAttrs) return tokenSpec.getAttrs(token, tokenStream, i); - else if (tokenSpec.attrs instanceof Function) return tokenSpec.attrs(token); + let attrs: TokenAttrs | undefined = {}; + + if (tokenSpec.getAttrs) { + attrs = tokenSpec.getAttrs(token, tokenStream, i); + } else if (tokenSpec.attrs instanceof Function) { + attrs = tokenSpec.attrs(token); + } else { + attrs = tokenSpec.attrs; + } + + // Inject nodeId attr if the markdown token has tokenId + const tokdenId = token.attrGet('tokenId'); + if (tokdenId) { + attrs = attrs ?? {}; + attrs.nodeId = tokdenId; + } - return tokenSpec.attrs; + // TODO: @makhnatkin add cache + return attrs; } private getTokenSpec(token: Token) { diff --git a/src/core/markdown/MarkdownSerializer.js b/src/core/markdown/MarkdownSerializer.js index d9926549..807a3385 100644 --- a/src/core/markdown/MarkdownSerializer.js +++ b/src/core/markdown/MarkdownSerializer.js @@ -3,6 +3,8 @@ // ::- A specification for serializing a ProseMirror document as // Markdown/CommonMark text. // prettier-ignore +import {MarkupManager} from "./MarkupManager"; + export class MarkdownSerializer { // :: (Object<(state: MarkdownSerializerState, node: Node, parent: Node, index: number)>, Object) // Construct a serializer with the given configuration. The `nodes` @@ -37,18 +39,23 @@ export class MarkdownSerializer { // outside the marks. This is necessary for emphasis marks as // CommonMark does not permit enclosing whitespace inside emphasis // marks, see: http://spec.commonmark.org/0.26/#example-330 - constructor(nodes, marks) { + constructor(nodes, marks, markupManager) { // :: Object<(MarkdownSerializerState, Node)> The node serializer // functions for this serializer. this.nodes = nodes; // :: Object The mark serializer info. this.marks = marks; + + this.markupManager = markupManager; } // :: (Node, ?Object) → string // Serialize the content of the given node to // [CommonMark](http://commonmark.org/). serialize(content, options) { + if (!this.markupManager.rawMarkdown) { + console.warn('[MarkdownSerializer] No raw markdown is set'); + } const state = new MarkdownSerializerState(this.nodes, this.marks, options); state.renderContent(content); return state.out; @@ -227,8 +234,8 @@ export class MarkdownSerializerState { } } - const inner = marks.length && marks[marks.length - 1]; const - noEsc = inner && this.marks[inner.type.name].escape === false; + const inner = marks.length && marks[marks.length - 1]; + const noEsc = inner && this.marks[inner.type.name].escape === false; const len = marks.length - (noEsc ? 1 : 0); // Try to reorder 'mixable' marks, such as em and strong, which diff --git a/src/core/markdown/MarkupManager.ts b/src/core/markdown/MarkupManager.ts new file mode 100644 index 00000000..eacb53ca --- /dev/null +++ b/src/core/markdown/MarkupManager.ts @@ -0,0 +1,180 @@ +import {EventEmitter} from 'events'; + +import type {Parser} from '../types/parser'; + +export interface IMarkupManager { + setMarkup(rawMarkdown: string): void; + setPos(tokenId: string, pos: [number, number]): void; + setParser(parser: Parser): void; + reset(): void; + on(event: string, listener: (...args: any[]) => void): void; +} + +export interface Logger { + log(message: string): void; + error(message: string): void; +} + +/** + * Validate if a value is a valid position array + */ +function isPosArray(pos: unknown): pos is [number, number] { + return Array.isArray(pos) && pos.length === 2 && pos.every((v) => typeof v === 'number'); +} + +export interface MarkupManagerOptions { + trackedTokensTypes?: string[]; + trackedNodesTypes?: string[]; + allowDynamicAttributesForTrackedEntities?: boolean; +} + +export class MarkupManager extends EventEmitter implements IMarkupManager { + private _rawMarkdown = ''; + private _poses: Map = new Map(); + private _parser: Parser | null = null; + private _trackedTokensTypes: Set = new Set(); + private _trackedNodesTypes: Set = new Set(); + private _allowDynamicAttributesForTrackedEntities = false; + + private readonly logger: Logger; + + constructor(options: MarkupManagerOptions = {}, logger?: Logger) { + super(); + this._trackedTokensTypes = new Set(options.trackedTokensTypes ?? []); + this._trackedNodesTypes = new Set(options.trackedNodesTypes ?? []); + this._allowDynamicAttributesForTrackedEntities = + options.allowDynamicAttributesForTrackedEntities ?? true; + this.logger = logger ?? console; + } + + /** + * Set the list of tokens types to track + */ + setTrackedTokensTypes(tokensTypes: string[]): void { + this._trackedTokensTypes = new Set(tokensTypes); + this.logger.log(`[MarkupManager] Tracked tokens types set to: ${tokensTypes.join(', ')}`); + } + + /** + * Set the list of tokens types to track + */ + setTrackedNodesTypes(nodesTypes: string[]): void { + this._trackedNodesTypes = new Set(nodesTypes); + this.logger.log(`[MarkupManager] Tracked nodes types set to: ${nodesTypes.join(', ')}`); + } + + /** + * Check if a token type is being tracked + */ + isTrackedTokenType(tokenType: string): boolean { + return this._trackedTokensTypes.has(tokenType); + } + + /** + * Check if a token type is being tracked + */ + isTrackedNodeType(nodeType: string): boolean { + return this._trackedNodesTypes.has(nodeType); + } + + /** + * Check if a token type is being tracked + */ + isAllowDynamicAttributesForTrackedEntities(): boolean { + return this._allowDynamicAttributesForTrackedEntities; + } + + /** + * Set raw markdown text and emit an event + */ + setMarkup(rawMarkdown: string): void { + if (typeof rawMarkdown !== 'string') { + this.logger.error('[MarkupManager] rawMarkdown must be a string'); + return; + } + this._rawMarkdown = rawMarkdown; + + this.emit('markupManager.markupChanged', {data: rawMarkdown}); + this.logger.log('[MarkupManager] Raw markdown set successfully'); + } + + /** + * Get raw markdown for a specific token by its unique ID + */ + getMarkupByTokenId(tokenId: string): string | null { + const pos = this._poses.get(tokenId); + + if (!pos) { + this.logger.error(`[MarkupManager] No position found for ID: ${tokenId}`); + return null; + } + + const [start, end] = pos; + + return this._rawMarkdown.slice(start, end); + } + + /** + * Get the stored raw markdown text + */ + get rawMarkdown(): string { + return this._rawMarkdown; + } + + /** + * Set the position of a token + */ + setPos(id: string, pos: [number, number] | null): void { + if (!isPosArray(pos)) { + this.logger.error( + '[MarkupManager] Invalid position format. Must be [start, end] with numbers', + ); + return; + } + this._poses.set(id, pos); + + this.logger.log(`[MarkupManager] Position for ID ${id} set to ${pos}`); + } + + /** + * Get the positions map + */ + get poses(): Map { + return this._poses; + } + + /** + * Set the parser instance + */ + setParser(parser: Parser): void { + this._parser = parser; + + this.logger.log('[MarkupManager] Parser instance set successfully'); + } + + /** + * Get the parser instance + */ + get parser(): Parser { + if (!this._parser) { + const error = new Error('[MarkupManager] Markdown Parser is not set'); + this.logger.error(`[MarkupManager] ${error.message}`); + this.emit('markupManager.error', {error}); + throw error; + } + + return this._parser; + } + + /** + * Reset the stored raw markdown, positions, and parser, and emit an event + */ + reset(): void { + this._rawMarkdown = ''; + this._poses.clear(); + this._parser = null; + + this.emit('markupManager.markupChanged', {data: null}); + this.logger.log('[MarkupManager] MarkupManager has been reset'); + } +}