diff --git a/.changeset/dirty-berries-learn.md b/.changeset/dirty-berries-learn.md new file mode 100644 index 00000000..f3271be1 --- /dev/null +++ b/.changeset/dirty-berries-learn.md @@ -0,0 +1,5 @@ +--- +"rhino-editor": minor +--- + +Add `getHTMLContentFromRange()` and `getTextContentFromRange()` functions diff --git a/.changeset/six-jars-count.md b/.changeset/six-jars-count.md new file mode 100644 index 00000000..b19d1175 --- /dev/null +++ b/.changeset/six-jars-count.md @@ -0,0 +1,5 @@ +--- +"rhino-editor": patch +--- + +Fixed a bug where the current focused figure did not have an outline diff --git a/.changeset/soft-books-jump.md b/.changeset/soft-books-jump.md new file mode 100644 index 00000000..4a0c2ff7 --- /dev/null +++ b/.changeset/soft-books-jump.md @@ -0,0 +1,5 @@ +--- +"rhino-editor": patch +--- + +Close link dialogs when clicking outside the editor or on other toolbar items diff --git a/.changeset/twenty-hornets-own.md b/.changeset/twenty-hornets-own.md new file mode 100644 index 00000000..4420ed2d --- /dev/null +++ b/.changeset/twenty-hornets-own.md @@ -0,0 +1,5 @@ +--- +"rhino-editor": patch +--- + +Fixed a bug where figcaptions would never update if you did not interact with the editor diff --git a/docs/src/_documentation/how_tos/15-getting-html-and-text-content.md b/docs/src/_documentation/how_tos/15-getting-html-and-text-content.md new file mode 100644 index 00000000..c9dab138 --- /dev/null +++ b/docs/src/_documentation/how_tos/15-getting-html-and-text-content.md @@ -0,0 +1,85 @@ +--- +title: Getting HTML and Text content of the editor +permalink: /how-tos/getting-html-and-text-content/ +--- + +Sometimes you may want to grab either a plain text representation or an HTML representation of Rhino Editor's content, or even the currently selected content. + +Rhino Editor exposes 2 functions to help with this. + +## getHTMLContentFromRange + +The first is `getHTMLContentFromRange(from, to)` where `from` and `to` are `number`. They are optional. If no parameters are passed, it will read from the user's current selection. + +### getHTMLContentFromRange examples + +#### Getting the HTML content of the user's current selection + +To get the HTML content of what the user has highlighted, you can do so by calling `getHTMLContentFromRange()` with no parameters. Like so: + +```js +const rhinoEditor = document.querySelector("rhino-editor") +rhinoEditor.getHTMLContentFromRange() +``` + +#### Getting HTML content for a given range + +Sometimes you may want to get the HTML content for a given range, to do so, pass in a `from` and `to` parameters that are a `number`. Like so: + +```js +const rhinoEditor = document.querySelector("rhino-editor") +rhinoEditor.getHTMLContentFromRange(0, 50) +``` + +#### Getting selected content and falling back to full editor content + +Sometimes you may want to get the current selection of the user, or fall back to the entire editor's HTML if the user has not selected anything. To do so, we can conditionally check if the `getHTMLContentFromRange()` is "empty". Like so: + +```js +const rhinoEditor = document.querySelector("rhino-editor") +let html = rhinoEditor.getHTMLContentFromRange() + +// If the user has nothing currently highlighted, fallback to getting the full HTML of the editor. +if (!html) { + html = rhinoEditor.editor.getHTML() +} +``` + +## getTextContentFromRange + +`getTextContentFromRange()` has much the same API as `getHTMLContentFromRange()`. You can either pass 2 numbers as a range, or you can pass nothing it will return the user's currently selected text. Let's look at the previous example but instead now grabbing text. + +### getTextContentFromRange examples + +#### Getting the text content of the user's current selection + +To get the Text content of what the user has highlighted, you can do so by calling `getTextContentFromRange()` with no parameters. Like so: + +```js +const rhinoEditor = document.querySelector("rhino-editor") +rhinoEditor.getTextContentFromRange() +``` + +#### Getting Text content for a given range + +Sometimes you may want to get the Text content for a given range, to do so, pass in a `from` and `to` parameters that are a `number`. Like so: + +```js +const rhinoEditor = document.querySelector("rhino-editor") +rhinoEditor.getTextContentFromRange(0, 50) +``` + +#### Getting selected content and falling back to full editor content + +Sometimes you may want to get the current selection of the user, or fall back to the entire editor's Text if the user has not selected anything. To do so, we can conditionally check if the `getTextContentFromRange()` is "empty". Like so: + +```js +const rhinoEditor = document.querySelector("rhino-editor") +let text = rhinoEditor.getTextContentFromRange() + +// If the user has nothing currently highlighted, fallback to getting the full Text of the editor. +if (!text) { + text = rhinoEditor.editor.getText() +} +``` + diff --git a/esbuild.config.js b/esbuild.config.js index 9acd0e68..584269db 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -14,12 +14,6 @@ const deps = [ const watchMode = process.argv.includes("--watch") -import { - hostStyles, - toolbarButtonStyles -} from "./src/exports/styles/editor.js" - - /** * @return {import("esbuild").Plugin} */ @@ -33,6 +27,13 @@ function AppendCssStyles () { { encoding: "utf8" } ) + let date = new Date() + + const { + hostStyles, + toolbarButtonStyles + } = await import(`./src/exports/styles/editor.js?cache=${date.toString()}`) + const finalString = `/* THIS FILE IS AUTO-GENERATED. DO NOT EDIT BY HAND! */ ${styles.toString()} /* src/exports/styles/editor.js:hostStyles */ diff --git a/src/exports/elements/tip-tap-editor-base.ts b/src/exports/elements/tip-tap-editor-base.ts index e7eef19c..9fddf3ff 100644 --- a/src/exports/elements/tip-tap-editor-base.ts +++ b/src/exports/elements/tip-tap-editor-base.ts @@ -34,7 +34,7 @@ import { RhinoBlurEvent } from "../events/rhino-blur-event.js"; import { RhinoChangeEvent } from "../events/rhino-change-event.js"; import { SelectionChangeEvent } from "../events/selection-change-event.js"; import { RhinoPasteEvent } from "../events/rhino-paste-event.js"; -import { Slice } from "@tiptap/pm/model"; +import { DOMSerializer, Slice } from "@tiptap/pm/model"; import { EditorView } from "@tiptap/pm/view"; export type Serializer = "" | "html" | "json"; @@ -191,6 +191,120 @@ export class TipTapEditorBase extends BaseElement { this.requestUpdate(); } + /** + * Grabs HTML content based on a given range. If no range is given, it will return the contents + * of the current editor selection. If the current selection is empty, it will return an empty string. + * @param from - The start of the selection + * @param to - The end of the selection + * @example Getting the HTML content of the current selection + * const rhinoEditor = document.querySelector("rhino-editor") + * rhinoEditor.getHTMLContentFromRange() + * + * @example Getting the HTML content of node range + * const rhinoEditor = document.querySelector("rhino-editor") + * rhinoEditor.getHTMLContentFromRange(0, 50) + * + * @example Getting the HTML content and falling back to entire editor HTML + * const rhinoEditor = document.querySelector("rhino-editor") + * let html = rhinoEditor.getHTMLContentFromRange() + * if (!html) { + * html = rhinoEditor.editor.getHTML() + * } + */ + getHTMLContentFromRange(from?: number, to?: number) { + const editor = this.editor; + + if (!editor) return ""; + + let empty; + + if (!from && !to) { + const currentSelection = editor.state.selection; + + from = currentSelection.from; + to = currentSelection.to; + } + + if (empty) { + return ""; + } + if (from == null) { + return ""; + } + if (to == null) { + return ""; + } + + const { state } = editor; + const htmlArray: string[] = []; + + const tempScript = document.createElement("script"); + // We want plain text so we don't parse. + tempScript.type = "text/plain"; + + state.doc.nodesBetween(from, to, (node, _pos, parent) => { + if (parent === state.doc) { + tempScript.innerHTML = ""; + const serializer = DOMSerializer.fromSchema(editor.schema); + const dom = serializer.serializeNode(node); + tempScript.appendChild(dom); + htmlArray.push(tempScript.innerHTML); + tempScript.innerHTML = ""; + } + }); + + return htmlArray.join(""); + } + + /** + * Grabs plain text representation based on a given range. If no parameters are given, it will return the contents + * of the current selection. If the current selection is empty, it will return an empty string. + * @param from - The start of the selection + * @param to - The end of the selection + * @example Getting the Text content of the current selection + * const rhinoEditor = document.querySelector("rhino-editor") + * rhinoEditor.getTextContentFromRange() + * + * @example Getting the Text content of node range + * const rhinoEditor = document.querySelector("rhino-editor") + * rhinoEditor.getTextContentFromRange(0, 50) + * + * @example Getting the Text content and falling back to entire editor Text + * const rhinoEditor = document.querySelector("rhino-editor") + * let text = rhinoEditor.getTextContentFromRange() + * if (!text) { + * text = rhinoEditor.editor.getText() + * } + */ + getTextContentFromRange(from?: number, to?: number) { + const editor = this.editor; + + if (!editor) { + return ""; + } + + let empty; + + if (!from && !to) { + const selection = editor.state.selection; + from = selection.from; + to = selection.to; + empty = selection.empty; + } + + if (empty) { + return ""; + } + if (from == null) { + return ""; + } + if (to == null) { + return ""; + } + + return editor.state.doc.textBetween(from, to, " "); + } + protected willUpdate( changedProperties: PropertyValueMap | Map, ): void { diff --git a/src/exports/elements/tip-tap-editor.ts b/src/exports/elements/tip-tap-editor.ts index 75a40d73..85154662 100644 --- a/src/exports/elements/tip-tap-editor.ts +++ b/src/exports/elements/tip-tap-editor.ts @@ -182,6 +182,13 @@ export class TipTapEditor extends TipTapEditorBase { if (this.editor) { this.editor.on("focus", this.closeLinkDialog); } + + document.addEventListener("click", this.__handleLinkDialogClick); + } + + disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener("click", this.__handleLinkDialogClick); } get icons(): typeof icons { @@ -221,7 +228,6 @@ export class TipTapEditor extends TipTapEditorBase { if (this.linkDialog == null) return; this.linkDialogExpanded = false; - this.linkDialog.setAttribute("hidden", ""); } showLinkDialog(): void { @@ -235,16 +241,13 @@ export class TipTapEditor extends TipTapEditorBase { this.__invalidLink__ = false; this.linkDialogExpanded = true; - this.linkDialog.removeAttribute("hidden"); setTimeout(() => { if (inputElement != null) inputElement.focus(); }); } - get linkDialog(): Maybe { - return this.shadowRoot?.querySelector( - ".link-dialog", - ) as Maybe; + get linkDialog(): Maybe { + return this.shadowRoot?.querySelector("#link-dialog"); } attachFiles(): void { @@ -1074,24 +1077,45 @@ export class TipTapEditor extends TipTapEditorBase { `; } + /** + * @private + */ + private __handleLinkDialogClick = (e: Event) => { + const linkDialogContainer = this.shadowRoot?.querySelector( + ".link-dialog__container", + ); + + if (!linkDialogContainer) { + this.linkDialogExpanded = false; + return; + } + + const composedPath = e.composedPath(); + + const linkButton = this.shadowRoot?.querySelector("[name='link-button']"); + + if (composedPath.includes(linkDialogContainer as EventTarget)) { + return; + } + + if (linkButton && composedPath.includes(linkButton as EventTarget)) { + return; + } + + this.linkDialogExpanded = false; + }; + /** @TODO: Lets think of a more friendly way to render dialogs for users to extend. */ renderDialog(): TemplateResult { - if (this.readonly) return html``; + if (this.readonly) { + return html``; + } + return html`