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 @@