Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions app/assets/stylesheets/common/base/modal.scss
Original file line number Diff line number Diff line change
Expand Up @@ -806,3 +806,22 @@ html:not(.keyboard-visible).mobile-device {
}
}
}

.wrap-attributes-modal {
.wrap-modal__attribute-row {
display: flex;
gap: var(--space-2);

.form-kit__field {
flex: 1;
}

.btn {
align-self: end;
}
}

&__unwrap {
margin-left: auto;
}
}
45 changes: 44 additions & 1 deletion app/assets/stylesheets/common/rich-editor/rich-editor.scss
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

> div:first-child,
> details:first-child {
margin-top: 0.5rem;
margin-top: var(--space-4);

// This is hacky, but helps having the leading gapcursor at the right position
&.ProseMirror-gapcursor {
Expand Down Expand Up @@ -221,6 +221,49 @@
}
}

.composer-wrap-node {
position: relative;
white-space: normal;
border: 2px dashed var(--primary-low-mid);
border-radius: var(--d-border-radius);
padding-inline: var(--space-2);
margin-block: var(--space-4);

&.ProseMirror-selectednode {
outline-offset: -2px;
border-radius: calc(var(--d-border-radius) + 2px);
}

.d-wrap-indicator {
color: var(--primary-medium);
background-color: var(--secondary);
border: none;
cursor: pointer;

&:hover {
color: var(--primary);
}

&.--inline {
font-size: var(--font-down-2);
padding: var(--space-half) 0;
margin-left: calc(-1 * var(--space-1));
margin-right: var(--space-2);
vertical-align: text-top;
display: inline-block;
}

&.--block {
position: absolute;
z-index: 1;
font-size: var(--font-down-1);
left: var(--space-2);
top: calc(-1 * var(--space-3) + 1px);
padding: 0 var(--space-1);
}
}
}

