Hosted onnodosdeconocimiento.esvia theHypermedia Protocol

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