Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions app/assets/stylesheets/lexxy-editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,23 @@
min-block-size: calc(var(--lexxy-toolbar-height) + var(--lexxy-editor-rows) + 2 * var(--lexxy-editor-padding));
}

/* The reservation above cannot help an editor sized by its content: with
`--lexxy-editor-rows: auto` the calc() is invalid and the declaration is
dropped, and a row count would be the wrong height for a field that has to
match the copy it stands in for anyway. Such an editor prerenders its content
instead (`rich_text_area … prerender: true`), which reserves the body, and
ships an empty toolbar alongside it, which this gives the height the filled
one will have. Only while undefined: from then on the toolbar's own contents
size it. A real <lexxy-toolbar> rather than a spacer, so a host that takes the
toolbar out of flow gets a reservation of nothing, automatically. */
:where(lexxy-toolbar:not(:defined)) {
/* --lexxy-toolbar-height already counts the toolbar's padding and border, so
the reservation has to be a border-box one or they are added a second time
and the field gives 5px back on connect. */
box-sizing: border-box;
min-block-size: var(--lexxy-toolbar-height);
}

/* Placeholder */
:where(.lexxy-editor--empty) {
.lexxy-editor__content:not(:has(h1, h2, h3, h4, h5, h6, table, ul, ol, figure, .attachment-gallery))::before {
Expand Down
11 changes: 11 additions & 0 deletions lib/action_text/editor/lexxy_editor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ class Editor::LexxyEditor::Tag < Editor::Tag
def render_in(view_context, ...)
# Strip html_safe to preserve attribute escaping (see #749)
options[:value] = options[:value].to_str if options[:value].respond_to?(:to_str)

# Opt-in: render the value into a content element the editor adopts on
# connect (see LexicalEditorElement#prerenderedContentElement), so the
# field has its final height at first paint instead of reflowing when the
# editor builds a frame after load. Mirrors the same option on the
# Rails 8.0/8.1 TagHelper fallback.
if options.delete(:prerender) && @block.nil?
prerendered = options.dup
@block = proc { Lexxy::Prerender.inner_html_for(view_context, prerendered) }
end

super
end
end
Expand Down
1 change: 1 addition & 0 deletions lib/lexxy.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require "lexxy/version"
require "lexxy/prerender"

module Lexxy
class << self
Expand Down
65 changes: 65 additions & 0 deletions lib/lexxy/prerender.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
module Lexxy
# Builds the static markup a prerendering editor emits inside <lexxy-editor>
# (see LexicalEditorElement#prerenderedContentElement). Shared by the Rails 8.2
# editor-adapter Tag and the 8.0/8.1 TagHelper fallback.
#
# Between them, the two elements below reserve the whole height the editor
# lands on: the toolbar's, which is fixed and could equally come from CSS, and
# the content's, which varies per record and is the part no stylesheet can know.
module Prerender
def self.inner_html_for(view, options)
parts = []
parts << toolbar_placeholder_tag(view) if reserves_toolbar?(options)
parts << content_tag_for(view, options[:value])

view.safe_join(parts)
end

# The value is the same editable HTML the editor parses from the `value`
# attribute, so the element renders at the height the live editor lands on.
# Two deliberate differences from the live element:
#
# - It is sanitized with Action Text's display sanitizer (whose allow-list
# this engine already extends): the attribute path is escaped and then
# DOMPurify-cleaned client-side before touching the live DOM, but this
# HTML enters the DOM straight from storage, so it must not trust it.
# - It carries none of the interactive attributes (contenteditable, role,
# aria-*): before Lexical mounts they would advertise an editability that
# isn't there yet — keystrokes would be discarded on adoption. The editor
# adds them when it adopts the element.
def self.content_tag_for(view, value)
html = view.sanitize(value.presence || "<p><br></p>",
tags: view.sanitizer_allowed_tags, attributes: view.sanitizer_allowed_attributes)

view.content_tag "div", html, class: "lexxy-editor__content"
end

# The toolbar itself, empty: the editor fills it in on connect rather than
# building another one. It has to be a real <lexxy-toolbar> and not a neutral
# spacer, because whether a toolbar occupies space at all is the host's CSS
# to decide — an inline editor floats it out of flow, and a spacer sized from
# --lexxy-toolbar-height would then reserve height nothing ever fills and
# shift the page *up* on connect. Styled by the host's own toolbar rules, it
# reserves exactly what the real one will take, including nothing.
def self.toolbar_placeholder_tag(view)
view.content_tag "lexxy-toolbar", "", data: { prerendered: "server" }, aria: { hidden: true }
end

# Only reserve a toolbar the editor is actually going to prepend, or the
# field would give the space back on connect — the same shift, upwards.
# Mirrors the client's own conditions: rich text off means no toolbar, and a
# `toolbar` that names an element by id puts it outside this editor.
def self.reserves_toolbar?(options)
# fetch, not `||`: an explicit `toolbar: false` is exactly the case this
# has to catch, and `||` would collapse it to nil and reserve anyway.
toolbar = options.fetch(:toolbar) { options["toolbar"] }
rich_text = options.fetch(:rich_text) { options.fetch("rich-text") { options["rich_text"] } }

return false if [ false, "false" ].include?(rich_text)
return false if [ false, "false" ].include?(toolbar)
return false if toolbar.is_a?(String)

true
end
end
end
8 changes: 7 additions & 1 deletion lib/lexxy/rich_text_area_tag.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,20 @@ def lexxy_rich_textarea_tag(name, value = nil, options = {}, &block)
# remove the html_safe attribute to preserve attribute escape
value = value.to_str if value.respond_to? :to_str

# Opt-in: render the value into a content element the editor adopts on
# connect, so the field has its final height at first paint instead of
# reflowing when the editor builds a frame after load. Off by default.
prerender = options.delete(:prerender)

options[:name] ||= name
options[:value] ||= value
options[:class] ||= "lexxy-content"
options[:data] ||= {}
options[:data][:direct_upload_url] ||= main_app.rails_direct_uploads_url
options[:data][:blob_url_template] ||= main_app.rails_service_blob_url(":signed_id", ":filename")

editor_tag = content_tag("lexxy-editor", "", options, &block)
inner = (block || !prerender) ? "" : Lexxy::Prerender.inner_html_for(self, options)
editor_tag = content_tag("lexxy-editor", inner, options, &block)
editor_tag
end

Expand Down
49 changes: 46 additions & 3 deletions src/elements/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,7 @@ export class LexicalEditorElement extends HTMLElement {
this.#registerFileAcceptFilter()
this.#attachDebugHooks()
this.#attachToolbar()
this.#discardUnusedPrerenderedToolbar()
this.#resetBeforeTurboCaches()

this.#setInternalFormValue(this.value, { suppressEvent: true })
Expand All @@ -417,7 +418,7 @@ export class LexicalEditorElement extends HTMLElement {
}

#createEditor() {
this.editorContentElement ||= this.#createEditorContentElement()
this.editorContentElement ||= this.#prerenderedContentElement() || this.#createEditorContentElement()
this.appendChild(this.editorContentElement)

const editor = buildEditorFromExtensions({
Expand Down Expand Up @@ -467,6 +468,30 @@ export class LexicalEditorElement extends HTMLElement {
return nodes
}

// Adopt a content element the server prerendered inside us, if present.
// Rendering the body server-side and reusing it here gives the field its final
// height at first paint, avoiding the reflow from building the editor a frame
// after load. Lexical reconciles its parsed state into this element on mount,
// replacing the static markup with the live editor at the same height. Returns
// null when absent (the default), so the empty-editor path is unchanged.
#prerenderedContentElement() {
const element = this.querySelector(":scope > .lexxy-editor__content")
if (!element) return null

element.id ||= `${this.id}-content`
element.setAttribute("contenteditable", "true")
element.setAttribute("role", "textbox")
element.setAttribute("aria-multiline", "true")
if (!element.hasAttribute("aria-label")) element.setAttribute("aria-label", this.#labelText)
if (this.hasAttribute("placeholder")) element.setAttribute("placeholder", this.getAttribute("placeholder"))

this.#ariaAttributes.forEach(attribute => element.setAttribute(attribute.name, attribute.value))
this.#transferAttributeToContentEditable(element, "autocapitalize")
this.#transferAttributeToContentEditable(element, "tabindex", { defaultValue: 0, removeSource: true })

return element
}

#createEditorContentElement() {
const editorContentElement = createElement("div", {
id: `${this.id}-content`,
Expand Down Expand Up @@ -740,20 +765,38 @@ export class LexicalEditorElement extends HTMLElement {
if (typeof toolbarConfig === "string") {
return document.getElementById(toolbarConfig)
} else {
return this.querySelector("lexxy-toolbar") ?? this.#createDefaultToolbar()
const existing = this.querySelector("lexxy-toolbar")
// A prerendered toolbar is ours and arrives empty — fill it rather than
// treat it as one the caller supplied and wants left alone.
if (existing?.dataset.prerendered) return this.#fillDefaultToolbar(existing)

return existing ?? this.#createDefaultToolbar()
}
}

get #hasToolbar() {
return this.supportsRichText && !!this.config.get("toolbar")
}

// A prerendered toolbar this editor turns out not to want — the toolbar is
// configured off, or points at an element elsewhere. Leaving it would reserve
// space nothing fills. Same task as connect, so nothing paints in between.
#discardUnusedPrerenderedToolbar() {
const prerendered = this.querySelector(":scope > lexxy-toolbar[data-prerendered]")
if (prerendered && prerendered !== this.toolbar) prerendered.remove()
}

#createDefaultToolbar() {
const toolbar = createElement("lexxy-toolbar")
this.prepend(toolbar)
return this.#fillDefaultToolbar(toolbar)
}

#fillDefaultToolbar(toolbar) {
toolbar.innerHTML = LexicalToolbar.defaultTemplate
toolbar.setAttribute("data-attachments", this.supportsAttachments) // Drives toolbar CSS styles
toolbar.removeAttribute("aria-hidden")
toolbar.configure(this.config.get("toolbar"))
this.prepend(toolbar)
return toolbar
}

Expand Down
154 changes: 154 additions & 0 deletions test/browser/fixtures/prerender.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Lexxy Prerender Reproduction</title>
<link rel="stylesheet" href="/styles.css">
<style>
body {
background: #f7f7f4;
color: #24231f;
margin: 0;
}

.demo {
margin: 0 auto;
max-inline-size: 1120px;
padding: 32px;
}

.demo > p {
max-inline-size: 68ch;
}

.examples {
display: grid;
gap: 24px;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-block-start: 24px;
}

.example {
background: #ffffff;
border: 1px solid #d8d5ce;
border-radius: 8px;
padding: 24px;
}

.example h2 {
font-size: 1rem;
margin-block: 0 56px;
}

.inline-editor {
--lexxy-editor-padding: 0;
--lexxy-editor-rows: 0;

border: 0;
border-radius: 0;
background: transparent;
}

.inline-editor .lexxy-editor__content {
min-block-size: 0;
padding: 0;
}

.inline-editor lexxy-toolbar {
background: #ffffff;
border-block-end: 1px solid #d8d5ce;
inset-block-end: calc(100% + 8px);
inset-inline: 0;
opacity: 0;
pointer-events: none;
position: absolute;
visibility: hidden;
z-index: 1;
}

.inline-editor:has(.lexxy-editor__content:focus-visible) lexxy-toolbar,
.inline-editor:has(lexxy-toolbar:focus-within) lexxy-toolbar {
opacity: 1;
pointer-events: auto;
visibility: visible;
}

.following-content {
border-block-start: 1px solid #d8d5ce;
margin-block-start: 16px;
padding-block-start: 16px;
}

@media (max-width: 720px) {
.examples {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<main class="demo">
<h1>Inline editor layout reproduction</h1>
<p>
Open this page with <code>?delay=1500</code> to slow Lexxy initialization.
The page content below each editor should start in its final position when
the editor is styled to hug and grow with its contents.
</p>

<form class="examples">
<section class="example" data-example="without-prerender">
<h2>Without prerendered content</h2>

<lexxy-editor
class="lexxy-content inline-editor"
value="&lt;p&gt;Alpha&lt;/p&gt;&lt;p&gt;Bravo&lt;/p&gt;&lt;p&gt;Charlie&lt;/p&gt;&lt;p&gt;Delta&lt;/p&gt;">
</lexxy-editor>

<div class="following-content" data-following="without-prerender">
<p>This content shifts down after Lexxy builds the editor.</p>
</div>
</section>

<section class="example" data-example="with-prerender">
<h2>With prerendered content</h2>

<!-- Simulates rich_text_area(..., prerender: true): the content element
is rendered server-side inside the editor, so the field has its
final content height at first paint. The editor adds the
interactive attributes (contenteditable, role, aria) when it
adopts the node on connect, rather than building another one.
`data-prerendered` lets the spec prove the same node. -->
<lexxy-editor
class="lexxy-content inline-editor"
value="&lt;p&gt;Alpha&lt;/p&gt;&lt;p&gt;Bravo&lt;/p&gt;&lt;p&gt;Charlie&lt;/p&gt;&lt;p&gt;Delta&lt;/p&gt;">
<lexxy-toolbar data-prerendered="server" aria-hidden="true"></lexxy-toolbar>
<div class="lexxy-editor__content" data-prerendered="server">
<p>Alpha</p>
<p>Bravo</p>
<p>Charlie</p>
<p>Delta</p>
</div>
</lexxy-editor>

<div class="following-content" data-following="with-prerender">
<p>This content is already in the right spot before Lexxy connects.</p>
</div>
</section>
</form>
</main>

<script type="module">
const params = new URLSearchParams(window.location.search)

window.loadLexxy = () => import("/editor.js")

// Tests pass ?manual and drive loadLexxy() themselves so they can measure
// the pre-connection layout without racing this timer. Humans open the page
// with ?delay=1500 to watch the editor build in slow motion.
if (!params.has("manual")) {
window.setTimeout(window.loadLexxy, Number(params.get("delay") || 0))
}
</script>
</body>
</html>
Loading