.onebox-loading {
border-radius: var(--d-border-radius);
padding: 0 0.125rem;
Expand Down
11 changes: 11 additions & 0 deletions config/locales/client.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3083,6 +3083,17 @@ en:
ulist_title: "Bulleted list"
list_item: "List item"
toggle_direction: "Toggle direction"
apply_wrap_title: "Apply wrap"
wrap_modal:
title: "Edit Wrap"
name_label: "Wrap name"
attributes_label: "Attributes"
add_attribute: "Add attribute"
remove_attribute: "Remove attribute"
apply: "Apply"
unwrap: "Unwrap"
no_attributes: "No attributes added yet."
wrap_text: "Wrap content"
help: "Markdown Editing Help"
collapse: "Minimize the composer panel"
open: "Open the composer panel"
Expand Down
3 changes: 3 additions & 0 deletions frontend/discourse/app/components/d-editor.gjs
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,8 @@ export default class DEditor extends Component {
* @property {function} addText - Adds text
* @property {function} applyList - Applies a list format
* @property {*} selected - The current selection
* @property {Object} commands - Available commands
* @property {Object} state - Current editor state (inWrap, inBold, etc.)
*/

/**
Expand All @@ -540,6 +542,7 @@ export default class DEditor extends Component {
});
return {
commands: this.textManipulation.commands,
state: this.textManipulation.state,
selected,
selectText: (from, length) =>
this.textManipulation.selectText(from, length, { scroll: false }),
Expand Down
47 changes: 47 additions & 0 deletions frontend/discourse/app/services/composer.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ import Composer, {
import Draft from "discourse/models/draft";
import PostLocalization from "discourse/models/post-localization";
import TopicLocalization from "discourse/models/topic-localization";
import WrapAttributesModal from "discourse/static/prosemirror/components/wrap-attributes-modal";
import { parseAttributesString } from "discourse/static/prosemirror/lib/wrap-utils";
import { i18n } from "discourse-i18n";

async function loadDraft(store, opts = {}) {
Expand Down Expand Up @@ -468,6 +470,17 @@ export default class ComposerService extends Service {
})
);

options.push(
this._setupPopupMenuOption({
name: "toggle-wrap",
action: this.toggleWrap,
icon: "right-to-bracket",
label: "composer.apply_wrap_title",
showActiveIcon: true,
active: ({ state }) => state?.inWrap,
})
);

return options.concat(
customPopupMenuOptions
.map((option) => this._setupPopupMenuOption({ ...option }))
Expand Down Expand Up @@ -832,6 +845,40 @@ export default class ComposerService extends Service {
});
}

@action
toggleWrap(toolbarEvent) {
const initialAttributes = toolbarEvent.state?.inWrap
? toolbarEvent.state.wrapAttributes || ""
: "";

this.modal.show(WrapAttributesModal, {
model: {
initialAttributes,
onApply: (attributesString) => {
if (toolbarEvent.state?.inWrap) {
toolbarEvent.commands?.updateWrap(attributesString);
} else if (toolbarEvent.commands?.insertWrap) {
toolbarEvent.commands.insertWrap(
parseAttributesString(attributesString)
);
} else {
const wrapTag = attributesString.trim()
? `[wrap ${attributesString}]`
: "[wrap]";
toolbarEvent.applySurround(
`${wrapTag}\n`,
"\n[/wrap]",
"wrap_text"
);
}
},
onRemove: toolbarEvent.state?.inWrap
? () => toolbarEvent.commands?.removeWrap()
: undefined,
},
});
}

@action
toggleToolbar() {
this.toggleProperty("showToolbar");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -363,15 +363,15 @@ export default class ProsemirrorEditor extends Component {
{{forceScrollingElementPosition}}
></div>
{{#each this.glimmerNodeViews key="dom" as |nodeView|}}
{{#in-element nodeView.dom insertBefore=null}}
{{~#in-element nodeView.dom insertBefore=null~}}
<nodeView.component
@node={{nodeView.node}}
@view={{nodeView.view}}
@getPos={{nodeView.getPos}}
@dom={{nodeView.dom}}
@onSetup={{nodeView.setComponentInstance}}
/>
{{/in-element}}
>{{nodeView.contentDOM}}</nodeView.component>
{{~/in-element~}}
{{/each}}
</template>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn, hash } from "@ember/helper";
import { action } from "@ember/object";
import DButton from "discourse/components/d-button";
import DModal from "discourse/components/d-modal";
import Form from "discourse/components/form";
import { i18n } from "discourse-i18n";
import { parseAttributesString, serializeFromForm } from "../lib/wrap-utils";

/**
* @typedef WrapAttributesModalArgs
* @property {(attributes: string) => void} onApply - Callback when wrap is applied
* @property {() => void} closeModal - Function to close the modal
* @property {string} [initialAttributes] - Initial attributes string
*/

/**
* @typedef WrapAttributesModalSignature
* @property {WrapAttributesModalArgs} Args
*/

/**
* Modal for defining wrap token attributes
*
* @extends {Component<WrapAttributesModalSignature>}
*/
export default class WrapAttributesModal extends Component {
@tracked formApi;

get initialData() {
const initialAttrs = this.args.model?.initialAttributes || "";
const parsedAttrs = parseAttributesString(initialAttrs);
return {
name: parsedAttrs.wrap || "",
attributes: Object.entries(parsedAttrs)
.filter(([key]) => key !== "wrap")
.map(([key, value]) => ({ key, value })),
};
}

@action
onSubmit(data) {
const attrsString = serializeFromForm(data.name, data.attributes);
this.args.model.onApply(attrsString);
this.args.closeModal();
}

@action
unwrap() {
this.args.model.onRemove?.();
this.args.closeModal();
}

get hasAttributeRows() {
return this.formApi?.get("attributes")?.length > 0;
}

@action
onRegisterApi(api) {
this.formApi = api;
}

@action
submitForm() {
this.formApi?.submit();
}

@action
cancel() {
this.args.closeModal();
}

<template>
<DModal
@title={{i18n "composer.wrap_modal.title"}}
@closeModal={{@closeModal}}
class="wrap-attributes-modal"
>
<:body>
<Form
@data={{this.initialData}}
@onSubmit={{this.onSubmit}}
@onRegisterApi={{this.onRegisterApi}}
as |form|
>
<form.Field
@name="name"
@title={{i18n "composer.wrap_modal.name_label"}}
as |field|
>
<field.Input @type="text" autocomplete="off" />
</form.Field>

<form.Section @title={{i18n "composer.wrap_modal.attributes_label"}}>
{{#unless this.hasAttributeRows}}
<div>
{{i18n "composer.wrap_modal.no_attributes"}}
</div>
{{/unless}}

<form.Collection @name="attributes" as |collection index|>
<collection.Object as |object|>
<div class="wrap-modal__attribute-row">
<object.Field
@name="key"
@title="Key"
@validation="required"
as |field|
>
<field.Input @type="text" />
</object.Field>

<object.Field
@name="value"
@title="Value"
@validation="required"
as |field|
>
<field.Input @type="text" />
</object.Field>

<DButton
@action={{fn collection.remove index}}
@icon="trash-can"
class="btn-default btn-small"
@title="composer.wrap_modal.remove_attribute"
/>
</div>
</collection.Object>
</form.Collection>

<form.Button
@action={{fn
form.addItemToCollection
"attributes"
(hash key="" value="")
}}
class="btn-default btn-small"
>
{{i18n "composer.wrap_modal.add_attribute"}}
</form.Button>
</form.Section>

</Form>
</:body>
<:footer>
<DButton
@action={{this.submitForm}}
@label="composer.wrap_modal.apply"
class="btn-primary"
/>
<DButton @action={{this.cancel}} @label="cancel" class="btn-default" />
{{#if @model.onRemove}}
<DButton
@action={{this.unwrap}}
@label="composer.wrap_modal.unwrap"
class="btn-danger wrap-attributes-modal__unwrap"
/>
{{/if}}
</:footer>
</DModal>
</template>
}
Loading
Loading