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-serifandTextFamily-seed-sans-serifare separate annotation objects with separate spansBoldhas no attributes, so all bolds merge into one annotation with multiple spansIdentity 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