Understanding the Marks System

Copy Markdown View Source

Marks are how Quillon represents text formatting. Unlike HTML where formatting is represented by nested tags, Quillon uses a flat list of marks attached to each text node.

Why Marks?

Consider the text "Hello world" in HTML:

<strong>Hello</strong> <em>world</em>

If you want to make "lo wo" bold and italic, you'd need complex nesting:

<strong>Hel</strong><strong><em>lo</em></strong> <em><strong>wo</strong>rld</em>

With marks, it's simpler - each text node has a list of active marks:

[
  {:text, %{text: "Hel", marks: [:bold]}, []},
  {:text, %{text: "lo wo", marks: [:bold, :italic]}, []},
  {:text, %{text: "rld", marks: [:italic]}, []}
]

Mark Types

Simple Marks

Simple marks are atoms with no additional data:

MarkDescriptionExample
:boldBold texttext
:italicItalic texttext
:underlineUnderlined text<u>text</u>
:strikeStrikethroughtext
:codeInline codetext
:subscriptSubscriptH₂O
:superscriptSuperscript
# Creating text with simple marks
Quillon.text("Bold text", [:bold])
Quillon.text("Bold and italic", [:bold, :italic])

Attributed Marks

Attributed marks are tuples with additional data:

MarkAttrsExample
:linkhref, title, target{:link, %{href: "https://example.com"}}
:highlightcolor{:highlight, %{color: "yellow"}}
:font_colorcolor{:font_color, %{color: "#ff0000"}}
:mentionid, type, label{:mention, %{id: "123", type: "user", label: "@alice"}}
# Creating text with attributed marks
Quillon.text("Click here", [{:link, %{href: "https://example.com"}}])
Quillon.text("Important", [{:highlight, %{color: "yellow"}}])

# Multiple marks including attributed
Quillon.text("Bold link", [:bold, {:link, %{href: "/"}}])

Mark Configuration

In the schema, each mark has configuration options that control its behavior:

inclusive

Controls whether new text typed at the mark boundary inherits the mark.

# In schema
bold: %{inclusive: true}   # Typing after bold text continues bold
link: %{inclusive: false}  # Typing after a link is not linked

Example: With inclusive: true for bold, if your cursor is at the end of "Hello" and you type " world", you get "Hello world". With inclusive: false, you'd get "Hello world".

keep_on_split

Controls whether the mark persists when pressing Enter to split a node.

# In schema
bold: %{keep_on_split: true}   # New line stays bold
link: %{keep_on_split: false}  # New line is not linked

Example: If you're typing bold text and press Enter, with keep_on_split: true the new paragraph starts with bold active.

excludes

Defines marks that cannot coexist. If you apply an excluded mark, the other is removed.

# In schema
subscript: %{excludes: [:superscript]}
superscript: %{excludes: [:subscript]}
code: %{excludes: [:link]}

Example: Text cannot be both subscript and superscript simultaneously. Applying subscript to superscript text removes the superscript.

Working with Marks

Mark Utilities

# Check mark type
Quillon.mark?(:bold)                    # => true
Quillon.mark?({:link, %{href: "/"}})    # => true
Quillon.mark?("not a mark")             # => false

Quillon.simple?(:bold)                  # => true
Quillon.attributed?({:link, %{href: "/"}})  # => true

# Get mark info
Quillon.mark_type(:bold)                # => :bold
Quillon.mark_type({:link, %{href: "/"}})    # => :link
Quillon.mark_attrs({:link, %{href: "/"}})   # => %{href: "/"}
Quillon.mark_attrs(:bold)               # => nil

Working with Mark Lists

marks = [:bold, {:link, %{href: "/"}}]

# Check if mark exists
Quillon.has_mark?(marks, :bold)         # => true
Quillon.has_mark?(marks, :link)         # => true
Quillon.has_mark?(marks, :italic)       # => false

# Get a mark from the list
Quillon.get_mark(marks, :link)          # => {:link, %{href: "/"}}
Quillon.get_mark(marks, :italic)        # => nil

# Modify mark lists
Quillon.add_mark(marks, :italic)        # => [:bold, {:link, ...}, :italic]
Quillon.remove_mark(marks, :bold)       # => [{:link, ...}]
Quillon.toggle_mark(marks, :italic)     # => add if missing, remove if present

