diff --git a/app/assets/stylesheets/lexxy-editor.css b/app/assets/stylesheets/lexxy-editor.css index 6456ffd27..be5c2944f 100644 --- a/app/assets/stylesheets/lexxy-editor.css +++ b/app/assets/stylesheets/lexxy-editor.css @@ -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 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 { diff --git a/lib/action_text/editor/lexxy_editor.rb b/lib/action_text/editor/lexxy_editor.rb index 3c6f24660..772a4a388 100644 --- a/lib/action_text/editor/lexxy_editor.rb +++ b/lib/action_text/editor/lexxy_editor.rb @@ -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 diff --git a/lib/lexxy.rb b/lib/lexxy.rb index 5221daf52..2d741708d 100644 --- a/lib/lexxy.rb +++ b/lib/lexxy.rb @@ -1,4 +1,5 @@ require "lexxy/version" +require "lexxy/prerender" module Lexxy class << self diff --git a/lib/lexxy/prerender.rb b/lib/lexxy/prerender.rb new file mode 100644 index 000000000..cf49038a1 --- /dev/null +++ b/lib/lexxy/prerender.rb @@ -0,0 +1,65 @@ +module Lexxy + # Builds the static markup a prerendering editor emits inside + # (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 || "


", + 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 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 diff --git a/lib/lexxy/rich_text_area_tag.rb b/lib/lexxy/rich_text_area_tag.rb index 7f8cdebfd..7d2dffcac 100644 --- a/lib/lexxy/rich_text_area_tag.rb +++ b/lib/lexxy/rich_text_area_tag.rb @@ -7,6 +7,11 @@ 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" @@ -14,7 +19,8 @@ def lexxy_rich_textarea_tag(name, value = nil, options = {}, &block) 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 diff --git a/src/elements/editor.js b/src/elements/editor.js index 7a9564df2..a6203ba28 100644 --- a/src/elements/editor.js +++ b/src/elements/editor.js @@ -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 }) @@ -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({ @@ -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`, @@ -740,7 +765,12 @@ 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() } } @@ -748,12 +778,25 @@ export class LexicalEditorElement extends HTMLElement { 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 } diff --git a/test/browser/fixtures/prerender.html b/test/browser/fixtures/prerender.html new file mode 100644 index 000000000..149acbd17 --- /dev/null +++ b/test/browser/fixtures/prerender.html @@ -0,0 +1,154 @@ + + + + + + Lexxy Prerender Reproduction + + + + +
+

Inline editor layout reproduction

+

+ Open this page with ?delay=1500 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. +

+ +
+
+

Without prerendered content

+ + + + +
+

This content shifts down after Lexxy builds the editor.

+
+
+ +
+

With prerendered content

+ + + + +
+

Alpha

+

Bravo

+

Charlie

+

Delta

+
+
+ +
+

This content is already in the right spot before Lexxy connects.

+
+
+
+
+ + + + diff --git a/test/browser/tests/editor/prerender_adoption.test.js b/test/browser/tests/editor/prerender_adoption.test.js new file mode 100644 index 000000000..37461d8ee --- /dev/null +++ b/test/browser/tests/editor/prerender_adoption.test.js @@ -0,0 +1,98 @@ +import { test } from "../../test_helper.js" +import { expect } from "@playwright/test" + +async function topOf(locator) { + return (await locator.boundingBox()).y +} + +test.describe("Prerendered content element", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/prerender.html?manual") + }) + + test("keeps following content stable when the editor hugs its contents", async ({ page }) => { + const withoutFollowing = page.locator("[data-following='without-prerender']") + const withFollowing = page.locator("[data-following='with-prerender']") + const withoutBefore = await topOf(withoutFollowing) + const withBefore = await topOf(withFollowing) + + await page.evaluate(() => window.loadLexxy()) + // `connected` is set before Lexical mounts (mount happens in a following + // animation frame), so wait for the mounted roots or the height change we + // assert on could be measured before it happens. + await expect(page.locator("lexxy-editor .lexxy-editor__content[data-lexical-editor='true']")).toHaveCount(2) + + const withoutAfter = await topOf(withoutFollowing) + const withAfter = await topOf(withFollowing) + + // Absolute displacement: a signed comparison would let a large *upward* shift + // through as a comfortably negative number. + expect(Math.abs(withAfter - withBefore)).toBeLessThan(1) + expect(withoutAfter - withoutBefore).toBeGreaterThan(60) + }) + + // The content element reserves the body; the toolbar placeholder reserves the + // element connectedCallback prepends above it. The fixture floats its toolbar, + // which would hide a failure here, so put it back in normal flow — the stock + // arrangement — and require the same stability. + test("reserves the default toolbar's height too, when it takes space in flow", async ({ page }) => { + await page.addStyleTag({ content: `.inline-editor lexxy-toolbar { + position: static !important; opacity: 1 !important; visibility: visible !important; inset: auto !important; }` }) + + const following = page.locator("[data-following='with-prerender']") + const before = await topOf(following) + + await page.evaluate(() => window.loadLexxy()) + await expect(page.locator("lexxy-editor .lexxy-editor__content[data-lexical-editor='true']")).toHaveCount(2) + + // The real toolbar is in flow and has height, so this asserts the reservation + // matched it rather than that there was nothing to reserve. + const toolbar = await page.locator("[data-example='with-prerender'] lexxy-toolbar").boundingBox() + expect(toolbar.height).toBeGreaterThan(0) + expect(Math.abs(await topOf(following) - before)).toBeLessThan(1) + }) + + // The prerendered toolbar is ours, not the caller's: the editor fills it in + // rather than leaving it empty or building a second one beside it. + test("fills the prerendered toolbar instead of adding another", async ({ page }) => { + const toolbars = page.locator("[data-example='with-prerender'] lexxy-toolbar") + await expect(toolbars).toHaveCount(1) + await expect(toolbars.first()).toBeEmpty() + + await page.evaluate(() => window.loadLexxy()) + await expect(page.locator("lexxy-editor[connected]")).toHaveCount(2) + + await expect(toolbars).toHaveCount(1) + await expect(toolbars.first()).not.toBeEmpty() + await expect(toolbars.first()).not.toHaveAttribute("aria-hidden") + }) + + test("adopts the server-rendered content element rather than creating a second", async ({ page }) => { + await page.evaluate(() => window.loadLexxy()) + await expect(page.locator("lexxy-editor[connected]")).toHaveCount(2) + + const content = page.locator("[data-example='with-prerender'] lexxy-editor > .lexxy-editor__content") + + // Exactly one content element: the server's was reused, not duplicated. + await expect(content).toHaveCount(1) + // ...and it is the very node the server rendered. + await expect(content).toHaveAttribute("data-prerendered", "server") + // Lexical reconciled its state into that adopted node. + await expect(content).toHaveAttribute("data-lexical-editor", "true") + // Content is intact... + await expect(content.locator("p")).toHaveText([ "Alpha", "Bravo", "Charlie", "Delta" ]) + // ...and adoption dressed the static server markup with the interactive + // attributes the server deliberately omits (it isn't editable until now). + await expect(content).toHaveAttribute("contenteditable", "true") + await expect(content).toHaveAttribute("role", "textbox") + await expect(content).toHaveAttribute("aria-multiline", "true") + }) + + test("exposes the value once, without duplicating the body", async ({ page }) => { + await page.evaluate(() => window.loadLexxy()) + await expect(page.locator("lexxy-editor[connected]")).toHaveCount(2) + + const value = await page.locator("[data-example='with-prerender'] lexxy-editor").evaluate(editor => editor.value) + expect(value).toBe("

Alpha

Bravo

Charlie

Delta

") + }) +}) diff --git a/test/helpers/prerender_test.rb b/test/helpers/prerender_test.rb new file mode 100644 index 000000000..bdeea9389 --- /dev/null +++ b/test/helpers/prerender_test.rb @@ -0,0 +1,108 @@ +require "test_helper" + +# The prerender: option renders the value into a static content element inside +# , which the editor adopts on connect instead of building an +# empty one — giving the field its final height at first paint (see +# LexicalEditorElement#prerenderedContentElement). Exercised through +# rich_textarea_tag so the same assertions cover whichever integration is +# active: the ActionText::Editor adapter or the TagHelper fallback. +class PrerenderTest < ActionView::TestCase + helper ActionText::ContentHelper + + test "renders an empty editor by default" do + render inline: <<~ERB + <%= rich_textarea_tag :body, "

Hello

" %> + ERB + + assert_dom "lexxy-editor", count: 1 + assert_dom "lexxy-editor > *", count: 0 + end + + test "prerender: true renders the value into a content element" do + render inline: <<~ERB + <%= rich_textarea_tag :body, "

Hello

", prerender: true %> + ERB + + assert_dom "lexxy-editor > div.lexxy-editor__content", count: 1 do + assert_dom "p", text: "Hello" + end + # prerender must not leak through as an HTML attribute + assert_dom "lexxy-editor[prerender]", count: 0 + end + + test "prerender: true also ships the toolbar the editor will fill" do + render inline: <<~ERB + <%= rich_textarea_tag :body, "

Hello

", prerender: true %> + ERB + + # Empty, and before the content: the editor prepends its toolbar there, so + # this has to occupy the same place to reserve the same space. A real + # rather than a spacer, so the host's own toolbar CSS decides + # whether it takes any room at all. + assert_dom "lexxy-editor > lexxy-toolbar[data-prerendered='server']:empty", count: 1 + assert_dom "lexxy-editor > *:first-child", count: 1 do |first, *| + assert_equal "lexxy-toolbar", first.name + end + end + + test "no toolbar is prerendered when the editor will not prepend one" do + render inline: <<~ERB + <%= rich_textarea_tag :body, "

Hello

", prerender: true, toolbar: false %> + <%= rich_textarea_tag :other, "

Hello

", prerender: true, toolbar: "shared-toolbar" %> + <%= rich_textarea_tag :third, "

Hello

", prerender: true, rich_text: false %> + ERB + + # Reserving space for a toolbar that never arrives is the same defect + # upwards: the field would give the height back on connect. + assert_dom "lexxy-toolbar", count: 0 + assert_dom "lexxy-editor > div.lexxy-editor__content", count: 3 + end + + test "prerendered content is static until the editor adopts it" do + render inline: <<~ERB + <%= rich_textarea_tag :body, "

Hello

", prerender: true %> + ERB + + # The element advertises no editability before Lexical mounts — keystrokes + # into it would be discarded. Adoption adds these attributes on connect. + assert_dom "lexxy-editor > div.lexxy-editor__content:not([contenteditable]):not([role])" + end + + test "prerendered content is sanitized" do + value = %(

Safe

) + + render inline: <<~ERB, locals: { value: value } + <%= rich_textarea_tag :body, value, prerender: true %> + ERB + + # The value attribute is escaped and DOMPurify cleans it client-side, but + # the prerendered element enters the DOM straight from storage — it must go + # through Action Text's sanitizer. + assert_dom "lexxy-editor > div.lexxy-editor__content" do + assert_dom "p", text: "Safe" + assert_dom "script", count: 0 + assert_dom "[onerror]", count: 0 + end + end + + test "prerendering a blank value renders the editor's empty paragraph" do + render inline: <<~ERB + <%= rich_textarea_tag :body, nil, prerender: true %> + ERB + + assert_dom "lexxy-editor > div.lexxy-editor__content" do + assert_dom "p br", count: 1 + end + end + + test "an explicit block wins over prerender" do + render inline: <<~ERB + <%= rich_textarea_tag :body, "

Hello

", prerender: true do %> + custom child + <% end %> + ERB + + assert_dom "lexxy-editor > span#custom", count: 1 + assert_dom "lexxy-editor > div.lexxy-editor__content", count: 0 + end +end