prosemirror 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +34 -7
- data/ROADMAP.md +4 -2
- data/TYPES.md +12 -0
- data/lib/prosemirror/markdown/builder.rb +46 -4
- data/lib/prosemirror/markdown_serializer.rb +5 -3
- data/lib/prosemirror/nodes/bullet_list.rb +11 -2
- data/lib/prosemirror/nodes/details.rb +27 -0
- data/lib/prosemirror/nodes/details_content.rb +12 -0
- data/lib/prosemirror/nodes/details_summary.rb +12 -0
- data/lib/prosemirror/nodes/mention.rb +36 -0
- data/lib/prosemirror/nodes/task_item.rb +40 -0
- data/lib/prosemirror/nodes/task_list.rb +24 -0
- data/lib/prosemirror/nodes/twitch.rb +94 -0
- data/lib/prosemirror/nodes/youtube.rb +71 -0
- data/lib/prosemirror/schema.rb +8 -4
- data/lib/prosemirror/text_align.rb +31 -0
- data/lib/prosemirror/version.rb +1 -1
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8aae1987528266c7bd7c4b3c3d8f7c82e74f74cf89ee9e7ac87b1732ff76634d
|
|
4
|
+
data.tar.gz: d5cb877b264eb0d39cd40cd8b4909ec94b423ac439a6719df23c70eb6cde6182
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 947eab5fdc041f059be90be81b94bc66bb1505118b983ed982100faf1ccf2748fdcf753f83384385af72e80cc610473e24eeca31aa6a404ed93f431a8d3138d2
|
|
7
|
+
data.tar.gz: 4a56748f16cc7ac1af9ad1660518870dfded5f22b089e714417cf74afb970e0ec8b9f53d2d9e1adc82662efac63c449ba1dff10d07988ed8ca110413143c2452
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.6.0] - 2026-10-24
|
|
4
|
+
|
|
5
|
+
- Tiptap's YouTube and Twitch embeds, in `Schema.tiptap`: `youtube` and `twitch`, with the video's URL in `src`, rendered as an iframe of its embed URL, built from the video's id. They are imported from Tiptap's markup, and from iframes of embed URLs without it. Subclasses set Twitch's `parent` domain and YouTube's nocookie host. Markdown export writes a link to the video, or the iframe with `html: true`.
|
|
6
|
+
- Tiptap's details, in `Schema.tiptap`: `details`, `details_summary` and `details_content`, rendered and imported as Tiptap does, including `<details>` without Tiptap's content wrapper. `open`, which Tiptap keeps with `persist: true`, is rendered and imported. Markdown export writes them as `<details>` HTML around Markdown content, as GitHub reads it.
|
|
7
|
+
- `Schema.tiptap(text_align: true)` adds Tiptap's TextAlign extension to headings and paragraphs: a `textAlign` attribute, rendered as a `text-align` style, and imported from it or from the old `align` attribute. Other types get it by including `Prosemirror::TextAlign`.
|
|
8
|
+
- Tiptap's task lists, in `Schema.tiptap`: `task_list` and `task_item`, with a `checked` attribute. They render Tiptap's HTML, and are imported from it and from GitHub's. Markdown import reads GitHub Flavored Markdown task lists: a bullet list whose items all have a checkbox is a task list, and other checkboxes stay as text. Markdown export writes `- [x]` items.
|
|
9
|
+
- Tiptap's mention node, in `Schema.tiptap`: rendered as `<span data-type="mention">@label</span>`, with its `id`, `label` and `mentionSuggestionChar` in `data-` attributes, and imported from that HTML. Its text is `@label`, or `@id` without a label. In Markdown, it's written as text, or as HTML with `html: true`.
|
|
10
|
+
|
|
3
11
|
## [0.5.0] - 2026-10-24
|
|
4
12
|
|
|
5
13
|
- `Document#to_markdown` writes Markdown, with GitHub Flavored Markdown tables and strikethrough, like prosemirror-markdown. Node types write themselves in `to_markdown`, and mark types declare their syntax with `markdown`. `html: true` writes underline, highlight, subscript, superscript and image sizes as HTML.
|
data/README.md
CHANGED
|
@@ -109,10 +109,10 @@ bundle add commonmarker
|
|
|
109
109
|
doc = Prosemirror.from_markdown("## Hello\n\nSome **bold** text.")
|
|
110
110
|
```
|
|
111
111
|
|
|
112
|
-
It reads GitHub Flavored Markdown: tables, strikethrough and
|
|
112
|
+
It reads GitHub Flavored Markdown: tables, strikethrough, autolinks and task lists. Each Markdown node becomes the element commonmarker renders for it, and the schema's parse rules apply as in HTML import, so custom types can match Markdown too: a type that matches `img` also gets Markdown images. For the built-in types, it gives the same JSON as rendering the Markdown to HTML and importing it in Tiptap, except that:
|
|
113
113
|
|
|
114
114
|
- code blocks lose their final newline, as in prosemirror-markdown;
|
|
115
|
-
-
|
|
115
|
+
- a bullet list whose items all have a checkbox (`- [ ] todo`) is a task list. In other lists, which Tiptap's task lists can't hold, checkboxes stay as text at the start of their item: `[ ] ` or `[x] `.
|
|
116
116
|
|
|
117
117
|
Raw HTML in Markdown goes through HTML import when the herb gem is installed, and stays as text otherwise.
|
|
118
118
|
|
|
@@ -120,17 +120,19 @@ YAML front matter, between `---` lines at the start, is dropped. Footnotes aren'
|
|
|
120
120
|
|
|
121
121
|
## Markdown export
|
|
122
122
|
|
|
123
|
-
`to_markdown` writes the document as Markdown, with GitHub Flavored Markdown for tables and
|
|
123
|
+
`to_markdown` writes the document as Markdown, with GitHub Flavored Markdown for tables, strikethrough and task lists, like [prosemirror-markdown](https://github.com/ProseMirror/prosemirror-markdown). It needs no gem.
|
|
124
124
|
|
|
125
125
|
```ruby
|
|
126
126
|
doc.to_markdown # => "## Hello\n\n**World**\n"
|
|
127
|
-
doc.to_markdown(html: true) # underline, highlight, subscript and
|
|
127
|
+
doc.to_markdown(html: true) # underline, highlight, subscript, superscript and mentions as HTML
|
|
128
128
|
```
|
|
129
129
|
|
|
130
130
|
- Text is escaped, so that it reads back as text: `*`, `_`, `[`, a `#` or `-` that would start a block, HTML-like `<b>`, and URLs that GitHub would turn into links.
|
|
131
|
-
- Markdown has no syntax for underline, highlight, subscript, superscript
|
|
131
|
+
- Markdown has no syntax for underline, highlight, subscript, superscript, image sizes or mentions: by default, only their text or image is written, like `@Ada` for a mention. With `html: true`, they are written as HTML.
|
|
132
132
|
- Emphasis that CommonMark can't write where it is, like bold on a comma between two letters, is dropped and its text kept.
|
|
133
133
|
- Tables are written as GFM tables. A cell's paragraphs and line breaks are joined with `<br>`, and its other blocks, like lists, are written as HTML.
|
|
134
|
+
- Details are written as `<details>` HTML, as on GitHub, with their content in Markdown.
|
|
135
|
+
- YouTube and Twitch embeds are written as links to the video. With `html: true`, they are written as their iframe.
|
|
134
136
|
- Empty paragraphs are left out.
|
|
135
137
|
|
|
136
138
|
Importing the Markdown gives the document back, as far as Markdown can hold it. Custom types write their own Markdown: see [Node and mark types](TYPES.md#markdown-export).
|
|
@@ -148,8 +150,13 @@ Blank lines separate paragraphs, and other line breaks become hard breaks. Space
|
|
|
148
150
|
Two schemas are included:
|
|
149
151
|
|
|
150
152
|
```ruby
|
|
151
|
-
|
|
152
|
-
|
|
153
|
+
# The default: Tiptap's StarterKit, with tables, images, task lists,
|
|
154
|
+
# details, YouTube and Twitch embeds, mentions, underline, subscript,
|
|
155
|
+
# superscript and highlight
|
|
156
|
+
Prosemirror::Schema.tiptap
|
|
157
|
+
|
|
158
|
+
# prosemirror-schema-basic and prosemirror-schema-list
|
|
159
|
+
Prosemirror::Schema.prosemirror
|
|
153
160
|
```
|
|
154
161
|
|
|
155
162
|
Type names are written in snake_case (`bullet_list`). Use `case: :camel` to write camelCase (`bulletList`), as Tiptap does. Both cases are always accepted on input.
|
|
@@ -158,6 +165,26 @@ Type names are written in snake_case (`bullet_list`). Use `case: :camel` to writ
|
|
|
158
165
|
Prosemirror.schema = Prosemirror::Schema.tiptap(case: :camel)
|
|
159
166
|
```
|
|
160
167
|
|
|
168
|
+
`text_align: true` adds Tiptap's TextAlign extension, configured for headings and paragraphs: a `textAlign` attribute, `left`, `center`, `right` or `justify`, rendered as a `text-align` style. HTML import also reads the old `align` attribute. Markdown has no syntax for it: Markdown export leaves it out. Without the option, `textAlign` is kept in `to_h`, but not rendered.
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
Prosemirror::Schema.tiptap(text_align: true)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
YouTube and Twitch embeds store the video's URL in `src`, and render an iframe of its embed URL, built from the video's id: other URLs render nothing. HTML import reads Tiptap's markup, and also iframes of embed URLs without it. Twitch only plays embeds on the domain they name, so set it in a subclass, as Tiptap's `parent` option. YouTube's `nocookie` option is a subclass too:
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
class Twitch < Prosemirror::Nodes::Twitch
|
|
178
|
+
def parent = "example.com"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
class Youtube < Prosemirror::Nodes::Youtube
|
|
182
|
+
def embed_host = "https://www.youtube-nocookie.com"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
Prosemirror.schema = Prosemirror::Schema.tiptap.with(Twitch, Youtube)
|
|
186
|
+
```
|
|
187
|
+
|
|
161
188
|
## Custom nodes and marks
|
|
162
189
|
|
|
163
190
|
A node type is a Phlex component, and its attributes are Literal properties. The type name comes from the class name (`ImageBlock` → `image_block`).
|
data/ROADMAP.md
CHANGED
|
@@ -199,9 +199,11 @@ They differ in more than names:
|
|
|
199
199
|
- Custom node types write themselves in `to_markdown`, and mark types declare their syntax with `markdown`.
|
|
200
200
|
- Importing the Markdown gives the document back, as far as Markdown can hold it, tested on real-world Markdown and HTML.
|
|
201
201
|
|
|
202
|
-
### 0.6: More Tiptap node types
|
|
202
|
+
### 0.6: More Tiptap node types (released)
|
|
203
203
|
|
|
204
|
-
-
|
|
204
|
+
- Mentions, task lists, details, and YouTube and Twitch embeds, in `Schema.tiptap`, with HTML that gives the same JSON as Tiptap's `generateJSON`.
|
|
205
|
+
- Text alignment, as an option: `Schema.tiptap(text_align: true)`.
|
|
206
|
+
- GitHub Flavored Markdown task lists in Markdown import and export. Details are written as GitHub's `<details>` HTML.
|
|
205
207
|
|
|
206
208
|
### 1.0
|
|
207
209
|
|
data/TYPES.md
CHANGED
|
@@ -259,3 +259,15 @@ Prosemirror.schema = Prosemirror::Schema.tiptap.with(Paragraph)
|
|
|
259
259
|
```
|
|
260
260
|
|
|
261
261
|
A subclass that declares parse rules replaces the rules of its parent. See the [cookbook](COOKBOOK.md) for more examples.
|
|
262
|
+
|
|
263
|
+
With `Schema.tiptap(text_align: true)`, paragraphs and headings are `Prosemirror::TextAlign::Paragraph` and `Prosemirror::TextAlign::Heading`: subclass these to keep the alignment. Other types get it by including `Prosemirror::TextAlign`, which declares the `text_align` attribute, and rendering `text_align_style`:
|
|
264
|
+
|
|
265
|
+
```ruby
|
|
266
|
+
class Callout < Prosemirror::NodeType
|
|
267
|
+
include Prosemirror::TextAlign
|
|
268
|
+
group :block
|
|
269
|
+
content "inline*"
|
|
270
|
+
|
|
271
|
+
def view_template(&) = aside(style: text_align_style, &)
|
|
272
|
+
end
|
|
273
|
+
```
|
|
@@ -6,7 +6,8 @@ module Prosemirror
|
|
|
6
6
|
# (https://github.com/gjtorikian/commonmarker), so that the schema's parse rules apply to Markdown too.
|
|
7
7
|
# Each Markdown node becomes the element commonmarker renders for it, without going through HTML.
|
|
8
8
|
#
|
|
9
|
-
# - GitHub Flavored Markdown: tables, strikethrough
|
|
9
|
+
# - GitHub Flavored Markdown: tables, strikethrough, autolinks and task lists. A bullet list whose items
|
|
10
|
+
# all have a checkbox is a task list. In other lists, the checkboxes stay as text (`[ ] `).
|
|
10
11
|
# - Raw HTML can span several Markdown blocks, like `<del>` around a paragraph: with the herb gem, a
|
|
11
12
|
# document holding raw HTML is rendered to HTML by commonmarker and imported. Without it, raw HTML
|
|
12
13
|
# stays as text.
|
|
@@ -15,16 +16,21 @@ module Prosemirror
|
|
|
15
16
|
class Builder
|
|
16
17
|
MAX_DEPTH = HTML::Builder::MAX_DEPTH
|
|
17
18
|
EXTENSIONS = {
|
|
18
|
-
strikethrough: true, table: true, autolink: true, tasklist:
|
|
19
|
+
strikethrough: true, table: true, autolink: true, tasklist: true, shortcodes: false, header_ids: nil,
|
|
19
20
|
front_matter_delimiter: "---"
|
|
20
21
|
}.freeze
|
|
21
|
-
|
|
22
|
+
# `tasklist_classes` gives task lists the classes of GitHub's HTML, which the task list type reads.
|
|
23
|
+
RENDER_OPTIONS = {
|
|
24
|
+
extension: EXTENSIONS, render: {unsafe: true, hardbreaks: false, github_pre_lang: false, tasklist_classes: true}
|
|
25
|
+
}.freeze
|
|
22
26
|
TAGS = {
|
|
23
27
|
paragraph: "p", block_quote: "blockquote", item: "li", thematic_break: "hr", table_row: "tr",
|
|
24
28
|
emph: "em", strong: "strong", strikethrough: "s", linebreak: "br"
|
|
25
29
|
}.freeze
|
|
26
30
|
# A table's delimiter row, like `| :-- | --: |`, at the end of its line.
|
|
27
31
|
DELIMITER_ROW = /\|?(?:\s*:?-+:?\s*\|)*\s*:?-+:?\s*\|?\s*\z/
|
|
32
|
+
# A task item's marker and checkbox, at its first line.
|
|
33
|
+
TASK_MARKER = /\A(?:[-+*]|\d+[.)])[ \t]+(\[[ xX]\])/
|
|
28
34
|
# The bytes commonmarker escapes in URLs, as comrak's `escape_href`: all but these, and `%` when it
|
|
29
35
|
# doesn't start an escape. `&` and `'` are kept: comrak writes them as HTML references.
|
|
30
36
|
URL_UNSAFE = %r{%(?!\h\h)|[^-_.+!*(),#@?=;:/$~A-Za-z0-9&'%]}n
|
|
@@ -87,6 +93,8 @@ module Prosemirror
|
|
|
87
93
|
# Adds a node to the target. Returns the element to add its children to, when they need adding.
|
|
88
94
|
def add(node, target)
|
|
89
95
|
type = node.type # commonmarker allocates for each call
|
|
96
|
+
return checkbox_text(node, type, target) if @checkbox
|
|
97
|
+
|
|
90
98
|
if (tag = TAGS[type])
|
|
91
99
|
return HTML::Element.new(tag)
|
|
92
100
|
end
|
|
@@ -94,6 +102,7 @@ module Prosemirror
|
|
|
94
102
|
case type
|
|
95
103
|
when :heading then HTML::Element.new("h#{node.header_level}")
|
|
96
104
|
when :list then list(node)
|
|
105
|
+
when :taskitem then task_item(node, target)
|
|
97
106
|
when :code_block then code_block(node, target)
|
|
98
107
|
when :table then table(node)
|
|
99
108
|
when :table_cell then table_cell(node)
|
|
@@ -110,11 +119,44 @@ module Prosemirror
|
|
|
110
119
|
end
|
|
111
120
|
|
|
112
121
|
def list(node)
|
|
113
|
-
|
|
122
|
+
if node.list_type != :ordered
|
|
123
|
+
return HTML::Element.new("ul", (node.all? { |item| item.type == :taskitem }) ? {"data-type" => "taskList"} : {})
|
|
124
|
+
end
|
|
114
125
|
|
|
115
126
|
HTML::Element.new("ol", (node.list_start == 1) ? {} : {"start" => node.list_start.to_s})
|
|
116
127
|
end
|
|
117
128
|
|
|
129
|
+
# commonmarker doesn't say whether a task item is checked: its checkbox is read from its first line.
|
|
130
|
+
# Outside a task list, the checkbox is written as text, at the start of the item's first paragraph.
|
|
131
|
+
def task_item(node, target)
|
|
132
|
+
position = node.source_position
|
|
133
|
+
checkbox = @lines[position[:start_line] - 1].to_s.byteslice((position[:start_column] - 1)..).to_s[TASK_MARKER, 1] || "[ ]"
|
|
134
|
+
if target["data-type"] == "taskList"
|
|
135
|
+
return HTML::Element.new("li", {"data-type" => "taskItem", "data-checked" => checkbox.match?(/x/i).to_s})
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
item = HTML::Element.new("li")
|
|
139
|
+
if node.first_child
|
|
140
|
+
@checkbox = checkbox
|
|
141
|
+
else
|
|
142
|
+
text(item.append(HTML::Element.new("p")), "#{checkbox} ")
|
|
143
|
+
end
|
|
144
|
+
item
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# The first child of a task item outside a task list: its paragraph starts with the checkbox, or a
|
|
148
|
+
# paragraph holding it goes before.
|
|
149
|
+
def checkbox_text(node, type, target)
|
|
150
|
+
checkbox = "#{@checkbox} "
|
|
151
|
+
@checkbox = nil
|
|
152
|
+
paragraph = HTML::Element.new("p")
|
|
153
|
+
text(paragraph, checkbox)
|
|
154
|
+
return paragraph if type == :paragraph
|
|
155
|
+
|
|
156
|
+
target.append(paragraph)
|
|
157
|
+
add(node, target)
|
|
158
|
+
end
|
|
159
|
+
|
|
118
160
|
def code_block(node, target)
|
|
119
161
|
language = node.fence_info.to_s.split.first
|
|
120
162
|
code = HTML::Element.new("code", language ? {"class" => "language-#{language}"} : {})
|
|
@@ -213,10 +213,12 @@ module Prosemirror
|
|
|
213
213
|
end
|
|
214
214
|
|
|
215
215
|
# Whether the list follows a list of the same type, which it would continue in Markdown with the same
|
|
216
|
-
# marker. Alternating lists in a row switch markers.
|
|
217
|
-
|
|
216
|
+
# marker. Alternating lists in a row switch markers. The block, called with the siblings before, tells
|
|
217
|
+
# which lists share markers, when other types than the list's do.
|
|
218
|
+
def list_after_list?(parent, index, &same)
|
|
219
|
+
same ||= ->(sibling) { sibling.type == parent.content[index].type }
|
|
218
220
|
run = 0
|
|
219
|
-
run += 1 while index - run - 1 >= 0 && parent.content[index - run - 1]
|
|
221
|
+
run += 1 while index - run - 1 >= 0 && same.call(parent.content[index - run - 1])
|
|
220
222
|
run.odd?
|
|
221
223
|
end
|
|
222
224
|
|
|
@@ -9,11 +9,20 @@ module Prosemirror
|
|
|
9
9
|
|
|
10
10
|
def view_template(&) = ul(&)
|
|
11
11
|
|
|
12
|
-
# `-` items, or `*` right after another bullet list, which would otherwise continue it.
|
|
12
|
+
# `-` items, or `*` right after another bullet or task list, which would otherwise continue it.
|
|
13
13
|
def to_markdown(markdown, parent, index)
|
|
14
|
-
marker =
|
|
14
|
+
marker = self.class.marker(markdown, parent, index)
|
|
15
15
|
markdown.render_list(node, " ") { marker }
|
|
16
16
|
end
|
|
17
|
+
|
|
18
|
+
# Bullet and task lists share markers.
|
|
19
|
+
def self.marker(markdown, parent, index)
|
|
20
|
+
after_list = markdown.list_after_list?(parent, index) do |sibling|
|
|
21
|
+
type = markdown.schema.node_type(sibling.type)
|
|
22
|
+
!type.nil? && (type <= BulletList || type <= TaskList)
|
|
23
|
+
end
|
|
24
|
+
after_list ? "* " : "- "
|
|
25
|
+
end
|
|
17
26
|
end
|
|
18
27
|
end
|
|
19
28
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
module Nodes
|
|
5
|
+
# Tiptap's details: a summary, and content shown when it's open. `open` is the attribute Tiptap keeps with
|
|
6
|
+
# `persist: true`.
|
|
7
|
+
class Details < NodeType
|
|
8
|
+
group :block
|
|
9
|
+
content "details_summary details_content"
|
|
10
|
+
|
|
11
|
+
prop :open, _Boolean, default: false, parse_html: ->(element) { element.key?("open") }
|
|
12
|
+
parse_html tag: "details"
|
|
13
|
+
|
|
14
|
+
def view_template(&) = details(open: @open, &)
|
|
15
|
+
|
|
16
|
+
# As HTML, which GitHub and Markdown import read, with the content in Markdown between blank lines.
|
|
17
|
+
def to_markdown(markdown, _parent, _index)
|
|
18
|
+
summary, content = node.content
|
|
19
|
+
markdown.text("<details#{" open" if @open}>\n#{markdown.node_html(summary)}", escape: false)
|
|
20
|
+
markdown.close_block(node)
|
|
21
|
+
markdown.render(content, node, 1)
|
|
22
|
+
markdown.text("</details>", escape: false)
|
|
23
|
+
markdown.close_block(node)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
module Nodes
|
|
5
|
+
class DetailsContent < NodeType
|
|
6
|
+
content "block+"
|
|
7
|
+
parse_html tag: %(div[data-type="detailsContent"])
|
|
8
|
+
|
|
9
|
+
def view_template(&) = div(data_type: "detailsContent", &)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
module Nodes
|
|
5
|
+
# Tiptap's mention: `@label`, or `@id` without a label. `mentionSuggestionChar` is the character typed
|
|
6
|
+
# to insert it, `@` by default, which tells mentions of different kinds apart.
|
|
7
|
+
class Mention < NodeType
|
|
8
|
+
group :inline
|
|
9
|
+
inline
|
|
10
|
+
|
|
11
|
+
prop :id, _Union?(String, Integer), parse_html: ->(element) { element["data-id"] }
|
|
12
|
+
prop :label, _String?, parse_html: ->(element) { element["data-label"] }
|
|
13
|
+
prop :mention_suggestion_char, _String(length: 1..), default: "@", json: "mentionSuggestionChar",
|
|
14
|
+
parse_html: ->(element) { element["data-mention-suggestion-char"] }
|
|
15
|
+
leaf_text { |node| display_text(node.attrs) }
|
|
16
|
+
parse_html tag: %(span[data-type="mention"])
|
|
17
|
+
|
|
18
|
+
def view_template
|
|
19
|
+
span(data: {type: "mention", id: @id, label: @label, mention_suggestion_char: @mention_suggestion_char}) do
|
|
20
|
+
self.class.display_text(node.attrs)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Its text, or with `html: true`, its HTML, which Markdown import reads back.
|
|
25
|
+
def to_markdown(markdown, _parent, _index)
|
|
26
|
+
if markdown.html?
|
|
27
|
+
markdown.write(markdown.node_html(node))
|
|
28
|
+
else
|
|
29
|
+
markdown.text(self.class.display_text(node.attrs))
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.display_text(attrs) = "#{attrs[:mention_suggestion_char] || "@"}#{attrs[:label] || attrs[:id]}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
module Nodes
|
|
5
|
+
# Tiptap's task item, with its content in a `div` after the checkbox. Its content is Tiptap's with
|
|
6
|
+
# `nested: true`, which also accepts documents written without it.
|
|
7
|
+
class TaskItem < NodeType
|
|
8
|
+
content "paragraph block*"
|
|
9
|
+
|
|
10
|
+
# From Tiptap's `data-checked`, or the checkbox.
|
|
11
|
+
prop :checked, _Boolean, default: false, parse_html: lambda { |element|
|
|
12
|
+
if element.key?("data-checked")
|
|
13
|
+
["", "true"].include?(element["data-checked"])
|
|
14
|
+
else
|
|
15
|
+
element.query(%(input[type="checkbox"]))&.key?("checked") || false
|
|
16
|
+
end
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
parse_html tag: %(li[data-type="taskItem"]), priority: 51, content_element: "div"
|
|
20
|
+
parse_html tag: "li.task-list-item", priority: 51, context: "task_list/"
|
|
21
|
+
|
|
22
|
+
def view_template(&)
|
|
23
|
+
li(data_checked: @checked.to_s, data_type: "taskItem") do
|
|
24
|
+
label do
|
|
25
|
+
input(type: "checkbox", checked: @checked)
|
|
26
|
+
span
|
|
27
|
+
end
|
|
28
|
+
div(&)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# `[x] ` before its content, without the space when its first paragraph is empty.
|
|
33
|
+
def to_markdown(markdown, _parent, _index)
|
|
34
|
+
markdown.write(@checked ? "[x]" : "[ ]")
|
|
35
|
+
markdown.write(" ") unless node.content.first&.content.to_a.empty?
|
|
36
|
+
markdown.render_content(node)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
module Nodes
|
|
5
|
+
# Tiptap's task list. Also imported from GitHub's HTML, as commonmarker renders it, when every item has a
|
|
6
|
+
# checkbox.
|
|
7
|
+
class TaskList < NodeType
|
|
8
|
+
group :block, :list
|
|
9
|
+
content "task_item+"
|
|
10
|
+
parse_html tag: %(ul[data-type="taskList"]), priority: 51
|
|
11
|
+
parse_html(tag: "ul.contains-task-list", priority: 51) do |element|
|
|
12
|
+
element.element_children.all? { |item| item.matches?("li.task-list-item") } && {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def view_template(&) = ul(data_type: "taskList", &)
|
|
16
|
+
|
|
17
|
+
# `- [x]` items. Its markers alternate with bullet lists', or Markdown would join them.
|
|
18
|
+
def to_markdown(markdown, parent, index)
|
|
19
|
+
marker = BulletList.marker(markdown, parent, index)
|
|
20
|
+
markdown.render_list(node, " ") { marker }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Prosemirror
|
|
6
|
+
module Nodes
|
|
7
|
+
# Tiptap's Twitch embed. `src` is the URL of a video, clip or channel, rendered as an iframe of its embed
|
|
8
|
+
# URL. Without a Twitch URL, it renders nothing. Twitch only plays embeds on the domain named by `parent`:
|
|
9
|
+
# override `parent` in a subclass, as Tiptap's `parent` option, which defaults to `localhost`.
|
|
10
|
+
class Twitch < NodeType
|
|
11
|
+
# The URLs Tiptap accepts.
|
|
12
|
+
URL = %r{\A(?:https?://)?(?:www\.)?(?:twitch\.tv|clips\.twitch\.tv)/(?:videos/\d+|\w+/clip/[\w-]+|[\w-]+/?)?(?:\?.*)?\z}
|
|
13
|
+
|
|
14
|
+
group :block
|
|
15
|
+
|
|
16
|
+
prop :src, _String(length: 1..), parse_html: ->(element) { Twitch.from_embed_url(element["src"])&.fetch(:src) || element["src"] }
|
|
17
|
+
prop :width, _Integer?(1..), default: 640, &Coerce::INTEGER
|
|
18
|
+
prop :height, _Integer?(1..), default: 480, &Coerce::INTEGER
|
|
19
|
+
prop :autoplay, _Boolean, default: false, parse_html: ->(element) { Twitch.from_embed_url(element["src"])&.fetch(:autoplay) }
|
|
20
|
+
prop :muted, _Boolean, default: false, parse_html: ->(element) { Twitch.from_embed_url(element["src"])&.fetch(:muted) }
|
|
21
|
+
prop :time, _String?, parse_html: ->(element) { Twitch.from_embed_url(element["src"])&.fetch(:time) }
|
|
22
|
+
|
|
23
|
+
# Tiptap's markup, `<div data-twitch-video><iframe>`, or an iframe of a Twitch embed URL.
|
|
24
|
+
parse_html(tag: "iframe[src]") do |element|
|
|
25
|
+
(element.parent&.key?("data-twitch-video") || Twitch.from_embed_url(element["src"])) ? {} : false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def view_template
|
|
29
|
+
src = safe_url(self.class.embed_url(@src, parent:, autoplay: @autoplay, muted: @muted, time: @time))
|
|
30
|
+
return unless src
|
|
31
|
+
|
|
32
|
+
div(data_twitch_video: "") { iframe(src:, width: @width, height: @height, allowfullscreen: true) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The domain of the site the embed is on.
|
|
36
|
+
def parent = "localhost"
|
|
37
|
+
|
|
38
|
+
# The URL, as text that Markdown links. With `html: true`, the iframe.
|
|
39
|
+
def to_markdown(markdown, _parent, _index)
|
|
40
|
+
markdown.write(markdown.html? ? markdown.node_html(node) : "<#{markdown.url(@src)}>")
|
|
41
|
+
markdown.close_block(node)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The video, clip or channel of a Twitch URL, as `[kind, id]`, or nil.
|
|
45
|
+
def self.identify(src)
|
|
46
|
+
return unless src.match?(URL)
|
|
47
|
+
|
|
48
|
+
path = src.split("?").first
|
|
49
|
+
if (clip = path[%r{clips\.twitch\.tv/([\w-]+)}, 1] || path[%r{twitch\.tv/[\w-]+/clip/([\w-]+)}, 1])
|
|
50
|
+
[:clip, clip]
|
|
51
|
+
elsif (video = path[%r{twitch\.tv/videos/(\d+)}, 1])
|
|
52
|
+
[:video, video]
|
|
53
|
+
elsif (channel = path[%r{twitch\.tv/([\w-]+)/?\z}, 1])
|
|
54
|
+
[:channel, channel]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The embed URL, as Tiptap builds it, or nil.
|
|
59
|
+
def self.embed_url(src, parent:, autoplay: false, muted: false, time: nil)
|
|
60
|
+
kind, id = identify(src)
|
|
61
|
+
return unless kind
|
|
62
|
+
|
|
63
|
+
options = {"autoplay" => ("true" if autoplay), "muted" => ("true" if muted)}
|
|
64
|
+
query = if kind == :clip
|
|
65
|
+
{"clip" => id, "parent" => parent, **options}
|
|
66
|
+
else
|
|
67
|
+
{kind.to_s => id, "parent" => parent, "allowfullscreen" => "true", **options, "time" => (time if kind == :video)}
|
|
68
|
+
end
|
|
69
|
+
base = (kind == :clip) ? "https://clips.twitch.tv/embed" : "https://player.twitch.tv/"
|
|
70
|
+
"#{base}?#{URI.encode_www_form(query.compact)}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The URL and options of an embed URL, as Tiptap reads them, or nil.
|
|
74
|
+
def self.from_embed_url(url)
|
|
75
|
+
uri = URI.parse(url.to_s)
|
|
76
|
+
return unless uri.is_a?(URI::HTTP)
|
|
77
|
+
|
|
78
|
+
query = URI.decode_www_form(uri.query.to_s).to_h
|
|
79
|
+
src = case [uri.host.to_s.delete_prefix("www."), uri.path]
|
|
80
|
+
in ["player.twitch.tv", _]
|
|
81
|
+
if query["video"] then "https://www.twitch.tv/videos/#{query["video"]}"
|
|
82
|
+
elsif query["channel"] then "https://www.twitch.tv/#{query["channel"]}"
|
|
83
|
+
end
|
|
84
|
+
in ["clips.twitch.tv", "/embed"]
|
|
85
|
+
query["clip"]&.then { |clip| "https://clips.twitch.tv/#{clip}" }
|
|
86
|
+
else nil
|
|
87
|
+
end
|
|
88
|
+
{src:, autoplay: query["autoplay"] == "true", muted: query["muted"] == "true", time: query["time"]} if src&.match?(URL)
|
|
89
|
+
rescue URI::Error, ArgumentError
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Prosemirror
|
|
6
|
+
module Nodes
|
|
7
|
+
# Tiptap's YouTube embed. `src` is the video's URL, like `https://www.youtube.com/watch?v=…`, rendered as
|
|
8
|
+
# an iframe of its embed URL. Without a YouTube URL, it renders nothing. Override `embed_host` for
|
|
9
|
+
# `https://www.youtube-nocookie.com`, as Tiptap's `nocookie` option.
|
|
10
|
+
class Youtube < NodeType
|
|
11
|
+
# The URLs Tiptap accepts: on youtube.com, youtu.be and youtube-nocookie.com.
|
|
12
|
+
URL = %r{\A(?:(?:https?:)?//)?(?:(?:www|m|music)\.)?(?:youtube\.com|youtu\.be|youtube-nocookie\.com)(?:/(?:[\w-]+\?v=|embed/|v/)?)[\w-]+\S*\z}
|
|
13
|
+
EMBED_HOSTS = %w[youtube.com youtube-nocookie.com].freeze
|
|
14
|
+
|
|
15
|
+
group :block
|
|
16
|
+
|
|
17
|
+
prop :src, _String(length: 1..), parse_html: ->(element) { Youtube.from_embed_url(element["src"])&.fetch(:src) || element["src"] }
|
|
18
|
+
prop :start, _Integer(0..), default: 0,
|
|
19
|
+
parse_html: ->(element) { Youtube.from_embed_url(element["src"])&.fetch(:start) }, &Coerce::INTEGER
|
|
20
|
+
prop :width, _Integer?(1..), default: 640, &Coerce::INTEGER
|
|
21
|
+
prop :height, _Integer?(1..), default: 480, &Coerce::INTEGER
|
|
22
|
+
|
|
23
|
+
# Tiptap's markup, `<div data-youtube-video><iframe>`, or an iframe of a YouTube embed URL.
|
|
24
|
+
parse_html(tag: "iframe[src]") do |element|
|
|
25
|
+
(element.parent&.key?("data-youtube-video") || Youtube.from_embed_url(element["src"])) ? {} : false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def view_template
|
|
29
|
+
src = safe_url(self.class.embed_url(@src, start: @start, host: embed_host))
|
|
30
|
+
return unless src
|
|
31
|
+
|
|
32
|
+
div(data_youtube_video: "") { iframe(src:, width: @width, height: @height, allowfullscreen: true) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def embed_host = "https://www.youtube.com"
|
|
36
|
+
|
|
37
|
+
# The URL, as text that Markdown links. With `html: true`, the iframe.
|
|
38
|
+
def to_markdown(markdown, _parent, _index)
|
|
39
|
+
markdown.write(markdown.html? ? markdown.node_html(node) : "<#{markdown.url(@src)}>")
|
|
40
|
+
markdown.close_block(node)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The embed URL of a video or playlist, as Tiptap builds it, or nil.
|
|
44
|
+
def self.embed_url(src, start: 0, host: "https://www.youtube.com")
|
|
45
|
+
return unless src.match?(URL)
|
|
46
|
+
|
|
47
|
+
if (playlist = src[/[?&]list=([\w-]+)/, 1])
|
|
48
|
+
return "#{host}/embed/videoseries?list=#{playlist}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
id = src[%r{(?:youtu\.be/|/embed/|/v/|/shorts/|/live/|[?&]v=)([\w-]+)}, 1]
|
|
52
|
+
id && "#{host}/embed/#{id}#{"?start=#{start}" if start.positive?}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The video URL and start time of an embed URL, as Tiptap reads them, or nil.
|
|
56
|
+
def self.from_embed_url(url)
|
|
57
|
+
uri = URI.parse(url.to_s)
|
|
58
|
+
return unless uri.is_a?(URI::HTTP) && EMBED_HOSTS.include?(uri.host.to_s.delete_prefix("www."))
|
|
59
|
+
|
|
60
|
+
query = URI.decode_www_form(uri.query.to_s).to_h
|
|
61
|
+
src = case uri.path
|
|
62
|
+
when "/embed/videoseries" then query["list"]&.then { |list| "https://www.youtube.com/playlist?list=#{list}" }
|
|
63
|
+
when %r{\A/embed/([\w-]+)\z} then "https://www.youtube.com/watch?v=#{Regexp.last_match(1)}"
|
|
64
|
+
end
|
|
65
|
+
{src:, start: query["start"].to_i} if src&.match?(URL)
|
|
66
|
+
rescue URI::Error, ArgumentError
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/prosemirror/schema.rb
CHANGED
|
@@ -24,13 +24,17 @@ module Prosemirror
|
|
|
24
24
|
# Called with an `InvalidDocument` error for each problem fixed.
|
|
25
25
|
prop :on_invalid, _Callable?, reader: false
|
|
26
26
|
|
|
27
|
-
# Tiptap's StarterKit and common extensions.
|
|
28
|
-
|
|
27
|
+
# Tiptap's StarterKit and common extensions. `text_align: true` adds the TextAlign extension, configured
|
|
28
|
+
# for headings and paragraphs.
|
|
29
|
+
def self.tiptap(text_align: false, **)
|
|
29
30
|
new(
|
|
30
31
|
nodes: [
|
|
31
|
-
Nodes::Doc,
|
|
32
|
+
Nodes::Doc, text_align ? TextAlign::Paragraph : Nodes::Paragraph, Nodes::Text,
|
|
33
|
+
text_align ? TextAlign::Heading : Nodes::Heading, Nodes::Blockquote, Nodes::BulletList,
|
|
32
34
|
Nodes::OrderedList, Nodes::ListItem, Nodes::CodeBlock, Nodes::HardBreak, Nodes::HorizontalRule,
|
|
33
|
-
Nodes::Image, Nodes::Table, Nodes::TableRow, Nodes::TableHeader, Nodes::TableCell
|
|
35
|
+
Nodes::Image, Nodes::Table, Nodes::TableRow, Nodes::TableHeader, Nodes::TableCell, Nodes::TaskList,
|
|
36
|
+
Nodes::TaskItem, Nodes::Details, Nodes::DetailsSummary, Nodes::DetailsContent, Nodes::Youtube,
|
|
37
|
+
Nodes::Twitch, Nodes::Mention
|
|
34
38
|
],
|
|
35
39
|
# In Tiptap's order, which sorts and nests marks.
|
|
36
40
|
marks: [
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prosemirror
|
|
4
|
+
# Tiptap's TextAlign extension: a `textAlign` attribute, rendered as a `text-align` style. Included in the
|
|
5
|
+
# paragraphs and headings of `Schema.tiptap(text_align: true)`. A custom type includes it, and renders
|
|
6
|
+
# `style: text_align_style`.
|
|
7
|
+
module TextAlign
|
|
8
|
+
ALIGNMENTS = %w[left center right justify].freeze
|
|
9
|
+
|
|
10
|
+
def self.included(type)
|
|
11
|
+
# From the `text-align` style, or the old `align` attribute, which Tiptap doesn't read.
|
|
12
|
+
type.prop :text_align, type._Union?(*ALIGNMENTS), json: "textAlign", parse_html: lambda { |element|
|
|
13
|
+
(element.style["text-align"] || element["align"]).to_s.strip.downcase.then { |value| value if ALIGNMENTS.include?(value) }
|
|
14
|
+
}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def text_align_style = @text_align && "text-align: #{@text_align}"
|
|
18
|
+
|
|
19
|
+
class Paragraph < Nodes::Paragraph
|
|
20
|
+
include TextAlign
|
|
21
|
+
|
|
22
|
+
def view_template(&) = p(style: text_align_style, &)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class Heading < Nodes::Heading
|
|
26
|
+
include TextAlign
|
|
27
|
+
|
|
28
|
+
def view_template(&) = tag(:"h#{@level}", style: text_align_style, &)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/prosemirror/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: prosemirror
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Hans Lemuet
|
|
@@ -107,22 +107,31 @@ files:
|
|
|
107
107
|
- lib/prosemirror/nodes/blockquote.rb
|
|
108
108
|
- lib/prosemirror/nodes/bullet_list.rb
|
|
109
109
|
- lib/prosemirror/nodes/code_block.rb
|
|
110
|
+
- lib/prosemirror/nodes/details.rb
|
|
111
|
+
- lib/prosemirror/nodes/details_content.rb
|
|
112
|
+
- lib/prosemirror/nodes/details_summary.rb
|
|
110
113
|
- lib/prosemirror/nodes/doc.rb
|
|
111
114
|
- lib/prosemirror/nodes/hard_break.rb
|
|
112
115
|
- lib/prosemirror/nodes/heading.rb
|
|
113
116
|
- lib/prosemirror/nodes/horizontal_rule.rb
|
|
114
117
|
- lib/prosemirror/nodes/image.rb
|
|
115
118
|
- lib/prosemirror/nodes/list_item.rb
|
|
119
|
+
- lib/prosemirror/nodes/mention.rb
|
|
116
120
|
- lib/prosemirror/nodes/ordered_list.rb
|
|
117
121
|
- lib/prosemirror/nodes/paragraph.rb
|
|
118
122
|
- lib/prosemirror/nodes/table.rb
|
|
119
123
|
- lib/prosemirror/nodes/table_cell.rb
|
|
120
124
|
- lib/prosemirror/nodes/table_header.rb
|
|
121
125
|
- lib/prosemirror/nodes/table_row.rb
|
|
126
|
+
- lib/prosemirror/nodes/task_item.rb
|
|
127
|
+
- lib/prosemirror/nodes/task_list.rb
|
|
122
128
|
- lib/prosemirror/nodes/text.rb
|
|
129
|
+
- lib/prosemirror/nodes/twitch.rb
|
|
130
|
+
- lib/prosemirror/nodes/youtube.rb
|
|
123
131
|
- lib/prosemirror/parse_rule.rb
|
|
124
132
|
- lib/prosemirror/rendering.rb
|
|
125
133
|
- lib/prosemirror/schema.rb
|
|
134
|
+
- lib/prosemirror/text_align.rb
|
|
126
135
|
- lib/prosemirror/text_serializer.rb
|
|
127
136
|
- lib/prosemirror/transformer.rb
|
|
128
137
|
- lib/prosemirror/type_definition.rb
|