diff --git a/.changeset/lemon-actors-report.md b/.changeset/lemon-actors-report.md
new file mode 100644
index 00000000..b9414479
--- /dev/null
+++ b/.changeset/lemon-actors-report.md
@@ -0,0 +1,5 @@
+---
+"rhino-editor": minor
+---
+
+Added a "faux selection" for link insertions to give a visual indicator of insertion / replacement points for links.
diff --git a/.changeset/many-poems-chew.md b/.changeset/many-poems-chew.md
new file mode 100644
index 00000000..84072fa1
--- /dev/null
+++ b/.changeset/many-poems-chew.md
@@ -0,0 +1,5 @@
+---
+"rhino-editor": patch
+---
+
+Fixed a bug around "progress" finishing prematurely
diff --git a/.changeset/violet-parrots-jump.md b/.changeset/violet-parrots-jump.md
new file mode 100644
index 00000000..7c04a894
--- /dev/null
+++ b/.changeset/violet-parrots-jump.md
@@ -0,0 +1,5 @@
+---
+"rhino-editor": minor
+---
+
+Add a `rhino-editor.css` which is a more minimal `trix.css` and has no styles on the editor content.
diff --git a/.changeset/witty-trainers-report.md b/.changeset/witty-trainers-report.md
new file mode 100644
index 00000000..a00aac75
--- /dev/null
+++ b/.changeset/witty-trainers-report.md
@@ -0,0 +1,5 @@
+---
+"rhino-editor": patch
+---
+
+Fixed a bug with direct upload events not dispatching under the proper name
diff --git a/.prettierignore b/.prettierignore
index dc69294b..307f9907 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1 +1,2 @@
src/exports/styles/trix.css
+src/exports/styles/rhino-editor.css
diff --git a/docs/esbuild.config.js b/docs/esbuild.config.js
index b85de04b..4f56a84b 100644
--- a/docs/esbuild.config.js
+++ b/docs/esbuild.config.js
@@ -43,6 +43,8 @@ const esbuildOptions = {
assets: {
from: [path.resolve(__dirname, '../exports/styles/trix.css')],
to: [path.resolve(__dirname, 'src/rhino-editor/exports/styles/trix.css')],
+ from: [path.resolve(__dirname, '../exports/styles/rhino-editor.css')],
+ to: [path.resolve(__dirname, 'src/rhino-editor/exports/styles/rhino-editor.css')],
},
verbose: false,
watch: true,
diff --git a/docs/src/_documentation/references/02-events.md b/docs/src/_documentation/references/02-events.md
index e6398da4..76ed77ae 100644
--- a/docs/src/_documentation/references/02-events.md
+++ b/docs/src/_documentation/references/02-events.md
@@ -51,3 +51,20 @@ document.querySelector("rhino-editor").addEventListener("rhino-direct-upload:pro
console.log(event.attachmentUpload.progress)
})
```
+
+## Waiting to submit a form until direct uploads finish
+
+Also of note, the `direct-upload` attachments will also add a `.pendingAttachmentUploads` to Rhino Editor.
+
+This can be useful for stopping form submissions until all uploads have finished.
+
+Here's an example of how you could stop form submissions:
+
+```js
+form.addEventListener("submit", (e) => {
+ if (rhinoEditor.pendingAttachmentUploads.length > 0) {
+ // There are still pending uploads, so preventDefault() to stop the submission
+ e.preventDefault()
+ }
+})
+```
diff --git a/docs/src/_documentation/references/07-fake-selections.md b/docs/src/_documentation/references/07-fake-selections.md
new file mode 100644
index 00000000..fae017f0
--- /dev/null
+++ b/docs/src/_documentation/references/07-fake-selections.md
@@ -0,0 +1,21 @@
+---
+title: Fake Selections and Insertions
+permalink: /references/fake-selections/
+---
+
+Rhino Editor has a couple of utilities for having "fake" insertions and selections.
+
+You'll notice as of v0.14.0 when you move focus to the link dialog inputs, the editor will either show a fake insertion cursor, or show a fake selection "box" around the currently selected text.
+
+There's also a fake cursor used for inline code blocks courtesy of the [Codemark Plugin](https://github.com/curvenote/editor/tree/main/packages/prosemirror-codemark)
+
+The CSS for these fake selections comes directly from `"rhino-editor/exports/styles/trix.css"`.
+
+However, some people may not use this because it can be overly opinionated. Fake selections / cursors can also be added to a CSS file via:
+
+`@import "rhino-editor/exports/styles/rhino-editor.css"`
+
+or from a JavaScript file:
+
+`import "rhino-editor/exports/styles/rhino-editor.css"`
+
diff --git a/docs/src/_layouts/home.erb b/docs/src/_layouts/home.erb
index 87ee6ebb..a175516a 100644
--- a/docs/src/_layouts/home.erb
+++ b/docs/src/_layouts/home.erb
@@ -21,7 +21,7 @@ layout: default
<% end %>
-
+
diff --git a/docs/src/rhino-editor/exports/styles/rhino-editor.css b/docs/src/rhino-editor/exports/styles/rhino-editor.css
new file mode 100644
index 00000000..c81e7466
--- /dev/null
+++ b/docs/src/rhino-editor/exports/styles/rhino-editor.css
@@ -0,0 +1,132 @@
+/* src/exports/styles/rhino-editor.css */
+:host,
+.trix-content {
+ --rhino-focus-ring: 0px 0px 1px 1px var(--rhino-button-active-border-color);
+ --rhino-border-radius: 4px;
+ --rhino-danger-border-color: red;
+ --rhino-danger-background-color: #ffdddd;
+ --rhino-text-color: #374151;
+ --rhino-dark-text-color: white;
+ --rhino-border-color: #cecece;
+ --rhino-placeholder-text-color: #cecece;
+ --rhino-dark-placeholder-text-color: gray;
+ --rhino-button-text-color: #889;
+ --rhino-button-dark-text-color: #eee;
+ --rhino-button-border-color: #cecece;
+ --rhino-button-disabled-text-color: #d1d5db;
+ --rhino-button-disabled-border-color: #d1d5db;
+ --rhino-button-disabled-background-color: #d1d5db;
+ --rhino-button-active-border-color: #005a9c;
+ --rhino-button-active-background-color: rgb(226 239 255);
+ --rhino-toolbar-text-color: hsl(219, 6%, 43%);
+ --rhino-toolbar-icon-size: 1em;
+ --rhino-dialog-border-color: hsl( var(--rhino-button-focus-background-color-hsl) / 50% );
+ --rhino-button-focus-background-color: hsl( var(--rhino-button-focus-background-color-hsl) );
+ --rhino-button-focus-background-color-hsl: 219 26% 95%;
+ --rhino-fake-selection-color: rgb(220, 220, 220);
+ display: block;
+ color: var(--rhino-text-color);
+ color: light-dark(var(--rhino-text-color), var(--rhino-dark-text-color));
+}
+@keyframes rhino-blink {
+ 49% {
+ border-color: unset;
+ }
+ 50% {
+ border-color: Canvas;
+ }
+ 99% {
+ border-color: Canvas;
+ }
+}
+.rhino-editor .no-cursor {
+ caret-color: transparent;
+}
+:where(.rhino-editor) .fake-cursor {
+ margin: 0;
+ padding: 0;
+ margin-right: -1px;
+ border-left-width: 1px;
+ border-left-style: solid;
+ animation: rhino-blink 1s;
+ animation-iteration-count: infinite;
+ position: relative;
+ z-index: 1;
+}
+:where(.rhino-editor .ProseMirror):not(:focus-within) .rhino-selection {
+ background: var(--rhino-fake-selection-color);
+}
+:where(.rhino-editor) .rhino-insertion-placeholder {
+ display: none;
+ user-select: none;
+}
+:where(.rhino-editor)[link-dialog-expanded] .rhino-insertion-placeholder {
+ margin: 0;
+ padding: 0;
+ margin-right: -1px;
+ margin-left: -2px;
+ border-left-width: 4px;
+ border-left-style: solid;
+ border-color: Highlight;
+ position: relative;
+ z-index: 1;
+ display: inline;
+}
+.ProseMirror-separator {
+ display: none !important;
+}
+.rhino-toolbar-button {
+ appearance: none;
+ -webkit-appearance: none;
+ border: 1px solid var(--rhino-border-color);
+ border-radius: var(--rhino-border-radius);
+ padding: 0.4em;
+ color: var(--rhino-button-text-color);
+ color: light-dark(var(--rhino-button-text-color), var(--rhino-button-dark-text-color));
+ background: Canvas;
+ font-size: inherit;
+ display: inline-grid;
+}
+.rhino-toolbar-button:is([aria-disabled=true], :disabled) {
+ color: var(--rhino-button-disabled-text-color);
+ border-color: var(--rhino-button-disabled-border-color);
+}
+.rhino-toolbar-button[aria-disabled=true]:focus {
+ border-color: var(--rhino-button-disabled-border-color);
+}
+.rhino-toolbar-button svg {
+ min-height: var(--rhino-toolbar-icon-size);
+ min-width: var(--rhino-toolbar-icon-size);
+ max-height: var(--rhino-toolbar-icon-size);
+ max-width: var(--rhino-toolbar-icon-size);
+}
+.rhino-toolbar-button:is(:focus, :hover):not([aria-disabled=true], :disabled) {
+ outline: transparent;
+ border-color: var(--rhino-button-active-border-color);
+}
+.rhino-toolbar-button:is(:focus):not([aria-disabled=true], :disabled) {
+ box-shadow: var(--rhino-focus-ring);
+}
+.rhino-toolbar-button:is(:hover):not([aria-disabled=true], :disabled, [aria-pressed=true], [part~=toolbar__button--active]) {
+ background-color: var(--rhino-button-focus-background-color);
+ background-color: light-dark(var(--rhino-button-focus-background-color), gray);
+}
+.rhino-toolbar-button:is([aria-disabled=true], :disabled):not([part~=toolbar__button--active]) {
+ color: var(--rhino-button-disabled-text-color);
+ color: light-dark(var(--rhino-button-disabled-text-color), gray);
+ border-color: var(--rhino-button-disabled-border-color);
+}
+.rhino-toolbar-button:is(:focus, :hover):is([aria-disabled=true], :disabled):not([part~=toolbar__button--active]) {
+ outline: transparent;
+ color: var(--rhino-button-disabled-text-color);
+ color: light-dark(var(--rhino-button-disabled-text-color), gray);
+ border-color: var(--rhino-button-disabled-border-color);
+ box-shadow: 0 0 0 1px var(--rhino-button-disabled-border-color);
+ box-shadow: 0 0 0 1px light-dark(var(--rhino-button-disabled-border-color), transparent);
+}
+svg,
+::slotted(svg) {
+ height: var(--rhino-toolbar-icon-size);
+ width: var(--rhino-toolbar-icon-size);
+}
+/*# sourceMappingURL=rhino-editor.css.map */
diff --git a/docs/src/rhino-editor/exports/styles/trix.css b/docs/src/rhino-editor/exports/styles/trix.css
index 2f71a277..d2998b06 100644
--- a/docs/src/rhino-editor/exports/styles/trix.css
+++ b/docs/src/rhino-editor/exports/styles/trix.css
@@ -1,29 +1,4 @@
/* src/exports/styles/trix.css */
-@keyframes blink {
- 49% {
- border-color: unset;
- }
- 50% {
- border-color: Canvas;
- }
- 99% {
- border-color: Canvas;
- }
-}
-.rhino-editor .no-cursor {
- caret-color: transparent;
-}
-.rhino-editor .fake-cursor {
- margin: 0;
- padding: 0;
- margin-right: -1px;
- border-left-width: 1px;
- border-left-style: solid;
- animation: blink 1s;
- animation-iteration-count: infinite;
- position: relative;
- z-index: 1;
-}
.trix-content {
border: 1px solid var(--rhino-border-color);
border-radius: 0px 0px var(--rhino-border-radius) var(--rhino-border-radius);
@@ -287,10 +262,58 @@
--rhino-dialog-border-color: hsl( var(--rhino-button-focus-background-color-hsl) / 50% );
--rhino-button-focus-background-color: hsl( var(--rhino-button-focus-background-color-hsl) );
--rhino-button-focus-background-color-hsl: 219 26% 95%;
+ --rhino-fake-selection-color: rgb(220, 220, 220);
display: block;
color: var(--rhino-text-color);
color: light-dark(var(--rhino-text-color), var(--rhino-dark-text-color));
}
+@keyframes rhino-blink {
+ 49% {
+ border-color: unset;
+ }
+ 50% {
+ border-color: Canvas;
+ }
+ 99% {
+ border-color: Canvas;
+ }
+}
+.rhino-editor .no-cursor {
+ caret-color: transparent;
+}
+:where(.rhino-editor) .fake-cursor {
+ margin: 0;
+ padding: 0;
+ margin-right: -1px;
+ border-left-width: 1px;
+ border-left-style: solid;
+ animation: rhino-blink 1s;
+ animation-iteration-count: infinite;
+ position: relative;
+ z-index: 1;
+}
+:where(.rhino-editor .ProseMirror):not(:focus-within) .rhino-selection {
+ background: var(--rhino-fake-selection-color);
+}
+:where(.rhino-editor) .rhino-fake-cursor-selection {
+ display: none;
+ user-select: none;
+}
+:where(.rhino-editor)[link-dialog-expanded] .rhino-fake-cursor-selection {
+ margin: 0;
+ padding: 0;
+ margin-right: -1px;
+ margin-left: -2px;
+ border-left-width: 4px;
+ border-left-style: solid;
+ border-color: Highlight;
+ position: relative;
+ z-index: 1;
+ display: inline;
+}
+.ProseMirror-separator {
+ display: none !important;
+}
.rhino-toolbar-button {
appearance: none;
-webkit-appearance: none;
diff --git a/esbuild.config.js b/esbuild.config.js
index 18e571e7..b44f6a4e 100644
--- a/esbuild.config.js
+++ b/esbuild.config.js
@@ -31,20 +31,30 @@ function AppendCssStyles () {
const {
hostStyles,
- toolbarButtonStyles
+ toolbarButtonStyles,
+ cursorStyles
} = await import(`./src/exports/styles/editor.js?cache=${date.toString()}`)
- const finalString = `/* THIS FILE IS AUTO-GENERATED. DO NOT EDIT BY HAND! */
-${styles.toString()}
+ const banner = `/* THIS FILE IS AUTO-GENERATED. DO NOT EDIT BY HAND! */`
+
+ const editorCSS = `
/* src/exports/styles/editor.js:hostStyles */
${hostStyles.toString()}
+/* src/exports/styles/editor.js:toolbarButtonStyles */
+${cursorStyles.toString()}
+
/* src/exports/styles/editor.js:toolbarButtonStyles */
${toolbarButtonStyles.toString()}
-`
+`.trim()
+
+ const trixCSS = `
+ ${styles.toString()}
+ ${editorCSS}
+`.trim()
- await fsPromises.writeFile(path.join(process.cwd(), "src", "exports", "styles", "trix.css"), finalString)
- // await fsPromises.writeFile(path.join(process.cwd(), "exports", "styles", "trix.css"), finalString)
+ await fsPromises.writeFile(path.join(process.cwd(), "src", "exports", "styles", "trix.css"), banner + "\n\n" + trixCSS)
+ await fsPromises.writeFile(path.join(process.cwd(), "src", "exports", "styles", "rhino-editor.css"), banner + "\n\n" + editorCSS)
})
}
}
diff --git a/src/exports/attachment-upload.ts b/src/exports/attachment-upload.ts
index 241d66d6..d8dea3a2 100644
--- a/src/exports/attachment-upload.ts
+++ b/src/exports/attachment-upload.ts
@@ -5,7 +5,7 @@ import { LOADING_STATES } from "./elements/attachment-editor.js";
import { BaseEvent } from "./events/base-event.js";
-class AttachmentUploadStartEvent<
+export class AttachmentUploadStartEvent<
T extends AttachmentUpload = AttachmentUpload,
> extends BaseEvent {
static eventName = "rhino-direct-upload:start" as const;
@@ -14,7 +14,7 @@ class AttachmentUploadStartEvent<
public attachmentUpload: T,
options?: EventInit | undefined,
) {
- super(AttachmentUploadStartEvent.name, options);
+ super(AttachmentUploadStartEvent.eventName, options);
this.attachmentUpload = attachmentUpload;
}
}
@@ -28,7 +28,7 @@ export class AttachmentUploadProgressEvent<
public attachmentUpload: T,
options?: EventInit | undefined,
) {
- super(AttachmentUploadProgressEvent.name, options);
+ super(AttachmentUploadProgressEvent.eventName, options);
this.attachmentUpload = attachmentUpload;
}
}
@@ -42,7 +42,7 @@ export class AttachmentUploadErrorEvent<
public attachmentUpload: T,
options?: EventInit | undefined,
) {
- super(AttachmentUploadErrorEvent.name, options);
+ super(AttachmentUploadErrorEvent.eventName, options);
this.attachmentUpload = attachmentUpload;
}
}
@@ -56,7 +56,7 @@ export class AttachmentUploadSucceedEvent<
public attachmentUpload: T,
options?: EventInit | undefined,
) {
- super(AttachmentUploadSucceedEvent.name, options);
+ super(AttachmentUploadSucceedEvent.eventName, options);
this.attachmentUpload = attachmentUpload;
}
}
@@ -69,7 +69,7 @@ export class AttachmentUploadCompleteEvent<
public attachmentUpload: T,
options?: EventInit | undefined,
) {
- super(AttachmentUploadCompleteEvent.name, options);
+ super(AttachmentUploadCompleteEvent.eventName, options);
this.attachmentUpload = attachmentUpload;
}
}
@@ -113,42 +113,73 @@ export class AttachmentUpload implements DirectUploadDelegate {
}
directUploadWillStoreFileWithXHR(xhr: XMLHttpRequest) {
+ const maxPossibleProgress = 90;
xhr.upload.addEventListener("progress", (event) => {
- const progress = (event.loaded / event.total) * 100;
+ // Cap upload progress to 90%. The last 10% needs to be filled by a successful load.
+ const progress = Math.min(
+ (event.loaded / event.total) * 100,
+ maxPossibleProgress,
+ );
this.progress = progress;
this.setUploadProgress();
this.element.dispatchEvent(new AttachmentUploadProgressEvent(this));
});
}
+ handleError(error?: Error) {
+ this.progress = 0;
+ if (this.attachment.content == null) {
+ this.attachment.setNodeMarkup({
+ progress: 0,
+ loadingState: LOADING_STATES.error,
+ });
+ }
+
+ this.element.dispatchEvent(new AttachmentUploadErrorEvent(this));
+ this.element.dispatchEvent(new AttachmentUploadCompleteEvent(this));
+
+ if (error) {
+ throw Error(`Direct upload failed: ${error}`);
+ }
+ }
+
directUploadDidComplete(
error: Error,
blob: Blob & { attachable_sgid?: string },
) {
if (error) {
- this.progress = 0;
- if (this.attachment.content == null) {
- this.attachment.setNodeMarkup({
- progress: 0,
- loadingState: LOADING_STATES.error,
- });
- }
-
- this.element.dispatchEvent(new AttachmentUploadErrorEvent(this));
- this.element.dispatchEvent(new AttachmentUploadCompleteEvent(this));
- throw Error(`Direct upload failed: ${error}`);
+ this.handleError(error);
+ return;
}
+ const blobUrl = this.createBlobUrl(blob.signed_id, blob.filename);
this.attachment.setAttributes({
sgid: blob.attachable_sgid ?? "",
- url: this.createBlobUrl(blob.signed_id, blob.filename),
+ url: blobUrl,
});
- this.progress = 100;
- this.setUploadProgress();
+ // TODO: This may create problems for non-images, could use something like an `` instead.
+ const template = document.createElement("template");
+ const obj = document.createElement("object");
+ obj.toggleAttribute("hidden", true);
+ template.append(obj);
- this.element.dispatchEvent(new AttachmentUploadSucceedEvent(this));
- this.element.dispatchEvent(new AttachmentUploadCompleteEvent(this));
+ obj.onload = () => {
+ template.remove();
+ this.progress = 100;
+ this.setUploadProgress();
+ this.element.dispatchEvent(new AttachmentUploadSucceedEvent(this));
+ this.element.dispatchEvent(new AttachmentUploadCompleteEvent(this));
+ };
+
+ obj.onerror = () => {
+ template.remove();
+ this.handleError();
+ };
+
+ obj.data = blobUrl;
+ // Needs to append to for onerror / onload to fire.
+ document.body.append(template);
}
setUploadProgress() {
diff --git a/src/exports/elements/tip-tap-editor-base.ts b/src/exports/elements/tip-tap-editor-base.ts
index 9dc3f784..03d7347f 100644
--- a/src/exports/elements/tip-tap-editor-base.ts
+++ b/src/exports/elements/tip-tap-editor-base.ts
@@ -16,7 +16,11 @@ import {
TemplateResult,
} from "lit";
-import { AttachmentUpload } from "../attachment-upload.js";
+import {
+ AttachmentUpload,
+ AttachmentUploadCompleteEvent,
+ AttachmentUploadStartEvent,
+} from "../attachment-upload.js";
import { AttachmentManager } from "../attachment-manager.js";
import { normalize } from "../styles/normalize.js";
@@ -95,6 +99,11 @@ export class TipTapEditorBase extends BaseElement {
*/
hasInitialized = false;
+ /**
+ * An array of "AttachmentUploads" added via direct upload. Check this for any attachment uploads that have not completed.
+ */
+ pendingAttachments: AttachmentUpload[] = [];
+
/**
* The hidden input to attach to
*/
@@ -387,11 +396,38 @@ export class TipTapEditorBase extends BaseElement {
this.registerDependencies();
this.addEventListener(AddAttachmentEvent.eventName, this.handleAttachment);
+ this.__addPendingAttachment = this.__addPendingAttachment.bind(this);
+ this.__removePendingAttachment = this.__removePendingAttachment.bind(this);
+
+ this.addEventListener(
+ AttachmentUploadStartEvent.eventName,
+ this.__addPendingAttachment,
+ );
+ this.addEventListener(
+ AttachmentUploadCompleteEvent.eventName,
+ this.__removePendingAttachment,
+ );
+
this.addEventListener("drop", this.handleNativeDrop);
this.addEventListener("rhino-paste", this.handlePaste);
this.addEventListener("rhino-file-accept", this.handleFileAccept);
}
+ __addPendingAttachment(e: { attachmentUpload: AttachmentUpload }) {
+ this.pendingAttachments.push(e.attachmentUpload);
+ }
+
+ __removePendingAttachment(e: { attachmentUpload: AttachmentUpload }) {
+ const index = this.pendingAttachments.findIndex((attachment) => {
+ return attachment === e.attachmentUpload;
+ });
+
+ console.log("complete");
+ if (index > -1) {
+ this.pendingAttachments.splice(index, 1);
+ }
+ }
+
async connectedCallback(): Promise {
super.connectedCallback();
diff --git a/src/exports/extensions/attachment.ts b/src/exports/extensions/attachment.ts
index cd6a059a..f8a6bbc2 100644
--- a/src/exports/extensions/attachment.ts
+++ b/src/exports/extensions/attachment.ts
@@ -747,7 +747,13 @@ export const Attachment = Node.create({
file-name=${fileName || ""}
file-size=${String(fileSize || 0)}
loading-state=${loadingState || LOADING_STATES.notStarted}
- progress=${String(sgid || content || !fileSize ? 100 : progress)}
+ progress=${String(
+ progress
+ ? progress
+ : sgid || content || !fileSize
+ ? 100
+ : progress,
+ )}
contenteditable="false"
?show-metadata=${isPreviewable}
.fileUploadErrorMessage=${this.options.fileUploadErrorMessage}
diff --git a/src/exports/extensions/rhino-starter-kit.ts b/src/exports/extensions/rhino-starter-kit.ts
index b9432054..acde0c90 100644
--- a/src/exports/extensions/rhino-starter-kit.ts
+++ b/src/exports/extensions/rhino-starter-kit.ts
@@ -21,6 +21,8 @@ import Link, { LinkOptions } from "@tiptap/extension-link";
import { Paste, PasteOptions } from "./paste.js";
import { BubbleMenuExtension, BubbleMenuOptions } from "./bubble-menu.js";
import { CodemarkPlugin } from "./codemark-plugin.js";
+import type { RhinoSelectionOptions } from "./selection.js";
+import { SelectionPlugin } from "./selection.js";
export interface RhinoStarterKitOptions {
/** Funky hack extension for contenteditable in firefox. */
@@ -69,6 +71,12 @@ export interface RhinoStarterKitOptions {
* A TipTap wrapper extension for https://github.com/curvenote/editor/tree/main/packages/prosemirror-codemark
*/
rhinoCodemarkPlugin: Partial<{}> | false;
+
+ /**
+ * A plugin that maintains selection "highlighting" even while the editor does not have focus.
+ * This is useful for things like entering in links.
+ */
+ rhinoSelection: Partial | false;
}
export type TipTapPlugin = Node | Extension | Mark;
@@ -110,6 +118,7 @@ export const RhinoStarterKit = Extension.create({
["rhinoPlaceholder", Placeholder],
["rhinoBubbleMenu", BubbleMenuExtension],
["rhinoCodemarkPlugin", CodemarkPlugin],
+ ["rhinoSelection", SelectionPlugin],
];
extensions.forEach(([string, extension]) => {
diff --git a/src/exports/extensions/selection-decorator.ts b/src/exports/extensions/selection-decorator.ts
deleted file mode 100644
index 788b6409..00000000
--- a/src/exports/extensions/selection-decorator.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-// import {Plugin, PluginKey} from '@tiptap/pm/state';
-// import {Decoration, DecorationSet} from '@tiptap/pm/view';
-// import {Extension, getMarkRange} from '@tiptap/core';
-
-// // import {CommentsPluginKey} from './keys';
-
-// const LinkSelectionDecorator = Extension.create({
-// addProseMirrorPlugins () {
-
-// return new Plugin({
-// key: new PluginKey("selection-decorator"),
-// state: {
-// init() {
-// return {
-// activeCommentId: null,
-// maybeMouseSelecting: false,
-// };
-// },
-
-// apply(tr, pluginState, oldState, newState) {
-// const {doc, selection} = tr;
-// const {$from, to, empty} = selection;
-// const $to = doc.resolve(empty ? to + 1 : to);
-// const {name: markTypeName} = newState.schema.marks.comments;
-
-// const fromMark = $from.marks().find((mark) => mark.type.name === markTypeName);
-// const toMark = $to.marks().find((mark) => mark.type.name === markTypeName);
-
-// let activeCommentId = null;
-
-// if ((fromMark && toMark) && (fromMark === toMark)) {
-// // If both ends are on a comment, check if it's the same instance of a mark,
-// // not a mark that has been split or duplicated, e.g. with copy and paste
-// activeCommentId = toMark.attrs.commentId;
-// } else if (selection.empty && toMark) {
-// // Otherwise, if the selection is empty and there's a mark at the next position, then select that one
-// activeCommentId = toMark.attrs.commentId;
-// }
-
-// const meta = tr.getMeta(CommentsPluginKey);
-
-// let {maybeMouseSelecting} = pluginState;
-
-// if (meta?.maybeMouseSelecting !== undefined) {
-// maybeMouseSelecting = meta.maybeMouseSelecting;
-// }
-
-// return {
-// activeCommentId,
-// maybeMouseSelecting,
-// };
-// },
-// },
-
-// props: {
-// handleDOMEvents: {
-// mousedown(view) {
-// view.dispatch(view.state.tr.setMeta(CommentsPluginKey, {maybeMouseSelecting: true}));
-// },
-
-// mouseup(view) {
-// view.dispatch(view.state.tr.setMeta(CommentsPluginKey, {maybeMouseSelecting: false}));
-// },
-// },
-
-// decorations(state) {
-// const {activeCommentId, maybeMouseSelecting} = this.getState(state);
-// const {doc, schema} = state;
-// const {name: markTypeName} = schema.marks.comments;
-
-// if (activeCommentId === null || maybeMouseSelecting) {
-// return DecorationSet.empty;
-// }
-
-// // If there is a comment, then find all its matches across the doc
-// const activeMarks = [];
-// doc.descendants((node, pos) => {
-// node.marks.forEach((mark) => {
-// if (mark.type.name === markTypeName
-// && mark.attrs.commentId === activeCommentId) {
-// activeMarks.push({
-// mark,
-// $pos: doc.resolve(pos),
-// });
-// }
-// });
-// });
-
-// const decorations = [];
-// activeMarks.forEach(({mark, $pos}) => {
-// const markRange = getMarkRange($pos, mark.type, mark.attrs);
-
-// if (markRange) {
-// decorations.push(Decoration.inline(markRange.from, markRange.to, {class: 'comment-highlight'}, {...mark.attrs}));
-// }
-// });
-
-// return DecorationSet.create(doc, decorations);
-// },
-// },
-// });
-
-// }
-// })
diff --git a/src/exports/extensions/selection.ts b/src/exports/extensions/selection.ts
new file mode 100644
index 00000000..c5f7ae15
--- /dev/null
+++ b/src/exports/extensions/selection.ts
@@ -0,0 +1,115 @@
+import { Extension } from "@tiptap/core";
+import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
+import { Decoration, DecorationSet } from "@tiptap/pm/view";
+import type { DecorationAttrs } from "@tiptap/pm/view";
+
+export type RhinoSelectionOptions = {
+ HTMLAttributes?: DecorationAttrs;
+};
+
+const selectionPlugin = (options: RhinoSelectionOptions) => {
+ return new Plugin({
+ key: new PluginKey("rhino-selection"),
+ state: {
+ init() {
+ return DecorationSet.empty;
+ },
+ apply(tr, set) {
+ set = set.map(tr.mapping, tr.doc);
+
+ set = set.remove(set.find());
+
+ // Whether selection was explicitly updated by this transaction.
+ // if (!tr.selectionSet) {
+ // return set
+ // }
+
+ const { doc, selection } = tr;
+
+ let deco: Decoration | null = null;
+
+ if (selection.to !== selection.from) {
+ // Highlight existing selection
+ deco = Decoration.inline(
+ selection.from,
+ selection.to,
+ options.HTMLAttributes || {},
+ );
+ } else {
+ // Show a fake cursor.
+ let widget = document.createElement("placeholder");
+ // TODO: Make this configurable.
+ widget.setAttribute("class", "rhino-insertion-placeholder");
+ widget.setAttribute("readonly", "");
+ widget.setAttribute("contenteditable", "false");
+ deco = Decoration.widget(selection.to, widget, {});
+ }
+
+ if (deco) {
+ set = DecorationSet.create(doc, [deco]);
+ }
+
+ return set;
+ },
+ },
+ props: {
+ decorations(state) {
+ return this.getState(state);
+ },
+ handleDOMEvents: {
+ keydown(view, event) {
+ if (event.key === "ArrowLeft") {
+ const { selection } = view.state;
+ const pos = selection.$from;
+
+ // This really bizarre piece of code is to "fix" some weird issue with Decorations and Firefox getting "stuck" on them.
+ // Basically if youre on a line like this:
+ // -> Hello
+ // And your cursor is before the H in Hello, and you hit the "LeftArrow" in Firefox, it will loop to the front. Like so:
+ // Hi
+ // |Hello -> "LeftArrow" -> Hello|
+ // ^ initial cursor position ^ new cursor position
+ // instead of moving up to the line that says "Hi"
+ if (selection.empty && pos.parentOffset === 0) {
+ if (selection.from - 2 <= 0) {
+ // Call `event.preventDefault()` so that the cursor doesn't jump to front if we're at start of document.
+ event.preventDefault();
+ return false;
+ }
+
+ const tr = view.state.tr.setSelection(
+ TextSelection.create(
+ view.state.doc,
+ Math.max(selection.from - 2, 0),
+ selection.from,
+ ),
+ );
+ view.dispatch(tr);
+ return true;
+ }
+ }
+ return false;
+ },
+ },
+ },
+ });
+};
+
+/**
+ * A plugin that maintains selection "highlighting" even while the editor does not have focus. This is useful for things like entering in links.
+ */
+export const SelectionPlugin = Extension.create({
+ name: "rhino-selection",
+ addOptions(): RhinoSelectionOptions {
+ return {
+ HTMLAttributes: {
+ class: "rhino-selection",
+ readonly: "",
+ },
+ };
+ },
+
+ addProseMirrorPlugins() {
+ return [selectionPlugin(this.options)];
+ },
+});
diff --git a/src/exports/styles/editor.js b/src/exports/styles/editor.js
index c075cf1e..cfa9cdc9 100644
--- a/src/exports/styles/editor.js
+++ b/src/exports/styles/editor.js
@@ -47,6 +47,11 @@ export const hostStyles = css`
--rhino-button-focus-background-color-hsl: 219 26% 95%;
+ /**
+ * Override "--rhino-fake-selection-color" to change the color of .rhino-selection when the editor is not focused.
+ */
+ --rhino-fake-selection-color: rgb(220, 220, 220);
+
display: block;
color: var(--rhino-text-color);
@@ -54,6 +59,73 @@ export const hostStyles = css`
}
`;
+// TODO: Should these cursor styles be made a separate CSS files? I worry about having too many external stylesheets, but I know some users are not using `trix.css` and will miss out on these.
+export const cursorStyles = css`
+ /**
+ * Cursor styles that are useful for providing a more "pleasant" editor experience.
+ */
+ /**
+* https://github.com/curvenote/editor/blob/main/packages/prosemirror-codemark/src/codemark.css
+*/
+ @keyframes rhino-blink {
+ 49% {
+ border-color: unset;
+ }
+ 50% {
+ border-color: Canvas;
+ }
+ 99% {
+ border-color: Canvas;
+ }
+ }
+ .rhino-editor .no-cursor {
+ caret-color: transparent;
+ }
+
+ :where(.rhino-editor) .fake-cursor {
+ margin: 0;
+ padding: 0;
+ margin-right: -1px;
+ border-left-width: 1px;
+ border-left-style: solid;
+ animation: rhino-blink 1s;
+ animation-iteration-count: infinite;
+ position: relative;
+ z-index: 1;
+ }
+
+ /** This is for actual "selection" which are highlighting more than 1 character. */
+ :where(.rhino-editor .ProseMirror):not(:focus-within) .rhino-selection {
+ background: var(--rhino-fake-selection-color);
+ }
+
+ /** .fake-cursor-selection is for link "insertions" without selected text. */
+ :where(.rhino-editor) .rhino-insertion-placeholder {
+ display: none;
+ user-select: none;
+ }
+
+ /**
+This is used for showing a fake cursor for selections like link insertions
+*/
+ :where(.rhino-editor)[link-dialog-expanded] .rhino-insertion-placeholder {
+ margin: 0;
+ padding: 0;
+ margin-right: -1px;
+ margin-left: -2px;
+ border-left-width: 4px;
+ border-left-style: solid;
+ border-color: Highlight;
+ position: relative;
+ z-index: 1;
+ display: inline;
+ }
+
+ .ProseMirror-separator {
+ display: none !important;
+ }
+`;
+
export const toolbarButtonStyles = css`
.rhino-toolbar-button {
appearance: none;
diff --git a/src/exports/styles/rhino-editor.css b/src/exports/styles/rhino-editor.css
new file mode 100644
index 00000000..e21a840e
--- /dev/null
+++ b/src/exports/styles/rhino-editor.css
@@ -0,0 +1,216 @@
+/* THIS FILE IS AUTO-GENERATED. DO NOT EDIT BY HAND! */
+
+/* src/exports/styles/editor.js:hostStyles */
+
+ :host,
+ .trix-content {
+ /* General tokens */
+ --rhino-focus-ring: 0px 0px 1px 1px var(--rhino-button-active-border-color);
+ --rhino-border-radius: 4px;
+
+ --rhino-danger-border-color: red;
+ --rhino-danger-background-color: #ffdddd;
+
+ /* Editor tokens */
+ --rhino-text-color: #374151;
+ --rhino-dark-text-color: white;
+
+ --rhino-border-color: #cecece;
+ --rhino-placeholder-text-color: #cecece;
+ --rhino-dark-placeholder-text-color: gray;
+
+ /* Regular buttons */
+ --rhino-button-text-color: #889;
+ --rhino-button-dark-text-color: #eee;
+ --rhino-button-border-color: #cecece;
+
+ /** Disabled Buttons */
+ --rhino-button-disabled-text-color: #d1d5db;
+ --rhino-button-disabled-border-color: #d1d5db;
+ --rhino-button-disabled-background-color: #d1d5db;
+
+ /** Active buttons */
+ --rhino-button-active-border-color: #005a9c;
+ --rhino-button-active-background-color: rgb(226 239 255);
+
+ --rhino-toolbar-text-color: hsl(219, 6%, 43%);
+ --rhino-toolbar-icon-size: 1em;
+
+ --rhino-dialog-border-color: hsl(
+ var(--rhino-button-focus-background-color-hsl) / 50%
+ );
+
+ /** Focus buttons */
+ --rhino-button-focus-background-color: hsl(
+ var(--rhino-button-focus-background-color-hsl)
+ );
+
+ --rhino-button-focus-background-color-hsl: 219 26% 95%;
+
+ /**
+ * Override "--rhino-fake-selection-color" to change the color of .rhino-selection when the editor is not focused.
+ */
+ --rhino-fake-selection-color: rgb(220, 220, 220);
+
+ display: block;
+
+ color: var(--rhino-text-color);
+ color: light-dark(var(--rhino-text-color), var(--rhino-dark-text-color));
+ }
+
+
+/* src/exports/styles/editor.js:toolbarButtonStyles */
+
+ /**
+ * Cursor styles that are useful for providing a more "pleasant" editor experience.
+ */
+ /**
+* https://github.com/curvenote/editor/blob/main/packages/prosemirror-codemark/src/codemark.css
+*/
+ @keyframes rhino-blink {
+ 49% {
+ border-color: unset;
+ }
+ 50% {
+ border-color: Canvas;
+ }
+ 99% {
+ border-color: Canvas;
+ }
+ }
+ .rhino-editor .no-cursor {
+ caret-color: transparent;
+ }
+
+ :where(.rhino-editor) .fake-cursor {
+ margin: 0;
+ padding: 0;
+ margin-right: -1px;
+ border-left-width: 1px;
+ border-left-style: solid;
+ animation: rhino-blink 1s;
+ animation-iteration-count: infinite;
+ position: relative;
+ z-index: 1;
+ }
+
+ /** This is for actual "selection" which are highlighting more than 1 character. */
+ :where(.rhino-editor .ProseMirror):not(:focus-within) .rhino-selection {
+ background: var(--rhino-fake-selection-color);
+ }
+
+ /** .fake-cursor-selection is for link "insertions" without selected text. */
+ :where(.rhino-editor) .rhino-insertion-placeholder {
+ display: none;
+ user-select: none;
+ }
+
+ /**
+This is used for showing a fake cursor for selections like link insertions
+*/
+ :where(.rhino-editor)[link-dialog-expanded] .rhino-insertion-placeholder {
+ margin: 0;
+ padding: 0;
+ margin-right: -1px;
+ margin-left: -2px;
+ border-left-width: 4px;
+ border-left-style: solid;
+ border-color: Highlight;
+ position: relative;
+ z-index: 1;
+ display: inline;
+ }
+
+ .ProseMirror-separator {
+ display: none !important;
+ }
+
+
+/* src/exports/styles/editor.js:toolbarButtonStyles */
+
+ .rhino-toolbar-button {
+ appearance: none;
+ -webkit-appearance: none;
+ border: 1px solid var(--rhino-border-color);
+ border-radius: var(--rhino-border-radius);
+ padding: 0.4em;
+ color: var(--rhino-button-text-color);
+ color: light-dark(
+ var(--rhino-button-text-color),
+ var(--rhino-button-dark-text-color)
+ );
+ background: Canvas;
+ font-size: inherit;
+ display: inline-grid;
+ }
+
+ .rhino-toolbar-button:is([aria-disabled="true"], :disabled) {
+ color: var(--rhino-button-disabled-text-color);
+ border-color: var(--rhino-button-disabled-border-color);
+ }
+
+ .rhino-toolbar-button[aria-disabled="true"]:focus {
+ border-color: var(--rhino-button-disabled-border-color);
+ }
+
+ .rhino-toolbar-button svg {
+ min-height: var(--rhino-toolbar-icon-size);
+ min-width: var(--rhino-toolbar-icon-size);
+
+ /* max-height / max-width needs to be set for safari */
+ max-height: var(--rhino-toolbar-icon-size);
+ max-width: var(--rhino-toolbar-icon-size);
+ }
+
+ .rhino-toolbar-button:is(:focus, :hover):not(
+ [aria-disabled="true"],
+ :disabled
+ ) {
+ outline: transparent;
+ border-color: var(--rhino-button-active-border-color);
+ }
+
+ .rhino-toolbar-button:is(:focus):not([aria-disabled="true"], :disabled) {
+ box-shadow: var(--rhino-focus-ring);
+ }
+
+ /* Only change the background color in certain scenarios */
+ .rhino-toolbar-button:is(:hover):not(
+ [aria-disabled="true"],
+ :disabled,
+ [aria-pressed="true"],
+ [part~="toolbar__button--active"]
+ ) {
+ background-color: var(--rhino-button-focus-background-color);
+ background-color: light-dark(
+ var(--rhino-button-focus-background-color),
+ gray
+ );
+ }
+
+ .rhino-toolbar-button:is([aria-disabled="true"], :disabled):not(
+ [part~="toolbar__button--active"]
+ ) {
+ color: var(--rhino-button-disabled-text-color);
+ color: light-dark(var(--rhino-button-disabled-text-color), gray);
+ border-color: var(--rhino-button-disabled-border-color);
+ }
+
+ .rhino-toolbar-button:is(:focus, :hover):is(
+ [aria-disabled="true"],
+ :disabled
+ ):not([part~="toolbar__button--active"]) {
+ outline: transparent;
+ color: var(--rhino-button-disabled-text-color);
+ color: light-dark(var(--rhino-button-disabled-text-color), gray);
+ border-color: var(--rhino-button-disabled-border-color);
+ box-shadow: 0 0 0 1px var(--rhino-button-disabled-border-color);
+ box-shadow: 0 0 0 1px
+ light-dark(var(--rhino-button-disabled-border-color), transparent);
+ }
+
+ svg,
+ ::slotted(svg) {
+ height: var(--rhino-toolbar-icon-size);
+ width: var(--rhino-toolbar-icon-size);
+ }
\ No newline at end of file
diff --git a/src/exports/styles/trix-core.css b/src/exports/styles/trix-core.css
index 281c1282..43a63069 100644
--- a/src/exports/styles/trix-core.css
+++ b/src/exports/styles/trix-core.css
@@ -1,37 +1,6 @@
/* These all come from Trix / ActionText. This should probably be cleaned up into
a regular .css for users to include.
*/
-/* @import "prosemirror-codemark/dist/codemark.css"; */
-
-/**
-* https://github.com/curvenote/editor/blob/main/packages/prosemirror-codemark/src/codemark.css
-*/
-@keyframes blink {
- 49% {
- border-color: unset;
- }
- 50% {
- border-color: Canvas;
- }
- 99% {
- border-color: Canvas;
- }
-}
-.rhino-editor .no-cursor {
- caret-color: transparent;
-}
-
-.rhino-editor .fake-cursor {
- margin: 0;
- padding: 0;
- margin-right: -1px;
- border-left-width: 1px;
- border-left-style: solid;
- animation: blink 1s;
- animation-iteration-count: infinite;
- position: relative;
- z-index: 1;
-}
.trix-content {
border: 1px solid var(--rhino-border-color);
diff --git a/src/exports/styles/trix.css b/src/exports/styles/trix.css
index cdf27f76..85c361e8 100644
--- a/src/exports/styles/trix.css
+++ b/src/exports/styles/trix.css
@@ -1,38 +1,8 @@
/* THIS FILE IS AUTO-GENERATED. DO NOT EDIT BY HAND! */
+
/* These all come from Trix / ActionText. This should probably be cleaned up into
a regular .css for users to include.
*/
-/* @import "prosemirror-codemark/dist/codemark.css"; */
-
-/**
-* https://github.com/curvenote/editor/blob/main/packages/prosemirror-codemark/src/codemark.css
-*/
-@keyframes blink {
- 49% {
- border-color: unset;
- }
- 50% {
- border-color: Canvas;
- }
- 99% {
- border-color: Canvas;
- }
-}
-.rhino-editor .no-cursor {
- caret-color: transparent;
-}
-
-.rhino-editor .fake-cursor {
- margin: 0;
- padding: 0;
- margin-right: -1px;
- border-left-width: 1px;
- border-left-style: solid;
- animation: blink 1s;
- animation-iteration-count: infinite;
- position: relative;
- z-index: 1;
-}
.trix-content {
border: 1px solid var(--rhino-border-color);
@@ -371,7 +341,7 @@
max-width: 100%;
}
-/* src/exports/styles/editor.js:hostStyles */
+ /* src/exports/styles/editor.js:hostStyles */
:host,
.trix-content {
@@ -418,6 +388,11 @@
--rhino-button-focus-background-color-hsl: 219 26% 95%;
+ /**
+ * Override "--rhino-fake-selection-color" to change the color of .rhino-selection when the editor is not focused.
+ */
+ --rhino-fake-selection-color: rgb(220, 220, 220);
+
display: block;
color: var(--rhino-text-color);
@@ -425,6 +400,73 @@
}
+/* src/exports/styles/editor.js:toolbarButtonStyles */
+
+ /**
+ * Cursor styles that are useful for providing a more "pleasant" editor experience.
+ */
+ /**
+* https://github.com/curvenote/editor/blob/main/packages/prosemirror-codemark/src/codemark.css
+*/
+ @keyframes rhino-blink {
+ 49% {
+ border-color: unset;
+ }
+ 50% {
+ border-color: Canvas;
+ }
+ 99% {
+ border-color: Canvas;
+ }
+ }
+ .rhino-editor .no-cursor {
+ caret-color: transparent;
+ }
+
+ :where(.rhino-editor) .fake-cursor {
+ margin: 0;
+ padding: 0;
+ margin-right: -1px;
+ border-left-width: 1px;
+ border-left-style: solid;
+ animation: rhino-blink 1s;
+ animation-iteration-count: infinite;
+ position: relative;
+ z-index: 1;
+ }
+
+ /** This is for actual "selection" which are highlighting more than 1 character. */
+ :where(.rhino-editor .ProseMirror):not(:focus-within) .rhino-selection {
+ background: var(--rhino-fake-selection-color);
+ }
+
+ /** .fake-cursor-selection is for link "insertions" without selected text. */
+ :where(.rhino-editor) .rhino-insertion-placeholder {
+ display: none;
+ user-select: none;
+ }
+
+ /**
+This is used for showing a fake cursor for selections like link insertions
+*/
+ :where(.rhino-editor)[link-dialog-expanded] .rhino-insertion-placeholder {
+ margin: 0;
+ padding: 0;
+ margin-right: -1px;
+ margin-left: -2px;
+ border-left-width: 4px;
+ border-left-style: solid;
+ border-color: Highlight;
+ position: relative;
+ z-index: 1;
+ display: inline;
+ }
+
+ .ProseMirror-separator {
+ display: none !important;
+ }
+
+
/* src/exports/styles/editor.js:toolbarButtonStyles */
.rhino-toolbar-button {
@@ -512,5 +554,4 @@
::slotted(svg) {
height: var(--rhino-toolbar-icon-size);
width: var(--rhino-toolbar-icon-size);
- }
-
+ }
\ No newline at end of file
diff --git a/tests/rails/app/frontend/controllers/tip_tap_mirror_controller.js b/tests/rails/app/frontend/controllers/tip_tap_mirror_controller.js
index 970f556a..474ad48a 100644
--- a/tests/rails/app/frontend/controllers/tip_tap_mirror_controller.js
+++ b/tests/rails/app/frontend/controllers/tip_tap_mirror_controller.js
@@ -5,12 +5,12 @@ export default class TipTapMirrorController extends Controller {
return document.querySelector("trix-editor")
}
connect () {
- this.trixInput.addEventListener("trix-change", this.handleChange)
+ this.trixInput?.addEventListener("trix-change", this.handleChange)
}
disconnect () {
- this.trixInput.removeEventListener("trix-change", this.handleChange)
+ this.trixInput?.removeEventListener("trix-change", this.handleChange)
}
diff --git a/tests/unit/list-items.test.js b/tests/unit/list-items.test.js
index ceae52fd..42efa183 100644
--- a/tests/unit/list-items.test.js
+++ b/tests/unit/list-items.test.js
@@ -80,9 +80,12 @@ test("Should allow swapping between list-items and sinking them appropriately",
assert.equal(orderedListButton.getAttribute("part").includes("toolbar__button--disabled"), false)
assert.equal(orderedListButton.getAttribute("part").includes("toolbar__button--active"), true)
+ tiptap().focus()
+ await aTimeout(1)
// Add a new line
await sendKeys({ press: "Enter" })
await elementUpdated(rhinoEditor)
+ await aTimeout(1)
// Now we can nest
assert.equal(increaseIndentation.getAttribute("aria-disabled"), "false")