diff --git a/packages/editor/src/bundle/toolbar/wysiwyg/WToolbarColors.tsx b/packages/editor/src/bundle/toolbar/wysiwyg/WToolbarColors.tsx index 67e363a5c..9e667131d 100644 --- a/packages/editor/src/bundle/toolbar/wysiwyg/WToolbarColors.tsx +++ b/packages/editor/src/bundle/toolbar/wysiwyg/WToolbarColors.tsx @@ -27,7 +27,7 @@ export const WToolbarColors: React.FC = ({ enable={enabled} currentColor={currentColor} exec={(color) => { - action.run({color: color === currentColor ? '' : color}); + action.run({color}); }} disablePortal={disablePortal} className={className} diff --git a/packages/editor/src/extensions/markdown/Bold/index.ts b/packages/editor/src/extensions/markdown/Bold/index.ts index 2b23c099b..161483859 100644 --- a/packages/editor/src/extensions/markdown/Bold/index.ts +++ b/packages/editor/src/extensions/markdown/Bold/index.ts @@ -1,7 +1,8 @@ -import {toggleMark} from 'prosemirror-commands'; - import type {Action, ExtensionAuto} from '../../../core'; -import {createMarkdownInlineMarkAction} from '../../../utils/actions'; +import { + createMarkdownInlineMarkAction, + createMarkdownInlineMarkCommand, +} from '../../../utils/actions'; import {markInputRule} from '../../../utils/inputrules'; import {withLogAction} from '../../../utils/keymap'; @@ -21,7 +22,7 @@ export const Bold: ExtensionAuto = (builder, opts) => { if (opts?.boldKey) { const {boldKey} = opts; builder.addKeymap(({schema}) => ({ - [boldKey]: withLogAction('bold', toggleMark(boldType(schema))), + [boldKey]: withLogAction('bold', createMarkdownInlineMarkCommand(boldType(schema))), })); } diff --git a/packages/editor/src/extensions/markdown/Code/index.ts b/packages/editor/src/extensions/markdown/Code/index.ts index 1ee6bfc66..2d3a10809 100644 --- a/packages/editor/src/extensions/markdown/Code/index.ts +++ b/packages/editor/src/extensions/markdown/Code/index.ts @@ -1,9 +1,11 @@ import codemark from 'prosemirror-codemark'; -import {toggleMark} from 'prosemirror-commands'; import {Plugin} from 'prosemirror-state'; import type {Action, ExtensionAuto} from '../../../core'; -import {createMarkdownInlineMarkAction} from '../../../utils/actions'; +import { + createMarkdownInlineMarkAction, + createMarkdownInlineMarkCommand, +} from '../../../utils/actions'; import {withLogAction} from '../../../utils/keymap'; import {CodeSpecs, codeType} from './CodeSpecs'; @@ -24,7 +26,10 @@ export const Code: ExtensionAuto = (builder, opts) => { if (opts?.codeKey) { const {codeKey} = opts; builder.addKeymap(({schema}) => ({ - [codeKey]: withLogAction('code_inline', toggleMark(codeType(schema))), + [codeKey]: withLogAction( + 'code_inline', + createMarkdownInlineMarkCommand(codeType(schema)), + ), })); } diff --git a/packages/editor/src/extensions/markdown/Italic/index.ts b/packages/editor/src/extensions/markdown/Italic/index.ts index e2f8bcb50..b6620d3e8 100644 --- a/packages/editor/src/extensions/markdown/Italic/index.ts +++ b/packages/editor/src/extensions/markdown/Italic/index.ts @@ -1,7 +1,8 @@ -import {toggleMark} from 'prosemirror-commands'; - import type {Action, ExtensionAuto} from '../../../core'; -import {createMarkdownInlineMarkAction} from '../../../utils/actions'; +import { + createMarkdownInlineMarkAction, + createMarkdownInlineMarkCommand, +} from '../../../utils/actions'; import {markInputRule} from '../../../utils/inputrules'; import {withLogAction} from '../../../utils/keymap'; @@ -29,7 +30,10 @@ export const Italic: ExtensionAuto = (builder, opts) => { if (opts?.italicKey) { const {italicKey} = opts; builder.addKeymap(({schema}) => ({ - [italicKey]: withLogAction('italic', toggleMark(italicType(schema))), + [italicKey]: withLogAction( + 'italic', + createMarkdownInlineMarkCommand(italicType(schema)), + ), })); } }; diff --git a/packages/editor/src/extensions/markdown/Strike/index.ts b/packages/editor/src/extensions/markdown/Strike/index.ts index e9a08a85b..718113382 100644 --- a/packages/editor/src/extensions/markdown/Strike/index.ts +++ b/packages/editor/src/extensions/markdown/Strike/index.ts @@ -1,7 +1,8 @@ -import {toggleMark} from 'prosemirror-commands'; - import type {Action, ExtensionAuto} from '../../../core'; -import {createMarkdownInlineMarkAction} from '../../../utils/actions'; +import { + createMarkdownInlineMarkAction, + createMarkdownInlineMarkCommand, +} from '../../../utils/actions'; import {markInputRule} from '../../../utils/inputrules'; import {withLogAction} from '../../../utils/keymap'; @@ -28,7 +29,10 @@ export const Strike: ExtensionAuto = (builder, opts) => { if (opts?.strikeKey) { const {strikeKey} = opts; builder.addKeymap(({schema}) => ({ - [strikeKey]: withLogAction('strike', toggleMark(strikeType(schema))), + [strikeKey]: withLogAction( + 'strike', + createMarkdownInlineMarkCommand(strikeType(schema)), + ), })); } }; diff --git a/packages/editor/src/extensions/markdown/Underline/index.ts b/packages/editor/src/extensions/markdown/Underline/index.ts index 1252f1b99..f9bad1d63 100644 --- a/packages/editor/src/extensions/markdown/Underline/index.ts +++ b/packages/editor/src/extensions/markdown/Underline/index.ts @@ -1,7 +1,8 @@ -import {toggleMark} from 'prosemirror-commands'; - import type {Action, ExtensionAuto} from '../../../core'; -import {createMarkdownInlineMarkAction} from '../../../utils/actions'; +import { + createMarkdownInlineMarkAction, + createMarkdownInlineMarkCommand, +} from '../../../utils/actions'; import {markInputRule} from '../../../utils/inputrules'; import {withLogAction} from '../../../utils/keymap'; @@ -28,7 +29,10 @@ export const Underline: ExtensionAuto = (builder, opts) => { if (opts?.underlineKey) { const {underlineKey} = opts; builder.addKeymap(({schema}) => ({ - [underlineKey]: withLogAction('underline', toggleMark(underlineType(schema))), + [underlineKey]: withLogAction( + 'underline', + createMarkdownInlineMarkCommand(underlineType(schema)), + ), })); } }; diff --git a/packages/editor/src/extensions/yfm/Color/Color.action.test.ts b/packages/editor/src/extensions/yfm/Color/Color.action.test.ts new file mode 100644 index 000000000..7f40ba0cb --- /dev/null +++ b/packages/editor/src/extensions/yfm/Color/Color.action.test.ts @@ -0,0 +1,131 @@ +import type {MarkType} from 'prosemirror-model'; +import {EditorState, TextSelection} from 'prosemirror-state'; + +import {ExtensionsManager} from '../../../core'; +import type {ActionSpec} from '../../../core/types/actions'; +import {BaseSchemaSpecs} from '../../base/specs'; + +import {colorMarkName, colorType} from './ColorSpecs'; +import {colorAction} from './const'; + +import {Color} from './index'; + +const {schema, rawActions} = new ExtensionsManager({ + extensions: (builder) => builder.use(BaseSchemaSpecs, {}).use(Color), +}).build(); + +const action: ActionSpec = rawActions[colorAction]; +const color: MarkType = colorType(schema); +const isActive = action.isActive as (state: EditorState) => boolean; +const meta = action.meta as (state: EditorState) => string | undefined; + +type Segment = string | {text: string; color: string}; + +function makeState(segments: Segment[], from: number, to: number): EditorState { + const paragraph = schema.nodes.paragraph; + const nodes = segments.map((segment) => + typeof segment === 'string' + ? schema.text(segment) + : schema.text(segment.text, [color.create({[colorMarkName]: segment.color})]), + ); + const doc = schema.node('doc', null, [paragraph.create(null, nodes)]); + const selection = TextSelection.create(doc, from + 1, to + 1); + return EditorState.create({doc, selection}); +} + +function run(state: EditorState, attrs?: {color?: string}) { + const ref = {state}; + action.run( + ref.state, + (tr) => { + ref.state = ref.state.apply(tr); + }, + undefined as never, + attrs, + ); + return ref.state; +} + +function colorValues(state: EditorState) { + const values: Array = []; + state.doc.descendants((node) => { + if (node.isText) { + values.push(color.isInSet(node.marks)?.attrs[colorMarkName]); + } + return true; + }); + return values; +} + +function storedColor(state: EditorState) { + return color.isInSet(state.storedMarks ?? [])?.attrs[colorMarkName]; +} + +describe('Color action', () => { + it('adds a stored color mark at the cursor', () => { + const next = run(makeState(['hello'], 2, 2), {color: 'red'}); + + expect(storedColor(next)).toBe('red'); + }); + + it('removes the stored color when the same color is chosen at the cursor', () => { + const base = makeState(['hello'], 2, 2); + const withStored = base.apply( + base.tr.addStoredMark(color.create({[colorMarkName]: 'red'})), + ); + + expect(storedColor(run(withStored, {color: 'red'}))).toBeUndefined(); + }); + + it('replaces the stored color when a different color is chosen at the cursor', () => { + const base = makeState(['hello'], 2, 2); + const withStored = base.apply( + base.tr.addStoredMark(color.create({[colorMarkName]: 'blue'})), + ); + + expect(storedColor(run(withStored, {color: 'red'}))).toBe('red'); + }); + + it('applies the chosen color to the whole mixed selection', () => { + const next = run(makeState([{text: 'AB', color: 'red'}, 'CD'], 0, 4), {color: 'red'}); + + expect(colorValues(next)).toEqual(['red']); + }); + + it('removes the color from a fully covered selection', () => { + const next = run(makeState([{text: 'ABC', color: 'red'}], 0, 3), {color: 'red'}); + + expect(colorValues(next)).toEqual([undefined]); + }); + + it('removes the color from a fully covered selection without coloring trailing whitespace', () => { + const next = run(makeState([{text: 'ABC', color: 'red'}, ' '], 0, 4), {color: 'red'}); + + expect(colorValues(next)).toEqual([undefined]); + }); + + it('replaces a fully covered selection with a different color', () => { + const next = run(makeState([{text: 'ABC', color: 'blue'}], 0, 3), {color: 'red'}); + + expect(colorValues(next)).toEqual(['red']); + }); + + it('replaces the color without extending it to trailing whitespace', () => { + const next = run(makeState([{text: 'ABC', color: 'blue'}, ' '], 0, 4), {color: 'red'}); + + expect(colorValues(next)).toEqual(['red', undefined]); + }); + + it('exposes stored-mark state through isActive and meta', () => { + const next = run(makeState(['hello'], 2, 2), {color: 'red'}); + + expect(isActive(next)).toBe(true); + expect(meta(next)).toBe('red'); + }); + + it('keeps partially colored selections active', () => { + const state = makeState([{text: 'AB', color: 'red'}, 'CD'], 0, 4); + + expect(isActive(state)).toBe(true); + }); +}); diff --git a/packages/editor/src/extensions/yfm/Color/index.ts b/packages/editor/src/extensions/yfm/Color/index.ts index 8c18fcaaa..b05dba5fd 100644 --- a/packages/editor/src/extensions/yfm/Color/index.ts +++ b/packages/editor/src/extensions/yfm/Color/index.ts @@ -1,11 +1,13 @@ import {toggleMark} from 'prosemirror-commands'; +import type {MarkType, ResolvedPos} from 'prosemirror-model'; +import type {EditorState, TextSelection, Transaction} from 'prosemirror-state'; import type {Action, ExtensionAuto} from '../../../core'; import {isMarkActive} from '../../../utils/marks'; import {ColorSpecs, colorType} from './ColorSpecs'; import {type Colors, colorAction, colorMarkName} from './const'; -import {chainAND, parseStyleColorValue, validateClassNameColorName} from './utils'; +import {parseStyleColorValue, validateClassNameColorName} from './utils'; import './colors.scss'; @@ -16,6 +18,103 @@ export type ColorActionParams = { [colorMarkName]: string; }; +function getEffectiveMarks(state: EditorState, $pos: ResolvedPos = state.selection.$to) { + return state.storedMarks ?? $pos.marks(); +} + +function rangeSelectionTextIsWhitespaceOnly( + text: string | undefined, + from: number, + to: number, + nodePos: number, +) { + const selectedText = text?.slice(Math.max(0, from - nodePos), Math.max(0, to - nodePos)) ?? ''; + + return /^\s*$/.test(selectedText); +} + +function selectionAllHasColor(state: EditorState, type: MarkType, color: string): boolean { + let hasText = false; + const allHave = state.selection.ranges.every(({$from, $to}) => { + let rangeAllHave = true; + state.doc.nodesBetween($from.pos, $to.pos, (node, pos, parent) => { + if (!rangeAllHave || !node.isText || !parent?.type.allowsMarkType(type)) { + return rangeAllHave; + } + + if (rangeSelectionTextIsWhitespaceOnly(node.text, $from.pos, $to.pos, pos)) { + return rangeAllHave; + } + + hasText = true; + rangeAllHave = type.isInSet(node.marks)?.attrs[colorMarkName] === color; + + return rangeAllHave; + }); + + return rangeAllHave; + }); + + return hasText && allHave; +} + +function toggleColorAtCursor( + state: EditorState, + dispatch: (tr: Transaction) => void, + type: MarkType, + color?: string, +) { + const {$cursor} = state.selection as TextSelection; + if (!$cursor) return false; + + const storedMark = type.isInSet(getEffectiveMarks(state, $cursor)); + if (!color || storedMark?.attrs[colorMarkName] === color) { + dispatch(state.tr.removeStoredMark(type)); + } else { + dispatch(state.tr.addStoredMark(type.create({[colorMarkName]: color}))); + } + + return true; +} + +function toggleColorInSelection( + state: EditorState, + dispatch: (tr: Transaction) => void, + type: MarkType, + color?: string, +) { + const tr = state.tr; + + if (color) { + const allSameColor = selectionAllHasColor(state, type, color); + state.selection.ranges.forEach(({$from, $to}) => { + if (allSameColor) { + tr.removeMark($from.pos, $to.pos, type); + } else { + let from = $from.pos; + let to = $to.pos; + const start = $from.nodeAfter; + const end = $to.nodeBefore; + const spaceStart = start?.isText ? /^\s*/.exec(start.text)?.[0].length ?? 0 : 0; + const spaceEnd = end?.isText ? /\s*$/.exec(end.text)?.[0].length ?? 0 : 0; + + if (from + spaceStart < to) { + from += spaceStart; + to -= spaceEnd; + tr.addMark(from, to, type.create({[colorMarkName]: color})); + } + } + }); + } else { + state.selection.ranges.forEach(({$from, $to}) => { + tr.removeMark($from.pos, $to.pos, type); + }); + } + + dispatch(tr.scrollIntoView()); + return true; +} + export const Color: ExtensionAuto = (builder) => { builder.use(ColorSpecs, { validateClassNameColorName, @@ -28,26 +127,18 @@ export const Color: ExtensionAuto = (builder) => { isActive: (state) => Boolean(isMarkActive(state, type)), isEnable: toggleMark(type), run: (state, dispatch, _view, attrs) => { - const params = attrs as ColorActionParams | undefined; - const hasMark = isMarkActive(state, type); - - if (!params || !params[colorMarkName]) { - if (!hasMark) return true; + const color = (attrs as ColorActionParams | undefined)?.[colorMarkName]; - // remove mark - return toggleMark(type, params)(state, dispatch); - } + if (!dispatch) return true; - if (hasMark) { - // remove old mark, then add new with new color - return chainAND(toggleMark(type), toggleMark(type, params))(state, dispatch); + if ((state.selection as TextSelection).empty) { + return toggleColorAtCursor(state, dispatch, type, color); } - // add mark - return toggleMark(type, params)(state, dispatch); + return toggleColorInSelection(state, dispatch, type, color); }, meta(state): Colors { - return type.isInSet(state.selection.$to.marks())?.attrs[colorMarkName]; + return type.isInSet(getEffectiveMarks(state))?.attrs[colorMarkName]; }, }; }); diff --git a/packages/editor/src/markup/commands/marks.test.ts b/packages/editor/src/markup/commands/marks.test.ts new file mode 100644 index 000000000..0bfffd99f --- /dev/null +++ b/packages/editor/src/markup/commands/marks.test.ts @@ -0,0 +1,39 @@ +import {EditorSelection, EditorState, type Transaction} from '@codemirror/state'; + +import {colorify} from './marks'; + +function runColorify(doc: string, anchor: number, head: number = anchor) { + const state = EditorState.create({doc, selection: EditorSelection.single(anchor, head)}); + const ref = {state}; + + colorify('red')({ + state, + dispatch: (tr: Transaction) => { + ref.state = tr.state; + }, + }); + + return ref.state; +} + +describe('colorify', () => { + it('wraps a plain selection', () => { + expect(runColorify('text', 0, 4).doc.toString()).toBe('{red}(text)'); + }); + + it('unwraps the same color', () => { + expect(runColorify('{red}(text)', 6, 10).doc.toString()).toBe('text'); + }); + + it('replaces an existing color wrapper without nesting', () => { + expect(runColorify('{blue}(text)', 7, 11).doc.toString()).toBe('{red}(text)'); + }); + + it('inserts a wrapper at the cursor and keeps the cursor inside', () => { + const next = runColorify('ab', 1); + + expect(next.doc.toString()).toBe('a{red}()b'); + expect(next.selection.main.from).toBe(7); + expect(next.selection.main.to).toBe(7); + }); +}); diff --git a/packages/editor/src/markup/commands/marks.ts b/packages/editor/src/markup/commands/marks.ts index 35baf8072..f118776d9 100644 --- a/packages/editor/src/markup/commands/marks.ts +++ b/packages/editor/src/markup/commands/marks.ts @@ -1,6 +1,77 @@ -import {inlineWrapTo, toggleInlineMarkupFactory} from './helpers'; +import {EditorSelection, type EditorState, type StateCommand} from '@codemirror/state'; -export const colorify = (color: string) => inlineWrapTo(`{${color}}(`, ')'); +import {toggleInlineMarkupFactory} from './helpers'; + +const COLOR_WRAPPER_RE = /\{([a-z0-9-]+)\}\($/i; + +type ColorWrapper = { + color: string; + from: number; + to: number; +}; + +function findColorWrapperBefore(state: EditorState, pos: number): ColorWrapper | null { + const from = state.doc.lineAt(pos).from; + const textBefore = state.sliceDoc(from, pos); + const match = textBefore.match(COLOR_WRAPPER_RE); + + if (!match) { + return null; + } + + return { + color: match[1], + from: pos - match[0].length, + to: pos, + }; +} + +export const colorify = (color: string): StateCommand => { + const opener = `{${color}}(`; + + return ({state, dispatch}) => { + const tr = state.changeByRange((range) => { + const wrapper = findColorWrapperBefore(state, range.from); + const hasClosingParen = state.sliceDoc(range.to, range.to + 1) === ')'; + let changes; + + if (wrapper && hasClosingParen) { + if (wrapper.color === color) { + changes = [ + {from: wrapper.from, to: wrapper.to, insert: ''}, + {from: range.to, to: range.to + 1, insert: ''}, + ]; + } else { + changes = [{from: wrapper.from, to: wrapper.to, insert: opener}]; + } + } else { + changes = [ + {from: range.from, insert: opener}, + {from: range.to, insert: ')'}, + ]; + } + + const changeSet = state.changes(changes); + + return { + changes: changeSet, + range: + range.empty && !(wrapper && hasClosingParen) + ? EditorSelection.range( + range.anchor + opener.length, + range.head + opener.length, + range.goalColumn, + range.bidiLevel ?? undefined, + ) + : range.map(changeSet), + }; + }); + + dispatch(state.update({...tr, scrollIntoView: true})); + + return true; + }; +}; export const toggleBold = toggleInlineMarkupFactory('**'); export const toggleItalic = toggleInlineMarkupFactory('_'); diff --git a/packages/editor/src/utils/actions.test.ts b/packages/editor/src/utils/actions.test.ts new file mode 100644 index 000000000..6aaf3153e --- /dev/null +++ b/packages/editor/src/utils/actions.test.ts @@ -0,0 +1,158 @@ +import MarkdownIt from 'markdown-it'; +import {Schema} from 'prosemirror-model'; +import {EditorState, TextSelection} from 'prosemirror-state'; + +import type {Parser} from '../core/types/parser'; +import {ParserFacet} from '../core/utils/parser'; + +import {createMarkdownInlineMarkAction, createMarkdownInlineMarkCommand} from './actions'; + +const schema = new Schema({ + nodes: { + doc: {content: 'block+'}, + paragraph: {content: 'inline*', group: 'block'}, + text: {group: 'inline'}, + }, + marks: { + bold: {}, + }, +}); + +const md = new MarkdownIt(); +const mockParser: Parser = { + isPunctChar: (ch: string) => md.utils.isPunctChar(ch), + parse: () => { + throw new Error('not implemented'); + }, + validateLink: () => true, + normalizeLink: (url) => url, + normalizeLinkText: (url) => url, + matchLinks: () => null, +}; + +const boldType = schema.marks.bold; +const parserPlugin = ParserFacet.of(mockParser); +const action = createMarkdownInlineMarkAction(boldType); +const command = createMarkdownInlineMarkCommand(boldType); + +type Segment = string | {text: string; bold: true}; + +function makeState(segments: Segment[], from: number, to: number): EditorState { + const nodes = segments.map((segment) => + typeof segment === 'string' + ? schema.text(segment) + : schema.text(segment.text, [boldType.create()]), + ); + const doc = schema.node('doc', null, [schema.node('paragraph', null, nodes)]); + const selection = TextSelection.create(doc, from + 1, to + 1); + return EditorState.create({doc, selection, plugins: [parserPlugin]}); +} + +function runAction(state: EditorState) { + const ref = {state}; + action.run( + ref.state, + (tr) => { + ref.state = ref.state.apply(tr); + }, + undefined as never, + undefined, + ); + return ref.state; +} + +function runCommand(state: EditorState) { + const ref = {state}; + const handled = command( + ref.state, + (tr) => { + ref.state = ref.state.apply(tr); + }, + undefined as never, + ); + + return {handled, state: ref.state}; +} + +function everyTextNodeHasBold(state: EditorState) { + let allBold = true; + state.doc.descendants((node) => { + if (node.isText && !boldType.isInSet(node.marks)) { + allBold = false; + } + return true; + }); + return allBold; +} + +function noTextNodeHasBold(state: EditorState) { + let hasBold = false; + state.doc.descendants((node) => { + if (node.isText && boldType.isInSet(node.marks)) { + hasBold = true; + } + return true; + }); + return !hasBold; +} + +function boldValues(state: EditorState) { + const values: boolean[] = []; + state.doc.descendants((node) => { + if (node.isText) { + values.push(Boolean(boldType.isInSet(node.marks))); + } + return true; + }); + return values; +} + +describe('createMarkdownInlineMarkAction', () => { + it('applies the mark to the whole mixed selection', () => { + const state = makeState([{text: 'hello', bold: true}, ' world'], 0, 11); + const next = runAction(state); + + expect(everyTextNodeHasBold(next)).toBe(true); + }); + + it('removes the mark from a fully covered selection', () => { + const state = makeState([{text: 'hello', bold: true}], 0, 5); + const next = runAction(state); + + expect(noTextNodeHasBold(next)).toBe(true); + }); + + it('blocks apply on invalid markdown boundaries but still allows removal', () => { + const blocked = makeState([{text: 'hello', bold: true}, ','], 5, 6); + expect(runAction(blocked).doc.eq(blocked.doc)).toBe(true); + + const removable = makeState([{text: 'hello,', bold: true}], 5, 6); + expect(boldValues(runAction(removable))).toEqual([true, false]); + }); +}); + +describe('createMarkdownInlineMarkCommand', () => { + it('matches the action behavior on mixed selections', () => { + const state = makeState([{text: 'hello', bold: true}, ' world'], 0, 11); + const next = runCommand(state); + + expect(next.handled).toBe(true); + expect(everyTextNodeHasBold(next.state)).toBe(true); + }); + + it('is blocked by the same markdown boundary guard', () => { + const state = makeState([{text: 'hello', bold: true}, ','], 5, 6); + const next = runCommand(state); + + expect(next.handled).toBe(false); + expect(next.state.doc.eq(state.doc)).toBe(true); + }); + + it('still removes the mark when the blocked punctuation is already fully covered', () => { + const state = makeState([{text: 'hello,', bold: true}], 5, 6); + const next = runCommand(state); + + expect(next.handled).toBe(true); + expect(boldValues(next.state)).toEqual([true, false]); + }); +}); diff --git a/packages/editor/src/utils/actions.ts b/packages/editor/src/utils/actions.ts index e3da4ba08..5d055087e 100644 --- a/packages/editor/src/utils/actions.ts +++ b/packages/editor/src/utils/actions.ts @@ -1,5 +1,6 @@ import {toggleMark} from 'prosemirror-commands'; import type {MarkType} from 'prosemirror-model'; +import type {Command} from 'prosemirror-state'; import type {ActionSpec} from '../core'; @@ -9,12 +10,64 @@ export function defineActions(actions: Record[0], markType: MarkType): boolean { + let hasText = false; + const allHave = state.selection.ranges.every(({$from, $to}) => { + let rangeAllHave = true; + state.doc.nodesBetween($from.pos, $to.pos, (node, _pos, parent) => { + if (!rangeAllHave || !node.isText || !parent?.type.allowsMarkType(markType)) { + return rangeAllHave; + } + + hasText = true; + rangeAllHave = Boolean(markType.isInSet(node.marks)); + + return rangeAllHave; + }); + + return rangeAllHave; + }); + + return hasText && allHave; +} + +export function createPartialToggleMarkCommand(markType: MarkType): Command { + return toggleMark(markType, undefined, {removeWhenPresent: false}); +} + export function createToggleMarkAction(markType: MarkType): ActionSpec { - const command = toggleMark(markType); + const command = createToggleMarkCommand(markType); + return { + isActive: (state) => Boolean(isMarkActive(state, markType)), + isEnable: command, + run: (state, dispatch, view) => { + command(state, dispatch, view); + }, + }; +} + +export function createPartialToggleMarkAction(markType: MarkType): ActionSpec { + const command = createPartialToggleMarkCommand(markType); return { isActive: (state) => Boolean(isMarkActive(state, markType)), isEnable: command, - run: command, + run: (state, dispatch, view) => { + command(state, dispatch, view); + }, + }; +} + +export function createMarkdownInlineMarkCommand(markType: MarkType): Command { + const base = createPartialToggleMarkCommand(markType); + return (state, dispatch, view) => { + const isBlocked = + !selectionAllHasMark(state, markType) && !canApplyInlineMarkInMarkdown(state); + if (isBlocked) return false; + return base(state, dispatch, view); }; } @@ -24,20 +77,14 @@ export function createToggleMarkAction(markType: MarkType): ActionSpec { * Removing the mark (toggling off) is always allowed. */ export function createMarkdownInlineMarkAction(markType: MarkType): ActionSpec { - const base = createToggleMarkAction(markType); + const base = createMarkdownInlineMarkCommand(markType); return { - isActive: base.isActive, - isEnable: (state, dispatch, view, attrs) => { - const isBlocked = - !isMarkActive(state, markType) && !canApplyInlineMarkInMarkdown(state); - if (isBlocked) return false; - return base.isEnable(state, dispatch, view, attrs); + isActive: (state) => Boolean(isMarkActive(state, markType)), + isEnable: (state, dispatch, view) => { + return base(state, dispatch, view); }, - run: (state, dispatch, view, attrs) => { - const isBlocked = - !isMarkActive(state, markType) && !canApplyInlineMarkInMarkdown(state); - if (isBlocked) return; - base.run(state, dispatch, view, attrs); + run: (state, dispatch, view) => { + base(state, dispatch, view); }, }; }