Skip to content

Commit

Permalink
allow light dom editor to be slotted (#143)
Browse files Browse the repository at this point in the history
* allow light dom editor to be slotted.

* uncomment some thing

* add the ability to slot in a light dom editor

* add the ability to slot in a light dom editor

* add the ability to slot in a light dom editor

* working on tests

* working on tests

* fix test suite

* fix test suite

* prettier
  • Loading branch information
KonnorRogers committed Oct 12, 2023
1 parent a69c1cc commit 13dce87
Show file tree
Hide file tree
Showing 11 changed files with 139 additions and 27 deletions.
5 changes: 5 additions & 0 deletions .changeset/late-zebras-march.md
@@ -0,0 +1,5 @@
---
"rhino-editor": patch
---

fix: link-dialog buttons now have proper hover / focus state.
5 changes: 5 additions & 0 deletions .changeset/serious-avocados-act.md
@@ -0,0 +1,5 @@
---
"rhino-editor": minor
---

BREAKING_CHANGE: Allow the light-dom editor to be slotted. Do note, this change may result in a small breaking change for the users relying on the original light-dom structure being `div > div.trix-content`. Most users should not see a difference.
2 changes: 1 addition & 1 deletion docs/frontend/styles/_normalize.css
@@ -1,7 +1,7 @@
html {
box-sizing: border-box;
height: 100%;
font-size: 18px;
font-size: 16px;
/* letter-spacing: 0.025em; */
}

Expand Down
2 changes: 2 additions & 0 deletions docs/frontend/styles/components/_top_nav.css
Expand Up @@ -20,6 +20,8 @@

.top-nav__hamburger__button {
font-size: 1.75em;
display: flex;
align-items: center;
}

.top-nav__hamburger__button::part(base),
Expand Down
@@ -0,0 +1,37 @@
---
title: Add Additional Attributes onto the Editor
permalink: /add-additional-attributes-onto-the-editor/
---

Sometimes you may want to add additional attributes directly onto the `contenteditable` of RhinoEditor.

The easiest way to do this is by slotting in an editor with the attributes you would like. Here's an example of how we
could add `aria-*` attributes onto the editor in cases where perhaps the form failed validation.

```erb
<rhino-editor>
<!-- This will get replaced by a new <div>, but will have the attributes copied. -->
<div slot="editor" aria-invalid="<%%= object.errors.any? %>" aria-describedby="description-errors">
</div>
</rhino-editor>
<div id="description-errors">
<%% if object.errors.any? %>
<%%= object.errors.to_s %>
<%% end %>
</div>
```

This will produce something like the following:

```html
<rhino-editor>
<!-- This will get replaced by a new <div>, but will have the attributes copied. -->
<div slot="editor" aria-invalid="true" aria-describedby="description-errors">
</div>
</rhino-editor>

<div id="description-errors">
Wow dude. You really messed up. What did you even submit?
</div>
```
73 changes: 53 additions & 20 deletions src/exports/elements/tip-tap-editor-base.ts
Expand Up @@ -121,33 +121,68 @@ export class TipTapEditorBase extends BaseElement {
*/
extensions: EditorOptions["extensions"] = [];

/**
* @internal
*/
__initialAttributes: Record<string, string> = {};

/**
* @internal
*/
__hasRendered: boolean = false;

__getInitialAttributes() {
if (this.__hasRendered) return;

const slottedEditor = this.slottedEditor;
if (slottedEditor) {
this.__initialAttributes = {};
[...slottedEditor.attributes].forEach((attr) => {
const { nodeName, nodeValue } = attr;
if (nodeName && nodeValue != null) {
this.__initialAttributes[nodeName] = nodeValue;
}
});
}

this.__hasRendered = true;
}

/**
* Reset mechanism. This is called on first connect, and called anytime extensions,
* or editor options get modified to make sure we have a fresh instance.
*/
rebuildEditor() {
const editors = this.querySelectorAll("[slot='editor']");

this.__getInitialAttributes();

// Make sure we dont render the editor more than once.
if (this.editor) this.editor.destroy();
const editors = this.querySelectorAll("[slot='editor']");

editors.forEach((el) => {
// @ts-expect-error
el.querySelector(".tiptap")?.editor?.destroy();
el.editor?.destroy();
el.remove();
});

// light-dom version.
const div = document.createElement("div");
div.setAttribute("slot", "editor");
this.editor = this.__setupEditor(this);

// This may seem strange, but for some reason its the only wayto get the DropCursor working correctly.
div.style.position = "relative";
this.insertAdjacentElement("beforeend", div);
this.__bindEditorListeners();

this.editor = this.__setupEditor(div);
this.editorElement = this.querySelector(".ProseMirror");

this.__bindEditorListeners();
this.editorElement = div.querySelector(".ProseMirror");
//
Object.entries(this.__initialAttributes)?.forEach(
([attrName, attrValue]) => {
if (attrName === "class") {
this.editorElement?.classList.add(...attrValue.split(" "));
return;
}
this.editorElement?.setAttribute(attrName, attrValue);
},
);

this.editorElement?.setAttribute("slot", "editor");
this.editorElement?.classList.add("trix-content");
this.editorElement?.setAttribute("tabindex", "0");
this.editorElement?.setAttribute("role", "textbox");
Expand All @@ -157,9 +192,7 @@ export class TipTapEditorBase extends BaseElement {
}

protected willUpdate(
changedProperties:
| PropertyValueMap<this & { class: string }>
| Map<PropertyKey, unknown>,
changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>,
): void {
if (changedProperties.has("class")) {
this.classList.add("rhino-editor");
Expand All @@ -175,6 +208,10 @@ export class TipTapEditorBase extends BaseElement {
protected updated(
changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>,
): void {
if (changedProperties.has("readonly")) {
this.editor?.setEditable(!this.readonly);
}

if (
changedProperties.has("extensions") ||
changedProperties.has("starterKitOptions") ||
Expand All @@ -183,10 +220,6 @@ export class TipTapEditorBase extends BaseElement {
this.rebuildEditor();
}

if (changedProperties.has("readonly")) {
this.editor?.setEditable(!this.readonly);
}

super.updated(changedProperties);
}

Expand Down Expand Up @@ -614,7 +647,7 @@ export class TipTapEditorBase extends BaseElement {
this.editor.off("blur", this.__handleBlur);
}

private __setupEditor(element: Element): Editor {
private __setupEditor(element: Element = this): Editor {
if (!this.serializer || this.serializer === "html") {
// This is a super hacky way to get __to_trix_html to support figcaptions without patching it.
this.normalizeDOM(this.inputElement);
Expand Down
4 changes: 2 additions & 2 deletions src/exports/elements/tip-tap-editor.ts
Expand Up @@ -1109,14 +1109,14 @@ export class TipTapEditor extends TipTapEditorBase {
/>
<div class="link-dialog__buttons" part="link-dialog__buttons">
<button
class="link-dialog__button"
class="rhino-toolbar-button link-dialog__button"
part="link-dialog__button link-dialog__button--link"
@click=${this.addLink}
>
${this.translations.linkDialogLink}
</button>
<button
class="link-dialog__button"
class="rhino-toolbar-button link-dialog__button"
part="link-dialog__button link-dialog__button--unlink"
@click=${() => {
this.editor
Expand Down
5 changes: 2 additions & 3 deletions src/exports/styles/editor.js
Expand Up @@ -130,7 +130,7 @@ export default css`
border-bottom-left-radius: 0px;
}
.toolbar::part(base):is(:focus-within) {
.toolbar::part(base):is(:focus-visible, :focus-within) {
border-color: var(--rhino-button-active-border-color);
outline: transparent;
}
Expand Down Expand Up @@ -177,7 +177,6 @@ export default css`
.link-dialog__input:is(:focus) {
outline: transparent;
box-shadow: var(--rhino-focus-ring);
border-color: var(--rhino-button-active-border-color);
}
Expand All @@ -188,7 +187,7 @@ export default css`
box-shadow: none;
}
.link-dialog__button {
.rhino-toolbar-button.link-dialog__button {
padding: 0.4em 0.6em;
border: 1px solid var(--rhino-button-border-color);
border-radius: var(--rhino-border-radius);
Expand Down
2 changes: 2 additions & 0 deletions tests/rails/app/views/posts/_form.html.erb
Expand Up @@ -36,6 +36,8 @@
<%# <button slot="toolbar-end" tabindex="-1" type="button" data-role="toolbar-item">Embed</button> %>
<%# <button slot="toolbar-end" tabindex="-1" type="button" data-role="toolbar-item">EmbedTwo</button> %>
<button slot="before-undo-button" tabindex="-1" type="button" data-role="toolbar-item" data-controller="embed">Embed</button>

<div slot="editor" aria-invalid="true" class="my-class"></div>
</rhino-editor>
</section>

Expand Down
27 changes: 27 additions & 0 deletions tests/unit/light-dom-render.test.js
@@ -0,0 +1,27 @@
// @ts-check
import "rhino-editor"
import { fixture, assert, aTimeout } from "@open-wc/testing"
import { html } from "lit"

test("Should only render a textbox once", async () => {
const rhinoEditor = await fixture(html`<rhino-editor>
<div slot="editor" aria-describedby="errors" aria-invalid="true" class="my-class"></div>
</rhino-editor>`)

await aTimeout(100)

assert.equal(rhinoEditor.querySelectorAll("[role='textbox']").length, 1)

const editor = rhinoEditor.querySelector(".trix-content")

// The attributes we put on the editor should not get overwritten when the rendering process happens.
assert.equal(editor?.getAttribute("aria-describedby"), "errors")
assert.equal(editor?.getAttribute("aria-invalid"), "true")

// Make sure classes don't get overwritten
assert(editor?.classList.contains("my-class"))
assert(editor?.classList.contains("trix-content"))
console.log(editor.outerHTML)
assert(editor?.classList.contains("tiptap"))
})

4 changes: 3 additions & 1 deletion tests/unit/serializer.test.js
Expand Up @@ -10,11 +10,13 @@ test("It should properly update the input element when the serializer changes",
<rhino-editor input="input"></rhino-editor>
</div>`)

await aTimeout(0)

const rhinoEditor = div.querySelector("rhino-editor")
const input = div.querySelector("input")

assert.equal(rhinoEditor.serializer, "html")
assert.equal(input.value, "<p></p>")
assert.equal(input.value, "")

rhinoEditor.serializer = "json"

Expand Down

0 comments on commit 13dce87

Please sign in to comment.