A rich-text editor stores its document as plain text plus a list of style ranges — but this time a range's style is an object, not a single tag: { bold: true, color: 'red' }. When the user selects part of the text and clicks the bold button, you don't want to overwrite the selection's color and italic settings — you only want to toggle bold on, leaving every other style key exactly as it was. Your job is to implement that targeted edit: a patchStyle that lays a small bundle of style keys over a character range and shallow-merges them into whatever was already there, touching only the keys in the patch.
Ranges are stored as half-open intervals [start, end) — start included, end excluded, so [0, 5) covers indices 0, 1, 2, 3, 4. Ranges may overlap: bold over [0, 5) and italic over [3, 8) both apply to characters 3 and 4. The patch covers its own window [start, end), and only the characters inside that window change. A range wider than the patch has to split at the patch boundaries so its untouched part keeps its original style. Characters in the window that no range covered get a fresh range carrying just the patch. Afterward you normalize: adjacent ranges whose style objects are equal collapse into one, and empty ranges are dropped.
type Style = Record<string, unknown>; // e.g. { bold: true, color: 'red' }
type Range = { start: number; end: number; style: Style }; // half-open [start, end)
type Doc = { text: string; ranges: Range[] };
// Apply `patch` over the window [start, end). Returns a NEW Doc where every
// character in [start, end) has its range's style SHALLOW-MERGED with `patch`
// (patch keys override; unrelated existing keys are preserved). Ranges are
// split at the window boundaries so only the covered slice changes; characters
// in the window with no prior range get a range carrying just `patch`. The
// result is normalized: adjacent ranges with deep-equal styles are merged and
// empty ranges dropped. doc is never mutated.
function patchStyle(doc: Doc, start: number, end: number, patch: Style): Doc;
// Patch adds a key (bold) while preserving the existing one (color).
const doc = {
text: 'hello',
ranges: [{ start: 0, end: 5, style: { color: 'red' } }],
};
patchStyle(doc, 0, 5, { bold: true });
// → {
// text: 'hello',
// ranges: [
// { start: 0, end: 5, style: { color: 'red', bold: true } }, // color kept
// ],
// }
// Patch over a PARTIAL slice of a wider range splits it into three pieces.
const doc = {
text: 'hello',
ranges: [{ start: 0, end: 5, style: { color: 'red' } }],
};
patchStyle(doc, 1, 3, { bold: true });
// → {
// text: 'hello',
// ranges: [
// { start: 0, end: 1, style: { color: 'red' } }, // before — untouched
// { start: 1, end: 3, style: { color: 'red', bold: true } }, // patched slice
// { start: 3, end: 5, style: { color: 'red' } }, // after — untouched
// ],
// }
{ ...oldStyle, ...patch }: patch keys win, but every key the patch doesn't mention is carried over unchanged. Patching { bold: true } onto { color: 'red' } yields { color: 'red', bold: true }, never { bold: true }.[start, end) slice changes. A range wider than the window splits at the boundaries; the parts outside the window keep their original style object untouched. The patch is not applied to whole ranges.patch.=== even when equal. Empty ranges (start === end) are dropped.doc, its ranges array, every range, and every nested style object must be left exactly as they were.0 ≤ start ≤ end ≤ doc.text.length and that styles are flat one-level objects), nested/deep merging, or removing keys — those are out of scope (see the solution's Going further).Unlock the solution & editor
Runnable editor + tests
Solve in the browser with instant Jest feedback.
Detailed solutions
Walkthroughs, edge cases, and complexity notes.
Multi-framework variants
React, Vue, Vanilla, Angular — same question, different stacks.