Code Monkey home page Code Monkey logo

rhino-editor's Introduction

Purpose

To create a grab and go WYSIWYG editing experience that can hook into Ruby on Rails ActionText backend. Currently this package does so using TipTap but will most likely include another integration for ProseMirror to allow for both Markdown + WYSIWYG editing.

Documentation

https://rhino-editor.vercel.app

Local Development

This section is for contributing to Rhino Editor.

Getting up and running locally is hopefully quite painless. We have a test suite using Ruby on Rails and is intended to provide a good demonstration of how this package can hook into ActionText.

Prerequisites

  • Ruby 3.1.2
  • Rails 7.0.4
  • PNPM (npm install -g pnpm)
  • Playwright
  • Node >= 16
  • Docker (Used to run a simulated S3 server)
  • Overmind (Preferred, not needed)

Installation

Run the following commands in the bash to setup dependencies:

git clone https://github.com/konnorrogers/rhino-editor
cd rhino-editor
pnpm run setup

Running the server

The easiest way to run the server is using Overmind

overmind start -f Procfile.dev

Then navigate to localhost:5100

Without Overmind

To run the server without overmind do the following in seperate terminals:

bin/vite dev --clobber
bin/rails s
docker compose up --build

Then navigate to localhost:5100

Running the test suite

Make sure to have the docker server up and running, the test suite will fail without it.

docker compose up --build
bundle exec rails test:all

Listening for changes to the package

To listen for changes, keep your rails server running and open a new terminal with the following:

pnpm run start

This will start an ESBuild watcher process. Vite in Rails will automatically pick up changes.

Adding a changelog entry

To add a changelog entry, we use https://github.com/changesets/changesets. Run the following command and then answer the prompts:

pnpm changeset

Roadmap to v1

  • - Collaboration Extension. Support collaboration!
  • - Document slots, CSS properties, Extending ActionText, and show common demo examples
  • - Create a ProseMirror base for a markdown + rich text editor
  • - Move the TipTap editor to extend the ProseMirror editor.
  • - Show how to do table editing
  • - Show how to do embeds
  • - Show how to do mentions
  • - Add testing for the basic operations bold, strike, etc.
  • - Add testing for ActionText / Trix compatibility.

rhino-editor's People

Contributors

fractaledmind avatar github-actions[bot] avatar jjb avatar konnorrogers avatar matthewkennedy avatar nathancolgate avatar tcannonfodder avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar

rhino-editor's Issues

Uncaught DOMException: Failed to construct 'CustomElement': The result must not have children

We've followed your getting started docs for Rails, and have been able to repeat our problem on a super simple HTML page:

<!DOCTYPE html>
<html>
<head>
<title>Simple Layout</title>
<meta name="csrf-param" content="authenticity_token" />
<meta name="csrf-token" content="k-G8G-RA2J_rvLXmpvnak6dL54j5KY8Z5DAZjuzAOF2BEor8Jrwnaop1hdbY2ky4Sasgn3y8XOwGEjqchjkwBg" />

<script src="/assets/application-0c52a859b34d97e02fb9eec6e2051b91a719fa9d66713bfca25f8cbf2ee13403.js" data-turbo-track="reload"></script>
</head>
<body>
<h1>Rhino Editor Test</h1>
<rhino-editor></rhino-editor>

<script async nonce="" type="text/javascript" id="mini-profiler" src="/mini-profiler-resources/includes.js"></script>
</body>
</html>

Our application.js file contains only:

// Entry point for the build script in your package.json
import "rhino-editor"
import "./application.scss"
console.log("Hello from JS")

Our application.scss file contains only:

@import "rhino-editor/exports/styles/trix";
body {background:blue;}

But we have not been able to get the editor to load due to the DOMException.

screenshot 33

Any advice would be greatly appreciated!

how to stop auto instantiated editor

Hi KonnorR,
I'm interested in this editor, but I don't want it to be instantiated automatically. I want to initialize only one specific tag and set some extensions just like

new Editor({
  element: document.querySelector('.element'),
  extensions: [
    Document,
    Paragraph,
    Text,
  ],
  content: '<p>Example Text</p>',
  autofocus: true,
  editable: true,
  injectCSS: false,
})

Do you think rhino editor can do it? How to do it? THANKS @KonnorRogers

Highlighting text and adding a link can leave you stuck in the link text

I've created a video showing how to reproduce.

  • Essentially, type some text but don't leave a trailing space
  • Highlight some text and create a link
  • Now you can't leave the link without doing some keyboard judo and copy/pasting a space or something

The video also demonstrates that links in the editor are clickable by default which is really annoying. It would be great to change the default for this to false using:

  rhinoEditor.starterKitOptions = {
    rhinoLink: {
      openOnClick: false,
    }
  }
Screen.Recording.2023-10-10.at.18.05.06.mov

Dropping an image always inserts it at the existing cursor position, not the intended drop target

When drag and dropping an image attachment into Rhino, the image is inserted at the current cursor position in the editor regardless of where you place your drag and drop cursor.

