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 %> - +

<%= resource.data.reason_header %>

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")