Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const MToolbarColors: React.FC<MToolbarColorsProps> = ({
focus,
onClick,
}) => {
// TODO: @makhnatkin check markup mode
return (
<ToolbarColors
enable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const WToolbarColors: React.FC<WToolbarColorsProps> = ({
enable={enabled}
currentColor={currentColor}
exec={(color) => {
action.run({color: color === currentColor ? '' : color});
action.run({color});
}}
disablePortal={disablePortal}
className={className}
Expand Down
67 changes: 51 additions & 16 deletions packages/editor/src/extensions/yfm/Color/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import {toggleMark} from 'prosemirror-commands';
import {TextSelection} from 'prosemirror-state';

Check failure on line 2 in packages/editor/src/extensions/yfm/Color/index.ts

View workflow job for this annotation

GitHub Actions / Verify Files

All imports in the declaration are only used as types. Use `import type`

import type {Action, ExtensionAuto} from '../../../core';
import {isMarkActive} from '../../../utils/marks';
import {selectionAllHasMarkWithAttr} 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';

Expand All @@ -25,29 +26,63 @@
builder.addAction(colorAction, ({schema}) => {
const type = colorType(schema);
return {
isActive: (state) => Boolean(isMarkActive(state, type)),
isActive: (state) =>
Boolean(type.isInSet(state.storedMarks ?? state.selection.$to.marks())),
isEnable: toggleMark(type),
run: (state, dispatch, _view, attrs) => {
const params = attrs as ColorActionParams | undefined;
const hasMark = isMarkActive(state, type);
const color = params?.[colorMarkName];

if (!params || !params[colorMarkName]) {
if (!hasMark) return true;
if (dispatch) {
const {empty, $cursor} = state.selection as TextSelection;

// remove mark
return toggleMark(type, params)(state, dispatch);
}
if (empty && $cursor) {
// cursor only — toggle stored marks
const storedMark = type.isInSet(state.storedMarks ?? $cursor.marks());
if (!color || storedMark?.attrs[colorMarkName] === color) {
dispatch(state.tr.removeStoredMark(type));
} else {
dispatch(

Check failure on line 45 in packages/editor/src/extensions/yfm/Color/index.ts

View workflow job for this annotation

GitHub Actions / Verify Files

Replace `⏎································state.tr.addStoredMark(type.create({[colorMarkName]:·color})),⏎····························` with `state.tr.addStoredMark(type.create({[colorMarkName]:·color}))`
state.tr.addStoredMark(type.create({[colorMarkName]: color})),
);
}
return true;
}

if (hasMark) {
// remove old mark, then add new with new color
return chainAND(toggleMark(type), toggleMark(type, params))(state, dispatch);
const tr = state.tr;
if (!color) {
// "default" / remove color: always strip
state.selection.ranges.forEach(({$from, $to}) =>
tr.removeMark($from.pos, $to.pos, type),
);
} else {
const allSameColor = selectionAllHasMarkWithAttr(
state,
type,
colorMarkName,
color,
);
state.selection.ranges.forEach(({$from, $to}) => {
if (allSameColor) {
tr.removeMark($from.pos, $to.pos, type);
} else {
// addMark replaces any existing color mark (same type = mutually exclusive)
tr.addMark(
$from.pos,
$to.pos,
type.create({[colorMarkName]: color}),
);
}
});
}
dispatch(tr.scrollIntoView());
}

// add mark
return toggleMark(type, params)(state, dispatch);
return true;
},
meta(state): Colors {
return type.isInSet(state.selection.$to.marks())?.attrs[colorMarkName];
return type.isInSet(state.storedMarks ?? state.selection.$to.marks())?.attrs[
colorMarkName
];
},
};
});
Expand Down
5 changes: 3 additions & 2 deletions packages/editor/src/markup/commands/marks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {inlineWrapTo, toggleInlineMarkupFactory} from './helpers';
import {toggleInlineMarkupFactory} from './helpers';

export const colorify = (color: string) => inlineWrapTo(`{${color}}(`, ')');
export const colorify = (color: string) =>
toggleInlineMarkupFactory({before: `{${color}}(`, after: ')'});

export const toggleBold = toggleInlineMarkupFactory('**');
export const toggleItalic = toggleInlineMarkupFactory('_');
Expand Down
2 changes: 1 addition & 1 deletion packages/editor/src/utils/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function defineActions<Keys extends string>(actions: Record<Keys, ActionS
}

export function createToggleMarkAction(markType: MarkType): ActionSpec {
const command = toggleMark(markType);
const command = toggleMark(markType, undefined, {removeWhenPresent: false});
return {
isActive: (state) => Boolean(isMarkActive(state, markType)),
isEnable: command,
Expand Down
98 changes: 97 additions & 1 deletion packages/editor/src/utils/marks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {EditorState, TextSelection} from 'prosemirror-state';
import type {Parser} from '../core/types/parser';
import {ParserFacet} from '../core/utils/parser';

import {canApplyInlineMarkInMarkdown} from './marks';
import {canApplyInlineMarkInMarkdown, selectionAllHasMarkWithAttr} from './marks';

const schema = new Schema({
nodes: {
Expand All @@ -15,6 +15,21 @@ const schema = new Schema({
},
});

// Schema with a color mark (parameterised, excludes itself)
const colorSchema = new Schema({
nodes: {
doc: {content: 'block+'},
paragraph: {content: 'inline*', group: 'block', marks: '_'},
text: {group: 'inline'},
},
marks: {
color: {
attrs: {color: {}},
excludes: '_',
},
},
});

const md = new MarkdownIt();
const mockParser: Parser = {
isPunctChar: (ch: string) => md.utils.isPunctChar(ch),
Expand All @@ -37,6 +52,87 @@ function canApply(text: string, from: number, to: number): boolean {
return canApplyInlineMarkInMarkdown(state);
}

// ─── helpers for selectionAllHasMarkWithAttr tests ───────────────────────────

const colorMark = colorSchema.marks.color;

/**
* Build a state whose paragraph contains segments described by `parts`.
* Each part is either a plain string, or {text, color} for a colored segment.
* `from`/`to` are 0-based character indices inside the paragraph text.
*/
function makeColorState(
parts: Array<string | {text: string; color: string}>,
from: number,
to: number,
): EditorState {
const nodes = parts.map((p) => {
if (typeof p === 'string') return colorSchema.text(p);
return colorSchema.text(p.text, [colorMark.create({color: p.color})]);
});
const doc = colorSchema.node('doc', null, [colorSchema.node('paragraph', null, nodes)]);
// PM positions: 0=before doc, 1=start of paragraph content
const sel = TextSelection.create(doc, from + 1, to + 1);
return EditorState.create({doc, selection: sel});
}

function allHasColor(
parts: Array<string | {text: string; color: string}>,
from: number,
to: number,
color: string,
): boolean {
const state = makeColorState(parts, from, to);
return selectionAllHasMarkWithAttr(state, colorMark, 'color', color);
}

describe('selectionAllHasMarkWithAttr', () => {
it('returns true when entire selection has the exact color', () => {
// "ABC" all red — select all 3 chars
expect(allHasColor([{text: 'ABC', color: 'red'}], 0, 3, 'red')).toBe(true);
});

it('returns false when part of the selection has no color', () => {
// "AB" red, "C" plain — select all 3
expect(allHasColor([{text: 'AB', color: 'red'}, 'C'], 0, 3, 'red')).toBe(false);
});

it('returns false when part of the selection has a different color', () => {
// "AB" red, "C" blue — select all 3, check for red
expect(
allHasColor(
[
{text: 'AB', color: 'red'},
{text: 'C', color: 'blue'},
],
0,
3,
'red',
),
).toBe(false);
});

it('returns false when checking a color that is not applied', () => {
// "ABC" all red — check for blue
expect(allHasColor([{text: 'ABC', color: 'red'}], 0, 3, 'blue')).toBe(false);
});

it('returns true when selection covers only a whitespace-only node (skipped)', () => {
// " " plain spaces — whitespace-only nodes are skipped, so result is vacuously true
expect(allHasColor([' '], 0, 3, 'red')).toBe(true);
});

it('returns true for sub-selection that is entirely colored', () => {
// "A" plain, "BCD" red, "E" plain — select chars 1–4 (BCD)
expect(allHasColor(['A', {text: 'BCD', color: 'red'}, 'E'], 1, 4, 'red')).toBe(true);
});

it('returns false for sub-selection that spans colored and plain', () => {
// "AB" plain, "CD" red — select chars 1–4 (BCD)
expect(allHasColor(['AB', {text: 'CD', color: 'red'}], 1, 4, 'red')).toBe(false);
});
});

describe('canApplyInlineMarkInMarkdown', () => {
it('allows empty selection (cursor)', () => {
expect(canApply('hello,', 0, 0)).toBe(true);
Expand Down
33 changes: 32 additions & 1 deletion packages/editor/src/utils/marks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type {Mark, MarkType, Node} from 'prosemirror-model';
import type {Attrs, Mark, MarkType, Node} from 'prosemirror-model';
import type {EditorState} from 'prosemirror-state';

import {getParserFromState} from '../core/utils/parser';
Expand All @@ -17,6 +17,37 @@
return state.doc.rangeHasMark(from, to, type);
}

/**
* Returns `true` if every non-whitespace text node in the selection has the given mark type
* with the given attr key set to exactly `attrValue`.
*
* Used to decide whether applying a parameterised mark (e.g. color) should toggle it off
* (full coverage with the same value) or apply it to the whole selection.
*/
export function selectionAllHasMarkWithAttr(
state: EditorState,
markType: MarkType,
attrKey: string,
attrValue: Attrs[string],
): boolean {
return state.selection.ranges.every((r) => {
let allHave = true;
state.doc.nodesBetween(r.$from.pos, r.$to.pos, (node, _pos, parent) => {
if (!allHave) return false;
if (

Check failure on line 37 in packages/editor/src/utils/marks.ts

View workflow job for this annotation

GitHub Actions / Verify Files

Replace `⏎················node.isText·&&⏎················parent?.type.allowsMarkType(markType)·&&⏎················!/^\s*$/.test(node.text!)⏎············` with `node.isText·&&·parent?.type.allowsMarkType(markType)·&&·!/^\s*$/.test(node.text!)`
node.isText &&
parent?.type.allowsMarkType(markType) &&
!/^\s*$/.test(node.text!)
) {
const mark = markType.isInSet(node.marks);
allHave = Boolean(mark) && mark!.attrs[attrKey] === attrValue;
}
return undefined;
});
return allHave;
});
}

/**
* Returns `false` when the current selection cannot be wrapped in an inline mark
* without breaking markdown round-trip, per CommonMark flanking delimiter rules:
Expand Down
Loading