What I expect to happen
When I drag an attachment into Rhino, I should be able to choose the drop target by moving my cursor into the intended position. Rhino gives an indication of where this will be (sometimes this is just a normal blinking cursor, at other times it's a horizontal rule – this seems to be a little inconsistent but likely unrelated to this issue), so when I drop the attachment, it should be inserted into the position I have chosen in the document.

What actually happens
If my cursor position before dragging and dropping was at the top of the document, when I drag and drop an attachment to a different place, the attachment is always inserted at the top i.e. where the cursor was placed before drag and drop.

I've made a video of this behaviour while running the editor locally in Safari. I can also reproduce this in Chrome. I've also noted that when you drop an image, it inserts a<p></p> tag before and after the attachment. Might be a separate bug.

drag-and-drop-issue.mov

Extra margin makes buttons look less orderly

The buttons have an extra margin on the left and right that makes everything look less ordered visually. That is to say, the left button has a larger left margin, and the right button has a larger right margin.

image

(I'm just describing the visual layout, not the structure of the markup—I realize that it is almost certainly an element encompassing all buttons which has the margins—since it is the visual appearance that is relevant to the average user.)

Compare that to the GitHub editor, where the different elements following the same line creates a sense of visual order:
image

Here that line is added to make the connection explicit (although viewing it with an explicit line instead of an implicit line makes it look worse):
image

Docs: how we added mention support

Leaving this here as a rough start to writing the docs for adding mention support:

We are going to be @mentioning Users, who have names.

The Rails Stuff

Let's do the railsy stuff first, and update our User model to be quack like ActionText::Attachments

# app/models/user.rb
class User < ApplicationRecord
  # Used when editing
  def to_trix_content_attachment_partial_path
    "user_mentions/trix_content_attachment"
  end

  # Used when displaying
  def to_attachable_partial_path
    "user_mentions/attachable"
  end

  # A custom content type for easy querying
  def attachable_content_type
    "application/vnd.active_record.user"
  end
end

Those two partials are used by action text to render the attachment in two places: First within the editor:

# app/views/user_mentions/_trix_content_attachment.html.erb
<%= user.name %>

Second, when rendered within the application in our views. In this case: we want to display a link to the user (but this could be whatever you want)

# app/views/user_mentions/_attachable.html.erb
<%= link_to user.name, user_path(user) %>

We also monkey patch ActionText to export these attachments as span tags, instead of figure tags. This is because TipTap really wants to convert figures into their own blocks, and not display them inline (which we really want for our mentions).

# config/initializers/action_text.rb
module ActionText
  class TrixAttachment
    TAG_NAME = "span"
  end
end

Now let's build the endpoint for for searching users. Routes first:

# config/routes.rb
resources :user_mentions, only: [:index]

And the controller:

# app/controllers/user_mentions_controller.rb
class UserMentionsController < ApplicationController
  def index
    # Use your own search logic here, but something like
    @q = User.ransack({name_cont: params[:query]})
    @users = @q.result.distinct.limit(5)
    respond_to do |format|
      format.json
    end
  end
end

With views that renders the JSON:

# app/views/user_mentions/index.json.jbuilder
json.array! @users, partial: "user_mentions/user_mention", as: :user

With the three important bits that our attachments care about:

# app/views/user_mentions/_user_mention.json.jbuilder
json.content user.name
json.sgid user.attachable_sgid
json.contentType user.attachable_content_type

Storing the Attachments

We are going to store the attachments in the database. Create a model that looks like:

# app/models/action_text_user_mention.rb
class ActionTextUserMention < ApplicationRecord
  belongs_to :action_text_rich_text, class_name: "ActionText::RichText"
  belongs_to :user
end

And then we hook into ActionText to maintain these records everytime an ActionText is created/updated:

# config/initializers/action_text_user_mentions.rb
ActiveSupport.on_load(:action_text_rich_text) do
  ActionText::RichText.class_eval do
    has_many :user_mentions, class_name: "ActionTextUserMention", foreign_key: :action_text_rich_text_id, dependent: :destroy
    has_many :users, through: :user_mentions

    before_save do
      self.users = body.attachables.grep(User).uniq if body.present?
    end
  end
end

And finally, a little CSS:

rhino-editor span.mention {
  border: 1px solid #000;
  border-radius: 0.4rem;
  padding: 0.1rem 0.3rem;
  box-decoration-break: clone;
}

The JavaScript

We didn't use Vue or React, but rather a custom LitElements. Make sure you enable decorators in your build environment

Our final view component looks like:

// app/javascript/rhino-editor/elements/MentionList.js
// This mention list takes an array of suggested 
// items that represent action text attachments
// with the properties of content/contentType/sgid
// and renders a tippy popover that the user can
// navigate and select.
//
// Selecting an item calls the command function
// and passes those three properties back.

import { html, css, LitElement } from "lit"
import {customElement} from 'lit/decorators/custom-element.js';
import {property} from 'lit/decorators/property.js';

@customElement('mention-list')
class MentionList extends LitElement {
  @property({ type: Array }) items = [];
  @property({ type: Number }) selectedIndex = 0;
  @property({ type: Function }) command;
  
  static styles = css`
    .suggested-items {
      padding: 0.2rem;
      position: relative;
      border-radius: 0.5rem;
      background: #fff;
      color: rgba(0, 0, 0, 0.8);
      overflow: hidden;
      font-size: 0.9rem;
      box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0px 10px 20px rgba(0, 0, 0, 0.1);
    }
    .suggested-item {
      display: block;
      margin: 0;
      width: 100%;
      text-align: left;
      background: transparent;
      border-radius: 0.4rem;
      border: 1px solid transparent;
      padding: 0.2rem 0.4rem;
    }
    .suggested-item.is-selected {
      border-color: #000;
    }
  `;

  render() {
    return html`
      <div class="suggested-items">
        ${this.items.length > 0 ? 
          this.items.map((item, index) => html`
            <button
              class="suggested-item ${index === this.selectedIndex ? 'is-selected' : ''}"
              @click=${() => this.selectItem(index)}
            >
              ${item.content}
            </button>`
          ) : html`<div class="suggested-item">No result</div>`}
      </div>
    `;
  }

  onKeyDown({event}) {
    if (event.key === 'ArrowUp') {
      this.upHandler()
      return true
    }

    if (event.key === 'ArrowDown') {
      this.downHandler()
      return true
    }

    if (event.key === 'Enter') {
      this.enterHandler()
      return true
    }

    return false
  }

  upHandler() {
    this.selectedIndex = ((this.selectedIndex + this.items.length) - 1) % this.items.length
  }

  downHandler() {
    this.selectedIndex = (this.selectedIndex + 1) % this.items.length
  }

  enterHandler() {
    this.selectItem(this.selectedIndex)
  }

  selectItem(index) {
    const item = this.items[index]
    if (item) {
      this.command({
        sgid: item.sgid,
        content: item.content,
        contentType: item.contentType
      })
    }
  }
}

export default MentionList;

We extend the Mention extension to integrate with Action Text Attachments.

// app/javascript/rhino-editor/extensions/ActionTextAttachmentMention.js
import Mention from '@tiptap/extension-mention'
import { mergeAttributes } from '@tiptap/core'
import tippy from 'tippy.js'
import '../elements/MentionList.js';

// https://github.com/KonnorRogers/rhino-editor/pull/111
// import { findAttribute } from "rhino-editor/exports/extensions/find-attribute.js";
function findAttribute(element, attribute) {
  const attr = element
    .closest("action-text-attachment")
    ?.getAttribute(attribute);
  if (attr) return attr;

  const attrs = element
    .closest("[data-trix-attachment]")
    ?.getAttribute("data-trix-attachment");
  if (!attrs) return null;

  return JSON.parse(attrs)[attribute];
}

const ActionTextAttachmentMention = Mention.extend({
  name: 'ActiveRecordMention',
  addOptions() {
    return {
      HTMLAttributes: {},
      renderLabel({ options, node }) {
        return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`
      },
      attachmentContentType: "application/octet-stream",
      suggestion: {
        render: () => {
          let component
          let popup
      
          return {
            onStart: props => {
              component = document.createElement('mention-list');
              component.items = props.items;
              component.command = props.command;
      
              if (!props.clientRect) {
                return;
              }
              popup = tippy('body', {
                getReferenceClientRect: props.clientRect,
                appendTo: () => document.body,
                content: component,
                showOnCreate: true,
                interactive: true,
                trigger: 'manual',
                placement: 'bottom-start',
              });
            },
      
            onUpdate(props) {
              component.items = props.items;
              component.command = props.command;
              if (!props.clientRect) {
                return
              }
      
              popup[0].setProps({
                getReferenceClientRect: props.clientRect,
              })
            },
      
            onKeyDown(props) {
              if (props.event.key === 'Escape') {
                popup[0].hide()
                return true
              }
              return component.onKeyDown(props)
            },
      
            onExit() {
              popup[0].destroy();
            },
          }
        },
        command: ({ editor, range, props }) => {
          const nodeAfter = editor.view.state.selection.$to.nodeAfter
          const overrideSpace = nodeAfter?.text?.startsWith(' ')

          if (overrideSpace) {
            range.to += 1
          }

          editor
            .chain()
            .focus()
            .insertContentAt(range, [
              {
                type: this.name,
                attrs: props,
              },
              {
                type: 'text',
                text: ' ',
              },
            ])
            .run()

          window.getSelection()?.collapseToEnd()
        },
        allow: ({ state, range }) => {
          const $from = state.doc.resolve(range.from)
          const type = state.schema.nodes[this.name]
          const allow = !!$from.parent.type.contentMatch.matchType(type)

          return allow
        },
      },
    }
  },
  parseHTML() {
    return [
      {
        tag: `[data-trix-attachment]`,
        getAttrs: (element) => {
          if (findAttribute(element, "contentType") == this.options.attachmentContentType) {
            return true;
          }
          return false;
        },
      },
    ]
  },
  addAttributes() {
    return {
      sgid: {
        default: null,
        parseHTML: (element) => {
          return (
            findAttribute(element, "sgid")
          );
        },
        renderHTML: attributes => {
          return {}
        },
      },
      content: {
        default: null,
        parseHTML: (element) => {
          return (
            findAttribute(element, "content").trim()
          );
        },
        renderHTML: attributes => {
          return {}
        },
      },
      contentType: {
        default: this.options.attachmentContentType,
        parseHTML: (element) => {
          return (
            findAttribute(element, "contentType")
          );
        },
        renderHTML: attributes => {
          return {}
        },
      },
    }
  },
  renderHTML({ node, HTMLAttributes }) {
    const trixAttributes = {
      sgid: node.attrs.sgid,
      content: node.attrs.content,
      contentType: node.attrs.contentType
    }
    const label = [
      'span',
      {class: "mention"},
      `#${node.attrs.content}`,
    ]
    return [
      'span',
      mergeAttributes({"data-trix-attachment": JSON.stringify(trixAttributes)}, this.options.HTMLAttributes, HTMLAttributes),
      label,
    ]
  },
})

export default ActionTextAttachmentMention;

You can use this extension directly if you want. Or (in our case) if you need to have more than one mention functionality, you can extend it. For example, you could @mention users, and #mention tags:

// app/javascript/rhino-editor/extensions/UserMention.js
import ActionTextAttachmentMention from './ActionTextAttachmentMention'
import { PluginKey } from '@tiptap/pm/state'
import Suggestion from '@tiptap/suggestion'

// When using multiple mention extensions at the same time
// you must make two things unique:
// * The name of the extension
// * The pluginKey for the Suggestion plugin
// 
// We do that here.
// Everything else is configured at a higher level.

const UserMention = ActionTextAttachmentMention.extend({
  name: 'UserMention',
  addProseMirrorPlugins() {
    return [
      Suggestion({
        editor: this.editor,
        pluginKey: new PluginKey('UserMentionSuggestion'),
        ...this.options.suggestion,
      }),
    ]
  },
})

export default UserMention

All that's left is to configure the extension with your editor:

import UserMention from './extensions/UserMention.js'

function extendRhinoEditor (event) {
  const rhinoEditor = event.target
  
  if (rhinoEditor == null) return

  rhinoEditor.addExtensions(UserMention.configure({
    suggestion: {
      char: '@',
      items: async ({ query }) => {
        const response = await fetch(`/user_mentions.json?query=${query}`);
        const data = await response.json();
        return data;
      },
    },
    attachmentContentType: "application/vnd.active_record.user"
  }))

  rhinoEditor.starterKitOptions = {
    ...rhinoEditor.starterKitOptions,
    // codeBlock: false,
    rhinoGallery: false,
    rhinoAttachment: false,
    rhinoFigcaption: false,
    rhinoImage: false,
    rhinoStrike: false,
    // rhinoFocus: false,
    rhinoLink: false,
    rhinoPlaceholder: false,
    // rhinoPasteEvent: false,
  }

  rhinoEditor.rebuildEditor()
}

document.addEventListener("rhino-before-initialize", extendRhinoEditor)

Note: We had to disable some of Rhino's default extensions due to #112

Unable to delete image without caption

I'm running the latest Rhino release and I've noticed that it's no longer possible to delete images from my editor unless the caption is populated.

I think this might be a regression, here's a video of it happening in my app:

Screen.Recording.2024-04-12.at.10.26.28.mp4

I wouldn't have thought this was due to my configuration, but maybe it is somehow?

Unfortunately I can't reproduce this using the Rhino test Rails app since I can not longer get that running on macOS Sonomo 14.4.1 ([ERROR] Failed to resolve entry for package "rhino-editor". The package may have incorrect main/module/exports specified in its package.json, plus I couldn't run pnpm run setup because it requires ruby 3.0.4 which I can't build... anyway, not that relevant). I will try and get this working when I have time but for now, I have to move on.

Issue with importmaps and lit-html

Playing with the latest Rhino and importmaps.

The problem is the second line below (to do with the trailing slash):

Pinning "lit-html" to vendor/javascript/lit-html.js via download from https://ga.jspm.io/npm:[email protected]/lit-html.js
Pinning "lit-html/" to vendor/javascript/lit-html/.js via download from https://ga.jspm.io/npm:[email protected]/
/Users/olly/.gem/ruby/3.3.0/gems/importmap-rails-2.0.1/lib/importmap/packager.rb:80:in `handle_failure_response': Unexpected response code (404) (Importmap::Packager::HTTPError)

Not sure if it's an Importmap gem issue or Rhino config, but figured I'd mention it here.

Toolbar bugs

Bold button only works when clicked in top left
Strikethrough doesn't have a toggle state

Chore: create a roadmap

  • pluggable attachment layer (XHR / fetch)
  • Gem for advanced tags / attributes
  • Pro editor with a lot more baked in
  • ????

Docs: how we added table support

Leaving this here as a rough start to writing the docs for adding tables:

We have a custom editor:

// This custom editor
// extends the default tiptap editor to have a toolbar
// with table editing buttons in it.

import { html } from "lit"
import { TipTapEditor } from "rhino-editor/exports/elements/tip-tap-editor.js"
import * as table_icons from "./table_icons.js"
import * as table_translations from "./table_translations.js"

class CustomEditor extends TipTapEditor {
  renderToolbar() {
    if (this.readonly) return html``;

    return html`
      <slot name="toolbar">
        <role-toolbar class="toolbar" part="toolbar" role="toolbar">
          <slot name="toolbar-start">${this.renderToolbarStart()}</slot>

          <!-- Bold -->
          <slot name="before-bold-button"></slot>
          <slot name="bold-button">${this.renderBoldButton()}</slot>
          <slot name="after-bold-button"></slot>

          <!-- Italic -->
          <slot name="before-italic-button"></slot>
          <slot name="italic-button">${this.renderItalicButton()}</slot>
          <slot name="after-italic-button"></slot>

          <!-- Strike -->
          <slot name="before-strike-button"></slot>
          <slot name="strike-button">${this.renderStrikeButton()}</slot>
          <slot name="after-strike-button"></slot>

          <!-- Link -->
          <slot name="before-link-button"></slot>
          <slot name="link-button">${this.renderLinkButton()}</slot>
          <slot name="after-link-button"></slot>

          <!-- Heading -->
          <slot name="before-heading-button"></slot>
          <slot name="heading-button">${this.renderHeadingButton()}</slot>
          <slot name="after-heading-button"></slot>

          <!-- Blockquote -->
          <slot name="before-blockquote-button"></slot>
          <slot name="blockquote-button">${this.renderBlockquoteButton()}</slot>
          <slot name="after-blockquote-button"></slot>

          <!-- Code block -->
          <slot name="before-code-block-button"></slot>
          <slot name="code-block-button">${this.renderCodeBlockButton()}</slot>
          <slot name="after-code-block-button"></slot>

          <!-- Bullet List -->
          <slot name="before-bullet-list-button"></slot>
          <slot name="bullet-list-button"
            >${this.renderBulletListButton()}</slot
          >
          <slot name="after-bullet-list-button"></slot>

          <!-- Ordered list -->
          <slot name="before-ordered-list-button"></slot>
          <slot name="ordered-list-button">
            ${this.renderOrderedListButton()}
          </slot>
          <slot name="after-ordered-list-button"></slot>

          <slot name="before-decrease-indentation-button"></slot>
          <slot name="decrease-indentation-button"
            >${this.renderDecreaseIndentation()}</slot
          >
          <slot name="after-decrease-indentation-button"></slot>

          <slot name="before-increase-indentation-button"></slot>
          <slot name="increase-indentation-button"
            >${this.renderIncreaseIndentation()}</slot
          >
          <slot name="after-increase-indentation-button"></slot>

          <slot name="table-button"
            >
            ${this.renderTableButton()}
            </slot
          >

          <!-- Attachments -->
          <slot name="before-attach-files-button"></slot>
          <slot name="attach-files-button"
            >${this.renderAttachmentButton()}</slot
          >
          <slot name="after-attach-files-button"></slot>


          <!-- Undo -->
          <slot name="before-undo-button"></slot>
          <!-- @ts-expect-error -->
          <slot name="undo-button"> ${this.renderUndoButton()} </slot>
          <slot name="after-undo-button"></slot>

          <!-- Redo -->
          <slot name="before-redo-button"></slot>
          <slot name="redo-button"> ${this.renderRedoButton()} </slot>
          <slot name="after-redo-button"></slot>

          <slot name="toolbar-end">${this.renderToolbarEnd()}</slot>
        </role-toolbar>

        ${this.renderTableMenu()}
      </slot>
    `;
  }

  renderTableButton() {
    const tableEnabled = true; // Boolean(this.editor?.commands.setAttachment);

    if (!tableEnabled) return html``;

    const isDisabled = this.editor == null;
    return html`
      <button
        class="toolbar__button rhino-toolbar-button"
        type="button"
        aria-describedby="table"
        aria-disabled=${isDisabled}
        data-role="toolbar-item"
        @click=${function(e) {
          this.editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
        }}
      >
        <slot name="table-tooltip">
          <role-tooltip
            id="table"
            hoist
            part="toolbar-tooltip toolbar-tooltip__table"
            exportparts=${this.__tooltipExportParts}
          >
            ${table_translations.insertTable}
          </role-tooltip>
        </slot>
        <slot name="table-icon">${table_icons.insertTable}</slot>

      </button>
    `;
  }
  renderTableMenu() {
    if (!this.editor.isActive('table')) return html``;
    return html`
      <role-toolbar class="toolbar" part="toolbar" role="toolbar">
      <button
        class="toolbar__button rhino-toolbar-button"
        type="button"
        aria-describedby="table"
        aria-disabled="false"
        data-role="toolbar-item"
        @click=${function(e) {
          this.editor.chain().focus().deleteTable().run()
        }}
      >
        <slot name="table-tooltip">
          <role-tooltip
            id="table"
            hoist
            part="toolbar-tooltip toolbar-tooltip__table"
            exportparts=${this.__tooltipExportParts}
          >
            ${table_translations.deleteTable}
          </role-tooltip>
        </slot>
        <slot name="table-icon">${table_icons.deleteTable}</slot>
      </button>
      <button
        class="toolbar__button rhino-toolbar-button"
        type="button"
        aria-describedby="table"
        aria-disabled="false"
        data-role="toolbar-item"
        @click=${function(e) {
          this.editor.chain().focus().addColumnBefore().run()
        }}
      >
        <slot name="table-tooltip">
          <role-tooltip
            id="table"
            hoist
            part="toolbar-tooltip toolbar-tooltip__table"
            exportparts=${this.__tooltipExportParts}
          >
            ${table_translations.addColumnBefore}
          </role-tooltip>
        </slot>
        <slot name="table-icon">${table_icons.addColumnBefore}</slot>
      </button>
      <button
        class="toolbar__button rhino-toolbar-button"
        type="button"
        aria-describedby="table"
        aria-disabled="false"
        data-role="toolbar-item"
        @click=${function(e) {
          this.editor.chain().focus().addColumnAfter().run()
        }}
      >
        <slot name="table-tooltip">
          <role-tooltip
            id="table"
            hoist
            part="toolbar-tooltip toolbar-tooltip__table"
            exportparts=${this.__tooltipExportParts}
          >
            ${table_translations.addColumnAfter}
          </role-tooltip>
        </slot>
        <slot name="table-icon">${table_icons.addColumnAfter}</slot>
      </button>
      <button
        class="toolbar__button rhino-toolbar-button"
        type="button"
        aria-describedby="table"
        aria-disabled="false"
        data-role="toolbar-item"
        @click=${function(e) {
          this.editor.chain().focus().deleteColumn().run()
        }}
      >
        <slot name="table-tooltip">
          <role-tooltip
            id="table"
            hoist
            part="toolbar-tooltip toolbar-tooltip__table"
            exportparts=${this.__tooltipExportParts}
          >
            ${table_translations.deleteColumn}
          </role-tooltip>
        </slot>
        <slot name="table-icon">${table_icons.deleteColumn}</slot>
      </button>
      <button
        class="toolbar__button rhino-toolbar-button"
        type="button"
        aria-describedby="table"
        aria-disabled="false"
        data-role="toolbar-item"
        @click=${function(e) {
          this.editor.chain().focus().addRowBefore().run()
        }}
      >
        <slot name="table-tooltip">
          <role-tooltip
            id="table"
            hoist
            part="toolbar-tooltip toolbar-tooltip__table"
            exportparts=${this.__tooltipExportParts}
          >
            ${table_translations.addRowBefore}
          </role-tooltip>
        </slot>
        <slot name="table-icon">${table_icons.addRowBefore}</slot>
      </button>
      <button
        class="toolbar__button rhino-toolbar-button"
        type="button"
        aria-describedby="table"
        aria-disabled="false"
        data-role="toolbar-item"
        @click=${function(e) {
          this.editor.chain().focus().addRowAfter().run()
        }}
      >
        <slot name="table-tooltip">
          <role-tooltip
            id="table"
            hoist
            part="toolbar-tooltip toolbar-tooltip__table"
            exportparts=${this.__tooltipExportParts}
          >
            ${table_translations.addRowAfter}
          </role-tooltip>
        </slot>
        <slot name="table-icon">${table_icons.addRowAfter}</slot>
      </button>
      <button
        class="toolbar__button rhino-toolbar-button"
        type="button"
        aria-describedby="table"
        aria-disabled="false"
        data-role="toolbar-item"
        @click=${function(e) {
          this.editor.chain().focus().deleteRow().run()
        }}
      >
        <slot name="table-tooltip">
          <role-tooltip
            id="table"
            hoist
            part="toolbar-tooltip toolbar-tooltip__table"
            exportparts=${this.__tooltipExportParts}
          >
            ${table_translations.deleteRow}
          </role-tooltip>
        </slot>
        <slot name="table-icon">${table_icons.deleteRow}</slot>
      </button>
      <button
        class="toolbar__button rhino-toolbar-button"
        type="button"
        aria-describedby="table"
        aria-disabled="false"
        data-role="toolbar-item"
        @click=${function(e) {
          this.editor.chain().focus().mergeOrSplit().run()
        }}
      >
        <slot name="table-tooltip">
          <role-tooltip
            id="table"
            hoist
            part="toolbar-tooltip toolbar-tooltip__table"
            exportparts=${this.__tooltipExportParts}
          >
            ${table_translations.mergeOrSplit}
          </role-tooltip>
        </slot>
        <slot name="table-icon">${table_icons.mergeOrSplit}</slot>
      </button>
      <button
        class="toolbar__button rhino-toolbar-button"
        type="button"
        aria-describedby="table"
        aria-disabled="false"
        data-role="toolbar-item"
        @click=${function(e) {
          this.editor.chain().focus().toggleHeaderRow().run()
        }}
      >
        <slot name="table-tooltip">
          <role-tooltip
            id="table"
            hoist
            part="toolbar-tooltip toolbar-tooltip__table"
            exportparts=${this.__tooltipExportParts}
          >
            ${table_translations.toggleHeaderRow}
          </role-tooltip>
        </slot>
        <slot name="table-icon">${table_icons.toggleHeaderRow}</slot>
      </button>
      <button
        class="toolbar__button rhino-toolbar-button"
        type="button"
        aria-describedby="table"
        aria-disabled="false"
        data-role="toolbar-item"
        @click=${function(e) {
          this.editor.chain().focus().toggleHeaderColumn().run()
        }}
      >
        <slot name="table-tooltip">
          <role-tooltip
            id="table"
            hoist
            part="toolbar-tooltip toolbar-tooltip__table"
            exportparts=${this.__tooltipExportParts}
          >
            ${table_translations.toggleHeaderColumn}
          </role-tooltip>
        </slot>
        <slot name="table-icon">${table_icons.toggleHeaderColumn}</slot>
      </button>
      </role-toolbar>
    `;
  }
}

CustomEditor.define("rhino-editor")

With some new table icons:

import { html, svg } from "lit";

function toSvg(path, size = 24) {
  return html`
    <svg
      xmlns="http://www.w3.org/2000/svg"
      aria-hidden="true"
      fill="currentColor"
      viewBox="0 0 ${size} ${size}"
      width="${size}px"
      height="${size}px"
      part="toolbar__icon"
    >
      ${path}
    </svg>
  `
}

export const insertTable = toSvg(
  svg`<path pid="0" fill-rule="evenodd" d="M17 17v5h2a3 3 0 0 0 3-3v-2h-5zm-2 0H9v5h6v-5zm2-2h5V9h-5v6zm-2 0V9H9v6h6zm2-8h5V5a3 3 0 0 0-3-3h-2v5zm-2 0V2H9v5h6zm9 9.177V19a5 5 0 0 1-5 5H5a5 5 0 0 1-5-5V5a5 5 0 0 1 5-5h14a5 5 0 0 1 5 5v2.823a.843.843 0 0 1 0 .354v7.646a.843.843 0 0 1 0 .354zM7 2H5a3 3 0 0 0-3 3v2h5V2zM2 9v6h5V9H2zm0 8v2a3 3 0 0 0 3 3h2v-5H2z"></path>`
);

export const deleteTable = toSvg(
  svg`<path pid="0" d="M19 14a5 5 0 1 1 0 10 5 5 0 0 1 0-10zm-2.5 5.938h5a.937.937 0 1 0 0-1.875h-5a.937.937 0 1 0 0 1.875zM12.29 17H9v5h3.674c.356.75.841 1.426 1.427 2H5a5 5 0 0 1-5-5V5a5 5 0 0 1 5-5h14a5 5 0 0 1 5 5v2.823a.843.843 0 0 1 0 .354V14.1a7.018 7.018 0 0 0-2-1.427V9h-5v3.29a6.972 6.972 0 0 0-2 .965V9H9v6h4.255a6.972 6.972 0 0 0-.965 2zM17 7h5V5a3 3 0 0 0-3-3h-2v5zm-2 0V2H9v5h6zM7 2H5a3 3 0 0 0-3 3v2h5V2zM2 9v6h5V9H2zm0 8v2a3 3 0 0 0 3 3h2v-5H2z"></path>`
);

export const addColumnBefore = toSvg(
  svg`<path pid="0" d="M19 14a5 5 0 1 1 0 10 5 5 0 0 1 0-10zm2.5 5.938a.937.937 0 1 0 0-1.875h-1.25a.312.312 0 0 1-.313-.313V16.5a.937.937 0 1 0-1.875 0v1.25c0 .173-.14.313-.312.313H16.5a.937.937 0 1 0 0 1.875h1.25c.173 0 .313.14.313.312v1.25a.937.937 0 1 0 1.875 0v-1.25c0-.173.14-.313.312-.313h1.25zM2 19a3 3 0 0 0 6 0V5a3 3 0 1 0-6 0v14zm-2 0V5a5 5 0 1 1 10 0v14a5 5 0 0 1-10 0z"></path>`
);

export const addColumnAfter = toSvg(
  svg`<path pid="0" d="M5 14a5 5 0 1 1 0 10 5 5 0 0 1 0-10zm2.5 5.938a.937.937 0 1 0 0-1.875H6.25a.312.312 0 0 1-.313-.313V16.5a.937.937 0 1 0-1.875 0v1.25c0 .173-.14.313-.312.313H2.5a.937.937 0 1 0 0 1.875h1.25c.173 0 .313.14.313.312v1.25a.937.937 0 1 0 1.875 0v-1.25c0-.173.14-.313.312-.313H7.5zM16 19a3 3 0 0 0 6 0V5a3 3 0 0 0-6 0v14zm-2 0V5a5 5 0 0 1 10 0v14a5 5 0 0 1-10 0z"></path>`
);

export const deleteColumn = toSvg(
  svg`<path pid="0" d="M12.641 21.931a7.01 7.01 0 0 0 1.146 1.74A5 5 0 0 1 7 19V5a5 5 0 1 1 10 0v7.29a6.972 6.972 0 0 0-2 .965V5a3 3 0 0 0-6 0v14a3 3 0 0 0 3.641 2.931zM19 14a5 5 0 1 1 0 10 5 5 0 0 1 0-10zm-2.5 5.938h5a.937.937 0 1 0 0-1.875h-5a.937.937 0 1 0 0 1.875z"></path>`
);

export const addRowBefore = toSvg(
  svg`<path pid="0" d="M19 14a5 5 0 1 1 0 10 5 5 0 0 1 0-10zm2.5 5.938a.937.937 0 1 0 0-1.875h-1.25a.312.312 0 0 1-.313-.313V16.5a.937.937 0 1 0-1.875 0v1.25c0 .173-.14.313-.312.313H16.5a.937.937 0 1 0 0 1.875h1.25c.173 0 .313.14.313.312v1.25a.937.937 0 1 0 1.875 0v-1.25c0-.173.14-.313.312-.313h1.25zM5 2a3 3 0 1 0 0 6h14a3 3 0 0 0 0-6H5zm0-2h14a5 5 0 0 1 0 10H5A5 5 0 1 1 5 0z"></path>`
);

export const addRowAfter = toSvg(
  svg`<path pid="0" d="M19 0a5 5 0 1 1 0 10 5 5 0 0 1 0-10zm2.5 5.938a.937.937 0 1 0 0-1.875h-1.25a.312.312 0 0 1-.313-.313V2.5a.937.937 0 1 0-1.875 0v1.25c0 .173-.14.313-.312.313H16.5a.937.937 0 1 0 0 1.875h1.25c.173 0 .313.14.313.312V7.5a.937.937 0 1 0 1.875 0V6.25c0-.173.14-.313.312-.313h1.25zM5 16a3 3 0 0 0 0 6h14a3 3 0 0 0 0-6H5zm0-2h14a5 5 0 0 1 0 10H5a5 5 0 0 1 0-10z"></path>`
);

export const deleteRow = toSvg(
  svg`<path pid="0" d="M13.255 15a6.972 6.972 0 0 0-.965 2H5A5 5 0 0 1 5 7h14a5 5 0 0 1 4.671 6.787 7.01 7.01 0 0 0-1.74-1.146A3 3 0 0 0 19 9H5a3 3 0 0 0 0 6h8.255zM19 14a5 5 0 1 1 0 10 5 5 0 0 1 0-10zm-2.5 5.938h5a.937.937 0 1 0 0-1.875h-5a.937.937 0 1 0 0 1.875z"></path>`
);

export const mergeCells = toSvg(
  svg``
);

export const splitCells = toSvg(
  svg``
);

export const toggleHeaderColumn = toSvg(
  svg`<path d="M 23.5625 0 c 1.3439 0 2.4375 1.0936 2.4375 2.4375 v 21.125 c 0 1.3439 -1.0936 2.4375 -2.4375 2.4375 H 2.4375 c -1.3439 0 -2.4375 -1.0936 -2.4375 -2.4375 V 2.4375 C 0 1.0936 1.0936 0 2.4375 0 h 21.125 Z m 0.8125 17.875 h -6.5 v 6.5 h 5.6875 c 0.4469 0 0.8125 -0.3656 0.8125 -0.8125 v -5.6875 Z m -8.125 0 H 9.75 v 6.5 h 6.5 v -6.5 Z m 8.125 -8.125 h -6.5 v 6.5 h 6.5 V 9.75 Z m -8.125 0 H 9.75 v 6.5 h 6.5 V 9.75 Z m 7.3125 -8.125 h -5.6875 v 6.5 h 6.5 V 2.4375 c 0 -0.4469 -0.3656 -0.8125 -0.8125 -0.8125 Z m -7.3125 0 H 9.75 v 6.5 h 6.5 V 1.625 Z" fill-rule="evenodd"/>`
);
  
export const toggleHeaderRow = toSvg(
  svg`<path d="M 23.5625 0 c 1.3439 0 2.4375 1.0936 2.4375 2.4375 v 21.125 c 0 1.3439 -1.0936 2.4375 -2.4375 2.4375 H 2.4375 c -1.3439 0 -2.4375 -1.0936 -2.4375 -2.4375 V 2.4375 C 0 1.0936 1.0936 0 2.4375 0 h 21.125 Z m -5.6875 16.25 h 6.5 V 9.75 h -6.5 v 6.5 Z m 6.5 7.3125 v -5.6875 h -6.5 v 6.5 h 5.6875 c 0.4469 0 0.8125 -0.3656 0.8125 -0.8125 Z M 9.75 16.25 h 6.5 V 9.75 H 9.75 v 6.5 Z m 0 8.125 h 6.5 v -6.5 H 9.75 v 6.5 Z m -8.125 -8.125 h 6.5 V 9.75 H 1.625 v 6.5 Z m 6.5 8.125 v -6.5 H 1.625 v 5.6875 c 0 0.4469 0.3656 0.8125 0.8125 0.8125 h 5.6875 Z" fill-rule="evenodd"/>`
);
    
export const toggleHeaderCell = toSvg(
  svg``
);

export const mergeOrSplit = toSvg(
  svg`<path pid="0" d="M2 19a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3V5a3 3 0 0 0-3-3H5a3 3 0 0 0-3 3v14zm-2 0V5a5 5 0 0 1 5-5h14a5 5 0 0 1 5 5v14a5 5 0 0 1-5 5H5a5 5 0 0 1-5-5zm12-9a1 1 0 0 1 1 1v2a1 1 0 0 1-2 0v-2a1 1 0 0 1 1-1zm0 6a1 1 0 0 1 1 1v3a1 1 0 0 1-2 0v-3a1 1 0 0 1 1-1zm0-13a1 1 0 0 1 1 1v3a1 1 0 0 1-2 0V4a1 1 0 0 1 1-1z"></path>`
);

export const setCellAttribute = toSvg(
  svg``
);

export const fixTables = toSvg(
  svg``
);

export const goToNextCell = toSvg(
  svg``
);
export const goToPreviousCell = toSvg(
  svg``
);

And table translations:

export const insertTable = "Insert a Table";
export const deleteTable = "Delete a Table";
export const addColumnBefore = "Add A Column Before";
export const addColumnAfter = "Add a Column After";
export const deleteColumn = "Delete a Column";
export const addRowBefore = "Add a Row Before";
export const addRowAfter = "Add a Row After";
export const deleteRow = "Delete a Row";
export const mergeCells = "Merge Cells";
export const splitCells = "Split Cells";
export const toggleHeaderColumn = "Toggle Header Column";
export const toggleHeaderRow = "Toggle Header Row";
export const toggleHeaderCell = "Toggle Header Cell";
export const mergeOrSplit = "Merge or Split Cells";
export const setCellAttribute = "Set Cell Attribute";
export const fixTables = "Fix Tables";
export const goToNextCell = "Go To Next Cell";
export const goToPreviousCell = "Go To Previous Cell";

And some custom styling:

rhino-editor table {
  border-collapse: collapse;
  margin: 0;
  overflow: hidden;
  table-layout: fixed;
  width: 100%;
}
rhino-editor table td, rhino-editor table th {
  border: 2px solid #ced4da;
  box-sizing: border-box;
  min-width: 1em;
  padding: 3px 5px;
  position: relative;
  vertical-align: top;
}
rhino-editor table td > *, rhino-editor table th > * {
  margin-bottom: 0;
}
rhino-editor table th {
  background-color: #f1f3f5;
  font-weight: bold;
  text-align: left;
}
rhino-editor table .selectedCell:after {
  background: rgba(200, 200, 255, 0.4);
  content: "";
  left: 0;
  right: 0;
  top: 0;
  bottom: 0;
  pointer-events: none;
  position: absolute;
  z-index: 2;
}
rhino-editor table .column-resize-handle {
  background-color: #adf;
  bottom: -2px;
  position: absolute;
  right: -2px;
  pointer-events: none;
  top: 0;
  width: 4px;
}
rhino-editor table p {
  margin: 0;
}
.tableWrapper {
  padding: 1rem 0;
  overflow-x: auto;
}

We then get things going by calling:

import "./custom-editor"
import Table from '@tiptap/extension-table'
import TableCell from '@tiptap/extension-table-cell'
import TableHeader from '@tiptap/extension-table-header'
import TableRow from '@tiptap/extension-table-row'

function extendRhinoEditor (event) {
  const rhinoEditor = event.target
  if (rhinoEditor == null) return
  rhinoEditor.addExtensions(Table)
  rhinoEditor.addExtensions(TableRow)
  rhinoEditor.addExtensions(TableHeader)
  rhinoEditor.addExtensions(TableCell)
  rhinoEditor.rebuildEditor()
}

document.addEventListener("rhino-before-initialize", extendRhinoEditor)

It gives us an editor that looks like:

screenshot

JavaScript definitions collision in Rails 7.1.2 when using Bun

I'm trying to use Rhino Editor in my rails app as a replacement for Trix in conjunction with ActionText, and when I try to load the javascript for Rhino, I'm getting the following error in the browser console.

Uncaught SyntaxError: Identifier 'i3' has already been declared

Rails Version: 7.1.2
package.json

{
  "name": "app",
  "private": "true",
  "scripts": {
    "build": "bun bun.config.js",
    "build:css": "tailwindcss -i ./app/assets/stylesheets/application.tailwind.css -o ./app/assets/builds/application.css --minify"
  },
  "dependencies": {
    "@hotwired/stimulus": "^3.2.2",
    "@hotwired/turbo-rails": "^7.3.0",
    "@rails/actioncable": "^7.1.2",
    "@rails/actiontext": "^7.1.2",
    "@tailwindcss/forms": "^0.5.6",
    "@tailwindcss/typography": "^0.5.10",
    "autoprefixer": "latest",
    "daisyui": "^4.4.0",
    "moment": "^2.29.4",
    "postcss": "latest",
    "rhino-editor": "^0.9.0",
    "tailwindcss": "latest",
    "chroma-js": "^2.4.2",
    "theme-change": "^2.5.0"
  },
  "devDependencies": {
    "tailwindcss-inner-border": "^0.2.0"
  }
}

application.js

// Entry point for the build script in your package.json
import "@hotwired/turbo-rails"
import "./controllers"
import "./channels"

import "rhino-editor"
import "rhino-editor/exports/styles/trix.css"
import "@rails/actiontext"

This is what the console is referencing in its error.
application.js line 31053 (after Rails asset processing)

// ../../node_modules/rhino-editor/ex
var t3 = { ATTRIBUTE: 1, CHILD: 2, PROPERTY: 3, BOOLEAN_ATTRIBUTE: 4, EVENT: 5, ELEMENT: 6 };
var e4 = (t4) => (...e5) => ({ _$litDirective$: t4, values: e5 });

class i3 {
  constructor(t4) {
  }
  get _$AU() {
    return this._$AM._$AU;
  }
  _$AT(t4, e5, i4) {
    this._$Ct = t4, this._$AM = e5, this._$Ci = i4;
  }
  _$AS(t4, e5) {
    return this.update(t4, e5);
  }
  update(t4, e5) {
    return this.render(...e5);
  }
}

I've also tried to remove everything from my application.js apart from these two lines per the documentation, but this changes nothing.

import "rhino-editor"
import "rhino-editor/exports/styles/trix.css"

Shrine support?

Quick question as I'm perusing the docs: does Rhino have a way to extend it so that it can use Shrine instead of ActionStorage? If so, I'm happy to write docs for it!

Pasting images

Currently there's no support for pasting images. Trix has this.

Anything I can do to help implement it?

Form Helpers

Create form helpers courtesy of @afomera

require "action_view/helpers/tags/placeholderable"

module ActionView::Helpers
  class Tags::RhinoEditor < Tags::Base
    include Tags::Placeholderable

    def render
      options = @options.stringify_keys
      options["value"] = options.fetch("value") { value&.to_trix_html }
      add_default_name_and_id(options)

      @template_object.rhino_editor_tag(options["name"], options["value"], options.except("value"))
    end
  end

  module FormHelper
    def rhino_editor_field(object_name, method, options = {})
      Tags::RhinoEditor.new(object_name, method, self, options).render
    end
  end

  class FormBuilder
    def rhino_editor(method, options = {})
      @template.rhino_editor_field(@object_name, method, objectify_options(options))
    end
  end
end


module TagHelper
  cattr_accessor(:id, instance_accessor: false) { 0 }

  include Rails.application.routes.url_helpers

  def rhino_editor_tag(name, value = nil, options = {})
    options = options.symbolize_keys

    content_tag("div") do
      hidden_field_tag(name, value) +
       tag("rhino-editor", { input: options[:id], data: { "blob-url-template": rails_service_blob_url(":signed_id", ":filename"), "direct-upload-url": rails_direct_uploads_url } }.merge(options))
    end
  end
end

[Feature]: Add getHTMLFromCurrentSelection and getTextFromCurrentSelection

function getTextFromCurrentSelection () {
  const editor = document.querySelector("rhino-editor").editor
  const { from, to, empty } = editor.state.selection
  if (empty) { return "" }
  return editor.state.doc.textBetween(from, to, ' '))
}
import { DOMSerializer } from 'prosemirror-model'

function getHTMLContentFromCurrentSelection() {

  const editor = document.querySelector("rhino-editor").editor

  const { from, to, empty } = editor.state.selection

  if (!empty) { return "" }

  const { state } = editor
  const nodesArray = []

  state.doc.nodesBetween(from, to, (node, pos, parent) => {
    if (parent === state.doc) {
      const serializer = DOMSerializer.fromSchema(editor.schema)
      const dom = serializer.serializeNode(node)
      const tempDiv = document.createElement('div')
      tempDiv.appendChild(dom)
      nodesArray.push(tempDiv.innerHTML)
    }
  })

  return nodesArray.join('')
}

Couple bugs in the demo

Load the demo, click to focus on body (not the editor), then click back on editor and type. The placeholder text isn’t removed. Similarly, delete doesn’t work.

Cool project: good luck!

In-editor attachment preview broken in iOS Safari

This is a tricky one. I've tried to run the test app locally to verify there but I'm having issues with 0.8.4 that prevent me booting it via Overmind (this is a separate issue that I will need to try and understand).

vite    | Error:   Failed to scan for dependencies from entries:
vite    |   /Users/olly/dev/rhino-editor/tests/rails/app/frontend/entrypoints/application.js
vite    | /Users/olly/dev/rhino-editor/tests/rails/app/frontend/entrypoints/collaboration.js
vite    |
vite    |   ✘ [ERROR] Failed to resolve entry for package "rhino-editor". The package may have incorrect main/module/exports specified in its package.json. [plugin vite:dep-scan]
vite    |
vite    |     node_modules/.pnpm/[email protected]/node_modules/esbuild/lib/main.js:1360:21:
vite    |       1360 │         let result = await callback({

To describe the issue itself, if I add a new image to my editor (S3-backed for ActiveStorage) when running in Safari iOS, the image initially appears to load but when I click off the image to another place in the editor then click the image again, the preview disappears. If I save my content, the image appears fine (the attachment is there). If I edit it again, the image preview is broken again... and so on.

Annoyingly I can't replicate this on a desktop browser and there are no console errors. I know I need to demonstrate reproducable steps using the test setup on this repo, but I thought I'd bring this up now anyway just in case there was something obvious.

RPReplay_Final1695244939.mov

Regression in 0.8.1. Editing a rich text with attachments no longer renders the attachments

This issue has been introduced in 0.8.1. 0.8.0 was fine.

If you add an attachment to a rich text and save it, Rhino renders it correctly. If you subsequently edit the rich text, the attachment preview is no longer present in Rhino (the attachment is still present in the database). If you then update the rich text in this state, it no longer renders. This is the same with local storage or S3. Here's a video demonstrating:

Screen.Recording.2023-09-14.at.08.55.55.mov

Regressions with drag and dropping images in 0.8.6

I've been trying the 0.8.6 release and I've found a couple of regressions, one minor, one major.

Minor

Adding an image and clicking on the caption clears the placeholder (nice!) but the cursor is in the wrong position. In Firefox it's position too high (see video), in Chrome it kinda disappears. Would be nice if the cursor appeared in the centre of the edit field.

Major

Dragging one image is fine. Dragging a second doesn't work. There's no error, it's just... consumed somewhere and not added to the DOM. Oddly, if you add some text after the first image, dragging a second image works fine. See video below.

0.8.6.regression.mov

Question: is vite required?

hey @KonnorRogers - love this!

Is Vite required? can it be used with JSBundling or Shakapacker or Importmap?

I am looking at implementing some rich text for my gem Hot Glue. I already implemented TinyMCE and was going to look at ActionText next, but now I want to give this a try.
-Jason

Recursion errors when uploading 2MB images with S3-backed ActiveStorage

I noticed this when trying to drag and drop images into a production Rhino instance, but I've also verified it locally when hooking up my ActiveStorage to S3 in development.

Dragging a single 2MB JPEG from my desktop into a basic Rhino ActionText editor results in the following errors in my console. It doesn't seem to happen when I drag in smaller (e.g. 100kb) images. Interestingly the images do seem to get uploaded eventually.

Kinda tricky to reproduce if you don't have an S3 config you could spin up, sorry.

Uncaught InternalError: too much recursion
    getHTMLFromFragment index.js:2097
    getHTML index.js:3699
    serialize chunk-QO4WP2WY.js:280
    updateInputElementValue chunk-QO4WP2WY.js:269
    __handleUpdate chunk-QO4WP2WY.js:136
    emit index.js:155
    emit index.js:155
    dispatchTransaction index.js:3673
    dispatch index.js:5471
    setNodeMarkup chunk-5KVYYAWF.js:77
    nodesBetween index.js:88
    nodesBetween index.js:1212
index.js:2097:14
    getHTMLFromFragment index.js:2097
    getHTML index.js:3699
    serialize chunk-QO4WP2WY.js:280
    updateInputElementValue chunk-QO4WP2WY.js:269
    __handleUpdate chunk-QO4WP2WY.js:136
    emit index.js:155
    forEach self-hosted:203
    emit index.js:155
    dispatchTransaction index.js:3673
    dispatch index.js:5471
    setNodeMarkup chunk-5KVYYAWF.js:77
    nodesBetween index.js:88
    nodesBetween index.js:1212
    nodesBetween index.js:90
    nodesBetween index.js:1212
    descendants index.js:1219
    setNodeMarkup chunk-5KVYYAWF.js:75
    setUploadProgress chunk-5KVYYAWF.js:40
    setUploadProgress chunk-LXPHJPCU.js:50
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51
    setUploadProgress chunk-LXPHJPCU.js:51

Uncaught InternalError: too much recursion
    getHTMLFromFragment index.js:2097
    getHTML index.js:3699
    serialize chunk-QO4WP2WY.js:280
    updateInputElementValue chunk-QO4WP2WY.js:269
    __handleUpdate chunk-QO4WP2WY.js:136
    emit index.js:155
    emit index.js:155
    dispatchTransaction index.js:3673
    dispatch index.js:5471
    setNodeMarkup chunk-5KVYYAWF.js:77
    nodesBetween index.js:88
    nodesBetween index.js:1212
index.js:2097:14

Uncaught InternalError: too much recursion
    getHTMLFromFragment index.js:2097
    getHTML index.js:3699
    serialize chunk-QO4WP2WY.js:280
    updateInputElementValue chunk-QO4WP2WY.js:269
    __handleUpdate chunk-QO4WP2WY.js:136
    emit index.js:155
    emit index.js:155
    dispatchTransaction index.js:3673
    dispatch index.js:5471
    setNodeMarkup chunk-5KVYYAWF.js:77
    nodesBetween index.js:88
    nodesBetween index.js:1212
index.js:2097:14

Uncaught InternalError: too much recursion
    getHTMLFromFragment index.js:2097
    getHTML index.js:3699
    serialize chunk-QO4WP2WY.js:280
    updateInputElementValue chunk-QO4WP2WY.js:269
    __handleUpdate chunk-QO4WP2WY.js:136
    emit index.js:155
    emit index.js:155
    dispatchTransaction index.js:3673
    dispatch index.js:5471
    setNodeMarkup chunk-5KVYYAWF.js:77
    nodesBetween index.js:88
    nodesBetween index.js:1212

Can we customize Rhino to have a BubbleMenu

Rhino Editor is based on tiptap. Can we customize it to a floating BubbleMenu instead of a fixed toolbar? For example, can we use "/" to toggle the BubbleMenu?

Like this project:

Rhino Editor is an exciting project. It looks like a perfect alternative to Trix and integrates well with Rails.

[Bug]: Figcaptions for figures should not allow rich text

TLDR: ActionText doesn't like RichText in figcaptions. I'm sure its possible to workaround, but will need to go source diving and have to add docs on how to do so. The correct fix for now is to not allow rich text figcaptions.

Auto-focus in the editor on page load

What is the correct way to have the editor receive focus on page load, if this is indeed possible? I have a Stimulus controller which calls focus() on my <rhino-editor> element but this doesn't seem to work (no error, just returns undefined). I've also tried adding autofocus in the HTML.

Can't install in Rails using Rails importmap

I'm trying to add Rhino Editor to a Rails 7.1 app using importmap-rails v2.0.1. After running bin/importmap pin rhino-editor, I see the following error when pinning lit-html/:

Pinning "lit" to vendor/javascript/lit.js via download from https://ga.jspm.io/npm:[email protected]/index.js
Pinning "lit-element/lit-element.js" to vendor/javascript/lit-element/lit-element.js.js via download from https://ga.jspm.io/npm:[email protected]/lit-element.js
Pinning "lit-html" to vendor/javascript/lit-html.js via download from https://ga.jspm.io/npm:[email protected]/lit-html.js
Pinning "lit-html/" to vendor/javascript/lit-html/.js via download from https://ga.jspm.io/npm:[email protected]/
/Users/cmoel/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/importmap-rails-2.0.1/lib/importmap/packager.rb:80:in `handle_failure_response': Unexpected response code (404) (Importmap::Packager::HTTPError)
        from /Users/cmoel/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/importmap-rails-2.0.1/lib/importmap/packager.rb:118:in `download_package_file'
        from /Users/cmoel/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/importmap-rails-2.0.1/lib/importmap/packager.rb:57:in `download'
        from /Users/cmoel/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/importmap-rails-2.0.1/lib/importmap/commands.rb:19:in `block in pin'
        from /Users/cmoel/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/importmap-rails-2.0.1/lib/importmap/commands.rb:17:in `each'
        from /Users/cmoel/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/importmap-rails-2.0.1/lib/importmap/commands.rb:17:in `pin'
        from /Users/cmoel/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/thor-1.3.0/lib/thor/command.rb:28:in `run'
        from /Users/cmoel/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/thor-1.3.0/lib/thor/invocation.rb:127:in `invoke_command'
        from /Users/cmoel/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/thor-1.3.0/lib/thor.rb:527:in `dispatch'
        from /Users/cmoel/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/thor-1.3.0/lib/thor/base.rb:584:in `start'
        from /Users/cmoel/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/importmap-rails-2.0.1/lib/importmap/commands.rb:141:in `<main>'

Curling the 404ing URL confirms that there's a 404:

$ curl -I https://ga.jspm.io/npm:[email protected]/
HTTP/2 404
date: Wed, 21 Feb 2024 03:40:25 GMT
content-type: text/html
content-length: 38

Installing lit by itself works fine:

$ bin/importmap pin lit
Pinning "@lit/reactive-element" to vendor/javascript/@lit/reactive-element.js via download from https://ga.jspm.io/npm:@lit/[email protected]/reactive-element.js
Pinning "lit-element/lit-element.js" to vendor/javascript/lit-element/lit-element.js.js via download from https://ga.jspm.io/npm:[email protected]/lit-element.js
Pinning "lit-html" to vendor/javascript/lit-html.js via download from https://ga.jspm.io/npm:[email protected]/lit-html.js
Pinning "lit-html/is-server.js" to vendor/javascript/lit-html/is-server.js.js via download from https://ga.jspm.io/npm:[email protected]/is-server.js

I'm not sure what the next troubleshooting step would be. Can you provide some guidance?

How to show the character count info?

In the "Setup" section, you give the example of plugging in the CharacterCount TipTap extension. The React example in the TipTap docs shows the character count info on the page. In trying to use rhino-editor, I wanted to setup the extension and also show the info. I can't figure out how to do that, however, because I can't figure out how to get a reference to the editor object defined on the RhinoEditor web component.

Any suggestions here?

How to add class and attributes to input within rhino editor?

Is there a way to apply a class or attribute to the input field (technically it's a div) within the Rhino Editor? When validation fails for a form, I render the form and apply the Bootstrap class is-invalid to inputs class=<%= "is-invalid" if model.errors[:description].any? %> (my examples will be using a field called "description").

Since the div that takes user input is nested within the rhino-editor tag, I can't directly modify the styles of the Rhino Editor's rich text input by adding the is-invalid class. Instead, I use the selector rhino-editor.is-invalid .trix-content to apply the same styling from Bootstrap.

Is there a better way to do this?

Another issue is making the error messages available to screen readers. As an example from when I was still using the Trix editor with Action Text, I add aria-errormessage="description-errors" and aria-invalid="<%= model.errors[:description].any? %>" to the form input and place another div, <div id="description-errors">, below the input to conditionally render an error message if there is one. This way a screen reader can read the error message when the user focuses on the form input.

Once I switched to the Rhino editor, this no longer worked since, as above with the class issue, the aria attributes are only added to the rhino-editor element and not the div with the input.

I can probably write a Stimulus controller that can watch then add the aria attributes to the correct div, but I was hoping there was an easier way to do this.

The main reason why we wanted to switch from Trix to Rhino was so the toolbar was accessible via keyboard, but losing the ability to have a screen reader correctly associate an error message with the input kind of makes it pointless to switch.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.