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) # => nilWorking 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/falseMark Ordering
Quillon maintains a consistent order for marks to ensure reliable comparison. The order is:
- Simple marks (alphabetically):
:bold,:code,:italic,:strike,:subscript,:superscript,:underline - 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.