Text Style Annotations — delivery work
Original source · exact version bafyreiaqgdnknxclpxisntpfwuqi2xxokclxhzatjdxzcaio7agutzwah4 · Migration decision
Specify TextColor, BackgroundColor, TextSize and TextFamily with attributes.value, identity handling, editor range replacement, publish-time validation, rendering conversions and a proposed backwards-compatible migration.
This source documents an implementation or design task; no release commitment is inferred.
Original source detail
Summary source block
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. source block
Enforcement applies to both documents and comments, on both web and desktop. source block
Annotation Categories source block
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. source block
Type Definitions source block
All exclusive annotations share the same structure via exclusiveAnnotationProperties: source block
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() source block
Value Constraints source block
Concrete value constraints are enforced with .enum() in the schemas, but the base uses z.string() for extensibility without schema migration. source block
Data Examples source block
{
"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 source block
The HMPublishableAnnotation union (used when publishing comments) is extended: source block
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 } } source block |
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: source block
TextFamily-seed-serif and TextFamily-seed-sans-serif are separate annotation objects with separate spans source block
Bold has no attributes, so all bolds merge into one annotation with multiple spans source block
Identity keys determine deduplication — they do NOT enforce non-overlap source block
Overlap Enforcement (three layers) source block
Layer 1: Editor UI (primary enforcement) source block
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. source block
Layer 2: Publish boundary (gate) source block
Before any publish operation (document or comment), a validation function checks the annotation array: source block
const EXCLUSIVETYPES = ['TextColor', 'BackgroundColor', 'TextSize', 'TextFamily']
export function validateExclusiveAnnotations(annotations: HMAnnotation[]): void {
for (const type of EXCLUSIVETYPES) {
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. source block
Comment path: Called inside annotationsToPublishable() in comment.ts — covers both createComment() and updateComment(). source block
Backend path: Also validated server-side in applyChanges as a safety net. source block
Layer 3: Zod refinement (defense-in-depth) source block
Optional .refine() on HMAnnotationsSchema to reject invalid arrays at deserialization time. source block
Rendering Pipeline source block
Slate → HM (editorblock-to-hmblock.ts) source block
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) source block
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) source block
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 source block
Existing documents with TextColor/BackgroundColor annotations using attributes: { color: "..." } need handling. source block
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. source block
Do you like what you are reading? Subscribe to receive updates.
Unsubscribe anytime