mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 18:55:32 -06:00
DEV: Introduce DecoratedHtml component and use for discourse-banner (#31305)
Eventually, this new component will be used everywhere that we render 'decorated' HTML (e.g. all posts and chat messages). It takes the best bits from our main widget-based post rendering and re-creates it in a more ember-native way. For example: - The HTML is first rendered in a detached DOM, so that requests for images/iframes/etc. are not triggered until after the decoration - HTML generation and decoration is done in a helper (i.e. during the 'render' phase of the runloop). I think that's the 'most Ember compatible' way for us to do this. It means that components added via `renderGlimmer` will be rendered in the same runloop, and it means that things like `schedule("afterRender")` will work exactly as expected. - HTML will be re-rendered and re-decorated whenever the `@html` or `@decorate` arguments change - BUT, `untrack` is used to ensure that reactive state accessed inside the decorate function will not trigger a re-render. This is mostly for compatibility with existing decorators, and we may want to make reactivity opt-in in future - A self-contained `renderGlimmer` system is included. This will allow `helper.renderGlimmer` to be used for any content in these components. Implementing it in a self-contained way rather than using the service means that the component will work ok in unit tests, and that rendered components will show up in the right place in the Ember inspector. This commit only introduces the new component in DiscourseBanner. Followups will introduce it elsewhere.
This commit is contained in:
parent
3f0e84054b
commit
8d709aeb9c
@ -0,0 +1,76 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { untrack } from "@glimmer/validator";
|
||||
import { htmlSafe, isHTMLSafe } from "@ember/template";
|
||||
import { TrackedArray } from "@ember-compat/tracked-built-ins";
|
||||
import helperFn from "discourse/helpers/helper-fn";
|
||||
|
||||
const detachedDocument = document.implementation.createHTMLDocument("detached");
|
||||
|
||||
/**
|
||||
* Reactively renders cooked HTML with decorations applied.
|
||||
*/
|
||||
export default class DecoratedHtml extends Component {
|
||||
renderGlimmerInfos = new TrackedArray();
|
||||
|
||||
decoratedContent = helperFn((args, on) => {
|
||||
const cookedDiv = this.elementToDecorate;
|
||||
|
||||
const helper = new DecorateHtmlHelper({
|
||||
renderGlimmerInfos: this.renderGlimmerInfos,
|
||||
});
|
||||
on.cleanup(() => helper.teardown());
|
||||
|
||||
const decorateFn = this.args.decorate;
|
||||
untrack(() => decorateFn?.(cookedDiv, helper));
|
||||
|
||||
document.adoptNode(cookedDiv);
|
||||
return cookedDiv;
|
||||
});
|
||||
|
||||
get elementToDecorate() {
|
||||
const cooked = this.args.html || htmlSafe("");
|
||||
if (!isHTMLSafe(cooked)) {
|
||||
throw "@cooked must be an htmlSafe string";
|
||||
}
|
||||
const cookedDiv = detachedDocument.createElement("div");
|
||||
cookedDiv.innerHTML = cooked.toString();
|
||||
|
||||
if (this.args.id) {
|
||||
cookedDiv.id = this.args.id;
|
||||
}
|
||||
|
||||
if (this.args.className) {
|
||||
cookedDiv.className = this.args.className;
|
||||
}
|
||||
return cookedDiv;
|
||||
}
|
||||
|
||||
<template>
|
||||
{{~this.decoratedContent~}}
|
||||
|
||||
{{~#each this.renderGlimmerInfos as |info|~}}
|
||||
{{~#in-element info.element insertBefore=null~}}
|
||||
<info.component @data={{info.data}} />
|
||||
{{~/in-element~}}
|
||||
{{~/each~}}
|
||||
</template>
|
||||
}
|
||||
|
||||
class DecorateHtmlHelper {
|
||||
constructor({ renderGlimmerInfos }) {
|
||||
this.renderGlimmerInfos = renderGlimmerInfos;
|
||||
}
|
||||
|
||||
renderGlimmer(element, component, data) {
|
||||
const info = { element, component, data };
|
||||
this.renderGlimmerInfos.push(info);
|
||||
}
|
||||
|
||||
getModel() {
|
||||
return null;
|
||||
}
|
||||
|
||||
teardown() {
|
||||
this.renderGlimmerInfos.length = 0;
|
||||
}
|
||||
}
|
@ -1,12 +1,13 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { action } from "@ember/object";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import { service } from "@ember/service";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import icon from "discourse/helpers/d-icon";
|
||||
import { bind } from "discourse/lib/decorators";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import DecoratedHtml from "./decorated-html";
|
||||
|
||||
export default class DiscourseBanner extends Component {
|
||||
@service appEvents;
|
||||
@ -27,7 +28,7 @@ export default class DiscourseBanner extends Component {
|
||||
newDiv.querySelectorAll("[id^='heading--']").forEach((el) => {
|
||||
el.removeAttribute("id");
|
||||
});
|
||||
return newDiv.innerHTML;
|
||||
return htmlSafe(newDiv.innerHTML);
|
||||
}
|
||||
|
||||
get visible() {
|
||||
@ -47,9 +48,13 @@ export default class DiscourseBanner extends Component {
|
||||
return !this.hide && bannerKey && dismissedBannerKey !== bannerKey;
|
||||
}
|
||||
|
||||
@action
|
||||
decorate(element) {
|
||||
this.appEvents.trigger("decorate-non-stream-cooked-element", element);
|
||||
@bind
|
||||
decorateContent(element, helper) {
|
||||
this.appEvents.trigger(
|
||||
"decorate-non-stream-cooked-element",
|
||||
element,
|
||||
helper
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
@ -66,7 +71,7 @@ export default class DiscourseBanner extends Component {
|
||||
}
|
||||
|
||||
<template>
|
||||
<div {{didInsert this.decorate}}>
|
||||
<div>
|
||||
{{#if this.visible}}
|
||||
<div class="row">
|
||||
<div id="banner">
|
||||
@ -91,9 +96,11 @@ export default class DiscourseBanner extends Component {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="banner-content">
|
||||
{{htmlSafe this.content}}
|
||||
</div>
|
||||
<DecoratedHtml
|
||||
@html={{this.content}}
|
||||
@decorate={{this.decorateContent}}
|
||||
@id="banner-content"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
@ -0,0 +1,46 @@
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import { render, settled } from "@ember/test-helpers";
|
||||
import { module, test } from "qunit";
|
||||
import DecoratedHtml from "discourse/components/decorated-html";
|
||||
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
|
||||
|
||||
module("Integration | Component | <DecoratedHtml />", function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test("renders and re-renders content", async function (assert) {
|
||||
const state = new (class {
|
||||
@tracked html = htmlSafe("<h1>Initial</h1>");
|
||||
})();
|
||||
|
||||
await render(<template><DecoratedHtml @html={{state.html}} /></template>);
|
||||
|
||||
assert.dom("h1").hasText("Initial");
|
||||
|
||||
state.html = htmlSafe("<h1>Updated</h1>");
|
||||
await settled();
|
||||
|
||||
assert.dom("h1").hasText("Updated");
|
||||
});
|
||||
|
||||
test("can decorate content, including renderGlimmer", async function (assert) {
|
||||
const state = new (class {
|
||||
@tracked html = htmlSafe("<h1>Initial</h1>");
|
||||
})();
|
||||
|
||||
const decorate = (element, helper) => {
|
||||
element.innerHTML += "<div id='appended'>Appended</div>";
|
||||
helper.renderGlimmer(element, <template>
|
||||
<div id="render-glimmer">Hello from Glimmer Component</div>
|
||||
</template>);
|
||||
};
|
||||
|
||||
await render(<template>
|
||||
<DecoratedHtml @html={{state.html}} @decorate={{decorate}} />
|
||||
</template>);
|
||||
|
||||
assert.dom("h1").hasText("Initial");
|
||||
assert.dom("#appended").hasText("Appended");
|
||||
assert.dom("#render-glimmer").hasText("Hello from Glimmer Component");
|
||||
});
|
||||
});
|
@ -55,14 +55,10 @@ export function checklistSyntax(elem, postDecorator) {
|
||||
const boxes = [...elem.getElementsByClassName("chcklst-box")];
|
||||
addUlClasses(boxes);
|
||||
|
||||
if (!postDecorator) {
|
||||
return;
|
||||
}
|
||||
const postWidget = postDecorator?.widget;
|
||||
const postModel = postDecorator?.getModel();
|
||||
|
||||
const postWidget = postDecorator.widget;
|
||||
const postModel = postDecorator.getModel();
|
||||
|
||||
if (!postModel.can_edit) {
|
||||
if (!postModel?.can_edit) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user