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:
| Mark | Description | Example |
|---|---|---|
:bold | Bold text | text |
:italic | Italic text | text |
:underline | Underlined text | <u>text</u> |
:strike | Strikethrough | |
:code | Inline code | text |
:subscript | Subscript | H₂O |
:superscript | Superscript | x² |
# 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:
| Mark | Attrs | Example |
|---|---|---|
:link | href, title, target | {:link, %{href: "https://example.com"}} |
:highlight | color | {:highlight, %{color: "yellow"}} |
:font_color | color | {:font_color, %{color: "#ff0000"}} |
:mention | id, 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 linkedExample: 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 linkedExample: 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) # => %{}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]) # => trueApplying 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/falseclear_formatting/3 removes only the built-in marks listed in Quillon.Types.all_marks/0. A custom mark from your own schema survives it - remove those explicitly with Quillon.remove_mark/4.
Mark Ordering
Quillon maintains a consistent order for marks to ensure reliable comparison. Marks sort by a priority table:
:bold,:italic,:underline,:strike,:code,:link- in that order- Every other mark, simple or attributed, sorts after those, alphabetically by type name:
:font_color,:highlight,:mention,:subscript,:superscript
Mark operations apply this ordering for you - apply_mark, toggle_mark, and normalization sort the resulting list - which keeps serialization deterministic. The factories do not: Quillon.text/2 stores the marks list exactly as you pass it.
Quillon.text("text", [:italic, :bold])
# => {:text, %{text: "text", marks: [:italic, :bold]}, []}Order rarely matters for comparison, because marks_equal?/2 sorts both lists before comparing:
Quillon.marks_equal?([:italic, :bold], [:bold, :italic]) # => trueHow 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.
Children That Are Not Text Nodes
A block's children can include a custom node type you introduced through Quillon.JSON's extra_types: option. Mark operations recurse into such a node and rewrite only its text descendants, leaving its type and attrs alone. The text inside it stays addressable from the block's own offsets, but the node itself never splits - an offset landing inside it is clamped and the children list comes back unchanged. See Extensibility for the full set of rules.
para = {:paragraph, %{}, [
{:line, %{page: 1}, [Quillon.text("Hello")]},
Quillon.text(" world")
]}
Quillon.toggle_bold(para, 0, 5)
# => {:paragraph, %{}, [
# {:line, %{page: 1}, [{:text, %{text: "Hello", marks: [:bold]}, []}]},
# {:text, %{text: " world", marks: []}, []}
# ]}
Quillon.split_at_offset([{:line, %{}, [Quillon.text("Hello")]}], 3)
# => [{:line, %{}, [{:text, %{text: "Hello", marks: []}, []}]}]selection_has_mark?/4 follows the same rule: it counts the text nested inside such a node and ignores the node itself.
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.
Normalization only touches text nodes. Any other child - a custom node type, say - is neither empty nor mergeable, so it passes through untouched. Quillon.normalize/1 accepts only :paragraph and :heading blocks, the ones whose children are inline text, and raises for anything else.
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: Quillon.mark?/1 and the other mark predicates check Quillon.Types, so they do not recognize a custom mark. JSON round-tripping needs no code change - pass your schema as schema:, or the mark names as extra_marks:, to Quillon.from_json/2:
Quillon.from_json(
%{"type" => "text", "attrs" => %{"text" => "hi", "marks" => ["custom_highlight"]}, "children" => []},
extra_marks: [:custom_highlight]
)
# => {:ok, {:text, %{text: "hi", marks: [:custom_highlight]}, []}}