Skip to content

Commit c0e9e4b

Browse files
authored
FEATURE: inline and block wrap nodes rich editor support (#36591)
Adds support to inline and block wrap nodes. <img width="758" height="371" alt="image" src="https://github.com/user-attachments/assets/3907078f-0832-426b-86a7-da510a803723" /> Clicking the "title" or using the Apply wrap toolbar item will open an attributes editor modal. It also enhances the GlimmerNodeView so we can define where to {{yield}} the editable contentDOM – still experimental, this API is likely to change.
1 parent 72e55ca commit c0e9e4b

File tree

13 files changed

+911
-12
lines changed

13 files changed

+911
-12
lines changed

app/assets/stylesheets/common/base/modal.scss

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -806,3 +806,22 @@ html:not(.keyboard-visible).mobile-device {
806806
}
807807
}
808808
}
809+
810+
.wrap-attributes-modal {
811+
.wrap-modal__attribute-row {
812+
display: flex;
813+
gap: var(--space-2);
814+
815+
.form-kit__field {
816+
flex: 1;
817+
}
818+
819+
.btn {
820+
align-self: end;
821+
}
822+
}
823+
824+
&__unwrap {
825+
margin-left: auto;
826+
}
827+
}

app/assets/stylesheets/common/rich-editor/rich-editor.scss

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
> div:first-child,
1919
> details:first-child {
20-
margin-top: 0.5rem;
20+
margin-top: var(--space-4);
2121

2222
// This is hacky, but helps having the leading gapcursor at the right position
2323
&.ProseMirror-gapcursor {
@@ -221,6 +221,49 @@
221221
}
222222
}
223223

