mirror of
https://github.com/discourse/discourse.git
synced 2025-10-03 17:21:20 +08:00
FEATURE: Text heading/paragraph menu in composer toolbar (#33461)
This PR introduces a heading/paragraph dropdown menu for the composer toolbar, that works for both the new rich text editor, and the old markdown editor. Features include: * Dynamically changing the icon based on the heading level * Checking the current heading level in the dropdown list * Applying the same heading level to multiple selections --------- Co-authored-by: chapoi <101828855+chapoi@users.noreply.github.com> Co-authored-by: Joffrey JAFFEUX <j.jaffeux@gmail.com> Co-authored-by: Renato Atilio <renatoat@gmail.com>
This commit is contained in:
parent
b4a376bd9a
commit
afc5d13c63
20 changed files with 722 additions and 127 deletions
|
@ -1,5 +1,4 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { hash } from "@ember/helper";
|
||||
import { action } from "@ember/object";
|
||||
import { eq } from "truth-helpers";
|
||||
import DButton from "discourse/components/d-button";
|
||||
|
@ -44,13 +43,17 @@ export default class ComposerToolbarButtons extends Component {
|
|||
<div class="toolbar-separator"></div>
|
||||
{{else if button.popupMenu}}
|
||||
<ToolbarPopupMenuOptions
|
||||
@context={{@data.context}}
|
||||
@content={{(button.popupMenu.options)}}
|
||||
@onChange={{button.popupMenu.action}}
|
||||
@onOpen={{button.action}}
|
||||
@tabindex={{this.tabIndex button}}
|
||||
@onKeydown={{this.rovingButtonBar}}
|
||||
@options={{hash icon=button.icon focusAfterOnChange=false}}
|
||||
@class={{button.className}}
|
||||
@icon={{button.icon}}
|
||||
@class={{concatClass
|
||||
button.className
|
||||
(if (this.isButtonActive button) "--active")
|
||||
}}
|
||||
/>
|
||||
{{else}}
|
||||
<DButton
|
||||
|
|
|
@ -140,8 +140,10 @@ export default class DEditor extends Component {
|
|||
get keymap() {
|
||||
const keymap = {};
|
||||
|
||||
// These are defined in lib/composer/toolbar.js via addButton.
|
||||
// It includes shortcuts for top level toolbar buttons, as well
|
||||
// as the toolbar popup menu option shortcuts.
|
||||
const shortcuts = this.get("toolbar.shortcuts");
|
||||
|
||||
Object.keys(shortcuts).forEach((sc) => {
|
||||
const button = shortcuts[sc];
|
||||
keymap[sc] = () => {
|
||||
|
@ -159,6 +161,10 @@ export default class DEditor extends Component {
|
|||
};
|
||||
});
|
||||
|
||||
// This refers to the "special" composer toolbar popup menu which
|
||||
// is launched from the (+) button in the toolbar. This menu is customizable
|
||||
// via the plugin API, so it differs from regular toolbar button definitions
|
||||
// from toolbar.js
|
||||
this.popupMenuOptions?.forEach((popupButton) => {
|
||||
if (popupButton.shortcut && popupButton.condition) {
|
||||
const shortcut =
|
||||
|
@ -471,7 +477,9 @@ export default class DEditor extends Component {
|
|||
* @returns {ToolbarEvent} An object with toolbar event actions
|
||||
*/
|
||||
newToolbarEvent(trimLeading) {
|
||||
const selected = this.textManipulation.getSelected(trimLeading);
|
||||
const selected = this.textManipulation.getSelected(trimLeading, {
|
||||
lineVal: true,
|
||||
});
|
||||
return {
|
||||
selected,
|
||||
selectText: (from, length) =>
|
||||
|
@ -486,6 +494,8 @@ export default class DEditor extends Component {
|
|||
),
|
||||
applyList: (head, exampleKey, opts) =>
|
||||
this.textManipulation.applyList(selected, head, exampleKey, opts),
|
||||
applyHeading: (level, exampleKey) =>
|
||||
this.textManipulation.applyHeading(selected, level, exampleKey),
|
||||
formatCode: () => this.textManipulation.formatCode(),
|
||||
addText: (text) => this.textManipulation.addText(selected, text),
|
||||
getText: () => this.value,
|
||||
|
|
|
@ -6,6 +6,7 @@ import DButton from "discourse/components/d-button";
|
|||
import DropdownMenu from "discourse/components/dropdown-menu";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import icon from "discourse/helpers/d-icon";
|
||||
import { iconHTML } from "discourse/lib/icon-library";
|
||||
import { PLATFORM_KEY_MODIFIER } from "discourse/lib/keyboard-shortcuts";
|
||||
import { translateModKey } from "discourse/lib/utilities";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
@ -30,44 +31,103 @@ export default class ToolbarPopupmenuOptions extends Component {
|
|||
|
||||
#convertMenuOption(content) {
|
||||
if (content.condition) {
|
||||
let label;
|
||||
if (content.label) {
|
||||
label = i18n(content.label);
|
||||
if (content.shortcut) {
|
||||
label = htmlSafe(
|
||||
`${label} <kbd class="shortcut">${translateModKey(
|
||||
PLATFORM_KEY_MODIFIER + "+" + content.shortcut
|
||||
)}</kbd>`
|
||||
);
|
||||
}
|
||||
}
|
||||
const label = this.#calculateLabel(content);
|
||||
const title = this.#calculateTitle(content);
|
||||
|
||||
let title = content.title ? i18n(content.title) : label;
|
||||
return Object.defineProperties(
|
||||
{},
|
||||
Object.getOwnPropertyDescriptors({ ...content, label, title })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#calculateTitle(content) {
|
||||
if (content.label && !content.title && !content.translatedTitle) {
|
||||
return this.#calculateLabel(content, { textOnly: true });
|
||||
}
|
||||
|
||||
if (!content.translatedTitle && !content.title) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = content.translatedTitle
|
||||
? content.translatedTitle
|
||||
: i18n(content.title);
|
||||
|
||||
if (content.shortcut) {
|
||||
return `${title} (${translateModKey(
|
||||
PLATFORM_KEY_MODIFIER + "+" + content.shortcut
|
||||
)})`;
|
||||
}
|
||||
|
||||
return title;
|
||||
}
|
||||
|
||||
#calculateLabel(content, opts = {}) {
|
||||
if (!content.label && !content.translatedLabel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const label = content.translatedLabel
|
||||
? content.translatedLabel
|
||||
: i18n(content.label);
|
||||
|
||||
if (opts.textOnly) {
|
||||
if (content.shortcut) {
|
||||
title += ` (${translateModKey(
|
||||
return `${label} (${translateModKey(
|
||||
PLATFORM_KEY_MODIFIER + "+" + content.shortcut
|
||||
)})`;
|
||||
}
|
||||
|
||||
return {
|
||||
icon: content.icon,
|
||||
label,
|
||||
title,
|
||||
name: content.name,
|
||||
action: content.action,
|
||||
};
|
||||
return label;
|
||||
}
|
||||
|
||||
let htmlLabel = `<span class="d-button-label__text">${label}</span>`;
|
||||
if (content.shortcut) {
|
||||
htmlLabel += ` <kbd class="shortcut ${
|
||||
content.alwaysShowShortcut ? "--always-visible" : ""
|
||||
}">${translateModKey(
|
||||
PLATFORM_KEY_MODIFIER + "+" + content.shortcut
|
||||
)}</kbd>`;
|
||||
}
|
||||
|
||||
if (content.showActiveIcon) {
|
||||
htmlLabel += iconHTML("check", {
|
||||
class: "d-button-label__active-icon",
|
||||
});
|
||||
}
|
||||
|
||||
return htmlSafe(htmlLabel);
|
||||
}
|
||||
|
||||
get convertedContent() {
|
||||
return this.args.content.map(this.#convertMenuOption).filter(Boolean);
|
||||
return this.args.content
|
||||
.map(this.#convertMenuOption.bind(this))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
get textManipulationState() {
|
||||
return this.args.context?.textManipulation?.state;
|
||||
}
|
||||
|
||||
@action
|
||||
getActive(option) {
|
||||
return option.active?.({ state: this.textManipulationState });
|
||||
}
|
||||
|
||||
@action
|
||||
getIcon(config) {
|
||||
if (typeof config.icon === "function") {
|
||||
return config.icon?.({ state: this.textManipulationState });
|
||||
}
|
||||
|
||||
return config.icon;
|
||||
}
|
||||
|
||||
<template>
|
||||
<DMenu
|
||||
@identifier={{concat "toolbar-menu__" @class}}
|
||||
@groupIdentifier="toolbar-menu"
|
||||
@icon={{@icon}}
|
||||
@onRegisterApi={{this.onRegisterApi}}
|
||||
@onShow={{@onOpen}}
|
||||
@modalForMobile={{true}}
|
||||
|
@ -76,10 +136,11 @@ export default class ToolbarPopupmenuOptions extends Component {
|
|||
@offset={{5}}
|
||||
@onKeydown={{@onKeydown}}
|
||||
tabindex="-1"
|
||||
class={{concatClass @class}}
|
||||
@triggerClass={{concatClass "toolbar__button" @class}}
|
||||
@class="toolbar-popup-menu-options"
|
||||
>
|
||||
<:trigger>
|
||||
{{icon @options.icon}}
|
||||
{{icon (this.getIcon this.args)}}
|
||||
</:trigger>
|
||||
<:content>
|
||||
<DropdownMenu as |dropdown|>
|
||||
|
@ -88,9 +149,13 @@ export default class ToolbarPopupmenuOptions extends Component {
|
|||
<DButton
|
||||
@translatedLabel={{option.label}}
|
||||
@translatedTitle={{option.title}}
|
||||
@icon={{option.icon}}
|
||||
@icon={{this.getIcon option}}
|
||||
@action={{fn this.onSelect option}}
|
||||
data-name={{option.name}}
|
||||
class={{concatClass
|
||||
"no-text"
|
||||
(if (this.getActive option) "--active")
|
||||
}}
|
||||
/>
|
||||
</dropdown.item>
|
||||
{{/each}}
|
||||
|
|
|
@ -17,6 +17,8 @@ export const TextManipulation = {};
|
|||
* @property {boolean} [inCode]
|
||||
* @property {boolean} [inCodeBlock]
|
||||
* @property {boolean} [inBlockquote]
|
||||
* @property {boolean} [inHeading]
|
||||
* @property {number} [inHeadingLevel]
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
// @ts-check
|
||||
import { action } from "@ember/object";
|
||||
import { PLATFORM_KEY_MODIFIER } from "discourse/lib/keyboard-shortcuts";
|
||||
import { translateModKey } from "discourse/lib/utilities";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
@ -48,12 +49,13 @@ export class ToolbarBase {
|
|||
* @param {string=} buttonAttrs.tabindex
|
||||
* @param {string=} buttonAttrs.className
|
||||
* @param {string=} buttonAttrs.label
|
||||
* @param {string|Function} buttonAttrs.icon
|
||||
* @param {string=} buttonAttrs.icon
|
||||
* @param {string=} buttonAttrs.href
|
||||
* @param {Function=} buttonAttrs.action
|
||||
* @param {Function=} buttonAttrs.perform
|
||||
* @param {boolean=} buttonAttrs.trimLeading
|
||||
* @param {boolean=} buttonAttrs.popupMenu
|
||||
* @param {Object=} buttonAttrs.popupMenu
|
||||
* @param {boolean=} buttonAttrs.preventFocus
|
||||
* @param {Function=} buttonAttrs.condition
|
||||
* @param {Function=} buttonAttrs.sendAction
|
||||
|
@ -96,6 +98,7 @@ export class ToolbarBase {
|
|||
);
|
||||
};
|
||||
|
||||
// Main button shortcut bindings and title text.
|
||||
const title = i18n(buttonAttrs.title || `composer.${buttonAttrs.id}_title`);
|
||||
if (buttonAttrs.shortcut) {
|
||||
const shortcutTitle = `${translateModKey(
|
||||
|
@ -107,6 +110,9 @@ export class ToolbarBase {
|
|||
} else {
|
||||
createdButton.title = `${title} (${shortcutTitle})`;
|
||||
}
|
||||
|
||||
// These shortcuts are actually bound in the keymap inside
|
||||
// components/d-editor.gjs
|
||||
this.shortcuts[
|
||||
`${PLATFORM_KEY_MODIFIER}+${buttonAttrs.shortcut}`.toLowerCase()
|
||||
] = createdButton;
|
||||
|
@ -114,6 +120,19 @@ export class ToolbarBase {
|
|||
createdButton.title = title;
|
||||
}
|
||||
|
||||
// Popup menu option item shortcut bindings and title text.
|
||||
if (buttonAttrs.popupMenu) {
|
||||
buttonAttrs.popupMenu.options()?.forEach((option) => {
|
||||
if (option.shortcut) {
|
||||
// These shortcuts are actually bound in the keymap inside
|
||||
// components/d-editor.gjs
|
||||
this.shortcuts[
|
||||
`${PLATFORM_KEY_MODIFIER}+${option.shortcut}`.toLowerCase()
|
||||
] = option;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (buttonAttrs.unshift) {
|
||||
group.buttons.unshift(createdButton);
|
||||
} else {
|
||||
|
@ -174,6 +193,79 @@ export default class Toolbar extends ToolbarBase {
|
|||
active: ({ state }) => state.inItalic,
|
||||
});
|
||||
|
||||
const headingLabel = getButtonLabel("composer.heading_label", "H");
|
||||
const unformattedHeadingIcon = headingLabel ? null : "discourse-text";
|
||||
this.addButton({
|
||||
id: "heading",
|
||||
group: "fontStyles",
|
||||
active: ({ state }) => {
|
||||
if (!state || !state.inHeading) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state.inHeadingLevel > 4) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
icon: ({ state }) => {
|
||||
if (!state || !state.inHeading) {
|
||||
return unformattedHeadingIcon;
|
||||
}
|
||||
|
||||
if (state.inHeadingLevel > 4) {
|
||||
return unformattedHeadingIcon;
|
||||
}
|
||||
|
||||
return `discourse-h${state.inHeadingLevel}`;
|
||||
},
|
||||
label: headingLabel,
|
||||
popupMenu: {
|
||||
options: () => {
|
||||
const headingOptions = [];
|
||||
for (let headingLevel = 1; headingLevel <= 4; headingLevel++) {
|
||||
headingOptions.push({
|
||||
name: `heading-${headingLevel}`,
|
||||
icon: `discourse-h${headingLevel}`,
|
||||
translatedLabel: i18n("composer.heading_level_n", {
|
||||
levelNumber: headingLevel,
|
||||
}),
|
||||
translatedTitle: i18n("composer.heading_level_n_title", {
|
||||
levelNumber: headingLevel,
|
||||
}),
|
||||
condition: true,
|
||||
showActiveIcon: true,
|
||||
active: ({ state }) => {
|
||||
if (!state || !state.inHeading) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state.inHeadingLevel === headingLevel) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
action: this.onHeadingMenuAction.bind(this),
|
||||
});
|
||||
}
|
||||
headingOptions.push({
|
||||
name: "heading-paragraph",
|
||||
icon: "discourse-text",
|
||||
label: "composer.heading_level_paragraph",
|
||||
title: "composer.heading_level_paragraph_title",
|
||||
condition: true,
|
||||
showActiveIcon: true,
|
||||
active: ({ state }) => state?.inParagraph,
|
||||
action: this.onHeadingMenuAction.bind(this),
|
||||
});
|
||||
return headingOptions;
|
||||
},
|
||||
action: this.onHeadingMenuAction.bind(this),
|
||||
},
|
||||
});
|
||||
|
||||
if (opts.showLink) {
|
||||
this.addButton({
|
||||
id: "link",
|
||||
|
@ -246,4 +338,17 @@ export default class Toolbar extends ToolbarBase {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
onHeadingMenuAction(menuItem) {
|
||||
let level;
|
||||
|
||||
if (menuItem.name === "heading-paragraph") {
|
||||
level = 0;
|
||||
} else {
|
||||
level = parseInt(menuItem.name.split("-")[1], 10);
|
||||
}
|
||||
|
||||
this.context.newToolbarEvent().applyHeading(level, "heading");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -776,9 +776,38 @@ export default class TextareaTextManipulation {
|
|||
sel.value = i18n(`composer.${exampleKey}`);
|
||||
}
|
||||
|
||||
const number = sel.value.startsWith(hval)
|
||||
? sel.value.slice(hlen)
|
||||
: `${hval}${sel.value}`;
|
||||
// Special handling for markdown headings starting with #,
|
||||
// they are "list-like" in that they have a character at
|
||||
// the start and a level, rather than having a surrounding format.
|
||||
let number;
|
||||
if (hval.includes("#")) {
|
||||
const currentHeadingLevel = sel.value.search(/[^#]/);
|
||||
|
||||
// Remove existing heading level if same as the new one,
|
||||
// mirrors list behavior.
|
||||
if (sel.value.startsWith(hval) && currentHeadingLevel + 1 === hlen) {
|
||||
number = sel.value.slice(hlen);
|
||||
} else {
|
||||
// Replace the existing heading level with the new one, or
|
||||
// if there is no heading level, add the new one.
|
||||
if (currentHeadingLevel > 0) {
|
||||
number =
|
||||
hval +
|
||||
sel.value.slice("#".repeat(currentHeadingLevel).length + 1);
|
||||
} else {
|
||||
number = hval + sel.value;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Remove existing list item if it's the same as the new
|
||||
// head, e.g. if a line is "* list item", then it converts
|
||||
// it to "list item"
|
||||
if (sel.value.startsWith(hval)) {
|
||||
number = sel.value.slice(hlen);
|
||||
} else {
|
||||
number = `${hval}${sel.value}`;
|
||||
}
|
||||
}
|
||||
|
||||
const preNewlines = sel.pre.trim() && "\n\n";
|
||||
const postNewlines = sel.post.trim() && "\n\n";
|
||||
|
@ -789,10 +818,39 @@ export default class TextareaTextManipulation {
|
|||
const postChars = sel.post.length - sel.post.trimStart().length;
|
||||
|
||||
this._insertAt(sel.start - preChars, sel.end + postChars, textToInsert);
|
||||
this.selectText(
|
||||
sel.start + (preNewlines.length - preChars),
|
||||
number.length
|
||||
);
|
||||
|
||||
if (opts?.excludeHeadInSelection) {
|
||||
this.selectText(
|
||||
sel.start + (preNewlines.length - preChars) + hval.length,
|
||||
number.length - hval.length
|
||||
);
|
||||
} else {
|
||||
this.selectText(
|
||||
sel.start + (preNewlines.length - preChars),
|
||||
number.length
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bind
|
||||
applyHeading(sel, level) {
|
||||
if (level > 0) {
|
||||
this.applyList(sel, "#".repeat(level) + " ", "heading_text", {
|
||||
excludeHeadInSelection: true,
|
||||
});
|
||||
} else {
|
||||
// Remove heading when the Paragrah level (0) is selected.
|
||||
const currentHeadingLevel = sel.lineVal.search(/[^#]/);
|
||||
if (currentHeadingLevel >= 0) {
|
||||
// When you apply the list with the same head chars, then they
|
||||
// are removed, so we can use the same function.
|
||||
this.applyList(
|
||||
sel,
|
||||
"#".repeat(currentHeadingLevel) + " ",
|
||||
"heading_text"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -69,13 +69,8 @@ export function buildKeymap(
|
|||
return true;
|
||||
});
|
||||
|
||||
keys["Mod-Shift-0"] = setBlockType(schema.nodes.paragraph);
|
||||
keys["Enter"] = splitListItem(schema.nodes.list_item);
|
||||
|
||||
for (let level = 1; level <= 6; level++) {
|
||||
keys["Mod-Shift-" + level] = setBlockType(schema.nodes.heading, { level });
|
||||
}
|
||||
|
||||
keys["Mod-Shift-_"] = (state, dispatch) => {
|
||||
dispatch?.(
|
||||
state.tr
|
||||
|
|
|
@ -190,6 +190,8 @@ export function findMarkOfType(marks, type, attrs = {}) {
|
|||
);
|
||||
}
|
||||
|
||||
// Check if a mark of a specific type is present in the current selection,
|
||||
// with optional scoping by specific attributes.
|
||||
export function hasMark(state, markType, attrs = {}) {
|
||||
const { from, to, empty } = state.selection;
|
||||
|
||||
|
@ -207,11 +209,13 @@ export function hasMark(state, markType, attrs = {}) {
|
|||
);
|
||||
}
|
||||
|
||||
// Check if a node of a specific type is present in the current selection,
|
||||
// with optional scoping by specific attributes.
|
||||
export function inNode(state, nodeType, attrs = {}) {
|
||||
const { $from } = state.selection;
|
||||
|
||||
for (let d = $from.depth; d >= 0; d--) {
|
||||
const node = $from.node(d);
|
||||
for (let depth = $from.depth; depth >= 0; depth--) {
|
||||
const node = $from.node(depth);
|
||||
if (node.type === nodeType) {
|
||||
if (!Object.keys(attrs).length) {
|
||||
return true;
|
||||
|
@ -223,3 +227,59 @@ export function inNode(state, nodeType, attrs = {}) {
|
|||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if a node of a specific type is active in the current selection,
|
||||
// (with optional scoping by specific attributes), and that no other nodes
|
||||
// of any other type are present in the selection.
|
||||
export function isNodeActive(state, nodeType, attrs = {}) {
|
||||
const { from, to, empty } = state.selection;
|
||||
const nodeRanges = [];
|
||||
|
||||
// Get all the nodes in the selection range and their positions.
|
||||
state.doc.nodesBetween(from, to, (node, pos) => {
|
||||
if (node.isText) {
|
||||
return;
|
||||
}
|
||||
|
||||
const relativeFrom = Math.max(from, pos);
|
||||
const relativeTo = Math.min(to, pos + node.nodeSize);
|
||||
|
||||
nodeRanges.push({
|
||||
node,
|
||||
from: relativeFrom,
|
||||
to: relativeTo,
|
||||
});
|
||||
});
|
||||
|
||||
const selectionRange = to - from;
|
||||
|
||||
// Find nodes that match the provided type and attributes.
|
||||
const matchedNodeRanges = nodeRanges
|
||||
.filter((nodeRange) => {
|
||||
return nodeType.name === nodeRange.node.type.name;
|
||||
})
|
||||
.filter((nodeRange) => {
|
||||
if (!Object.keys(attrs).length) {
|
||||
return true;
|
||||
} else {
|
||||
return Object.keys(attrs).every(
|
||||
(key) => nodeRange.node.attrs[key] === attrs[key]
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (empty) {
|
||||
return !!matchedNodeRanges.length;
|
||||
}
|
||||
|
||||
// Determines if there are other nodes not matching nodeType in the selection
|
||||
// by summing selection ranges to find "gaps" in the selection.
|
||||
const range = matchedNodeRanges.reduce(
|
||||
(sum, nodeRange) => sum + nodeRange.to - nodeRange.from,
|
||||
0
|
||||
);
|
||||
|
||||
// If there are no "gaps" in the selection, it means the nodeType is active
|
||||
// with no other node types selected.
|
||||
return range >= selectionRange;
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ import { TextSelection } from "prosemirror-state";
|
|||
import { bind } from "discourse/lib/decorators";
|
||||
import escapeRegExp from "discourse/lib/escape-regexp";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import { hasMark, inNode } from "./plugin-utils";
|
||||
import { hasMark, inNode, isNodeActive } from "./plugin-utils";
|
||||
|
||||
/**
|
||||
* @typedef {import("discourse/lib/composer/text-manipulation").TextManipulation} TextManipulation
|
||||
|
@ -190,6 +190,17 @@ export default class ProsemirrorTextManipulation {
|
|||
command?.(this.view.state, this.view.dispatch);
|
||||
}
|
||||
|
||||
applyHeading(_selection, level) {
|
||||
let command;
|
||||
if (level === 0) {
|
||||
command = setBlockType(this.schema.nodes.paragraph);
|
||||
} else {
|
||||
command = setBlockType(this.schema.nodes.heading, { level });
|
||||
}
|
||||
command?.(this.view.state, this.view.dispatch);
|
||||
this.focus();
|
||||
}
|
||||
|
||||
formatCode() {
|
||||
let command;
|
||||
|
||||
|
@ -345,6 +356,12 @@ export default class ProsemirrorTextManipulation {
|
|||
* Updates the toolbar state object based on the current editor active states
|
||||
*/
|
||||
updateState() {
|
||||
const activeHeadingLevel = [1, 2, 3, 4, 5, 6].find((headingLevel) =>
|
||||
isNodeActive(this.view.state, this.schema.nodes.heading, {
|
||||
level: headingLevel,
|
||||
})
|
||||
);
|
||||
|
||||
Object.assign(this.state, {
|
||||
inBold: hasMark(this.view.state, this.schema.marks.strong),
|
||||
inItalic: hasMark(this.view.state, this.schema.marks.em),
|
||||
|
@ -354,6 +371,9 @@ export default class ProsemirrorTextManipulation {
|
|||
inOrderedList: inNode(this.view.state, this.schema.nodes.ordered_list),
|
||||
inCodeBlock: inNode(this.view.state, this.schema.nodes.code_block),
|
||||
inBlockquote: inNode(this.view.state, this.schema.nodes.blockquote),
|
||||
inHeading: !!activeHeadingLevel,
|
||||
inHeadingLevel: activeHeadingLevel,
|
||||
inParagraph: inNode(this.view.state, this.schema.nodes.paragraph),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -584,7 +584,7 @@ third line`
|
|||
});
|
||||
|
||||
testCase(
|
||||
`bullet button with no selection`,
|
||||
"unordered list button with no selection",
|
||||
async function (assert, textarea) {
|
||||
const example = i18n("composer.list_item");
|
||||
|
||||
|
@ -598,23 +598,26 @@ third line`
|
|||
}
|
||||
);
|
||||
|
||||
testCase(`bullet button with a selection`, async function (assert, textarea) {
|
||||
textarea.selectionStart = 6;
|
||||
textarea.selectionEnd = 11;
|
||||
testCase(
|
||||
"unordered list button with a selection",
|
||||
async function (assert, textarea) {
|
||||
textarea.selectionStart = 6;
|
||||
textarea.selectionEnd = 11;
|
||||
|
||||
await click(`button.bullet`);
|
||||
assert.strictEqual(this.value, `hello\n\n* world\n\n.`);
|
||||
assert.strictEqual(textarea.selectionStart, 7);
|
||||
assert.strictEqual(textarea.selectionEnd, 14);
|
||||
await click(`button.bullet`);
|
||||
assert.strictEqual(this.value, `hello\n\n* world\n\n.`);
|
||||
assert.strictEqual(textarea.selectionStart, 7);
|
||||
assert.strictEqual(textarea.selectionEnd, 14);
|
||||
|
||||
await click(`button.bullet`);
|
||||
assert.strictEqual(this.value, `hello\n\nworld\n\n.`);
|
||||
assert.strictEqual(textarea.selectionStart, 7);
|
||||
assert.strictEqual(textarea.selectionEnd, 12);
|
||||
});
|
||||
await click(`button.bullet`);
|
||||
assert.strictEqual(this.value, `hello\n\nworld\n\n.`);
|
||||
assert.strictEqual(textarea.selectionStart, 7);
|
||||
assert.strictEqual(textarea.selectionEnd, 12);
|
||||
}
|
||||
);
|
||||
|
||||
testCase(
|
||||
`bullet button with a multiple line selection`,
|
||||
"unordered list button with a multiple line selection",
|
||||
async function (assert, textarea) {
|
||||
this.set("value", "* Hello\n\nWorld\n\nEvil");
|
||||
|
||||
|
@ -633,54 +636,63 @@ third line`
|
|||
}
|
||||
);
|
||||
|
||||
testCase(`list button with no selection`, async function (assert, textarea) {
|
||||
const example = i18n("composer.list_item");
|
||||
testCase(
|
||||
"ordered list button with no selection",
|
||||
async function (assert, textarea) {
|
||||
const example = i18n("composer.list_item");
|
||||
|
||||
await click(`button.list`);
|
||||
assert.strictEqual(this.value, `hello world.\n\n1. ${example}`);
|
||||
assert.strictEqual(textarea.selectionStart, 14);
|
||||
assert.strictEqual(textarea.selectionEnd, 17 + example.length);
|
||||
await click(`button.list`);
|
||||
assert.strictEqual(this.value, `hello world.\n\n1. ${example}`);
|
||||
assert.strictEqual(textarea.selectionStart, 14);
|
||||
assert.strictEqual(textarea.selectionEnd, 17 + example.length);
|
||||
|
||||
await click(`button.list`);
|
||||
assert.strictEqual(this.value, `hello world.\n\n${example}`);
|
||||
assert.strictEqual(textarea.selectionStart, 14);
|
||||
assert.strictEqual(textarea.selectionEnd, 14 + example.length);
|
||||
});
|
||||
|
||||
testCase(`list button with a selection`, async function (assert, textarea) {
|
||||
textarea.selectionStart = 6;
|
||||
textarea.selectionEnd = 11;
|
||||
|
||||
await click(`button.list`);
|
||||
assert.strictEqual(this.value, `hello\n\n1. world\n\n.`);
|
||||
assert.strictEqual(textarea.selectionStart, 7);
|
||||
assert.strictEqual(textarea.selectionEnd, 15);
|
||||
|
||||
await click(`button.list`);
|
||||
assert.strictEqual(this.value, `hello\n\nworld\n\n.`);
|
||||
assert.strictEqual(textarea.selectionStart, 7);
|
||||
assert.strictEqual(textarea.selectionEnd, 12);
|
||||
});
|
||||
|
||||
testCase(`list button with line sequence`, async function (assert, textarea) {
|
||||
this.set("value", "Hello\n\nWorld\n\nEvil");
|
||||
|
||||
textarea.selectionStart = 0;
|
||||
textarea.selectionEnd = 18;
|
||||
|
||||
await click(`button.list`);
|
||||
assert.strictEqual(this.value, "1. Hello\n\n2. World\n\n3. Evil");
|
||||
assert.strictEqual(textarea.selectionStart, 0);
|
||||
assert.strictEqual(textarea.selectionEnd, 27);
|
||||
|
||||
await click(`button.list`);
|
||||
assert.strictEqual(this.value, "Hello\n\nWorld\n\nEvil");
|
||||
assert.strictEqual(textarea.selectionStart, 0);
|
||||
assert.strictEqual(textarea.selectionEnd, 18);
|
||||
});
|
||||
await click(`button.list`);
|
||||
assert.strictEqual(this.value, `hello world.\n\n${example}`);
|
||||
assert.strictEqual(textarea.selectionStart, 14);
|
||||
assert.strictEqual(textarea.selectionEnd, 14 + example.length);
|
||||
}
|
||||
);
|
||||
|
||||
testCase(
|
||||
"list button does not reset undo history",
|
||||
"ordered list button with a selection",
|
||||
async function (assert, textarea) {
|
||||
textarea.selectionStart = 6;
|
||||
textarea.selectionEnd = 11;
|
||||
|
||||
await click(`button.list`);
|
||||
assert.strictEqual(this.value, `hello\n\n1. world\n\n.`);
|
||||
assert.strictEqual(textarea.selectionStart, 7);
|
||||
assert.strictEqual(textarea.selectionEnd, 15);
|
||||
|
||||
await click(`button.list`);
|
||||
assert.strictEqual(this.value, `hello\n\nworld\n\n.`);
|
||||
assert.strictEqual(textarea.selectionStart, 7);
|
||||
assert.strictEqual(textarea.selectionEnd, 12);
|
||||
}
|
||||
);
|
||||
|
||||
testCase(
|
||||
"ordered list button with line sequence",
|
||||
async function (assert, textarea) {
|
||||
this.set("value", "Hello\n\nWorld\n\nEvil");
|
||||
|
||||
textarea.selectionStart = 0;
|
||||
textarea.selectionEnd = 18;
|
||||
|
||||
await click(`button.list`);
|
||||
assert.strictEqual(this.value, "1. Hello\n\n2. World\n\n3. Evil");
|
||||
assert.strictEqual(textarea.selectionStart, 0);
|
||||
assert.strictEqual(textarea.selectionEnd, 27);
|
||||
|
||||
await click(`button.list`);
|
||||
assert.strictEqual(this.value, "Hello\n\nWorld\n\nEvil");
|
||||
assert.strictEqual(textarea.selectionStart, 0);
|
||||
assert.strictEqual(textarea.selectionEnd, 18);
|
||||
}
|
||||
);
|
||||
|
||||
testCase(
|
||||
"ordered list button does not reset undo history",
|
||||
async function (assert, textarea) {
|
||||
this.set("value", "existing");
|
||||
textarea.selectionStart = 0;
|
||||
|
@ -695,6 +707,86 @@ third line`
|
|||
}
|
||||
);
|
||||
|
||||
testCase(
|
||||
"heading button with no selection",
|
||||
async function (assert, textarea) {
|
||||
this.set("value", "");
|
||||
textarea.selectionStart = 0;
|
||||
textarea.selectionEnd = 0;
|
||||
|
||||
await click("button.heading");
|
||||
await click('.btn[data-name="heading-2"]');
|
||||
|
||||
assert.strictEqual(
|
||||
this.value,
|
||||
"## Heading",
|
||||
"it adds a placeholder and selects it"
|
||||
);
|
||||
assert.strictEqual(textarea.selectionStart, 3);
|
||||
assert.strictEqual(textarea.selectionEnd, 10);
|
||||
}
|
||||
);
|
||||
|
||||
testCase(
|
||||
"heading button with a selection",
|
||||
async function (assert, textarea) {
|
||||
this.set("value", "Hello world");
|
||||
textarea.selectionStart = 0;
|
||||
textarea.selectionEnd = 11;
|
||||
|
||||
await click("button.heading");
|
||||
await click('.btn[data-name="heading-2"]');
|
||||
|
||||
assert.strictEqual(
|
||||
this.value,
|
||||
"## Hello world",
|
||||
"it applies heading 2 and selects the text"
|
||||
);
|
||||
assert.strictEqual(textarea.selectionStart, 3);
|
||||
assert.strictEqual(textarea.selectionEnd, 14);
|
||||
}
|
||||
);
|
||||
|
||||
testCase(
|
||||
"heading button with a selection and existing heading",
|
||||
async function (assert, textarea) {
|
||||
this.set("value", "## Hello world");
|
||||
textarea.selectionStart = 0;
|
||||
textarea.selectionEnd = 14;
|
||||
|
||||
await click("button.heading");
|
||||
await click('.btn[data-name="heading-4"]');
|
||||
|
||||
assert.strictEqual(
|
||||
this.value,
|
||||
"#### Hello world",
|
||||
"it applies heading 4 and selects the text"
|
||||
);
|
||||
assert.strictEqual(textarea.selectionStart, 5);
|
||||
assert.strictEqual(textarea.selectionEnd, 16);
|
||||
}
|
||||
);
|
||||
|
||||
testCase(
|
||||
"heading button with a selection and existing heading converting to paragraph",
|
||||
async function (assert, textarea) {
|
||||
this.set("value", "## Hello world");
|
||||
textarea.selectionStart = 0;
|
||||
textarea.selectionEnd = 14;
|
||||
|
||||
await click("button.heading");
|
||||
await click('.btn[data-name="heading-paragraph"]');
|
||||
|
||||
assert.strictEqual(
|
||||
this.value,
|
||||
"Hello world",
|
||||
"it applies paragraph and selects the text"
|
||||
);
|
||||
assert.strictEqual(textarea.selectionStart, 0);
|
||||
assert.strictEqual(textarea.selectionEnd, 11);
|
||||
}
|
||||
);
|
||||
|
||||
test("clicking the toggle-direction changes dir from ltr to rtl and back", async function (assert) {
|
||||
const self = this;
|
||||
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
import Component from "@ember/component";
|
||||
|
||||
export default class ToolbarPopupMenuOptionsHeading extends Component {
|
||||
<template>{{this.heading}}</template>
|
||||
}
|
|
@ -318,7 +318,6 @@
|
|||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
margin: 0.25rem 0;
|
||||
|
||||
.composer-toggle-switch {
|
||||
|
@ -345,10 +344,12 @@
|
|||
|
||||
.discourse-no-touch & {
|
||||
&:hover {
|
||||
background-color: var(--primary-low);
|
||||
background-color: var(--d-hover);
|
||||
|
||||
.d-icon {
|
||||
color: var(--primary-medium);
|
||||
color: var(
|
||||
--primary-high
|
||||
); // to remove when btn hover variable is scoped better
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -397,6 +398,10 @@
|
|||
color: var(--primary-high);
|
||||
}
|
||||
}
|
||||
|
||||
&.-expanded {
|
||||
background: var(--d-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.d-editor #form-template-form {
|
||||
|
|
|
@ -135,15 +135,4 @@
|
|||
&__divider {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.shortcut {
|
||||
border: none;
|
||||
color: var(--primary-low-mid);
|
||||
background: transparent;
|
||||
margin-left: 1em;
|
||||
margin-right: 0;
|
||||
padding: 0;
|
||||
font-family: var(--font-family);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
@use "lib/viewport";
|
||||
|
||||
.toolbar-menu__options-content {
|
||||
.toolbar-menu__options-content,
|
||||
.toolbar-menu__heading-content {
|
||||
@include viewport.from(sm) {
|
||||
z-index: z("composer", "content");
|
||||
|
||||
|
@ -20,9 +21,105 @@
|
|||
z-index: z("header") + 1;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
.btn {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
.shortcut {
|
||||
@include viewport.until(sm) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-menu__heading-content {
|
||||
.dropdown-menu {
|
||||
.btn {
|
||||
&.--active {
|
||||
.d-button-label__active-icon {
|
||||
visibility: visible;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
.shortcut {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.d-button-label__active-icon {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.d-button-label {
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
|
||||
&__active-icon {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&__text {
|
||||
padding-right: var(--space-8); // extra spacing for the shortcut
|
||||
}
|
||||
|
||||
.shortcut {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: none;
|
||||
margin: 0;
|
||||
font-size: var(--font-down-1-rem);
|
||||
}
|
||||
}
|
||||
|
||||
.btn[data-name="heading-1"] {
|
||||
.d-button-label__text,
|
||||
.d-icon[class*="d-icon-discourse"] {
|
||||
font-size: var(--font-up-2-rem);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.btn[data-name="heading-2"] {
|
||||
.d-button-label__text,
|
||||
.d-icon {
|
||||
font-size: var(--font-up-1-rem);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.btn[data-name="heading-3"] {
|
||||
.d-button-label__text,
|
||||
.d-icon {
|
||||
font-size: var(--font-0);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.btn[data-name="heading-4"] {
|
||||
.d-button-label__text,
|
||||
.d-icon {
|
||||
font-weight: bold;
|
||||
font-size: var(--font-down-1-rem);
|
||||
}
|
||||
}
|
||||
|
||||
.btn[data-name="heading-paragraph"] {
|
||||
.d-button-label__text,
|
||||
.d-icon {
|
||||
font-size: var(--font-down-1-rem);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2865,6 +2865,12 @@ en:
|
|||
italic_label: "I"
|
||||
italic_title: "Emphasis"
|
||||
italic_text: "emphasized text"
|
||||
heading_label: "H"
|
||||
heading_title: "Headings"
|
||||
heading_level_n: "Heading %{levelNumber}"
|
||||
heading_level_n_title: "Heading %{levelNumber}"
|
||||
heading_level_paragraph: "Paragraph"
|
||||
heading_level_paragraph_title: "Paragraph"
|
||||
link_title: "Link"
|
||||
link_description: "enter link description here"
|
||||
link_dialog_title: "Insert link"
|
||||
|
@ -2877,6 +2883,7 @@ en:
|
|||
link_url_placeholder: "Paste link or search topics"
|
||||
blockquote_title: "Blockquote"
|
||||
blockquote_text: "Blockquote"
|
||||
heading_text: "Heading"
|
||||
code_title: "Preformatted text"
|
||||
code_text: "indent preformatted text by 4 spaces"
|
||||
paste_code_text: "type or paste code here"
|
||||
|
|
|
@ -97,6 +97,11 @@ module SvgSprite
|
|||
discourse-threads
|
||||
discourse-add-translation
|
||||
download
|
||||
discourse-h1
|
||||
discourse-h2
|
||||
discourse-h3
|
||||
discourse-h4
|
||||
discourse-h5
|
||||
earth-americas
|
||||
ellipsis
|
||||
ellipsis-vertical
|
||||
|
|
|
@ -171,6 +171,7 @@ function initializeDiscourseLocalDates(api) {
|
|||
model: { insertDate: (markup) => event.addText(markup) },
|
||||
}),
|
||||
shortcut: "Shift+.",
|
||||
alwaysShowShortcut: true,
|
||||
shortcutAction: (event) => {
|
||||
const timezone = api.getCurrentUser().user_option.timezone;
|
||||
const time = moment().format("HH:mm:ss");
|
||||
|
|
|
@ -443,9 +443,10 @@ describe "Composer - ProseMirror editor", type: :system do
|
|||
expect(rich).to have_css("blockquote", text: "This is a blockquote")
|
||||
end
|
||||
|
||||
it "supports Ctrl + Shift + 1-6 for headings, 0 for reset" do
|
||||
# TODO (martin) Bring this back once we decide what to do for cross-platform shortcuts
|
||||
xit "supports Ctrl + Shift + 1-4 for headings, 0 for reset" do
|
||||
open_composer_and_toggle_rich_editor
|
||||
(1..6).each do |i|
|
||||
(1..4).each do |i|
|
||||
composer.type_content("\nHeading #{i}")
|
||||
composer.send_keys([PLATFORM_KEY_MODIFIER, :shift, i.to_s])
|
||||
|
||||
|
@ -453,7 +454,7 @@ describe "Composer - ProseMirror editor", type: :system do
|
|||
end
|
||||
|
||||
composer.send_keys([PLATFORM_KEY_MODIFIER, :shift, "0"])
|
||||
expect(rich).not_to have_css("h6")
|
||||
expect(rich).not_to have_css("h4")
|
||||
end
|
||||
|
||||
it "supports Ctrl + Z and Ctrl + Shift + Z to undo and redo" do
|
||||
|
@ -655,6 +656,7 @@ describe "Composer - ProseMirror editor", type: :system do
|
|||
|
||||
expect(page).to have_css(".toolbar__button.bold.--active", count: 0)
|
||||
expect(page).to have_css(".toolbar__button.italic.--active", count: 0)
|
||||
expect(page).to have_css(".toolbar__button.heading.--active", count: 0)
|
||||
expect(page).to have_css(".toolbar__button.link.--active", count: 0)
|
||||
expect(page).to have_css(".toolbar__button.bullet.--active", count: 0)
|
||||
expect(page).to have_css(".toolbar__button.list.--active", count: 0)
|
||||
|
@ -1285,4 +1287,73 @@ describe "Composer - ProseMirror editor", type: :system do
|
|||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe "heading toolbar" do
|
||||
it "updates toolbar active state and icon based on current heading level" do
|
||||
open_composer_and_toggle_rich_editor
|
||||
|
||||
composer.type_content("## This is a test\n#### And this is another test")
|
||||
expect(page).to have_css(".toolbar__button.heading.--active", count: 1)
|
||||
expect(find(".toolbar__button.heading")).to have_css(".d-icon-discourse-h4")
|
||||
|
||||
composer.send_keys(:up)
|
||||
expect(page).to have_css(".toolbar__button.heading.--active", count: 1)
|
||||
expect(find(".toolbar__button.heading")).to have_css(".d-icon-discourse-h2")
|
||||
|
||||
composer.select_all
|
||||
expect(page).to have_no_css(".toolbar__button.heading.--active")
|
||||
expect(find(".toolbar__button.heading")).to have_css(".d-icon-discourse-text")
|
||||
end
|
||||
|
||||
it "puts a check next to current heading level in toolbar dropdown, or no check if multiple formats are selected" do
|
||||
open_composer_and_toggle_rich_editor
|
||||
|
||||
composer.type_content("## This is a test\n#### And this is another test")
|
||||
|
||||
heading_menu = composer.heading_menu
|
||||
heading_menu.expand
|
||||
expect(heading_menu.option("[data-name='heading-4']")).to have_css(".d-icon-check")
|
||||
heading_menu.collapse
|
||||
|
||||
composer.select_range_rich_editor(0, 0)
|
||||
heading_menu.expand
|
||||
expect(heading_menu.option("[data-name='heading-2']")).to have_css(".d-icon-check")
|
||||
heading_menu.collapse
|
||||
|
||||
composer.select_all
|
||||
heading_menu.expand
|
||||
expect(heading_menu.option("[data-name='heading-2']")).to have_no_css(".d-icon-check")
|
||||
expect(heading_menu.option("[data-name='heading-4']")).to have_no_css(".d-icon-check")
|
||||
end
|
||||
|
||||
it "can change heading level or reset to paragraph" do
|
||||
open_composer_and_toggle_rich_editor
|
||||
|
||||
composer.type_content("This is a test")
|
||||
heading_menu = composer.heading_menu
|
||||
heading_menu.expand
|
||||
heading_menu.option("[data-name='heading-2']").click
|
||||
|
||||
expect(rich).to have_css("h2", text: "This is a test")
|
||||
|
||||
heading_menu.expand
|
||||
heading_menu.option("[data-name='heading-3']").click
|
||||
expect(rich).to have_css("h3", text: "This is a test")
|
||||
|
||||
heading_menu.expand
|
||||
heading_menu.option("[data-name='heading-paragraph']").click
|
||||
expect(rich).to have_css("p", text: "This is a test")
|
||||
end
|
||||
|
||||
it "can insert a heading on an empty line" do
|
||||
open_composer_and_toggle_rich_editor
|
||||
|
||||
heading_menu = composer.heading_menu
|
||||
heading_menu.expand
|
||||
heading_menu.option("[data-name='heading-2']").click
|
||||
|
||||
composer.type_content("This is a test")
|
||||
expect(rich).to have_css("h2", text: "This is a test")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -44,6 +44,10 @@ module PageObjects
|
|||
self
|
||||
end
|
||||
|
||||
def heading_menu
|
||||
PageObjects::Components::DMenu.new(find(".d-editor-button-bar button.heading"))
|
||||
end
|
||||
|
||||
def focus
|
||||
find(COMPOSER_INPUT_SELECTOR).click
|
||||
self
|
||||
|
@ -295,6 +299,11 @@ module PageObjects
|
|||
JS
|
||||
end
|
||||
|
||||
def select_range_rich_editor(start_index, length)
|
||||
focus
|
||||
select_text_range(RICH_EDITOR, start_index, length)
|
||||
end
|
||||
|
||||
def submit
|
||||
find("#{COMPOSER_ID} .save-or-cancel .create").click
|
||||
end
|
||||
|
|
|
@ -99,4 +99,10 @@ Additional SVG icons
|
|||
<path d="M8.7955 5.57376C8.16545 5.57379 7.7574 6.01638 7.7574 6.33616V7.96517C7.7574 8.58191 9.1145 8.58191 9.1145 7.96517V7.1343H11.0822L11.1102 10.8732H10.1584C9.42007 10.8733 9.42004 12.4337 10.1584 12.4338H13.5884C14.3268 12.4337 14.3268 10.8733 13.5884 10.8732H12.6853L12.6574 7.1343H14.6174V7.96517C14.6174 8.58191 16 8.60641 16 7.98968V6.36066C16 6.04088 15.66 5.57379 15.03 5.57376H8.7955Z" />
|
||||
<path d="M0.778442 0C0.155688 0 0 0.66007 0 0.990106V2.57251C0 3.85876 1.715 3.85876 1.715 2.57251C1.715 1.28625 1.715 1.715 1.715 1.715H4.21453V10.5513H2.62724C1.89745 10.5513 1.84272 12.4338 2.57251 12.4338H7.30397C8.03376 12.4338 8.08849 10.5513 7.35871 10.5513H5.77141V1.715H8.57502V2.57251C8.57502 3.85876 10.29 3.70423 10.29 2.57251V0.857502C10.29 0.527467 9.83026 0 9.2075 0H0.778442Z"/>
|
||||
</symbol>
|
||||
<symbol id="discourse-h1" viewBox="0 0 700 500"><path d="M 15.445 57.815 C 15.445 40.115 29.745 25.815 47.445 25.815 L 95.445 25.815 L 143.445 25.815 C 161.145 25.815 175.445 40.115 175.445 57.815 C 175.445 75.515 161.145 89.815 143.445 89.815 L 127.445 89.815 L 127.445 201.816 L 351.445 201.816 L 351.445 89.815 L 335.445 89.815 C 317.745 89.815 303.445 75.515 303.445 57.815 C 303.445 40.115 317.745 25.815 335.445 25.815 L 383.445 25.815 L 431.445 25.815 C 449.145 25.815 463.445 40.115 463.445 57.815 C 463.445 75.515 449.145 89.815 431.445 89.815 L 415.445 89.815 L 415.445 233.816 L 415.445 409.816 L 431.445 409.816 C 449.145 409.816 463.445 424.116 463.445 441.816 C 463.445 459.516 449.145 473.816 431.445 473.816 L 383.445 473.816 L 335.445 473.816 C 317.745 473.816 303.445 459.516 303.445 441.816 C 303.445 424.116 317.745 409.816 335.445 409.816 L 351.445 409.816 L 351.445 265.816 L 127.445 265.816 L 127.445 409.816 L 143.445 409.816 C 161.145 409.816 175.445 424.116 175.445 441.816 C 175.445 459.516 161.145 473.816 143.445 473.816 L 95.445 473.816 L 47.445 473.816 C 29.745 473.816 15.445 459.516 15.445 441.816 C 15.445 424.116 29.745 409.816 47.445 409.816 L 63.445 409.816 L 63.445 233.816 L 63.445 89.815 L 47.445 89.815 C 29.745 89.815 15.445 75.515 15.445 57.815 Z" /><path d="M 608.204 130.88 C 608.204 121.152 602.845 112.249 594.272 107.633 C 585.698 103.016 575.311 103.511 567.232 108.952 L 488.091 161.712 C 475.891 169.791 472.676 186.113 480.754 198.314 C 488.833 210.515 505.239 213.73 517.357 205.651 L 555.443 180.178 L 555.443 421.062 L 502.683 421.062 C 488.091 421.062 476.303 432.85 476.303 447.442 C 476.303 462.033 488.091 473.822 502.683 473.822 L 581.823 473.822 L 660.964 473.822 C 675.555 473.822 687.344 462.033 687.344 447.442 C 687.344 432.85 675.555 421.062 660.964 421.062 L 608.204 421.062 L 608.204 130.88 Z" /></symbol>
|
||||
|
||||
<symbol id="discourse-h2" viewBox="0 0 700 500"><path d="M 15.445 57.815 C 15.445 40.115 29.745 25.815 47.445 25.815 L 95.445 25.815 L 143.445 25.815 C 161.145 25.815 175.445 40.115 175.445 57.815 C 175.445 75.515 161.145 89.815 143.445 89.815 L 127.445 89.815 L 127.445 201.816 L 351.445 201.816 L 351.445 89.815 L 335.445 89.815 C 317.745 89.815 303.445 75.515 303.445 57.815 C 303.445 40.115 317.745 25.815 335.445 25.815 L 383.445 25.815 L 431.445 25.815 C 449.145 25.815 463.445 40.115 463.445 57.815 C 463.445 75.515 449.145 89.815 431.445 89.815 L 415.445 89.815 L 415.445 233.816 L 415.445 409.816 L 431.445 409.816 C 449.145 409.816 463.445 424.116 463.445 441.816 C 463.445 459.516 449.145 473.816 431.445 473.816 L 383.445 473.816 L 335.445 473.816 C 317.745 473.816 303.445 459.516 303.445 441.816 C 303.445 424.116 317.745 409.816 335.445 409.816 L 351.445 409.816 L 351.445 265.816 L 127.445 265.816 L 127.445 409.816 L 143.445 409.816 C 161.145 409.816 175.445 424.116 175.445 441.816 C 175.445 459.516 161.145 473.816 143.445 473.816 L 95.445 473.816 L 47.445 473.816 C 29.745 473.816 15.445 459.516 15.445 441.816 C 15.445 424.116 29.745 409.816 47.445 409.816 L 63.445 409.816 L 63.445 233.816 L 63.445 89.815 L 47.445 89.815 C 29.745 89.815 15.445 75.515 15.445 57.815 Z" /><path d="M 571.552 176.628 C 557.109 176.628 543.202 183.2 532.991 195.029 L 512.232 218.84 C 503.834 228.505 490.197 228.505 481.8 218.84 C 473.402 209.177 473.402 193.482 481.8 183.819 L 502.558 160.006 C 520.899 138.977 545.688 127.149 571.552 127.149 C 625.363 127.149 669.03 177.402 669.03 239.326 C 669.03 269.092 658.752 297.62 640.479 318.649 L 548.98 424.026 L 669.03 424.026 C 680.921 424.026 690.528 435.08 690.528 448.765 C 690.528 462.449 680.921 473.505 669.03 473.505 L 497.049 473.505 C 488.383 473.505 480.523 467.475 477.164 458.198 C 473.805 448.919 475.686 438.328 481.8 431.215 L 610.047 283.704 C 620.258 271.952 626.035 255.949 626.035 239.326 C 626.035 204.693 601.649 176.628 571.552 176.628 Z" /></symbol>
|
||||
<symbol viewBox="0 0 700 500" id="discourse-h3"><path d="M 15.445 57.815 C 15.445 40.115 29.745 25.815 47.445 25.815 L 95.445 25.815 L 143.445 25.815 C 161.145 25.815 175.445 40.115 175.445 57.815 C 175.445 75.515 161.145 89.815 143.445 89.815 L 127.445 89.815 L 127.445 201.816 L 351.445 201.816 L 351.445 89.815 L 335.445 89.815 C 317.745 89.815 303.445 75.515 303.445 57.815 C 303.445 40.115 317.745 25.815 335.445 25.815 L 383.445 25.815 L 431.445 25.815 C 449.145 25.815 463.445 40.115 463.445 57.815 C 463.445 75.515 449.145 89.815 431.445 89.815 L 415.445 89.815 L 415.445 233.816 L 415.445 409.816 L 431.445 409.816 C 449.145 409.816 463.445 424.116 463.445 441.816 C 463.445 459.516 449.145 473.816 431.445 473.816 L 383.445 473.816 L 335.445 473.816 C 317.745 473.816 303.445 459.516 303.445 441.816 C 303.445 424.116 317.745 409.816 335.445 409.816 L 351.445 409.816 L 351.445 265.816 L 127.445 265.816 L 127.445 409.816 L 143.445 409.816 C 161.145 409.816 175.445 424.116 175.445 441.816 C 175.445 459.516 161.145 473.816 143.445 473.816 L 95.445 473.816 L 47.445 473.816 C 29.745 473.816 15.445 459.516 15.445 441.816 C 15.445 424.116 29.745 409.816 47.445 409.816 L 63.445 409.816 L 63.445 233.816 L 63.445 89.815 L 47.445 89.815 C 29.745 89.815 15.445 75.515 15.445 57.815 Z" /><path d="M 473.255 156.521 C 473.255 143.016 482.923 132.105 494.892 132.105 L 657.172 132.105 C 666.097 132.105 674.076 138.286 677.322 147.67 C 680.568 157.055 678.336 167.737 671.777 174.529 L 582.996 266.398 L 597.669 266.398 C 648.45 266.398 689.628 312.865 689.628 370.167 C 689.628 427.47 648.45 473.937 597.669 473.937 L 544.522 473.937 C 515.853 473.937 489.617 455.624 476.839 426.706 L 475.554 423.807 C 470.212 411.752 474.54 397.102 485.222 391.074 C 495.906 385.046 508.889 389.928 514.23 401.985 L 515.515 404.885 C 520.993 417.321 532.284 425.104 544.522 425.104 L 597.669 425.104 C 624.581 425.104 646.353 400.536 646.353 370.167 C 646.353 339.8 624.581 315.23 597.669 315.23 L 527.348 315.23 C 518.423 315.23 510.444 309.05 507.198 299.666 C 503.952 290.281 506.184 279.599 512.743 272.807 L 601.524 180.937 L 494.892 180.937 C 482.923 180.937 473.255 170.026 473.255 156.521 Z" /></symbol>
|
||||
<symbol id="discourse-h4" viewBox="0 0 700 500"><path d="M 15.445 57.815 C 15.445 40.115 29.745 25.815 47.445 25.815 L 95.445 25.815 L 143.445 25.815 C 161.145 25.815 175.445 40.115 175.445 57.815 C 175.445 75.515 161.145 89.815 143.445 89.815 L 127.445 89.815 L 127.445 201.816 L 351.445 201.816 L 351.445 89.815 L 335.445 89.815 C 317.745 89.815 303.445 75.515 303.445 57.815 C 303.445 40.115 317.745 25.815 335.445 25.815 L 383.445 25.815 L 431.445 25.815 C 449.145 25.815 463.445 40.115 463.445 57.815 C 463.445 75.515 449.145 89.815 431.445 89.815 L 415.445 89.815 L 415.445 233.816 L 415.445 409.816 L 431.445 409.816 C 449.145 409.816 463.445 424.116 463.445 441.816 C 463.445 459.516 449.145 473.816 431.445 473.816 L 383.445 473.816 L 335.445 473.816 C 317.745 473.816 303.445 459.516 303.445 441.816 C 303.445 424.116 317.745 409.816 335.445 409.816 L 351.445 409.816 L 351.445 265.816 L 127.445 265.816 L 127.445 409.816 L 143.445 409.816 C 161.145 409.816 175.445 424.116 175.445 441.816 C 175.445 459.516 161.145 473.816 143.445 473.816 L 95.445 473.816 L 47.445 473.816 C 29.745 473.816 15.445 459.516 15.445 441.816 C 15.445 424.116 29.745 409.816 47.445 409.816 L 63.445 409.816 L 63.445 233.816 L 63.445 89.815 L 47.445 89.815 C 29.745 89.815 15.445 75.515 15.445 57.815 Z" /><path d="M 575.204 170.97 C 579.64 158.991 575.619 144.692 566.157 139.076 C 556.694 133.461 545.399 138.551 540.963 150.531 L 465.204 354.242 C 462.425 361.654 462.898 370.34 466.328 377.302 C 469.758 384.264 475.85 388.382 482.355 388.382 L 614.829 388.382 L 614.829 448.275 C 614.829 461.527 623.286 472.233 633.754 472.233 C 644.222 472.233 652.678 461.527 652.678 448.275 L 652.678 388.382 L 671.603 388.382 C 682.071 388.382 690.528 377.676 690.528 364.424 C 690.528 351.172 682.071 340.468 671.603 340.468 L 652.678 340.468 L 652.678 232.659 C 652.678 219.407 644.222 208.704 633.754 208.704 C 623.286 208.704 614.829 219.407 614.829 232.659 L 614.829 340.468 L 512.161 340.468 L 575.204 170.97 Z" /></symbol>
|
||||
<symbol id="discourse-h5" viewBox="0 0 700 500"><path d="M 15.445 57.815 C 15.445 40.115 29.745 25.815 47.445 25.815 L 95.445 25.815 L 143.445 25.815 C 161.145 25.815 175.445 40.115 175.445 57.815 C 175.445 75.515 161.145 89.815 143.445 89.815 L 127.445 89.815 L 127.445 201.816 L 351.445 201.816 L 351.445 89.815 L 335.445 89.815 C 317.745 89.815 303.445 75.515 303.445 57.815 C 303.445 40.115 317.745 25.815 335.445 25.815 L 383.445 25.815 L 431.445 25.815 C 449.145 25.815 463.445 40.115 463.445 57.815 C 463.445 75.515 449.145 89.815 431.445 89.815 L 415.445 89.815 L 415.445 233.816 L 415.445 409.816 L 431.445 409.816 C 449.145 409.816 463.445 424.116 463.445 441.816 C 463.445 459.516 449.145 473.816 431.445 473.816 L 383.445 473.816 L 335.445 473.816 C 317.745 473.816 303.445 459.516 303.445 441.816 C 303.445 424.116 317.745 409.816 335.445 409.816 L 351.445 409.816 L 351.445 265.816 L 127.445 265.816 L 127.445 409.816 L 143.445 409.816 C 161.145 409.816 175.445 424.116 175.445 441.816 C 175.445 459.516 161.145 473.816 143.445 473.816 L 95.445 473.816 L 47.445 473.816 C 29.745 473.816 15.445 459.516 15.445 441.816 C 15.445 424.116 29.745 409.816 47.445 409.816 L 63.445 409.816 L 63.445 233.816 L 63.445 89.815 L 47.445 89.815 C 29.745 89.815 15.445 75.515 15.445 57.815 Z" /><path d="M 486.457 155.75 C 488.44 144.281 497.788 135.903 508.766 135.903 L 644.744 135.903 C 657.279 135.903 667.407 146.695 667.407 160.051 C 667.407 173.406 657.279 184.196 644.744 184.196 L 527.675 184.196 L 513.228 268.704 L 593.752 268.704 C 646.939 268.704 690.07 314.655 690.07 371.321 C 690.07 427.986 646.939 473.938 593.752 473.938 L 534.616 473.938 C 506.712 473.938 481.216 457.112 468.751 430.552 L 465.848 424.365 C 460.253 412.443 464.785 397.956 475.975 391.995 C 487.165 386.034 500.763 390.863 506.358 402.785 L 509.262 408.972 C 514.077 419.234 523.851 425.648 534.616 425.648 L 593.752 425.648 C 621.939 425.648 644.744 401.351 644.744 371.321 C 644.744 341.29 621.939 316.994 593.752 316.994 L 486.103 316.994 C 479.375 316.994 473.001 313.826 468.681 308.317 C 464.36 302.809 462.59 295.566 463.794 288.548 L 486.457 155.75 Z" /></symbol>
|
||||
</svg>
|
||||
|
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 27 KiB |
Loading…
Add table
Add a link
Reference in a new issue