Summary

Add four annotation types for inline text styling: TextColor (existing), BackgroundColor (existing, renamed), TextSize (new), TextFamily (new). Formalize the distinction between additive and exclusive annotations, and rename attributes.color → attributes.value for consistency across all exclusive types.

Enforcement applies to both documents and comments, on both web and desktop.

Annotation Categories


This distinction already exists in practice — TextColor uses its color value in the identity key (unicode.ts:57-73) but has never been formalized or enforced.

Type Definitions

All exclusive annotations share the same structure via exclusiveAnnotationProperties:

const exclusiveAnnotationProperties = { ...baseAnnotationProperties, attributes: z.object({ value: z.string() }).optional(), } export const TextColorAnnotationSchema = z.object({ type: z.literal('TextColor'), ...exclusiveAnnotationProperties, }).strict() export const BackgroundColorAnnotationSchema = z.object({ type: z.literal('BackgroundColor'), ...exclusiveAnnotationProperties, }).strict() export const TextSizeAnnotationSchema = z.object({ type: z.literal('TextSize'), ...exclusiveAnnotationProperties, }).strict() export const TextFamilyAnnotationSchema = z.object({ type: z.literal('TextFamily'), ...exclusiveAnnotationProperties, }).strict()

Value Constraints


Concrete value constraints are enforced with .enum() in the schemas, but the base uses z.string() for extensibility without schema migration.

Data Examples

{ "type": "TextColor", "starts": [0, 12], "ends": [5, 18], "attributes": { "value": "seed-blue" } } { "type": "TextFamily", "starts": [0], "ends": [25], "attributes": { "value": "seed-serif" } }

Comment Publishing Type

The HMPublishableAnnotation union (used when publishing comments) is extended:

export type HMPublishableAnnotation = | { type: 'Bold' | 'Italic' | 'Underline' | 'Strike' | 'Code'; starts: number[]; ends: number[] } | { type: 'Link' | 'Embed'; starts: number[]; ends: number[]; link: string } | { type: 'TextColor' | 'BackgroundColor' | 'TextSize' | 'TextFamily'; starts: number[]; ends: number[]; attributes: { value: string } }

Identity Model

The AnnotationSet._annotationId function in unicode.ts determines how annotations are deduplicated:

_annotationId(type: string, attributes: {[key: string]: string} | null) { if (attributes) { if (attributes.link) return `${type}-${attributes.link}` if (attributes.href) return `${type}-${attributes.href}` if (attributes.value) return `${type}-${attributes.value}` // was attributes.color } return type }

This means:

    TextFamily-seed-serif and TextFamily-seed-sans-serif are separate annotation objects with separate spans

    Bold has no attributes, so all bolds merge into one annotation with multiple spans

    Identity keys determine deduplication — they do NOT enforce non-overlap

Overlap Enforcement (three layers)

Layer 1: Editor UI (primary enforcement)

When applying an exclusive annotation to a selection, the editor first removes any existing annotation of the same type with a different value from that range. This is the UX guarantee — the user can't accidentally create overlapping exclusive annotations.

Layer 2: Publish boundary (gate)

Before any publish operation (document or comment), a validation function checks the annotation array:

const EXCLUSIVE_TYPES = ['TextColor', 'BackgroundColor', 'TextSize', 'TextFamily'] export function validateExclusiveAnnotations(annotations: HMAnnotation[]): void { for (const type of EXCLUSIVE_TYPES) { const ofType = annotations.filter(a => a.type === type) for (let i = 0; i < ofType.length; i++) { for (let j = i + 1; j < ofType.length; j++) { const aVal = (ofType[i].attributes as any)?.value const bVal = (ofType[j].attributes as any)?.value if (aVal && bVal && aVal === bVal) continue // same value = fine if (spansOverlap(ofType[i], ofType[j])) { throw new Error(`${type} annotations with different values overlap`) } } } } }

Document path: Called in publishDocument() before PrepareDocumentChange.

Comment path: Called inside annotationsToPublishable() in comment.ts — covers both createComment() and updateComment().

Backend path: Also validated server-side in applyChanges as a safety net.

Layer 3: Zod refinement (defense-in-depth)

Optional .refine() on HMAnnotationsSchema to reject invalid arrays at deserialization time.

Rendering Pipeline

Slate → HM (editorblock-to-hmblock.ts)

if (leaf.styles?.textColor) { annotations.addSpan('TextColor', {value: leaf.styles.textColor}, start, end) } if (leaf.styles?.backgroundColor) { annotations.addSpan('BackgroundColor', {value: leaf.styles.backgroundColor}, start, end) } if (leaf.styles?.textSize) { annotations.addSpan('TextSize', {value: leaf.styles.textSize}, start, end) } if (leaf.styles?.textFamily) { annotations.addSpan('TextFamily', {value: leaf.styles.textFamily}, start, end) }

HM → Slate (hmblock-to-editorblock.ts)

if (annotationData.type === 'TextColor') { leaf.styles.textColor = attributes?.value } if (annotationData.type === 'BackgroundColor') { leaf.styles.backgroundColor = attributes?.value } if (annotationData.type === 'TextSize') { leaf.styles.textSize = attributes?.value } if (annotationData.type === 'TextFamily') { leaf.styles.textFamily = attributes?.value }

SSR Renderer (ssr-render.ts)

case 'TextColor': { html = `<span data-text-color="${esc(value)}">${html}</span>` break } case 'BackgroundColor': { html = `<span data-background-color="${esc(value)}">${html}</span>` break } case 'TextSize': { html = `<span data-text-size="${esc(value)}">${html}</span>` break } case 'TextFamily': { html = `<span data-text-family="${esc(value)}">${html}</span>` break }

Data Migration

Existing documents with TextColor/BackgroundColor annotations using attributes: { color: "..." } need handling.

Recommendation: lazy migration. Read attributes.value ?? (attributes as any).color as a fallback in the renderer and identity key. Add a deprecation comment on the color path. Remove the fallback after 6 months when all active documents have been re-saved with the new format.

Do you like what you are reading? Subscribe to receive updates.

Unsubscribe anytime

Comments