1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-08-18 21:21:21 +02:00

Fix #234: fix typing text near inline void nodes (#339)

* Add base tests for isVoid with around text node

* Ensure that void nodes are surrounded by text nodes in Node.normalize

* Only wrap inline void nodes with text

* Fix indentation

* Add emojis example

* Adapt unit test "transforms/fixtures/at-current-range/set-inline/with-is-void"

* Adapt unit test "transforms/fixtures/at-current-range/insert-inline/with-inline"

* Adapt unit test "transforms/fixtures/at-current-range/insert-inline/block-start"

* add passing parent to leaf nodes, for rendering breaks

* add zero-width spaces in empty text nodes, to allow selections

* add zero-width space handling to copy/cut

* fix delete handling around inline void nodes

* fix tests for inline void nodes

* fix style

* fix void cursor handling across browsers

* fix void rendering tests
This commit is contained in:
Samy Pessé
2016-09-23 18:46:24 +02:00
committed by Ian Storm Taylor
parent f7e132ed61
commit d1c3700bd2
42 changed files with 491 additions and 83 deletions

View File

@@ -0,0 +1,8 @@
# Emojis Example
![](../../docs/images/links-example.png)
This example shows you how you can insert inline void nodes. This is how you'd add emojis or inline images to Slate.
Check out the [Examples readme](..) to see how to run it!

144
examples/emojis/index.js Normal file
View File

@@ -0,0 +1,144 @@
import { Editor, Mark, Raw } from '../..'
import React from 'react'
import ReactDOM from 'react-dom'
import initialState from './state.json'
import isUrl from 'is-url'
import { Map } from 'immutable'
const EMOJIS = [
'😃', '😬', '🍔'
]
/**
* Define a schema.
*
* @type {Object}
*/
const schema = {
nodes: {
paragraph: props => <p>{props.children}</p>,
emoji: (props) => {
const { state, node } = props
const { data } = node
const code = data.get('code')
const isSelected = state.selection.hasFocusIn(node)
return <span className={`emoji ${isSelected ? 'selected' : ''}`} {...props.attributes} contentEditable={false}>{code}</span>
}
}
}
/**
* The links example.
*
* @type {Component}
*/
class Emojis extends React.Component {
/**
* Deserialize the raw initial state.
*
* @type {Object}
*/
state = {
state: Raw.deserialize(initialState, { terse: true })
};
/**
* On change.
*
* @param {State} state
*/
onChange = (state) => {
this.setState({ state })
}
/**
* When clicking a emoji, insert it
*
* @param {Event} e
*/
onClickEmoji = (e, code) => {
e.preventDefault()
let { state } = this.state
state = state
.transform()
.insertInline({
type: 'emoji',
isVoid: true,
data: { code }
})
.apply()
this.setState({ state })
}
/**
* Render the app.
*
* @return {Element} element
*/
render = () => {
return (
<div>
{this.renderToolbar()}
{this.renderEditor()}
</div>
)
}
/**
* Render the toolbar.
*
* @return {Element} element
*/
renderToolbar = () => {
return (
<div className="menu toolbar-menu">
{EMOJIS.map((emoji, i) => {
const onMouseDown = e => this.onClickEmoji(e, emoji)
return (
<span key={i} className="button" onMouseDown={onMouseDown}>
<span className="material-icons">{emoji}</span>
</span>
)
})}
</div>
)
}
/**
* Render the editor.
*
* @return {Element} element
*/
renderEditor = () => {
return (
<div className="editor">
<Editor
schema={schema}
state={this.state.state}
onChange={this.onChange}
/>
</div>
)
}
}
/**
* Export.
*/
export default Emojis

View File

@@ -0,0 +1,50 @@
{
"nodes": [
{
"kind": "block",
"type": "paragraph",
"nodes": [
{
"kind": "text",
"text": "In addition to block nodes, you can create inline void nodes, like "
},
{
"kind": "inline",
"type": "emoji",
"isVoid": true,
"data": {
"code": "😃"
}
},
{
"kind": "text",
"text": "!"
}
]
},
{
"kind": "block",
"type": "paragraph",
"nodes": [
{
"kind": "inline",
"type": "emoji",
"isVoid": true,
"data": {
"code": "🍔"
}
}
]
},
{
"kind": "block",
"type": "paragraph",
"nodes": [
{
"kind": "text",
"text": "This example shows emojis in action."
}
]
}
]
}

