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

    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

    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" }

} source block

    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

} source block

    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)

}

}

}

}

} source block

    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)

} source block

    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

} source block

    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

} source block

    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

Comments