diff --git a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue
new file mode 100644
index 000000000..291dd3c5a
--- /dev/null
+++ b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue
@@ -0,0 +1,202 @@
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/src/blockInputRules.js b/app/javascript/dashboard/components/widgets/WootWriter/src/blockInputRules.js
new file mode 100644
index 000000000..df2992a83
--- /dev/null
+++ b/app/javascript/dashboard/components/widgets/WootWriter/src/blockInputRules.js
@@ -0,0 +1,176 @@
+import {
+ textblockTypeInputRule,
+ wrappingInputRule,
+ inputRules,
+} from 'prosemirror-inputrules';
+
+import { leafNodeReplacementCharacter } from './utils';
+import { createInputRule, defaultInputRuleHandler } from './utils';
+import {
+ isConvertableToCodeBlock,
+ transformToCodeBlockAction,
+ insertBlock,
+} from './commands';
+import { safeInsert } from 'prosemirror-utils';
+
+const MAX_HEADING_LEVEL = 5;
+
+function getHeadingLevel(match) {
+ return {
+ level: match[1].length,
+ };
+}
+
+export function headingRule(nodeType, maxLevel) {
+ return textblockTypeInputRule(
+ new RegExp('^(#{1,' + maxLevel + '})\\s$'),
+ nodeType,
+ getHeadingLevel
+ );
+}
+
+export function blockQuoteRule(nodeType) {
+ return wrappingInputRule(/^\s*>\s$/, nodeType);
+}
+
+export function codeBlockRule(nodeType) {
+ return textblockTypeInputRule(/^```$/, nodeType);
+}
+
+/**
+ * Get heading rules
+ *
+ * @param {Schema} schema
+ * @returns {}
+ */
+function getHeadingRules(schema) {
+ // '# ' for h1, '## ' for h2 and etc
+ const hashRule = defaultInputRuleHandler(
+ headingRule(schema.nodes.heading, MAX_HEADING_LEVEL),
+ true
+ );
+
+ const leftNodeReplacementHashRule = createInputRule(
+ new RegExp(`${leafNodeReplacementCharacter}(#{1,6})\\s$`),
+ (state, match, start, end) => {
+ const level = match[1].length;
+ return insertBlock(
+ state,
+ schema.nodes.heading,
+ `heading${level}`,
+ start,
+ end,
+ { level }
+ );
+ },
+ true
+ );
+
+ return [hashRule, leftNodeReplacementHashRule];
+}
+
+/**
+ * Get all block quote input rules
+ *
+ * @param {Schema} schema
+ * @returns {}
+ */
+function getBlockQuoteRules(schema) {
+ // '> ' for blockquote
+ const greatherThanRule = defaultInputRuleHandler(
+ blockQuoteRule(schema.nodes.blockquote),
+ true
+ );
+
+ const leftNodeReplacementGreatherRule = createInputRule(
+ new RegExp(`${leafNodeReplacementCharacter}\\s*>\\s$`),
+ (state, _match, start, end) => {
+ return insertBlock(
+ state,
+ schema.nodes.blockquote,
+ 'blockquote',
+ start,
+ end
+ );
+ },
+ true
+ );
+
+ return [greatherThanRule, leftNodeReplacementGreatherRule];
+}
+
+/**
+ * Get all code block input rules
+ *
+ * @param {Schema} schema
+ * @returns {}
+ */
+function getCodeBlockRules(schema) {
+ const threeTildeRule = createInputRule(
+ /((^`{3,})|(\s`{3,}))(\S*)$/,
+ (state, match, start, end) => {
+ const attributes = {};
+ if (match[4]) {
+ attributes.language = match[4];
+ }
+ const newStart = match[0][0] === ' ' ? start + 1 : start;
+ if (isConvertableToCodeBlock(state)) {
+ const tr = transformToCodeBlockAction(state, attributes)
+ // remove markdown decorator ```
+ .delete(newStart, end)
+ .scrollIntoView();
+ return tr;
+ }
+ let { tr } = state;
+ tr = tr.delete(newStart, end);
+ const codeBlock = state.schema.nodes.code_block.createChecked();
+ return safeInsert(codeBlock)(tr);
+ },
+ true
+ );
+
+ const leftNodeReplacementThreeTildeRule = createInputRule(
+ new RegExp(`((${leafNodeReplacementCharacter}\`{3,})|(\\s\`{3,}))(\\S*)$`),
+ (state, match, start, end) => {
+ const attributes = {};
+ if (match[4]) {
+ attributes.language = match[4];
+ }
+ let tr = insertBlock(
+ state,
+ schema.nodes.code_block,
+ 'codeblock',
+ start,
+ end,
+ attributes
+ );
+ return tr;
+ },
+ true
+ );
+
+ return [threeTildeRule, leftNodeReplacementThreeTildeRule];
+}
+
+export function blocksInputRule(schema) {
+ const rules = [];
+
+ if (schema.nodes.heading) {
+ rules.push(...getHeadingRules(schema));
+ }
+
+ if (schema.nodes.blockquote) {
+ rules.push(...getBlockQuoteRules(schema));
+ }
+
+ if (schema.nodes.code_block) {
+ rules.push(...getCodeBlockRules(schema));
+ }
+
+ if (rules.length !== 0) {
+ return inputRules({ rules });
+ }
+ return false;
+}
+
+export default blocksInputRule;
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/src/commands.js b/app/javascript/dashboard/components/widgets/WootWriter/src/commands.js
new file mode 100644
index 000000000..2dd137343
--- /dev/null
+++ b/app/javascript/dashboard/components/widgets/WootWriter/src/commands.js
@@ -0,0 +1,157 @@
+import { hasParentNodeOfType } from 'prosemirror-utils';
+import { TextSelection, NodeSelection } from 'prosemirror-state';
+import { mapSlice } from './utils';
+
+export const applyMarkOnRange = (from, to, removeMark, mark, tr) => {
+ // const { schema } = tr.doc.type;
+ // const { code } = schema.marks;
+ // if (mark.type === code) {
+ // // When turning to code we need to flat some special characters
+ // import { transformSmartCharsMentionsAndEmojis } from '../plugins/text-formatting/commands/transform-to-code';
+ // transformSmartCharsMentionsAndEmojis(from, to, tr);
+ // }
+
+ tr.doc.nodesBetween(tr.mapping.map(from), tr.mapping.map(to), (node, pos) => {
+ if (!node.isText) {
+ return true;
+ }
+
+ // This is an issue when the user selects some text.
+ // We need to check if the current node position is less than the range selection from.
+ // If it’s true, that means we should apply the mark using the range selection,
+ // not the current node position.
+ const nodeBetweenFrom = Math.max(pos, tr.mapping.map(from));
+ const nodeBetweenTo = Math.min(pos + node.nodeSize, tr.mapping.map(to));
+
+ if (removeMark) {
+ tr.removeMark(nodeBetweenFrom, nodeBetweenTo, mark);
+ } else {
+ tr.addMark(nodeBetweenFrom, nodeBetweenTo, mark);
+ }
+
+ return true;
+ });
+
+ return tr;
+};
+
+export const insertBlock = (state, nodeType, nodeName, start, end, attrs) => {
+ // To ensure that match is done after HardBreak.
+ const { hardBreak, codeBlock, listItem } = state.schema.nodes;
+ const $pos = state.doc.resolve(start);
+ if ($pos.nodeAfter.type !== hardBreak) {
+ return null;
+ }
+
+ // To ensure no nesting is done. (unless we're inserting a codeBlock inside lists)
+ if (
+ $pos.depth > 1 &&
+ !(nodeType === codeBlock && hasParentNodeOfType(listItem)(state.selection))
+ ) {
+ return null;
+ }
+
+ // Split at the start of autoformatting and delete formatting characters.
+ let tr = state.tr.delete(start, end).split(start);
+ let currentNode = tr.doc.nodeAt(start + 1);
+
+ // If node has more content split at the end of autoformatting.
+ let nodeHasMoreContent = false;
+ tr.doc.nodesBetween(start, start + currentNode.nodeSize, (node, pos) => {
+ if (!nodeHasMoreContent && node.type === hardBreak) {
+ nodeHasMoreContent = true;
+ tr = tr.split(pos + 1).delete(pos, pos + 1);
+ }
+ });
+ if (nodeHasMoreContent) {
+ currentNode = tr.doc.nodeAt(start + 1);
+ }
+
+ // Create new node and fill with content of current node.
+ const { blockquote, paragraph } = state.schema.nodes;
+ let content;
+ let depth;
+ if (nodeType === blockquote) {
+ depth = 3;
+ content = [paragraph.create({}, currentNode.content)];
+ } else {
+ depth = 2;
+ content = currentNode.content;
+ }
+ const newNode = nodeType.create(attrs, content);
+
+ // Add new node.
+ tr = tr
+ .setSelection(new NodeSelection(tr.doc.resolve(start + 1)))
+ .replaceSelectionWith(newNode)
+ .setSelection(new TextSelection(tr.doc.resolve(start + depth)));
+ return tr;
+};
+
+export function transformToCodeBlockAction(state, attrs) {
+ if (!state.selection.empty) {
+ // Don't do anything, if there is something selected
+ return state.tr;
+ }
+
+ const codeBlock = state.schema.nodes.code_block;
+ const startOfCodeBlockText = state.selection.$from;
+ const parentPos = startOfCodeBlockText.before();
+ const end = startOfCodeBlockText.end();
+
+ const codeBlockSlice = mapSlice(
+ state.doc.slice(startOfCodeBlockText.pos, end),
+ node => {
+ if (node.type === state.schema.nodes.hard_break) {
+ return state.schema.text('\n');
+ }
+
+ if (node.isText) {
+ return node.mark([]);
+ }
+ if (node.isInline) {
+ return node.attrs.text ? state.schema.text(node.attrs.text) : null;
+ }
+ return node.content.childCount ? node.content : null;
+ }
+ );
+
+ const tr = state.tr.replaceRange(
+ startOfCodeBlockText.pos,
+ end,
+ codeBlockSlice
+ );
+ // If our offset isnt at 3 (backticks) at the start of line, cater for content.
+ if (startOfCodeBlockText.parentOffset >= 3) {
+ return tr.split(startOfCodeBlockText.pos, undefined, [
+ { type: codeBlock, attrs },
+ ]);
+ }
+ // TODO: Check parent node for valid code block marks, ATM It's not necessary because code block doesn't have any valid mark.
+ const codeBlockMarks = [];
+ return tr.setNodeMarkup(parentPos, codeBlock, attrs, codeBlockMarks);
+}
+
+export function isConvertableToCodeBlock(state) {
+ // Before a document is loaded, there is no selection.
+ if (!state.selection) {
+ return false;
+ }
+
+ const { $from } = state.selection;
+ const node = $from.parent;
+
+ if (!node.isTextblock || node.type === state.schema.nodes.code_block) {
+ return false;
+ }
+
+ const parentDepth = $from.depth - 1;
+ const parentNode = $from.node(parentDepth);
+ const index = $from.index(parentDepth);
+
+ return parentNode.canReplaceWith(
+ index,
+ index + 1,
+ state.schema.nodes.code_block
+ );
+}
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/src/icons.js b/app/javascript/dashboard/components/widgets/WootWriter/src/icons.js
new file mode 100644
index 000000000..4d4e196ff
--- /dev/null
+++ b/app/javascript/dashboard/components/widgets/WootWriter/src/icons.js
@@ -0,0 +1,75 @@
+const BaseIcon = {
+ width: 24,
+ height: 24,
+ viewBox: '0 0 24 24',
+ fill: 'none',
+ stroke: 'currentColor',
+ strokeWidth: 2,
+ strokeLinecap: 'round',
+ strokeLinejoin: 'round',
+ path: '',
+};
+export const BoldIcon = {
+ ...BaseIcon,
+ path:
+ 'M6.935 4.44A1.5 1.5 0 0 1 7.996 4h4.383C15.017 4 17 6.182 17 8.625a4.63 4.63 0 0 1-.865 2.682c1.077.827 1.866 2.12 1.866 3.813C18 18.232 15.3 20 13.12 20H8a1.5 1.5 0 0 1-1.5-1.5l-.004-13c0-.397.158-.779.44-1.06ZM9.5 10.25h2.88c.903 0 1.62-.76 1.62-1.625S13.281 7 12.38 7H9.498l.002 3.25Zm0 3V17h3.62c.874 0 1.88-.754 1.88-1.88 0-1.13-.974-1.87-1.88-1.87H9.5Z',
+};
+
+export const ItalicsIcon = {
+ ...BaseIcon,
+ path:
+ 'M9.75 4h8.504a.75.75 0 0 1 .102 1.493l-.102.006h-3.197L10.037 18.5h4.213a.75.75 0 0 1 .742.648l.007.102a.75.75 0 0 1-.648.743L14.25 20h-9.5a.747.747 0 0 1-.746-.75c0-.38.28-.694.645-.743l.101-.007h3.685l.021-.065L13.45 5.499h-3.7a.75.75 0 0 1-.742-.648L9 4.75a.75.75 0 0 1 .648-.743L9.751 4h8.503-8.503Z',
+};
+
+export const CodeIcon = {
+ ...BaseIcon,
+ path:
+ 'm8.066 18.943 6.5-14.5a.75.75 0 0 1 1.404.518l-.036.096-6.5 14.5a.75.75 0 0 1-1.404-.518l.036-.096 6.5-14.5-6.5 14.5ZM2.22 11.47l4.25-4.25a.75.75 0 0 1 1.133.976l-.073.085L3.81 12l3.72 3.719a.75.75 0 0 1-.976 1.133l-.084-.073-4.25-4.25a.75.75 0 0 1-.073-.976l.073-.084 4.25-4.25-4.25 4.25Zm14.25-4.25a.75.75 0 0 1 .976-.073l.084.073 4.25 4.25a.75.75 0 0 1 .073.976l-.073.085-4.25 4.25a.75.75 0 0 1-1.133-.977l.073-.084L20.19 12l-3.72-3.72a.75.75 0 0 1 0-1.06Z',
+};
+
+export const LinkIcon = {
+ ...BaseIcon,
+ path:
+ 'M9.25 7a.75.75 0 0 1 .11 1.492l-.11.008H7a3.5 3.5 0 0 0-.206 6.994L7 15.5h2.25a.75.75 0 0 1 .11 1.492L9.25 17H7a5 5 0 0 1-.25-9.994L7 7h2.25ZM17 7a5 5 0 0 1 .25 9.994L17 17h-2.25a.75.75 0 0 1-.11-1.492l.11-.008H17a3.5 3.5 0 0 0 .206-6.994L17 8.5h-2.25a.75.75 0 0 1-.11-1.492L14.75 7H17ZM7 11.25h10a.75.75 0 0 1 .102 1.493L17 12.75H7a.75.75 0 0 1-.102-1.493L7 11.25h10H7Z',
+};
+
+export const UndoIcon = {
+ ...BaseIcon,
+ path:
+ 'M4.75 2a.75.75 0 0 1 .743.648l.007.102v5.69l4.574-4.56a6.41 6.41 0 0 1 8.879-.179l.186.18a6.41 6.41 0 0 1 0 9.063l-8.846 8.84a.75.75 0 0 1-1.06-1.062l8.845-8.838a4.91 4.91 0 0 0-6.766-7.112l-.178.17L6.562 9.5h5.688a.75.75 0 0 1 .743.648l.007.102a.75.75 0 0 1-.648.743L12.25 11h-7.5a.75.75 0 0 1-.743-.648L4 10.25v-7.5A.75.75 0 0 1 4.75 2Z',
+};
+
+export const RedoIcon = {
+ ...BaseIcon,
+ path:
+ 'M19.25 2a.75.75 0 0 0-.743.648l-.007.102v5.69l-4.574-4.56a6.41 6.41 0 0 0-8.878-.179l-.186.18a6.41 6.41 0 0 0 0 9.063l8.845 8.84a.75.75 0 0 0 1.06-1.062l-8.845-8.838a4.91 4.91 0 0 1 6.766-7.112l.178.17L17.438 9.5H11.75a.75.75 0 0 0-.743.648L11 10.25c0 .38.282.694.648.743l.102.007h7.5a.75.75 0 0 0 .743-.648L20 10.25v-7.5a.75.75 0 0 0-.75-.75Z',
+};
+export const BulletListIcon = {
+ ...BaseIcon,
+ path:
+ 'M3.25 17.5a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5Zm3.5.5h14.5a.75.75 0 0 1 .102 1.494l-.102.006H6.75a.75.75 0 0 1-.102-1.493L6.75 18h14.5-14.5Zm-3.5-7a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5Zm3.5.5h14.5a.75.75 0 0 1 .102 1.494L21.25 13H6.75a.75.75 0 0 1-.102-1.493l.102-.007h14.5-14.5Zm-3.5-7A1.25 1.25 0 1 1 3.25 7a1.25 1.25 0 0 1 0-2.499Zm3.5.5h14.5a.75.75 0 0 1 .102 1.494l-.102.006H6.75a.75.75 0 0 1-.102-1.493L6.75 5h14.5-14.5Z',
+};
+
+export const TextNumberListIcon = {
+ ...BaseIcon,
+ path:
+ 'M6 2.75a.75.75 0 0 0-1.434-.307l-.002.003a1.45 1.45 0 0 1-.067.132 4.126 4.126 0 0 1-.238.384c-.217.313-.524.663-.906.902a.75.75 0 1 0 .794 1.272c.125-.078.243-.161.353-.248V7.25a.75.75 0 0 0 1.5 0v-4.5ZM20.5 18.75a.75.75 0 0 0-.75-.75h-9a.75.75 0 0 0 0 1.5h9a.75.75 0 0 0 .75-.75ZM20.5 12.244a.75.75 0 0 0-.75-.75h-9a.75.75 0 1 0 0 1.5h9a.75.75 0 0 0 .75-.75ZM20.5 5.75a.75.75 0 0 0-.75-.75h-9a.75.75 0 0 0 0 1.5h9a.75.75 0 0 0 .75-.75ZM5.15 10.52c-.3-.053-.676.066-.87.26a.75.75 0 1 1-1.06-1.06c.556-.556 1.43-.812 2.192-.677.397.07.805.254 1.115.605.316.358.473.825.473 1.352 0 .62-.271 1.08-.606 1.42-.278.283-.63.511-.906.689l-.08.051a5.88 5.88 0 0 0-.481.34H6.25a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75c0-1.314.984-1.953 1.575-2.337l.06-.04c.318-.205.533-.345.69-.504.134-.136.175-.238.175-.369 0-.223-.061-.318-.098-.36a.42.42 0 0 0-.251-.12ZM2.97 21.28s.093.084.004.005l.006.005.013.013a1.426 1.426 0 0 0 .15.125c.095.07.227.158.397.243.341.17.83.329 1.46.329.64 0 1.196-.181 1.601-.54.408-.36.61-.857.595-1.359A1.775 1.775 0 0 0 6.77 19c.259-.305.412-.685.426-1.101a1.73 1.73 0 0 0-.595-1.36C6.196 16.181 5.64 16 5 16c-.63 0-1.119.158-1.46.33a2.592 2.592 0 0 0-.51.334 1.426 1.426 0 0 0-.037.033l-.013.013-.006.005-.002.003H2.97l-.001.002a.75.75 0 0 0 1.048 1.072 1.1 1.1 0 0 1 .192-.121c.159-.08.42-.171.79-.171.36 0 .536.1.608.164.07.061.09.127.088.187a.325.325 0 0 1-.123.23c-.089.077-.263.169-.573.169a.75.75 0 0 0 0 1.5c.31 0 .484.092.573.168.091.08.121.166.123.231a.232.232 0 0 1-.088.187c-.072.064-.247.164-.608.164a1.75 1.75 0 0 1-.79-.17 1.1 1.1 0 0 1-.192-.122.75.75 0 0 0-1.048 1.072Zm.002-4.563-.001.002c.007-.006.2-.168 0-.002Z',
+};
+
+export const Heading1Icon = {
+ ...BaseIcon,
+ path:
+ 'M19.59 5.081a.746.746 0 0 0-.809.084.751.751 0 0 0-.249.367c-.69 2.051-2.057 3.409-3.168 4.075a.75.75 0 0 0 .772 1.286c.774-.464 1.623-1.18 2.364-2.146v9.503a.75.75 0 0 0 1.5 0V5.772a.75.75 0 0 0-.41-.69ZM3.5 5.75a.75.75 0 0 0-1.5 0v12.5a.75.75 0 0 0 1.5 0V12.5H10v5.75a.75.75 0 0 0 1.5 0V5.75a.75.75 0 0 0-1.5 0V11H3.5V5.75Z',
+};
+
+export const Heading2Icon = {
+ ...BaseIcon,
+ path:
+ 'M4.5 5.75a.75.75 0 0 0-1.5 0v12.5a.75.75 0 0 0 1.5 0V12.5H11v5.75a.75.75 0 0 0 1.5 0V5.75a.75.75 0 0 0-1.5 0V11H4.5V5.75Zm10.921 2.085c.23-.46.913-1.335 2.58-1.335.842 0 1.459.26 1.86.639.397.376.64.921.64 1.611 0 1.963-1.3 3.068-2.958 4.343l-.212.163C15.825 14.409 14 15.806 14 18.25a.75.75 0 0 0 .75.75h6.5a.75.75 0 0 0 0-1.5h-5.66c.315-1.252 1.427-2.11 2.866-3.218C20.05 13.057 22 11.537 22 8.75c0-1.06-.383-2.015-1.11-2.702C20.166 5.364 19.158 5 18 5c-2.333 0-3.484 1.291-3.92 2.165a.75.75 0 0 0 1.341.67Z',
+};
+
+export const Heading3Icon = {
+ ...BaseIcon,
+ path:
+ 'M3.5 5.75a.75.75 0 0 0-1.5 0v12.5a.75.75 0 0 0 1.5 0V12.5H10v5.75a.75.75 0 0 0 1.5 0V5.75a.75.75 0 0 0-1.5 0V11H3.5V5.75Zm11.92 2.085c.23-.46.914-1.335 2.58-1.335.843 0 1.46.26 1.86.639.398.376.64.921.64 1.611 0 .606-.161 1.026-.384 1.332-.228.314-.555.554-.953.735-.816.37-1.802.433-2.383.433a.75.75 0 0 0 0 1.5c.581 0 1.567.063 2.383.433.398.18.725.42.953.735.223.306.384.726.384 1.332 0 1.086-.914 2.25-2.5 2.25-1.727 0-2.348-.76-2.553-1.276a.75.75 0 1 0-1.394.552C14.508 17.926 15.727 19 18 19c2.414 0 4-1.836 4-3.75 0-.894-.245-1.63-.67-2.214A3.679 3.679 0 0 0 20.144 12a3.679 3.679 0 0 0 1.186-1.036c.425-.584.67-1.32.67-2.214 0-1.06-.383-2.015-1.11-2.702C20.165 5.364 19.157 5 18 5c-2.334 0-3.484 1.291-3.92 2.165a.75.75 0 1 0 1.34.67Z',
+};
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/src/marksInputRules.js b/app/javascript/dashboard/components/widgets/WootWriter/src/marksInputRules.js
new file mode 100644
index 000000000..371b466a6
--- /dev/null
+++ b/app/javascript/dashboard/components/widgets/WootWriter/src/marksInputRules.js
@@ -0,0 +1,248 @@
+/* eslint-disable no-useless-escape */
+import { inputRules } from 'prosemirror-inputrules';
+import { applyMarkOnRange } from './commands';
+import { createInputRule } from './utils';
+
+const validCombos = {
+ '**': ['_', '~~'],
+ '*': ['__', '~~'],
+ __: ['*', '~~'],
+ _: ['**', '~~'],
+ '~~': ['__', '_', '**', '*'],
+};
+
+const validRegex = (char, str) => {
+ for (let i = 0; i < validCombos[char].length; i += 1) {
+ const ch = validCombos[char][i];
+ if (ch === str) {
+ return true;
+ }
+ const matchLength = str.length - ch.length;
+ if (str.substr(matchLength, str.length) === ch) {
+ return validRegex(ch, str.substr(0, matchLength));
+ }
+ }
+ return false;
+};
+
+function addMark(markType, schema, charSize, char) {
+ return (state, match, start, end) => {
+ const [, prefix, textWithCombo] = match;
+ const to = end;
+ // in case of *string* pattern it matches the text from beginning of the paragraph,
+ // because we want ** to work for strong text
+ // that's why "start" argument is wrong and we need to calculate it ourselves
+ const from = textWithCombo ? start + prefix.length : start;
+ const nodeBefore = state.doc.resolve(start + prefix.length).nodeBefore;
+
+ if (
+ prefix &&
+ prefix.length > 0 &&
+ !validRegex(char, prefix) &&
+ !(nodeBefore && nodeBefore.type === state.schema.nodes.hard_break)
+ ) {
+ return null;
+ }
+ // fixes the following case: my `*name` is *
+ // expected result: should ignore special characters inside "code"
+ if (
+ state.schema.marks.code &&
+ state.schema.marks.code.isInSet(state.doc.resolve(from + 1).marks())
+ ) {
+ return null;
+ }
+
+ // Prevent autoformatting across hardbreaks
+ let containsHardBreak;
+ state.doc.nodesBetween(from, to, node => {
+ if (node.type === schema.nodes.hard_break) {
+ containsHardBreak = true;
+ return false;
+ }
+ return !containsHardBreak;
+ });
+ if (containsHardBreak) {
+ return null;
+ }
+
+ // fixes autoformatting in heading nodes: # Heading *bold*
+ // expected result: should not autoformat *bold*;
Heading *bold*
+ if (state.doc.resolve(from).sameParent(state.doc.resolve(to))) {
+ if (!state.doc.resolve(from).parent.type.allowsMarkType(markType)) {
+ return null;
+ }
+ }
+
+ // apply mark to the range (from, to)
+ let tr = state.tr.addMark(from, to, markType.create());
+
+ if (charSize > 1) {
+ // delete special characters after the text
+ // Prosemirror removes the last symbol by itself, so we need to remove "charSize - 1" symbols
+ tr = tr.delete(to - (charSize - 1), to);
+ }
+
+ return (
+ tr
+ // delete special characters before the text
+ .delete(from, from + charSize)
+ .removeStoredMark(markType)
+ );
+ };
+}
+
+function addCodeMark(markType, specialChar) {
+ return (state, match, start, end) => {
+ if (match[1] && match[1].length > 0) {
+ const allowedPrefixConditions = [
+ prefix => {
+ return prefix === '(';
+ },
+ prefix => {
+ const nodeBefore = state.doc.resolve(start + prefix.length)
+ .nodeBefore;
+ return (
+ (nodeBefore && nodeBefore.type === state.schema.nodes.hard_break) ||
+ false
+ );
+ },
+ ];
+
+ if (allowedPrefixConditions.every(condition => !condition(match[1]))) {
+ return null;
+ }
+ }
+ // fixes autoformatting in heading nodes: # Heading `bold`
+ // expected result: should not autoformat *bold*; Heading `bold`
+ if (state.doc.resolve(start).sameParent(state.doc.resolve(end))) {
+ if (!state.doc.resolve(start).parent.type.allowsMarkType(markType)) {
+ return null;
+ }
+ }
+
+ let tr = state.tr;
+ // checks if a selection exists and needs to be removed
+ if (state.selection.from !== state.selection.to) {
+ tr.delete(state.selection.from, state.selection.to);
+ end -= state.selection.to - state.selection.from;
+ }
+
+ const regexStart = end - match[2].length + 1;
+ const codeMark = state.schema.marks.code.create();
+ return applyMarkOnRange(regexStart, end, false, codeMark, tr)
+ .setStoredMarks([codeMark])
+ .delete(regexStart, regexStart + specialChar.length)
+ .removeStoredMark(markType);
+ };
+}
+
+export const strongRegex1 = /(\S*)(\_\_([^\_\s](\_(?!\_)|[^\_])*[^\_\s]|[^\_\s])\_\_)$/;
+export const strongRegex2 = /(\S*)(\*\*([^\*\s](\*(?!\*)|[^\*])*[^\*\s]|[^\*\s])\*\*)$/;
+export const italicRegex1 = /(\S*[^\s\_]*)(\_([^\s\_][^\_]*[^\s\_]|[^\s\_])\_)$/;
+export const italicRegex2 = /(\S*[^\s\*]*)(\*([^\s\*][^\*]*[^\s\*]|[^\s\*])\*)$/;
+export const strikeRegex = /(\S*)(\~\~([^\s\~](\~(?!\~)|[^\~])*[^\s\~]|[^\s\~])\~\~)$/;
+export const codeRegex = /(\S*)(`[^\s][^`]*`)$/;
+
+/**
+ * Create input rules for strong mark
+ *
+ * @param {Schema} schema
+ * @returns {InputRule[]}
+ */
+function getStrongInputRules(schema) {
+ // **string** or __strong__ should bold the text
+
+ const markLength = 2;
+ const doubleUnderscoreRule = createInputRule(
+ strongRegex1,
+ addMark(schema.marks.strong, schema, markLength, '__')
+ );
+
+ const doubleAsterixRule = createInputRule(
+ strongRegex2,
+ addMark(schema.marks.strong, schema, markLength, '**')
+ );
+
+ return [doubleUnderscoreRule, doubleAsterixRule];
+}
+
+/**
+ * Create input rules for em mark
+ *
+ * @param {Schema} schema
+ * @returns {InputRule[]}
+ */
+function getItalicInputRules(schema) {
+ // *string* or _string_ should italic the text
+ const markLength = 1;
+
+ const underscoreRule = createInputRule(
+ italicRegex1,
+ addMark(schema.marks.em, schema, markLength, '_')
+ );
+
+ const asterixRule = createInputRule(
+ italicRegex2,
+ addMark(schema.marks.em, schema, markLength, '*')
+ );
+
+ return [underscoreRule, asterixRule];
+}
+
+/**
+ * Create input rules for strike mark
+ *
+ * @param {Schema} schema
+ * @returns {InputRule[]}
+ */
+function getStrikeInputRules(schema) {
+ const markLength = 2;
+ const doubleTildeRule = createInputRule(
+ strikeRegex,
+ addMark(schema.marks.strike, schema, markLength, '~~')
+ );
+
+ return [doubleTildeRule];
+}
+
+/**
+ * Create input rules for code mark
+ *
+ * @param {Schema} schema
+ * @returns {InputRule[]}
+ */
+function getCodeInputRules(schema) {
+ const backTickRule = createInputRule(
+ codeRegex,
+ addCodeMark(schema.marks.code, '`')
+ );
+
+ return [backTickRule];
+}
+
+export function textFormattingInputRules(schema) {
+ const rules = [];
+
+ if (schema.marks.strong) {
+ rules.push(...getStrongInputRules(schema));
+ }
+
+ if (schema.marks.em) {
+ rules.push(...getItalicInputRules(schema));
+ }
+
+ if (schema.marks.strike) {
+ rules.push(...getStrikeInputRules(schema));
+ }
+
+ if (schema.marks.code) {
+ rules.push(...getCodeInputRules(schema));
+ }
+
+ if (rules.length !== 0) {
+ return inputRules({ rules });
+ }
+ return false;
+}
+
+export default textFormattingInputRules;
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/src/menu.js b/app/javascript/dashboard/components/widgets/WootWriter/src/menu.js
new file mode 100644
index 000000000..11ef94d09
--- /dev/null
+++ b/app/javascript/dashboard/components/widgets/WootWriter/src/menu.js
@@ -0,0 +1,227 @@
+/* eslint-disable no-cond-assign */
+/* eslint-disable no-plusplus */
+import { MenuItem } from 'prosemirror-menu';
+import { toggleMark, setBlockType } from 'prosemirror-commands';
+import { undo, redo } from 'prosemirror-history';
+import { wrapInList } from 'prosemirror-schema-list';
+import { openPrompt } from '@chatwoot/prosemirror-schema/src/prompt';
+import { TextField } from '@chatwoot/prosemirror-schema/src/TextField';
+import {
+ BoldIcon,
+ ItalicsIcon,
+ CodeIcon,
+ UndoIcon,
+ RedoIcon,
+ LinkIcon,
+ Heading3Icon,
+ Heading2Icon,
+ Heading1Icon,
+ TextNumberListIcon,
+ BulletListIcon,
+} from './icons';
+import { markActive } from './utils';
+
+// Helpers to create specific types of items
+
+function cmdItem(cmd, options) {
+ let passedOptions = {
+ label: options.title,
+ run: cmd,
+ };
+ Object.keys(options).reduce((acc, optionKey) => {
+ acc[optionKey] = options[optionKey];
+ return acc;
+ }, passedOptions);
+ if ((!options.enable || options.enable === true) && !options.select)
+ passedOptions[options.enable ? 'enable' : 'select'] = state => cmd(state);
+
+ return new MenuItem(passedOptions);
+}
+
+function blockTypeIsActive(state, type, attrs) {
+ const { $from } = state.selection;
+
+ let wrapperDepth;
+ let currentDepth = $from.depth;
+ while (currentDepth > 0) {
+ const currentNodeAtDepth = $from.node(currentDepth);
+
+ const comparisonAttrs = {
+ ...attrs,
+ };
+ // debugger;
+ if (currentNodeAtDepth.attrs.level) {
+ comparisonAttrs.level = currentNodeAtDepth.attrs.level;
+ }
+ const isType = type.name === currentNodeAtDepth.type.name;
+ const hasAttrs = Object.keys(attrs).reduce((prev, curr) => {
+ if (attrs[curr] !== currentNodeAtDepth.attrs[curr]) {
+ return false;
+ }
+ return prev;
+ }, true);
+
+ if (isType && hasAttrs) {
+ wrapperDepth = currentDepth;
+ }
+ currentDepth -= 1;
+ }
+
+ // return wrapperDepth !== undefined;
+ return wrapperDepth;
+}
+
+const toggleBlockType = (type, attrs) => (state, dispatch) => {
+ const isActive = blockTypeIsActive(state, type, attrs);
+ const newNodeType = isActive ? state.schema.nodes.paragraph : type;
+ const setBlockFunction = setBlockType(newNodeType, attrs);
+ return setBlockFunction(state, dispatch);
+};
+
+function markItem(markType, options) {
+ let passedOptions = {
+ active(state) {
+ return markActive(state, markType);
+ },
+ enable: true,
+ };
+ Object.keys(options).reduce((acc, optionKey) => {
+ acc[optionKey] = options[optionKey];
+ return acc;
+ }, passedOptions);
+ return cmdItem(toggleMark(markType), passedOptions);
+}
+
+function linkItem(markType) {
+ return new MenuItem({
+ title: 'Add or remove link',
+ icon: LinkIcon,
+ active(state) {
+ return markActive(state, markType);
+ },
+ enable(state) {
+ return !state.selection.empty;
+ },
+ run(state, dispatch, view) {
+ if (markActive(state, markType)) {
+ toggleMark(markType)(state, dispatch);
+ return true;
+ }
+ openPrompt({
+ title: 'Create a link',
+ fields: {
+ href: new TextField({
+ label: 'https://example.com',
+ class: 'small',
+ required: true,
+ }),
+ },
+ callback(attrs) {
+ toggleMark(markType, attrs)(view.state, view.dispatch);
+ view.focus();
+ },
+ });
+ return false;
+ },
+ });
+}
+
+function headerItem(nodeType, options) {
+ const { level = 1 } = options;
+ return new MenuItem({
+ title: `Heading ${level}`,
+ icon: options.icon,
+ active(state) {
+ return blockTypeIsActive(state, nodeType, { level });
+ },
+ enable() {
+ return true;
+ },
+ run(state, dispatch, view) {
+ if (blockTypeIsActive(state, nodeType, { level })) {
+ toggleBlockType(nodeType, { level })(state, dispatch);
+ return true;
+ }
+
+ toggleBlockType(nodeType, { level })(view.state, view.dispatch);
+ view.focus();
+
+ return false;
+ },
+ });
+}
+
+function wrapListItem(nodeType, options) {
+ return cmdItem(wrapInList(nodeType, options.attrs), options);
+}
+
+export function buildMenuItems(schema) {
+ let r = {
+ toggleStrong: markItem(schema.marks.strong, {
+ title: 'Toggle strong style',
+ icon: BoldIcon,
+ }),
+ toggleEm: markItem(schema.marks.em, {
+ title: 'Toggle emphasis',
+ icon: ItalicsIcon,
+ }),
+ toggleCode: markItem(schema.marks.code, {
+ title: 'Toggle code font',
+ icon: CodeIcon,
+ }),
+ toggleLink: linkItem(schema.marks.link),
+ wrapBulletList: wrapListItem(schema.nodes.bullet_list, {
+ title: 'Wrap in bullet list',
+ icon: BulletListIcon,
+ }),
+ wrapOrderedList: wrapListItem(schema.nodes.ordered_list, {
+ title: 'Wrap in ordered list',
+ icon: TextNumberListIcon,
+ }),
+ toggleH1: headerItem(schema.nodes.heading, {
+ level: 1,
+ title: 'Toggle code font',
+ icon: Heading1Icon,
+ }),
+ toggleH2: headerItem(schema.nodes.heading, {
+ level: 2,
+ title: 'Toggle code font',
+ icon: Heading2Icon,
+ }),
+ toggleH3: headerItem(schema.nodes.heading, {
+ level: 3,
+ title: 'Toggle code font',
+ icon: Heading3Icon,
+ }),
+ undoItem: new MenuItem({
+ title: 'Undo last change',
+ run: undo,
+ enable: state => undo(state),
+ icon: UndoIcon,
+ }),
+ redoItem: new MenuItem({
+ title: 'Redo last undone change',
+ run: redo,
+ enable: state => redo(state),
+ icon: RedoIcon,
+ }),
+ };
+
+ let cut = arr => arr.filter(x => x);
+
+ r.inlineMenu = [
+ cut([r.toggleStrong, r.toggleEm, r.toggleCode, r.toggleLink]),
+ ];
+ r.blockMenu = [
+ cut([
+ r.toggleH1,
+ r.toggleH2,
+ r.toggleH3,
+ r.wrapBulletList,
+ r.wrapOrderedList,
+ ]),
+ ];
+ r.fullMenu = r.inlineMenu.concat([[r.undoItem, r.redoItem]], r.blockMenu);
+
+ return r;
+}
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/src/utils.js b/app/javascript/dashboard/components/widgets/WootWriter/src/utils.js
new file mode 100644
index 000000000..c7a807cf0
--- /dev/null
+++ b/app/javascript/dashboard/components/widgets/WootWriter/src/utils.js
@@ -0,0 +1,225 @@
+import { InputRule } from 'prosemirror-inputrules';
+import { Fragment, Slice } from 'prosemirror-model';
+import { TextSelection } from 'prosemirror-state';
+
+/**
+ * Determine if a mark (with specific attribute values) exists anywhere in the selection.
+ */
+export const markActive = (state, mark) => {
+ let { from, $from, to, empty } = state.selection;
+ if (empty) return mark.isInSet(state.storedMarks || $from.marks());
+ return state.doc.rangeHasMark(from, to, mark);
+};
+
+export const hasCode = (state, pos) => {
+ const { code } = state.schema.marks;
+ const node = pos >= 0 && state.doc.nodeAt(pos);
+ if (node) {
+ return !!node.marks.filter(mark => mark.type === code).length;
+ }
+
+ return false;
+};
+
+const hasUnsupportedMarkForBlockInputRule = (state, start, end) => {
+ const {
+ doc,
+ schema: { marks },
+ } = state;
+ let unsupportedMarksPresent = false;
+ const isUnsupportedMark = node =>
+ node.type === marks.code || node.type === marks.link;
+ doc.nodesBetween(start, end, node => {
+ unsupportedMarksPresent =
+ unsupportedMarksPresent ||
+ node.marks.filter(isUnsupportedMark).length > 0;
+ });
+ return unsupportedMarksPresent;
+};
+
+const hasUnsupportedMarkForInputRule = (state, start, end) => {
+ const {
+ doc,
+ schema: { marks },
+ } = state;
+ let unsupportedMarksPresent = false;
+ const isCodemark = mark => mark.type === marks.code;
+ doc.nodesBetween(start, end, node => {
+ unsupportedMarksPresent =
+ unsupportedMarksPresent || node.marks.filter(isCodemark).length > 0;
+ });
+ return unsupportedMarksPresent;
+};
+
+export function defaultInputRuleHandler(inputRule, isBlockNodeRule = false) {
+ const originalHandler = inputRule.handler;
+ inputRule.handler = (state, match, start, end) => {
+ const unsupportedMarks = isBlockNodeRule
+ ? hasUnsupportedMarkForBlockInputRule(state, start, end)
+ : hasUnsupportedMarkForInputRule(state, start, end);
+ if (state.selection.$from.parent.type.spec.code || unsupportedMarks) {
+ return false;
+ }
+ return originalHandler(state, match, start, end);
+ };
+ return inputRule;
+}
+
+export const createInputRule = (match, handler, isBlockNodeRule = false) =>
+ defaultInputRuleHandler(new InputRule(match, handler), isBlockNodeRule);
+
+// ProseMirror uses the Unicode Character 'OBJECT REPLACEMENT CHARACTER' (U+FFFC) as text representation for
+// leaf nodes, i.e. nodes that don't have any content or text property (e.g. hardBreak, emoji, mention, rule)
+// It was introduced because of https://github.com/ProseMirror/prosemirror/issues/262
+// This can be used in an input rule regex to be able to include or exclude such nodes.
+export const leafNodeReplacementCharacter = '\ufffc';
+
+/**
+ * Returns false if node contains only empty inline nodes and hardBreaks.
+ */
+export function hasVisibleContent(node) {
+ const isInlineNodeHasVisibleContent = inlineNode => {
+ return inlineNode.isText
+ ? !!inlineNode.textContent.trim()
+ : inlineNode.type.name !== 'hardBreak';
+ };
+
+ if (node.isInline) {
+ return isInlineNodeHasVisibleContent(node);
+ }
+ if (node.isBlock && (node.isLeaf || node.isAtom)) {
+ return true;
+ }
+ if (!node.childCount) {
+ return false;
+ }
+
+ for (let index = 0; index < node.childCount; index += 1) {
+ const child = node.child(index);
+
+ if (hasVisibleContent(child)) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Checks if node is an empty paragraph.
+ */
+export function isEmptyParagraph(node) {
+ return (
+ !node ||
+ (node.type.name === 'paragraph' && !node.textContent && !node.childCount)
+ );
+}
+
+/**
+ * Checks if a node has any content. Ignores node that only contain empty block nodes.
+ */
+export function isNodeEmpty(node) {
+ if (node && node.textContent) {
+ return false;
+ }
+
+ if (
+ !node ||
+ !node.childCount ||
+ (node.childCount === 1 && isEmptyParagraph(node.firstChild))
+ ) {
+ return true;
+ }
+
+ const block = [];
+ const nonBlock = [];
+
+ node.forEach(child =>
+ child.isInline ? nonBlock.push(child) : block.push(child)
+ );
+
+ return (
+ !nonBlock.length &&
+ !block.filter(
+ childNode =>
+ (!!childNode.childCount &&
+ !(
+ childNode.childCount === 1 && isEmptyParagraph(childNode.firstChild)
+ )) ||
+ childNode.isAtom
+ ).length
+ );
+}
+
+export const compose = (...functions) => args =>
+ functions.reduceRight((arg, fn) => fn(arg), args);
+
+/**
+ * A helper to get the underlying array of a fragment.
+ */
+export function getFragmentBackingArray(fragment) {
+ return fragment.content;
+}
+
+export function mapFragment(content, callback, parent) {
+ const children = [];
+ for (let i = 0, size = content.childCount; i < size; i += 1) {
+ const node = content.child(i);
+ const transformed = node.isLeaf
+ ? callback(node, parent, i)
+ : callback(
+ node.copy(mapFragment(node.content, callback, node)),
+ parent,
+ i
+ );
+ if (transformed) {
+ if (transformed) {
+ children.push(...getFragmentBackingArray(transformed));
+ } else if (Array.isArray(transformed)) {
+ children.push(...transformed);
+ } else {
+ children.push(transformed);
+ }
+ }
+ }
+ return Fragment.fromArray(children);
+}
+
+export function mapSlice(slice, callback) {
+ const fragment = mapFragment(slice.content, callback);
+ return new Slice(fragment, slice.openStart, slice.openEnd);
+}
+
+export function atTheEndOfDoc(state) {
+ const { selection, doc } = state;
+ return doc.nodeSize - selection.$to.pos - 2 === selection.$to.depth;
+}
+
+export function canMoveDown(state) {
+ const { selection } = state;
+
+ if (selection instanceof TextSelection) {
+ if (!selection.empty) {
+ return true;
+ }
+ }
+
+ return !atTheEndOfDoc(state);
+}
+
+export function atTheBeginningOfDoc(state) {
+ const { selection } = state;
+ return selection.$from.pos === selection.$from.depth;
+}
+
+export function canMoveUp(state) {
+ const { selection } = state;
+
+ if (selection instanceof TextSelection) {
+ if (!selection.empty) {
+ return true;
+ }
+ }
+
+ return !atTheBeginningOfDoc(state);
+}
diff --git a/app/javascript/dashboard/routes/dashboard/helpcenter/components/ArticleEditor.vue b/app/javascript/dashboard/routes/dashboard/helpcenter/components/ArticleEditor.vue
index 856ce6a38..f31df89f4 100644
--- a/app/javascript/dashboard/routes/dashboard/helpcenter/components/ArticleEditor.vue
+++ b/app/javascript/dashboard/routes/dashboard/helpcenter/components/ArticleEditor.vue
@@ -14,8 +14,6 @@
v-model="articleContent"
class="article-content"
:placeholder="$t('HELP_CENTER.EDIT_ARTICLE.CONTENT_PLACEHOLDER')"
- :is-format-mode="true"
- :override-line-breaks="true"
@focus="onFocus"
@blur="onBlur"
@input="onContentInput"
@@ -26,7 +24,8 @@