View File

@@ -165,3 +165,7 @@ input:focus {
.hover-menu .button[data-active="true"] { .hover-menu .button[data-active="true"] {
color: #fff; color: #fff;
} }
.emoji.selected {
outline: 2px solid blue;
}

View File

@@ -10,6 +10,7 @@ import { Router, Route, Link, IndexRedirect, hashHistory } from 'react-router'
import AutoMarkdown from './auto-markdown' import AutoMarkdown from './auto-markdown'
import CodeHighlighting from './code-highlighting' import CodeHighlighting from './code-highlighting'
import Embeds from './embeds' import Embeds from './embeds'
import Emojis from './emojis'
import HoveringMenu from './hovering-menu' import HoveringMenu from './hovering-menu'
import Iframes from './iframes' import Iframes from './iframes'
import Images from './images' import Images from './images'
@@ -72,6 +73,7 @@ class App extends React.Component {
{this.renderTab('Links', 'links')} {this.renderTab('Links', 'links')}
{this.renderTab('Images', 'images')} {this.renderTab('Images', 'images')}
{this.renderTab('Embeds', 'embeds')} {this.renderTab('Embeds', 'embeds')}
{this.renderTab('Emojis', 'emojis')}
{this.renderTab('Tables', 'tables')} {this.renderTab('Tables', 'tables')}
{this.renderTab('Code Highlighting', 'code-highlighting')} {this.renderTab('Code Highlighting', 'code-highlighting')}
{this.renderTab('Paste HTML', 'paste-html')} {this.renderTab('Paste HTML', 'paste-html')}
@@ -126,6 +128,7 @@ const router = (
<Route path="auto-markdown" component={AutoMarkdown} /> <Route path="auto-markdown" component={AutoMarkdown} />
<Route path="code-highlighting" component={CodeHighlighting} /> <Route path="code-highlighting" component={CodeHighlighting} />
<Route path="embeds" component={Embeds} /> <Route path="embeds" component={Embeds} />
<Route path="emojis" component={Emojis} />
<Route path="hovering-menu" component={HoveringMenu} /> <Route path="hovering-menu" component={HoveringMenu} />
<Route path="iframes" component={Iframes} /> <Route path="iframes" component={Iframes} />
<Route path="images" component={Images} /> <Route path="images" component={Images} />

View File

@@ -691,6 +691,7 @@ class Content extends React.Component {
<Node <Node
key={node.key} key={node.key}
node={node} node={node}
parent={state.document}
schema={schema} schema={schema}
state={state} state={state}
editor={editor} editor={editor}

View File

@@ -32,6 +32,7 @@ class Leaf extends React.Component {
isVoid: React.PropTypes.bool, isVoid: React.PropTypes.bool,
marks: React.PropTypes.object.isRequired, marks: React.PropTypes.object.isRequired,
node: React.PropTypes.object.isRequired, node: React.PropTypes.object.isRequired,
parent: React.PropTypes.object.isRequired,
ranges: React.PropTypes.object.isRequired, ranges: React.PropTypes.object.isRequired,
schema: React.PropTypes.object.isRequired, schema: React.PropTypes.object.isRequired,
state: React.PropTypes.object.isRequired, state: React.PropTypes.object.isRequired,
@@ -243,10 +244,15 @@ class Leaf extends React.Component {
* @return {Element} * @return {Element}
*/ */
renderText({ text, index, ranges }) { renderText({ parent, text, index, ranges }) {
// If the text is empty, we need to render a <br/> to get the block to have // COMPAT: If the text is empty and it's the only child, we need to render a
// the proper height. // <br/> to get the block to have the proper height.
if (text == '') return <br /> if (text == '' && parent.kind == 'block' && parent.text == '') return <br />
// COMPAT: If the text is empty otherwise, it's because it's on the edge of
// an inline void node, so we render a zero-width space so that the
// selection can be inserted next to it still.
if (text == '') return <span className="slate-zero-width-space">{'\u200B'}</span>
// COMPAT: Browsers will collapse trailing new lines at the end of blocks, // COMPAT: Browsers will collapse trailing new lines at the end of blocks,
// so we need to add an extra trailing new lines to prevent that. // so we need to add an extra trailing new lines to prevent that.

View File

@@ -33,6 +33,7 @@ class Node extends React.Component {
static propTypes = { static propTypes = {
editor: React.PropTypes.object.isRequired, editor: React.PropTypes.object.isRequired,
node: React.PropTypes.object.isRequired, node: React.PropTypes.object.isRequired,
parent: React.PropTypes.object.isRequired,
schema: React.PropTypes.object.isRequired, schema: React.PropTypes.object.isRequired,
state: React.PropTypes.object.isRequired state: React.PropTypes.object.isRequired
} }
@@ -237,6 +238,7 @@ class Node extends React.Component {
<Node <Node
key={child.key} key={child.key}
node={child} node={child}
parent={this.props.node}
editor={this.props.editor} editor={this.props.editor}
schema={this.props.schema} schema={this.props.schema}
state={this.props.state} state={this.props.state}
@@ -251,7 +253,7 @@ class Node extends React.Component {
*/ */
renderElement = () => { renderElement = () => {
const { editor, node, state } = this.props const { editor, node, parent, state } = this.props
const { Component } = this.state const { Component } = this.state
const children = node.nodes const children = node.nodes
.map(child => this.renderNode(child)) .map(child => this.renderNode(child))
@@ -276,6 +278,7 @@ class Node extends React.Component {
attributes={attributes} attributes={attributes}
key={node.key} key={node.key}
editor={editor} editor={editor}
parent={parent}
node={node} node={node}
state={state} state={state}
> >
@@ -325,7 +328,7 @@ class Node extends React.Component {
*/ */
renderLeaf = (ranges, range, index, offset) => { renderLeaf = (ranges, range, index, offset) => {
const { node, schema, state } = this.props const { node, parent, schema, state } = this.props
const text = range.text const text = range.text
const marks = range.marks const marks = range.marks
@@ -335,6 +338,7 @@ class Node extends React.Component {
index={index} index={index}
marks={marks} marks={marks}
node={node} node={node}
parent={parent}
ranges={ranges} ranges={ranges}
schema={schema} schema={schema}
state={state} state={state}

View File

@@ -31,6 +31,7 @@ class Void extends React.Component {
children: React.PropTypes.any.isRequired, children: React.PropTypes.any.isRequired,
editor: React.PropTypes.object.isRequired, editor: React.PropTypes.object.isRequired,
node: React.PropTypes.object.isRequired, node: React.PropTypes.object.isRequired,
parent: React.PropTypes.object.isRequired,
schema: React.PropTypes.object.isRequired, schema: React.PropTypes.object.isRequired,
state: React.PropTypes.object.isRequired, state: React.PropTypes.object.isRequired,
}; };
@@ -66,7 +67,8 @@ class Void extends React.Component {
// Make the outer wrapper relative, so the spacer can overlay it. // Make the outer wrapper relative, so the spacer can overlay it.
const style = { const style = {
position: 'relative' position: 'relative',
lineHeight: '0px'
} }
return ( return (
@@ -89,21 +91,11 @@ class Void extends React.Component {
*/ */
renderSpacer = () => { renderSpacer = () => {
// COMPAT: In Firefox, if the <span> is positioned absolutely, it won't const style = {
// receive the cursor properly when navigating via arrow keys. position: 'relative',
const style = IS_FIREFOX
? {
pointerEvents: 'none',
width: '0px',
height: '0px',
lineHeight: '0px',
visibility: 'hidden'
}
: {
position: 'absolute',
top: '0px', top: '0px',
left: '-9999px', left: '-9999px',
textIndent: '-9999px' textIndent: '-9999px',
} }
return ( return (
@@ -137,6 +129,7 @@ class Void extends React.Component {
schema={schema} schema={schema}
state={state} state={state}
node={child} node={child}
parent={node}
ranges={ranges} ranges={ranges}
index={index} index={index}
text={text} text={text}

View File

@@ -1121,6 +1121,37 @@ const Node = {
node = node.removeDescendant(key) node = node.removeDescendant(key)
}) })
// Ensure that void nodes are surrounded by text nodes
node = node.mapDescendants((desc) => {
if (desc.kind == 'text') {
return desc
}
const nodes = desc.nodes.reduce((accu, child, i) => {
// We wrap only inline void nodes
if (!child.isVoid || child.kind === 'block') {
return accu.push(child)
}
const prev = accu.last()
const next = desc.nodes.get(i + 1)
if (!prev || prev.kind !== 'text') {
accu = accu.push(Text.create())
}
accu = accu.push(child)
if (!next || next.kind !== 'text') {
accu = accu.push(Text.create())
}
return accu
}, List())
return desc.merge({ nodes })
})
return node return node
}, },

View File

@@ -188,11 +188,16 @@ function Plugin(options = {}) {
const { fragment } = data const { fragment } = data
const encoded = Base64.serializeNode(fragment) const encoded = Base64.serializeNode(fragment)
const range = native.getRangeAt(0)
const contents = range.cloneContents()
// Remove any zero-width space spans from the cloned DOM so that they don't
// show up elsewhere when copied.
const zws = [].slice.call(contents.querySelectorAll('.slate-zero-width-space'))
zws.forEach(zw => zw.parentNode.removeChild(zw))
// Wrap the first character of the selection in a span that has the encoded // Wrap the first character of the selection in a span that has the encoded
// fragment attached as an attribute, so it will show up in the copied HTML. // fragment attached as an attribute, so it will show up in the copied HTML.
const range = native.getRangeAt(0)
const contents = range.cloneContents()
const wrapper = window.document.createElement('span') const wrapper = window.document.createElement('span')
const text = contents.childNodes[0] const text = contents.childNodes[0]
const char = text.textContent.slice(0, 1) const char = text.textContent.slice(0, 1)
@@ -328,6 +333,8 @@ function Plugin(options = {}) {
case 'enter': return onKeyDownEnter(e, data, state) case 'enter': return onKeyDownEnter(e, data, state)
case 'backspace': return onKeyDownBackspace(e, data, state) case 'backspace': return onKeyDownBackspace(e, data, state)
case 'delete': return onKeyDownDelete(e, data, state) case 'delete': return onKeyDownDelete(e, data, state)
case 'left': return onKeyDownLeft(e, data, state)
case 'right': return onKeyDownRight(e, data, state)
case 'y': return onKeyDownY(e, data, state) case 'y': return onKeyDownY(e, data, state)
case 'z': return onKeyDownZ(e, data, state) case 'z': return onKeyDownZ(e, data, state)
} }
@@ -450,6 +457,82 @@ function Plugin(options = {}) {
.apply() .apply()
} }
/**
* On `left` key down, move backward.
*
* COMPAT: This is required to solve for the case where an inline void node is
* surrounded by empty text nodes with zero-width spaces in them. Without this
* the zero-width spaces will cause two arrow keys to jump to the next text.
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
*/
function onKeyDownLeft(e, data, state) {
if (data.isCtrl) return
if (data.isOpt) return
if (state.isExpanded) return
const { document, startText } = state
const hasVoidParent = document.hasVoidParent(startText)
if (
startText.text == '' ||
hasVoidParent
) {
const previousText = document.getPreviousText(startText)
if (!previousText) return
debug('onKeyDownLeft', { data })
e.preventDefault()
return state
.transform()
.collapseToEndOf(previousText)
.apply()
}
}
/**
* On `right` key down, move forward.
*
* COMPAT: This is required to solve for the case where an inline void node is
* surrounded by empty text nodes with zero-width spaces in them. Without this
* the zero-width spaces will cause two arrow keys to jump to the next text.
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
*/
function onKeyDownRight(e, data, state) {
if (data.isCtrl) return
if (data.isOpt) return
if (state.isExpanded) return
const { document, startText } = state
const hasVoidParent = document.hasVoidParent(startText)
if (
startText.text == '' ||
hasVoidParent
) {
const nextText = document.getNextText(startText)
if (!nextText) return state
debug('onKeyDownRight', { data })
e.preventDefault()
return state
.transform()
.collapseToStartOf(nextText)
.apply()
}
}
/** /**
* On `y` key down, redo. * On `y` key down, redo.
* *

View File

@@ -117,7 +117,8 @@ export function deleteBackward(transform, n = 1) {
if (prevBlock && prevBlock.isVoid) { if (prevBlock && prevBlock.isVoid) {
after = selection after = selection
} else if (prevInline && prevInline.isVoid) { } else if (prevInline && prevInline.isVoid) {
after = selection const prevPrev = document.getPreviousText(previous)
after = selection.collapseToEndOf(prevPrev)
} else { } else {
after = selection.collapseToEndOf(previous) after = selection.collapseToEndOf(previous)
} }
@@ -321,7 +322,13 @@ export function insertInline(transform, inline) {
} }
else { else {
const text = document.getTexts().find(n => !keys.includes(n.key)) const text = document.getTexts().find((n) => {
if (keys.includes(n.key)) return false
const parent = document.getParent(n)
if (parent.kind != 'inline') return false
return true
})
after = selection.collapseToEndOf(text) after = selection.collapseToEndOf(text)
} }

View File

@@ -195,6 +195,16 @@ export function deleteForwardAtRange(transform, range, n = 1) {
if (range.isAtEndOf(text)) { if (range.isAtEndOf(text)) {
const next = document.getNextText(text) const next = document.getNextText(text)
const nextBlock = document.getClosestBlock(next)
const nextInline = document.getClosestInline(next)
if (nextBlock && nextBlock.isVoid) {
return transform.removeNodeByKey(nextBlock.key)
}
if (nextInline && nextInline.isVoid) {
return transform.removeNodeByKey(nextInline.key)
}
range = range.merge({ range = range.merge({
focusKey: next.key, focusKey: next.key,

View File

@@ -1,9 +1,9 @@
<div contenteditable="true"> <div contenteditable="true">
<div style="position:relative;"> <div style="position:relative;line-height:0px;">
<span style="position:absolute;top:0px;left:-9999px;text-indent:-9999px;"> <span style="position:relative;top:0px;left:-9999px;text-indent:-9999px;">
<span> <span>
<br> <span class="slate-zero-width-space">&#x200B;</span>
</span> </span>
</span> </span>
<div contenteditable="false"> <div contenteditable="false">

View File

@@ -0,0 +1,12 @@
import React from 'react'
function Image(props) {
return <img {...props.attributes} />
}
export const schema = {
nodes: {
image: Image
}
}

View File

@@ -1,10 +1,8 @@
nodes: nodes:
- kind: block - kind: block
type: paragraph type: default
nodes: nodes:
- kind: inline - kind: inline
type: image type: image
isVoid: true isVoid: true
- kind: text
text: word

View File

@@ -0,0 +1,25 @@
<div contenteditable="true">
<div style="position:relative;">
<span>
<span>
<span class="slate-zero-width-space">&#x200B;</span>
</span>
</span>
<span style="position:relative;line-height:0px;">
<span style="position:relative;top:0px;left:-9999px;text-indent:-9999px;">
<span>
<span class="slate-zero-width-space">&#x200B;</span>
</span>
</span>
<span contenteditable="false">
<img>
</span>
</span>
<span>
<span>
<span class="slate-zero-width-space">&#x200B;</span>
</span>
</span>
</div>
</div>

View File

@@ -0,0 +1,2 @@
export default {}

View File

@@ -0,0 +1,8 @@
nodes:
- kind: block
type: default
nodes:
- kind: inline
isVoid: true
type: image

View File

@@ -0,0 +1,13 @@
nodes:
- kind: block
type: default
nodes:
- kind: text
text: ""
- kind: inline
isVoid: true
type: image
- kind: text
text: ""

View File

@@ -4,6 +4,7 @@ nodes:
isVoid: false isVoid: false
data: {} data: {}
nodes: nodes:
- characters: []
- type: link - type: link
isVoid: true isVoid: true
data: {} data: {}
@@ -11,3 +12,4 @@ nodes:
- characters: - characters:
- text: " " - text: " "
marks: [] marks: []
- characters: []

View File

@@ -4,6 +4,7 @@ nodes:
isVoid: false isVoid: false
data: {} data: {}
nodes: nodes:
- characters: []
- type: link - type: link
isVoid: true isVoid: true
data: {} data: {}
@@ -11,3 +12,4 @@ nodes:
- characters: - characters:
- text: " " - text: " "
marks: [] marks: []
- characters: []

View File

@@ -4,6 +4,7 @@ nodes:
isVoid: false isVoid: false
data: {} data: {}
nodes: nodes:
- characters: []
- type: link - type: link
isVoid: true isVoid: true
data: {} data: {}
@@ -11,3 +12,4 @@ nodes:
- characters: - characters:
- text: " " - text: " "
marks: [] marks: []
- characters: []

View File

@@ -3,6 +3,10 @@ nodes:
- kind: block - kind: block
type: paragraph type: paragraph
nodes: nodes:
- kind: text
text: ""
- kind: inline - kind: inline
type: link type: link
isVoid: true isVoid: true
- kind: text
text: ""

View File

@@ -8,6 +8,11 @@ document:
data: {} data: {}
isVoid: false isVoid: false
nodes: nodes:
- kind: text
ranges:
- kind: range
text: ""
marks: []
- kind: inline - kind: inline
type: link type: link
isVoid: true isVoid: true
@@ -18,3 +23,8 @@ document:
- kind: range - kind: range
text: " " text: " "
marks: [] marks: []
- kind: text
ranges:
- kind: range
text: ""
marks: []

View File

@@ -21,7 +21,7 @@ export default function (state) {
}) })
.apply() .apply()
const updated = next.document.getTexts().last() const updated = next.document.getTexts().get(1)
assert.deepEqual( assert.deepEqual(
next.selection.toJS(), next.selection.toJS(),

View File

@@ -8,3 +8,5 @@ nodes:
- kind: inline - kind: inline
type: hashtag type: hashtag
isVoid: true isVoid: true
- kind: text
text: ""

View File

@@ -21,7 +21,7 @@ export default function (state) {
}) })
.apply() .apply()
const updated = next.document.getTexts().first() const updated = next.document.getTexts().get(1)
assert.deepEqual( assert.deepEqual(
next.selection.toJS(), next.selection.toJS(),

View File

@@ -3,6 +3,8 @@ nodes:
- kind: block - kind: block
type: paragraph type: paragraph
nodes: nodes:
- kind: text
text: ""
- kind: inline - kind: inline
type: hashtag type: hashtag
isVoid: true isVoid: true

View File

@@ -21,7 +21,7 @@ export default function (state) {
}) })
.apply() .apply()
const updated = next.document.getTexts().last() const updated = next.document.getTexts().get(1)
assert.deepEqual( assert.deepEqual(
next.selection.toJS(), next.selection.toJS(),

View File

@@ -3,6 +3,10 @@ nodes:
- kind: block - kind: block
type: paragraph type: paragraph
nodes: nodes:
- kind: text
text: ""
- kind: inline - kind: inline
type: hashtag type: hashtag
isVoid: true isVoid: true
- kind: text
text: ""

View File

@@ -1,33 +0,0 @@
import { Inline } from '../../../../../..'
import assert from 'assert'
export default function (state) {
const { document, selection } = state
const texts = document.getTexts()
const first = texts.first()
const range = selection.merge({
anchorKey: first.key,
anchorOffset: 0,
focusKey: first.key,
focusOffset: 0
})
const next = state
.transform()
.moveTo(range)
.insertInline(Inline.create({
type: 'image',
isVoid: true
}))
.apply()
const updated = next.document.getTexts().first()
assert.deepEqual(
next.selection.toJS(),
range.collapseToEndOf(updated).toJS()
)
return next
}

View File

@@ -1,7 +0,0 @@
nodes:
- kind: block
type: paragraph
nodes:
- kind: text
text: word

View File

@@ -3,6 +3,10 @@ nodes:
- kind: block - kind: block
type: paragraph type: paragraph
nodes: nodes:
- kind: text
text: ""
- kind: inline - kind: inline
type: emoji type: emoji
isVoid: true isVoid: true
- kind: text
text: ""

View File

@@ -8,3 +8,5 @@ nodes:
- kind: inline - kind: inline
type: hashtag type: hashtag
isVoid: true isVoid: true
- kind: text
text: ""

View File

@@ -3,6 +3,8 @@ nodes:
- kind: block - kind: block
type: paragraph type: paragraph
nodes: nodes:
- kind: text
text: ""
- kind: inline - kind: inline
type: hashtag type: hashtag
isVoid: true isVoid: true

View File

@@ -3,6 +3,10 @@ nodes:
- kind: block - kind: block
type: paragraph type: paragraph
nodes: nodes:
- kind: text
text: ""
- kind: inline - kind: inline
type: hashtag type: hashtag
isVoid: true isVoid: true
- kind: text
text: ""

View File

@@ -3,6 +3,8 @@ nodes:
- kind: block - kind: block
type: paragraph type: paragraph
nodes: nodes:
- kind: text
text: ""
- kind: inline - kind: inline
type: image type: image
isVoid: true isVoid: true

View File

@@ -3,6 +3,10 @@ nodes:
- kind: block - kind: block
type: paragraph type: paragraph
nodes: nodes:
- kind: text
text: ""
- kind: inline - kind: inline
type: emoji type: emoji
isVoid: true isVoid: true
- kind: text
text: ""