224+
.composer-wrap-node {
225+
position: relative;
226+
white-space: normal;
227+
border: 2px dashed var(--primary-low-mid);
228+
border-radius: var(--d-border-radius);
229+
padding-inline: var(--space-2);
230+
margin-block: var(--space-4);
231+
232+
&.ProseMirror-selectednode {
233+
outline-offset: -2px;
234+
border-radius: calc(var(--d-border-radius) + 2px);
235+
}
236+
237+
.d-wrap-indicator {
238+
color: var(--primary-medium);
239+
background-color: var(--secondary);
240+
border: none;
241+
cursor: pointer;
242+
243+
&:hover {
244+
color: var(--primary);
245+
}
246+
247+
&.--inline {
248+
font-size: var(--font-down-2);
249+
padding: var(--space-half) 0;
250+
margin-left: calc(-1 * var(--space-1));
251+
margin-right: var(--space-2);
252+
vertical-align: text-top;
253+
display: inline-block;
254+
}
255+
256+
&.--block {
257+
position: absolute;
258+
z-index: 1;
259+
font-size: var(--font-down-1);
260+
left: var(--space-2);
261+
top: calc(-1 * var(--space-3) + 1px);
262+
padding: 0 var(--space-1);
263+
}
264+
}
265+
}
266+
224267
.onebox-loading {
225268
border-radius: var(--d-border-radius);
226269
padding: 0 0.125rem;

config/locales/client.en.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3083,6 +3083,17 @@ en:
30833083
ulist_title: "Bulleted list"
30843084
list_item: "List item"
30853085
toggle_direction: "Toggle direction"
3086+
apply_wrap_title: "Apply wrap"
3087+
wrap_modal:
3088+
title: "Edit Wrap"
3089+
name_label: "Wrap name"
3090+
attributes_label: "Attributes"
3091+
add_attribute: "Add attribute"
3092+
remove_attribute: "Remove attribute"
3093+
apply: "Apply"
3094+
unwrap: "Unwrap"
3095+
no_attributes: "No attributes added yet."
3096+
wrap_text: "Wrap content"
30863097
help: "Markdown Editing Help"
30873098
collapse: "Minimize the composer panel"
30883099
open: "Open the composer panel"

frontend/discourse/app/components/d-editor.gjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,8 @@ export default class DEditor extends Component {
526526
* @property {function} addText - Adds text
527527
* @property {function} applyList - Applies a list format
528528
* @property {*} selected - The current selection
529+
* @property {Object} commands - Available commands
530+
* @property {Object} state - Current editor state (inWrap, inBold, etc.)
529531
*/
530532

531533
/**
@@ -540,6 +542,7 @@ export default class DEditor extends Component {
540542
});
541543
return {
542544
commands: this.textManipulation.commands,
545+
state: this.textManipulation.state,
543546
selected,
544547
selectText: (from, length) =>
545548
this.textManipulation.selectText(from, length, { scroll: false }),

frontend/discourse/app/services/composer.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ import Composer, {
4848
import Draft from "discourse/models/draft";
4949
import PostLocalization from "discourse/models/post-localization";
5050
import TopicLocalization from "discourse/models/topic-localization";
51+
import WrapAttributesModal from "discourse/static/prosemirror/components/wrap-attributes-modal";
52+
import { parseAttributesString } from "discourse/static/prosemirror/lib/wrap-utils";
5153
import { i18n } from "discourse-i18n";
5254

5355
async function loadDraft(store, opts = {}) {
@@ -468,6 +470,17 @@ export default class ComposerService extends Service {
468470
})
469471
);
470472

473+
options.push(
474+
this._setupPopupMenuOption({
475+
name: "toggle-wrap",
476+
action: this.toggleWrap,
477+
icon: "right-to-bracket",
478+
label: "composer.apply_wrap_title",
479+
showActiveIcon: true,
480+
active: ({ state }) => state?.inWrap,
481+
})
482+
);
483+
471484
return options.concat(
472485
customPopupMenuOptions
473486
.map((option) => this._setupPopupMenuOption({ ...option }))
@@ -832,6 +845,40 @@ export default class ComposerService extends Service {
832845
});
833846
}
834847

848+
@action
849+
toggleWrap(toolbarEvent) {
850+
const initialAttributes = toolbarEvent.state?.inWrap
851+
? toolbarEvent.state.wrapAttributes || ""
852+
: "";
853+
854+
this.modal.show(WrapAttributesModal, {
855+
model: {
856+
initialAttributes,
857+
onApply: (attributesString) => {
858+
if (toolbarEvent.state?.inWrap) {
859+
toolbarEvent.commands?.updateWrap(attributesString);
860+
} else if (toolbarEvent.commands?.insertWrap) {
861+
toolbarEvent.commands.insertWrap(
862+
parseAttributesString(attributesString)
863+
);
864+
} else {
865+
const wrapTag = attributesString.trim()
866+
? `[wrap ${attributesString}]`
867+
: "[wrap]";
868+
toolbarEvent.applySurround(
869+
`${wrapTag}\n`,
870+
"\n[/wrap]",
871+
"wrap_text"
872+
);
873+
}
874+
},
875+
onRemove: toolbarEvent.state?.inWrap
876+
? () => toolbarEvent.commands?.removeWrap()
877+
: undefined,
878+
},
879+
});
880+
}
881+
835882
@action
836883
toggleToolbar() {
837884
this.toggleProperty("showToolbar");

frontend/discourse/app/static/prosemirror/components/prosemirror-editor.gjs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -363,15 +363,15 @@ export default class ProsemirrorEditor extends Component {
363363
{{forceScrollingElementPosition}}
364364
></div>
365365
{{#each this.glimmerNodeViews key="dom" as |nodeView|}}
366-
{{#in-element nodeView.dom insertBefore=null}}
366+
{{~#in-element nodeView.dom insertBefore=null~}}
367367
<nodeView.component
368368
@node={{nodeView.node}}
369369
@view={{nodeView.view}}
370370
@getPos={{nodeView.getPos}}
371371
@dom={{nodeView.dom}}
372372
@onSetup={{nodeView.setComponentInstance}}
373-
/>
374-
{{/in-element}}
373+
>{{nodeView.contentDOM}}</nodeView.component>
374+
{{~/in-element~}}
375375
{{/each}}
376376
</template>
377377
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import Component from "@glimmer/component";
2+
import { tracked } from "@glimmer/tracking";
3+
import { fn, hash } from "@ember/helper";
4+
import { action } from "@ember/object";
5+
import DButton from "discourse/components/d-button";
6+
import DModal from "discourse/components/d-modal";
7+
import Form from "discourse/components/form";
8+
import { i18n } from "discourse-i18n";
9+
import { parseAttributesString, serializeFromForm } from "../lib/wrap-utils";
10+
11+
/**
12+
* @typedef WrapAttributesModalArgs
13+
* @property {(attributes: string) => void} onApply - Callback when wrap is applied
14+
* @property {() => void} closeModal - Function to close the modal
15+
* @property {string} [initialAttributes] - Initial attributes string
16+
*/
17+
18+
/**
19+
* @typedef WrapAttributesModalSignature
20+
* @property {WrapAttributesModalArgs} Args
21+
*/
22+
23+
/**
24+
* Modal for defining wrap token attributes
25+
*
26+
* @extends {Component<WrapAttributesModalSignature>}
27+
*/
28+
export default class WrapAttributesModal extends Component {
29+
@tracked formApi;
30+
31+
get initialData() {
32+
const initialAttrs = this.args.model?.initialAttributes || "";
33+
const parsedAttrs = parseAttributesString(initialAttrs);
34+
return {
35+
name: parsedAttrs.wrap || "",
36+
attributes: Object.entries(parsedAttrs)
37+
.filter(([key]) => key !== "wrap")
38+
.map(([key, value]) => ({ key, value })),
39+
};
40+
}
41+
42+
@action
43+
onSubmit(data) {
44+
const attrsString = serializeFromForm(data.name, data.attributes);
45+
this.args.model.onApply(attrsString);
46+
this.args.closeModal();
47+
}
48+
49+
@action
50+
unwrap() {
51+
this.args.model.onRemove?.();
52+
this.args.closeModal();
53+
}
54+
55+
get hasAttributeRows() {
56+
return this.formApi?.get("attributes")?.length > 0;
57+
}
58+
59+
@action
60+
onRegisterApi(api) {
61+
this.formApi = api;
62+
}
63+
64+
@action
65+
submitForm() {
66+
this.formApi?.submit();
67+
}
68+
69+
@action
70+
cancel() {
71+
this.args.closeModal();
72+
}
73+
74+
<template>
75+
<DModal
76+
@title={{i18n "composer.wrap_modal.title"}}
77+
@closeModal={{@closeModal}}
78+
class="wrap-attributes-modal"
79+
>
80+
<:body>
81+
<Form
82+
@data={{this.initialData}}
83+
@onSubmit={{this.onSubmit}}
84+
@onRegisterApi={{this.onRegisterApi}}
85+
as |form|
86+
>
87+
<form.Field
88+
@name="name"
89+
@title={{i18n "composer.wrap_modal.name_label"}}
90+
as |field|
91+
>
92+
<field.Input @type="text" autocomplete="off" />
93+
</form.Field>
94+
95+
<form.Section @title={{i18n "composer.wrap_modal.attributes_label"}}>
96+
{{#unless this.hasAttributeRows}}
97+
<div>
98+
{{i18n "composer.wrap_modal.no_attributes"}}
99+
</div>
100+
{{/unless}}
101+
102+
<form.Collection @name="attributes" as |collection index|>
103+
<collection.Object as |object|>
104+
<div class="wrap-modal__attribute-row">
105+
<object.Field
106+
@name="key"
107+
@title="Key"
108+
@validation="required"
109+
as |field|
110+
>
111+
<field.Input @type="text" />
112+
</object.Field>
113+
114+
<object.Field
115+
@name="value"
116+
@title="Value"
117+
@validation="required"
118+
as |field|
119+
>
120+
<field.Input @type="text" />
121+
</object.Field>
122+
123+
<DButton
124+
@action={{fn collection.remove index}}
125+
@icon="trash-can"
126+
class="btn-default btn-small"
127+
@title="composer.wrap_modal.remove_attribute"
128+
/>
129+
</div>
130+
</collection.Object>
131+
</form.Collection>
132+
133+
<form.Button
134+
@action={{fn
135+
form.addItemToCollection
136+
"attributes"
137+
(hash key="" value="")
138+
}}
139+
class="btn-default btn-small"
140+
>
141+
{{i18n "composer.wrap_modal.add_attribute"}}
142+
</form.Button>
143+
</form.Section>
144+
145+
</Form>
146+
</:body>
147+
<:footer>
148+
<DButton
149+
@action={{this.submitForm}}
150+
@label="composer.wrap_modal.apply"
151+
class="btn-primary"
152+
/>
153+
<DButton @action={{this.cancel}} @label="cancel" class="btn-default" />
154+
{{#if @model.onRemove}}
155+
<DButton
156+
@action={{this.unwrap}}
157+
@label="composer.wrap_modal.unwrap"
158+
class="btn-danger wrap-attributes-modal__unwrap"
159+
/>
160+
{{/if}}
161+
</:footer>
162+
</DModal>
163+
</template>
164+
}

0 commit comments

Comments
 (0)