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)               # => %{}

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

clear_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:

  1. :bold, :italic, :underline, :strike, :code, :link - in that order
  2. 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])  # => true

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.

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]}, []}}