# Compare mark lists (order-independent)
Quillon.marks_equal?([:bold, :italic], [:italic, :bold])  # => true

Applying Marks to Text Ranges

The Quillon.Commands module provides functions to apply marks to ranges within blocks:

para = Quillon.paragraph("Hello world")

# Toggle marks (add if absent, remove if present)
para = Quillon.toggle_bold(para, 0, 5)      # "Hello" becomes bold
para = Quillon.toggle_italic(para, 6, 11)   # "world" becomes italic

# Set attributed marks
para = Quillon.set_link(para, 0, 5, "https://example.com")
para = Quillon.set_highlight(para, 0, 5, "yellow")
para = Quillon.set_font_color(para, 0, 5, "#ff0000")
para = Quillon.set_mention(para, 0, 6, %{id: "123", type: "user", label: "@alice"})

# Remove marks
para = Quillon.unset_link(para, 0, 5)
para = Quillon.unset_highlight(para, 0, 5)

# Clear all formatting
para = Quillon.clear_formatting(para, 0, 11)

# Check if selection has a mark
Quillon.selection_has_mark?(para, 0, 5, :bold)  # => true/false

Mark Ordering

Quillon maintains a consistent order for marks to ensure reliable comparison. The order is:

  1. Simple marks (alphabetically): :bold, :code, :italic, :strike, :subscript, :superscript, :underline
  2. Attributed marks (alphabetically by type): :font_color, :highlight, :link, :mention

This ordering is automatic - you don't need to worry about it when creating marks, but it ensures that marks_equal? works correctly and that serialization is deterministic.

# These are equivalent after normalization
Quillon.text("text", [:italic, :bold])
Quillon.text("text", [:bold, :italic])
# Both normalize to marks: [:bold, :italic]

How Text Splitting Works

When you apply a mark to a range, Quillon splits text nodes at the boundaries:

# Original
{:paragraph, %{}, [
  {:text, %{text: "Hello world", marks: []}, []}
]}

# After toggle_bold(para, 0, 5)
{:paragraph, %{}, [
  {:text, %{text: "Hello", marks: [:bold]}, []},
  {:text, %{text: " world", marks: []}, []}
]}

The split happens at the END offset first, then the START offset. This preserves positions for subsequent operations.

Normalization

After operations, adjacent text nodes with identical marks are merged:

# Before normalization (could happen after removing a mark)
[
  {:text, %{text: "Hel", marks: [:bold]}, []},
  {:text, %{text: "lo", marks: [:bold]}, []}
]

# After normalization
[
  {:text, %{text: "Hello", marks: [:bold]}, []}
]

Empty text nodes are also removed during normalization.

Default Schema Configuration

Here's how marks are configured in the default schema:

marks: %{
  bold: %{inclusive: true, keep_on_split: true},
  italic: %{inclusive: true, keep_on_split: true},
  underline: %{inclusive: true, keep_on_split: true},
  strike: %{inclusive: true, keep_on_split: true},
  code: %{inclusive: false, keep_on_split: true, excludes: [:link]},
  subscript: %{inclusive: true, keep_on_split: true, excludes: [:superscript]},
  superscript: %{inclusive: true, keep_on_split: true, excludes: [:subscript]},
  link: %{inclusive: false, keep_on_split: true, attrs: %{href: %{required: true}, ...}},
  highlight: %{inclusive: true, keep_on_split: true, attrs: %{color: %{required: true}}},
  font_color: %{inclusive: true, keep_on_split: true, attrs: %{color: %{required: true}}},
  mention: %{inclusive: false, keep_on_split: false, attrs: %{id: %{required: true}, ...}}
}

Custom Marks

You can define custom marks by creating a custom schema:

custom_schema = %Quillon.Schema{
  marks: %{
    # Keep existing marks
    bold: %{inclusive: true, keep_on_split: true},
    # Add custom mark
    custom_highlight: %{
      inclusive: true,
      keep_on_split: true,
      attrs: %{
        color: %{required: true},
        opacity: %{default: 1.0}
      }
    }
  },
  # ... nodes and groups
}

Note: Custom marks also need to be added to Quillon.Types and handled in Quillon.JSON for full support.