Operand

thee, sea, us.

gram: docs

> ./packages/core/src/prosemirror/applyFormatting.ts

/**
* Agent-facing edit operations shared by the React and Vue adapters.
*
* `applyFormatting` maps a mark-toggle request (bold/italic/underline/strike/
* color/highlight/fontSize/fontFamily) onto a PM transaction over a paragraph
* range located by `paraId` (+ optional `search`). `setParagraphStyle` applies
* a named paragraph style to that range. `insertBreak` inserts a page or
* section break after the paragraph located by `paraId`.
*
* All take the `EditorView` as a parameter. `setParagraphStyle` takes the
* style resolver as an injected dependency so each adapter keeps its own
* resolver-sourcing strategy (React caches per styles object; Vue rebuilds).
*
* Previously duplicated byte-for-byte at
* `packages/react/.../useDocxEditorRefApi.ts` and
* `packages/vue/.../useFormattingActions.ts`.
*/
import { TextSelection } from 'prosemirror-state';
import type { EditorView } from 'prosemirror-view';
import { applyStyle } from './commands/paragraph';
import type { StyleResolver } from './styles';
import type { NumberingMap } from '../docx/numberingParser';
import { mapHexToHighlightName } from '../utils/highlightColors';
import { pointsToHalfPoints } from '../utils/units';
import { findParaIdRange, findTextInPmParagraph } from './paraText';
export interface ApplyFormattingOptions {
paraId: string;
search?: string;
marks: {
bold?: boolean;
italic?: boolean;
underline?: boolean | { style?: string };
strike?: boolean;
color?: { rgb?: string; themeColor?: string };
highlight?: string;
fontSize?: number;
fontFamily?: { ascii?: string; hAnsi?: string };
};
}
/**
* Apply mark toggles to a paragraph range. Returns false when the paraId /
* search can't be resolved; true (a no-op) when the resolved range is empty.
*/
export function applyFormatting(view: EditorView, options: ApplyFormattingOptions): boolean {
const range = findParaIdRange(view.state.doc, options.paraId);
if (!range) return false;
// Default range: the paragraph's text content (skip open/close tokens).
let from = range.from + 1;
let to = range.to - 1;
if (options.search) {
const textRange = findTextInPmParagraph(view.state.doc, range.from, range.to, options.search);
if (!textRange) return false;
from = textRange.from;
to = textRange.to;
}
if (from >= to) return true;
const { schema } = view.state;
const m = options.marks;
let tr = view.state.tr;
if (m.bold !== undefined && schema.marks.bold) {
tr = m.bold
? tr.addMark(from, to, schema.marks.bold.create())
: tr.removeMark(from, to, schema.marks.bold);
}
if (m.italic !== undefined && schema.marks.italic) {
tr = m.italic
? tr.addMark(from, to, schema.marks.italic.create())
: tr.removeMark(from, to, schema.marks.italic);
}
if (m.underline !== undefined && schema.marks.underline) {
if (m.underline) {
const style = typeof m.underline === 'object' ? m.underline.style : undefined;
tr = tr.addMark(from, to, schema.marks.underline.create({ style: style ?? 'single' }));
} else {
tr = tr.removeMark(from, to, schema.marks.underline);
}
}
if (m.strike !== undefined && schema.marks.strike) {
tr = m.strike
? tr.addMark(from, to, schema.marks.strike.create())
: tr.removeMark(from, to, schema.marks.strike);
}
if (m.color !== undefined && schema.marks.textColor) {
if (m.color && (m.color.rgb || m.color.themeColor)) {
tr = tr.addMark(
from,
to,
schema.marks.textColor.create({
rgb: m.color.rgb ?? null,
themeColor: m.color.themeColor ?? null,
})
);
} else {
tr = tr.removeMark(from, to, schema.marks.textColor);
}
}
if (m.highlight !== undefined && schema.marks.highlight) {
if (m.highlight) {
const name = mapHexToHighlightName(m.highlight);
tr = tr.addMark(from, to, schema.marks.highlight.create({ color: name || m.highlight }));
} else {
tr = tr.removeMark(from, to, schema.marks.highlight);
}
}
if (m.fontSize !== undefined && schema.marks.fontSize) {
if (m.fontSize > 0) {
tr = tr.addMark(
from,
to,
schema.marks.fontSize.create({ size: pointsToHalfPoints(m.fontSize) })
);
} else {
tr = tr.removeMark(from, to, schema.marks.fontSize);
}
}
if (m.fontFamily !== undefined && schema.marks.fontFamily) {
if (m.fontFamily && (m.fontFamily.ascii || m.fontFamily.hAnsi)) {
tr = tr.addMark(
from,
to,
schema.marks.fontFamily.create({
ascii: m.fontFamily.ascii ?? null,
hAnsi: m.fontFamily.hAnsi ?? m.fontFamily.ascii ?? null,
})
);
} else {
tr = tr.removeMark(from, to, schema.marks.fontFamily);
}
}
view.dispatch(tr);
return true;
}
/**
* Apply a named paragraph style to the paragraph identified by `paraId`.
*
* The style resolver is injected: when present, unknown styleIds are rejected
* (the agent gets a clear error instead of a silently-broken `<w:pStyle>`), and
* the resolved paragraph/run formatting is threaded into `applyStyle`. Without
* a resolver (no styles loaded) the styleId is applied as-is. Returns false
* when the paraId can't be resolved or the styleId is unknown.
*/
export function setParagraphStyle(
view: EditorView,
options: { paraId: string; styleId: string },
deps: { styleResolver: StyleResolver | null; numbering?: NumberingMap | null }
): boolean {
const range = findParaIdRange(view.state.doc, options.paraId);
if (!range) return false;
const { styleResolver, numbering } = deps;
if (styleResolver && !styleResolver.hasParagraphStyle(options.styleId)) {
return false;
}
// Build a synthetic state with selection inside the target paragraph so
// applyStyle's cursor-driven walk lands on it. Restore the original
// selection on the dispatched transaction.
const $from = view.state.doc.resolve(range.from + 1);
const $to = view.state.doc.resolve(range.to - 1);
const paraSelection = TextSelection.between($from, $to);
const stateWithSel = view.state.apply(view.state.tr.setSelection(paraSelection));
const cmd = styleResolver
? (() => {
const r = styleResolver.resolveParagraphStyle(options.styleId);
return applyStyle(options.styleId, {
paragraphFormatting: r.paragraphFormatting,
runFormatting: r.runFormatting,
numbering,
});
})()
: applyStyle(options.styleId);
let didApply = false;
cmd(stateWithSel, (newTr) => {
didApply = true;
newTr.setSelection(view.state.selection.map(newTr.doc, newTr.mapping));
view.dispatch(newTr);
});
return didApply;
}
/** Kind of break `insertBreak` can insert after a paragraph. */
export type BreakKind = 'page' | 'sectionNextPage' | 'sectionContinuous';
export interface InsertBreakOptions {
paraId: string;
type: BreakKind;
}
/**
* Insert a page or section break after the paragraph identified by `paraId`.
*
* This is the agent edit path. It produces the same document shape as the
* headless `reviewerBridge.insertBreak` (so a given `insert_break` call yields
* identical output whether the agent drives a live editor or the headless
* reviewer) — which is intentionally leaner than the interactive Insert > Break
* menu command:
*
* - `page`: insert a single page-break node right after the target paragraph.
* `fromProseDoc` converts it to a break-run paragraph, matching the headless
* model. No trailing empty paragraph (the menu command adds one for caret
* placement; the agent keeps the caret where it was).
* - `sectionNextPage` / `sectionContinuous`: a section break is the `sectPr`
* carried by the section's *last* paragraph, so mark the target paragraph
* directly. No new block — the existing following paragraph starts the new
* section.
*
* The user's selection is preserved (mapped through the edit) rather than
* following the inserted break. Returns false when the paraId can't be resolved,
* the target isn't a top-level paragraph, or `type` is unknown.
*/
export function insertBreak(view: EditorView, options: InsertBreakOptions): boolean {
const range = findParaIdRange(view.state.doc, options.paraId);
if (!range) return false;
const { state } = view;
// `range.from` is the position of the target paragraph node itself;
// `range.to` is just after its closing token.
const targetPara = state.doc.nodeAt(range.from);
if (!targetPara) return false;
// Breaks belong on top-level body paragraphs. `findParaIdRange` matches
// paragraphs anywhere (incl. table cells / block SDTs), but a `w:sectPr` or
// page-break node nested in a cell is invalid OOXML — and a page-break node
// isn't even allowed by the cell schema (the insert would throw). Mirror the
// headless reviewer bridge, which only resolves top-level paragraphs.
if (state.doc.resolve(range.from).depth !== 0) return false;
const tr = state.tr;
if (options.type === 'page') {
const pageBreakType = state.schema.nodes.pageBreak;
if (!pageBreakType) return false;
tr.insert(range.to, pageBreakType.create());
} else if (options.type === 'sectionNextPage' || options.type === 'sectionContinuous') {
const sectionBreakType = options.type === 'sectionNextPage' ? 'nextPage' : 'continuous';
tr.setNodeMarkup(range.from, undefined, {
...targetPara.attrs,
sectionBreakType,
});
} else {
return false;
}
// Map the caller's selection through the structural edit so the caret stays
// put instead of jumping to the break.
tr.setSelection(state.selection.map(tr.doc, tr.mapping));
view.dispatch(tr);
return true;
}