From 7a4687d5ca593f11e0fcb5f08509ca35703f7cdc Mon Sep 17 00:00:00 2001 From: Renato Atilio Date: Sat, 26 Apr 2025 15:25:10 -0300 Subject: [PATCH 1/3] FEATURE: rich editor link ui for editing it --- .../discourse/app/components/d-editor.gjs | 115 +++++++++---- .../app/components/modal/insert-hyperlink.gjs | 2 +- .../components/composer-link-toolbar.gjs | 75 ++++++++ .../components/prosemirror-editor.gjs | 8 + .../app/static/prosemirror/extensions/link.js | 160 +++++++++++++++++- .../static/prosemirror/lib/plugin-utils.js | 72 ++++++++ app/assets/stylesheets/common/d-editor.scss | 18 ++ .../common/rich-editor/rich-editor.scss | 23 +++ 8 files changed, 432 insertions(+), 41 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/static/prosemirror/components/composer-link-toolbar.gjs diff --git a/app/assets/javascripts/discourse/app/components/d-editor.gjs b/app/assets/javascripts/discourse/app/components/d-editor.gjs index 4dbc6a80f674a..897b58ace9f56 100644 --- a/app/assets/javascripts/discourse/app/components/d-editor.gjs +++ b/app/assets/javascripts/discourse/app/components/d-editor.gjs @@ -8,6 +8,7 @@ import { schedule, scheduleOnce } from "@ember/runloop"; import { service } from "@ember/service"; import { classNames } from "@ember-decorators/component"; import { observes, on as onEvent } from "@ember-decorators/object"; +import curryComponent from "ember-curry-component"; import { emojiSearch, isSkinTonableEmoji } from "pretty-text/emoji"; import { translations } from "pretty-text/emoji/data"; import { Promise } from "rsvp"; @@ -68,6 +69,8 @@ export default class DEditor extends Component { @tracked editorComponent; /** @type {TextManipulation} */ @tracked textManipulation; + @tracked replacedToolbarComponent; + @tracked replacedToolbar; @tracked preview; @@ -616,6 +619,12 @@ export default class DEditor extends Component { }); } + @action + resetToolbar() { + this.replacedToolbar = false; + this.replacedToolbarComponent = null; + } + @action onChange(event) { this.set("value", event?.target?.value); @@ -652,7 +661,22 @@ export default class DEditor extends Component { "indentSelection" ); + const replaceToolbar = ({ component, data }) => { + this.replacedToolbar = true; + this.replacedToolbarComponent = curryComponent( + component, + { data }, + getOwner(this) + ); + }; + + this.appEvents.on("composer:replace-toolbar", replaceToolbar); + this.appEvents.on("composer:reset-toolbar", this, "resetToolbar"); + return () => { + this.appEvents.off("composer:replace-toolbar", replaceToolbar); + this.appEvents.off("composer:reset-toolbar", this, "resetToolbar"); + this.appEvents.off( "composer:insert-block", textManipulation, @@ -715,45 +739,62 @@ export default class DEditor extends Component { {{if this.disabled 'disabled'}} {{if this.isEditorFocused 'in-focus'}}" > - + {{/if}} { + const markdownLinkRegex = /\[(.*?)\]\((.*?)\)/; + const [, linkText, linkUrl] = text.match(markdownLinkRegex); + this.args.data.save({ text: linkText, href: linkUrl }); + }, + }, + }, + }); + } + + @action + copy() { + clipboardCopy(this.args.data.href); + // TODO Show "Link copied!" inline + this.toasts.success({ + duration: 1500, + data: { message: i18n("post.controls.link_copied") }, + }); + } + + get canUnlink() { + return !AUTO_LINKS.includes(this.args.data.markup); + } + + get canVisit() { + return !!getLinkify().matchAtStart(this.args.data.href); + } + + +} diff --git a/app/assets/javascripts/discourse/app/static/prosemirror/components/prosemirror-editor.gjs b/app/assets/javascripts/discourse/app/static/prosemirror/components/prosemirror-editor.gjs index 247c9b43ef0ba..3811f17887043 100644 --- a/app/assets/javascripts/discourse/app/static/prosemirror/components/prosemirror-editor.gjs +++ b/app/assets/javascripts/discourse/app/static/prosemirror/components/prosemirror-editor.gjs @@ -63,6 +63,10 @@ const AUTOCOMPLETE_KEY_DOWN_SUPPRESS = ["Enter", "Tab"]; export default class ProsemirrorEditor extends Component { @service session; @service dialog; + @service menu; + @service site; + @service appEvents; + @service capabilities; schema = createSchema(this.extensions, this.args.includeDefault); view; @@ -86,6 +90,10 @@ export default class ProsemirrorEditor extends Component { topicId: this.args.topicId, categoryId: this.args.categoryId, session: this.session, + menu: this.menu, + site: this.site, + appEvents: this.appEvents, + capabilities: this.capabilities, }), }; } diff --git a/app/assets/javascripts/discourse/app/static/prosemirror/extensions/link.js b/app/assets/javascripts/discourse/app/static/prosemirror/extensions/link.js index 60e08f48d4df3..7640ddd64cc56 100644 --- a/app/assets/javascripts/discourse/app/static/prosemirror/extensions/link.js +++ b/app/assets/javascripts/discourse/app/static/prosemirror/extensions/link.js @@ -3,6 +3,8 @@ import { getChangedRanges, markInputRule, } from "discourse/static/prosemirror/lib/plugin-utils"; +import { updatePosition } from "float-kit/lib/update-position"; +import ComposerLinkToolbar from "../components/composer-link-toolbar"; const REPLACE_STEPS = [ReplaceStep, ReplaceAroundStep]; @@ -127,8 +129,8 @@ const extension = { } ), ], - plugins: ({ pmState: { Plugin }, utils }) => - new Plugin({ + plugins: ({ pmState: { Plugin }, utils, getContext, schema }) => { + const plugin = new Plugin({ props: { // Auto-linkify plain-text pasted URLs over a selection clipboardTextParser(text, $context, plain, view) { @@ -303,7 +305,159 @@ const extension = { return tr; }, - }), + + state: { + init() { + return null; + }, + apply(tr) { + const range = utils.getMarkRange( + tr.selection.$head, + schema.marks.link + ); + + if (!range) { + return null; + } + + // if not empty, the selection should contain a link + if ( + !tr.selection.empty && + !tr.doc.rangeHasMark( + tr.selection.from, + tr.selection.to, + schema.marks.link + ) + ) { + return null; + } + + return { + ...range.mark.attrs, + text: tr.doc.textBetween(range.from, range.to), + head: tr.selection.head, + }; + }, + }, + view() { + let menuInstance; + let toolbarReplaced = false; + + return { + update(view) { + const attrs = plugin.getState(view.state); + + if (!attrs) { + if (menuInstance) { + menuInstance.destroy(); + menuInstance = null; + } + + if (toolbarReplaced) { + getContext().appEvents.trigger("composer:reset-toolbar"); + toolbarReplaced = false; + } + + return; + } + + const data = { + ...attrs, + unlink: () => { + let range = view.state.selection; + + if (range.empty) { + range = utils.getMarkRange( + view.state.doc.resolve(attrs.head), + view.state.schema.marks.link + ); + } + + if (range) { + view.dispatch(view.state.tr.removeMark(range.from, range.to)); + } + }, + save: ({ text, href }) => { + const { state, dispatch } = view; + + const mark = state.schema.marks.link.create({ href }); + + let range = view.state.selection; + + if (range.empty) { + range = utils.getMarkRange( + view.state.doc.resolve(attrs.head), + view.state.schema.marks.link + ); + } + + const tr = state.tr.replaceRangeWith( + range.from, + range.to, + state.schema.text(text, [mark]), + false + ); + + dispatch(tr); + view.focus(); + }, + }; + + if (!getContext().capabilities.viewport.sm) { + getContext().appEvents.trigger("composer:replace-toolbar", { + component: ComposerLinkToolbar, + data, + }); + toolbarReplaced = true; + } else { + const { left, top } = view.coordsAtPos(attrs.head); + const topMargin = 12; + const coords = { + left, + top: top + topMargin, + width: 0, + height: 0, + }; + + const trigger = { getBoundingClientRect: () => coords }; + + if ( + menuInstance && + menuInstance.expanded && + menuInstance.options.data.href === attrs.href + ) { + menuInstance.trigger = trigger; + + Object.assign(menuInstance.options.data, data); + + // setup transition animation before updating position + // not set via css to avoid affecting initial positioning + menuInstance.content.style.transition = + "left 0.1s linear, top 0.1s linear"; + + updatePosition(menuInstance.trigger, menuInstance.content, {}); + return; + } + + getContext() + .menu.show(trigger, { + identifier: "composer-link-toolbar", + component: ComposerLinkToolbar, + placement: "bottom", + placements: ["bottom-start", "bottom-end"], + data, + }) + .then((instance) => { + menuInstance = instance; + }); + } + }, + }; + }, + }); + + return plugin; + }, }; function addLinkMark(view, text, utils) { diff --git a/app/assets/javascripts/discourse/app/static/prosemirror/lib/plugin-utils.js b/app/assets/javascripts/discourse/app/static/prosemirror/lib/plugin-utils.js index 8b5750af3f7be..b38132618f0d6 100644 --- a/app/assets/javascripts/discourse/app/static/prosemirror/lib/plugin-utils.js +++ b/app/assets/javascripts/discourse/app/static/prosemirror/lib/plugin-utils.js @@ -117,3 +117,75 @@ export function changedDescendants(old, cur, f, offset = 0) { offset += child.nodeSize; } } + +/** + * Get the continuous range of a mark at a given position. + * + * @param $pos + * {import("prosemirror-model").ResolvedPos} - The position in the document. + * @param type + * {import("prosemirror-model").MarkType} - The type of mark to find. + * @param attrs + * {Object} - Optional attributes to match against the mark. + * @returns {{ from: number, to: number, mark: import("prosemirror-model").Mark } | undefined} + */ +export function getMarkRange($pos, type, attrs = {}) { + if (!$pos || !type) { + return; + } + + // Try node after, then before, return if neither has the mark + let start = $pos.parent.childAfter($pos.parentOffset); + if (!start.node || !findMarkOfType(start.node.marks, type, attrs)) { + start = $pos.parent.childBefore($pos.parentOffset); + if (!start.node || !findMarkOfType(start.node.marks, type, attrs)) { + return; + } + } + + const mark = findMarkOfType(start.node.marks, type, attrs); + + let from = $pos.start() + start.offset; + let to = from + start.node.nodeSize; + + // Expand backward + let { index } = start; + while ( + index > 0 && + findMarkOfType($pos.parent.child(index - 1).marks, type, attrs) + ) { + index--; + from -= $pos.parent.child(index).nodeSize; + } + + // Expand forward + index = start.index + 1; + while ( + index < $pos.parent.childCount && + findMarkOfType($pos.parent.child(index).marks, type, attrs) + ) { + to += $pos.parent.child(index).nodeSize; + index++; + } + + return { from, to, mark }; +} + +/** + * Find a mark of a specific type within marks, matching the attributes if provided. + * + * @param marks + * {import("prosemirror-model").Mark[]} - Array of marks to search through. + * @param type + * {import("prosemirror-model").MarkType} - The type of mark to find. + * @param attrs + * {Object} - Optional attributes to match against the mark. + * @returns {import("prosemirror-model").Mark | undefined} + */ +export function findMarkOfType(marks, type, attrs = {}) { + return marks.find( + (item) => + item.type === type && + Object.keys(attrs).every((key) => item.attrs[key] === attrs[key]) + ); +} diff --git a/app/assets/stylesheets/common/d-editor.scss b/app/assets/stylesheets/common/d-editor.scss index b349c880406c1..12f5e31d9e10f 100644 --- a/app/assets/stylesheets/common/d-editor.scss +++ b/app/assets/stylesheets/common/d-editor.scss @@ -323,6 +323,7 @@ grid-template-columns: repeat(auto-fill, minmax(2.35em, 1fr)); align-items: center; border-bottom: 1px solid var(--primary-low); + min-height: 2.42rem; width: 100%; box-sizing: border-box; flex-shrink: 0; @@ -362,6 +363,23 @@ } } +.d-editor-replaced-toolbar { + display: flex; + animation: float-left ease 0.2s 1 forwards; + + @keyframes float-left { + 0% { + opacity: 0; + transform: translateX(10px); + } + + 100% { + opacity: 1; + transform: translateX(0); + } + } +} + .d-editor #form-template-form { overflow: auto; background: var(--primary-very-low); diff --git a/app/assets/stylesheets/common/rich-editor/rich-editor.scss b/app/assets/stylesheets/common/rich-editor/rich-editor.scss index 6194a3d1ba077..a2bf6e091f1bc 100644 --- a/app/assets/stylesheets/common/rich-editor/rich-editor.scss +++ b/app/assets/stylesheets/common/rich-editor/rich-editor.scss @@ -191,6 +191,29 @@ } } +.composer-link-toolbar { + display: flex; + align-items: center; + + &__divider { + width: 1px; + height: 1rem; + background-color: var(--primary-low); + margin: 0 0.5rem; + } + + &__visit { + padding-left: 0.5rem; + padding-right: 0.5rem; + margin-right: 0.5rem; + } +} + +.fk-d-menu[data-identifier="composer-link-toolbar"] { + z-index: z("composer", "dropdown") + 1; + animation: fade-in ease 0.25s 1 forwards; +} + /********************************************************* Section below from prosemirror-view/style/prosemirror.css ********************************************************/ From 3a6af693f8257d724d99a364d8078cbad3676deb Mon Sep 17 00:00:00 2001 From: Renato Atilio Date: Mon, 12 May 2025 19:18:17 -0300 Subject: [PATCH 2/3] DEV: tests + tweaks --- .../components/composer-link-toolbar.gjs | 30 ++++- .../app/static/prosemirror/extensions/link.js | 3 +- .../integration/components/d-editor-test.gjs | 39 ++++++ config/locales/client.en.yml | 7 ++ .../composer/prosemirror_editor_spec.rb | 117 ++++++++++++++++++ 5 files changed, 190 insertions(+), 6 deletions(-) diff --git a/app/assets/javascripts/discourse/app/static/prosemirror/components/composer-link-toolbar.gjs b/app/assets/javascripts/discourse/app/static/prosemirror/components/composer-link-toolbar.gjs index 87d833fc3dc7b..f83739157d3ce 100644 --- a/app/assets/javascripts/discourse/app/static/prosemirror/components/composer-link-toolbar.gjs +++ b/app/assets/javascripts/discourse/app/static/prosemirror/components/composer-link-toolbar.gjs @@ -34,28 +34,47 @@ export default class ComposerLinkToolbar extends Component { @action copy() { clipboardCopy(this.args.data.href); - // TODO Show "Link copied!" inline + + // TODO(renato) Show "Link copied!" inline this.toasts.success({ duration: 1500, - data: { message: i18n("post.controls.link_copied") }, + data: { message: i18n("composer.link_toolbar.link_copied") }, }); } get canUnlink() { + // Unlinking autolinked links is cumbersome (relies on escaping), + // it would be confusing to users so we just avoid it. return !AUTO_LINKS.includes(this.args.data.markup); } get canVisit() { + // Follows the same logic from preview and doesn't show the button for invalid URLs return !!getLinkify().matchAtStart(this.args.data.href); }