mirror of
https://github.com/flarum/core.git
synced 2025-08-08 01:16:52 +02:00
Use custom JSX implementation of GitHub's markdown toolbar that works in IE 11
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
import Component from 'flarum/Component';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
import apply from '../util/apply';
|
||||
|
||||
const modifierKey = navigator.userAgent.match(/Macintosh/) ? '⌘' : 'ctrl';
|
||||
|
||||
export default class MarkdownButton extends Component {
|
||||
config(isInitialized) {
|
||||
if (isInitialized) return;
|
||||
|
||||
this.$().tooltip();
|
||||
}
|
||||
|
||||
view() {
|
||||
return <button className="Button Button--icon Button--link" title={this.title()} data-hotkey={this.props.hotkey}
|
||||
onclick={this.click.bind(this)} onkeydown={this.keydown.bind(this)}>
|
||||
{icon(this.icon())}
|
||||
</button>;
|
||||
}
|
||||
|
||||
keydown(event) {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
this.click();
|
||||
}
|
||||
}
|
||||
|
||||
click() {
|
||||
return apply(this.element, this.styles());
|
||||
}
|
||||
|
||||
title() {
|
||||
let tooltip = this.props.title;
|
||||
|
||||
if (this.props.hotkey) tooltip += ` <${modifierKey}-${this.props.hotkey}>`;
|
||||
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
icon() {
|
||||
return this.props.icon;
|
||||
}
|
||||
|
||||
styles() {
|
||||
return this.props.style;
|
||||
}
|
||||
}
|
@@ -0,0 +1,30 @@
|
||||
import Component from 'flarum/Component';
|
||||
|
||||
const modifierKey = navigator.userAgent.match(/Macintosh/) ? 'Meta' : 'Control';
|
||||
|
||||
export default class MarkdownToolbar extends Component {
|
||||
config(isInitialized) {
|
||||
if (isInitialized) return;
|
||||
|
||||
const field = document.getElementById(this.props.for);
|
||||
|
||||
field.addEventListener('keydown', this.shortcut.bind(this));
|
||||
}
|
||||
|
||||
view() {
|
||||
return <div id="MarkdownToolbar" data-for={this.props.for} style={{ display: 'inline-block' }}>
|
||||
{this.props.children}
|
||||
</div>;
|
||||
}
|
||||
|
||||
shortcut(event) {
|
||||
if ((event.metaKey && modifierKey === 'Meta') || (event.ctrlKey && modifierKey === 'Control')) {
|
||||
const button = this.element.querySelector(`[data-hotkey="${event.key}"]`);
|
||||
|
||||
if (button) {
|
||||
button.click();
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,17 +1,12 @@
|
||||
import { extend } from 'flarum/extend';
|
||||
import TextEditor from 'flarum/components/TextEditor';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
import MarkdownArea from 'mdarea';
|
||||
|
||||
let MarkdownArea;
|
||||
|
||||
if (window.Reflect) {
|
||||
require('@webcomponents/custom-elements');
|
||||
require('@github/markdown-toolbar-element');
|
||||
MarkdownArea = require('mdarea/mdarea.js');
|
||||
}
|
||||
import './pollyfills';
|
||||
import MarkdownToolbar from './components/MarkdownToolbar';
|
||||
import MarkdownButton from './components/MarkdownButton';
|
||||
|
||||
app.initializers.add('flarum-markdown', function(app) {
|
||||
if (!MarkdownArea) return;
|
||||
|
||||
let index = 1;
|
||||
|
||||
@@ -28,6 +23,7 @@ app.initializers.add('flarum-markdown', function(app) {
|
||||
|
||||
const editor = new MarkdownArea(element);
|
||||
editor.disableInline();
|
||||
editor.ignoreTab();
|
||||
|
||||
context.onunload = function() {
|
||||
editor.destroy();
|
||||
@@ -35,24 +31,19 @@ app.initializers.add('flarum-markdown', function(app) {
|
||||
});
|
||||
|
||||
extend(TextEditor.prototype, 'toolbarItems', function(items) {
|
||||
const attrs = {
|
||||
className: 'Button Button--icon Button--link',
|
||||
config: elm => $(elm).tooltip()
|
||||
};
|
||||
|
||||
const tooltip = name => app.translator.trans(`flarum-markdown.forum.composer.${name}_tooltip`);
|
||||
|
||||
items.add('markdown', (
|
||||
<markdown-toolbar for={this.textareaId}>
|
||||
<md-header title={tooltip('header')} {...attrs}>{icon('fas fa-heading')}</md-header>
|
||||
<md-bold title={tooltip('bold')+' <cmd-b>'} {...attrs}>{icon('fas fa-bold')}</md-bold>
|
||||
<md-italic title={tooltip('italic')+' <cmd-i>'} {...attrs}>{icon('fas fa-italic')}</md-italic>
|
||||
<md-quote title={tooltip('quote')} {...attrs}>{icon('fas fa-quote-left')}</md-quote>
|
||||
<md-code title={tooltip('code')} {...attrs}>{icon('fas fa-code')}</md-code>
|
||||
<md-link title={tooltip('link')+' <cmd-k>'} {...attrs}>{icon('fas fa-link')}</md-link>
|
||||
<md-unordered-list title={tooltip('unordered_list')} {...attrs}>{icon('fas fa-list-ul')}</md-unordered-list>
|
||||
<md-ordered-list title={tooltip('ordered_list')} {...attrs}>{icon('fas fa-list-ol')}</md-ordered-list>
|
||||
</markdown-toolbar>
|
||||
<MarkdownToolbar for={this.textareaId}>
|
||||
<MarkdownButton title={tooltip('header')} icon="fas fa-heading" style={{ prefix: '### ' }} />
|
||||
<MarkdownButton title={tooltip('bold')} icon="fas fa-bold" style={{ prefix: '**', suffix: '**', trimFirst: true }} hotkey="b" />
|
||||
<MarkdownButton title={tooltip('italic')} icon="fas fa-italic" style={{ prefix: '_', suffix: '_', trimFirst: true }} hotkey="i" />
|
||||
<MarkdownButton title={tooltip('quote')} icon="fas fa-quote-left" style={{ prefix: '> ', multiline: true, surroundWithNewlines: true }} />
|
||||
<MarkdownButton title={tooltip('code')} icon="fas fa-code" style={{ prefix: '`', suffix: '`', blockPrefix: '```', blockSuffix: '```' }} />
|
||||
<MarkdownButton title={tooltip('link')} icon="fas fa-link" style={{ prefix: '[', suffix: '](url)', replaceNext: 'url', scanFor: 'https?://' }} />
|
||||
<MarkdownButton title={tooltip('unordered_list')} icon="fas fa-list-ul" style={{ prefix: '- ', multiline: true, surroundWithNewlines: true }} />
|
||||
<MarkdownButton title={tooltip('ordered_list')} icon="fas fa-list-ol" style={{ prefix: '1. ', multiline: true, orderedList: true }} />
|
||||
</MarkdownToolbar>
|
||||
), 100);
|
||||
});
|
||||
});
|
||||
|
17
extensions/markdown/js/src/forum/pollyfills.js
Normal file
17
extensions/markdown/js/src/forum/pollyfills.js
Normal file
@@ -0,0 +1,17 @@
|
||||
if (!String.prototype.startsWith) {
|
||||
Object.defineProperty(String.prototype, 'startsWith', {
|
||||
value: function(search, pos) {
|
||||
pos = !pos || pos < 0 ? 0 : +pos;
|
||||
return this.substring(pos, pos + search.length) === search;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!String.prototype.endsWith) {
|
||||
String.prototype.endsWith = function(search, this_len) {
|
||||
if (this_len === undefined || this_len > this.length) {
|
||||
this_len = this.length;
|
||||
}
|
||||
return this.substring(this_len - search.length, this_len) === search;
|
||||
};
|
||||
}
|
45
extensions/markdown/js/src/forum/util/apply.js
Normal file
45
extensions/markdown/js/src/forum/util/apply.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import insertText from './insertText';
|
||||
import {blockStyle, isMultipleLines, multilineStyle, orderedList} from "./styles";
|
||||
|
||||
export const styleSelectedText = (textarea, styleArgs) => {
|
||||
const text = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd);
|
||||
let result;
|
||||
if (styleArgs.orderedList) {
|
||||
result = orderedList(textarea);
|
||||
}
|
||||
else if (styleArgs.multiline && isMultipleLines(text)) {
|
||||
result = multilineStyle(textarea, styleArgs);
|
||||
}
|
||||
else {
|
||||
result = blockStyle(textarea, styleArgs);
|
||||
}
|
||||
|
||||
insertText(textarea, result);
|
||||
};
|
||||
|
||||
export default (button, stylesToApply) => {
|
||||
const toolbar = button.parentElement;
|
||||
|
||||
const defaults = {
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
blockPrefix: '',
|
||||
blockSuffix: '',
|
||||
multiline: false,
|
||||
replaceNext: '',
|
||||
prefixSpace: false,
|
||||
scanFor: '',
|
||||
surroundWithNewlines: false,
|
||||
orderedList: false,
|
||||
trimFirst: false
|
||||
};
|
||||
|
||||
const style = Object.assign({}, defaults, stylesToApply);
|
||||
|
||||
const field = document.getElementById(toolbar.dataset.for);
|
||||
|
||||
if (field) {
|
||||
field.focus();
|
||||
styleSelectedText(field, style);
|
||||
}
|
||||
}
|
49
extensions/markdown/js/src/forum/util/insertText.js
Normal file
49
extensions/markdown/js/src/forum/util/insertText.js
Normal file
@@ -0,0 +1,49 @@
|
||||
export let canInsertText = null;
|
||||
|
||||
export default (textarea, { text, selectionStart, selectionEnd }) => {
|
||||
const originalSelectionStart = textarea.selectionStart;
|
||||
const before = textarea.value.slice(0, originalSelectionStart);
|
||||
const after = textarea.value.slice(textarea.selectionEnd);
|
||||
|
||||
if (canInsertText === null || canInsertText === true) {
|
||||
textarea.contentEditable = 'true';
|
||||
try {
|
||||
canInsertText = document.execCommand('insertText', false, text);
|
||||
}
|
||||
catch (error) {
|
||||
canInsertText = false;
|
||||
}
|
||||
textarea.contentEditable = 'false';
|
||||
}
|
||||
if (canInsertText && !textarea.value.slice(0, textarea.selectionStart).endsWith(text)) {
|
||||
canInsertText = false;
|
||||
}
|
||||
if (!canInsertText) {
|
||||
try {
|
||||
document.execCommand('ms-beginUndoUnit');
|
||||
}
|
||||
catch (e) {
|
||||
// Do nothing.
|
||||
}
|
||||
textarea.value = before + text + after;
|
||||
try {
|
||||
document.execCommand('ms-endUndoUnit');
|
||||
}
|
||||
catch (e) {
|
||||
// Do nothing.
|
||||
}
|
||||
|
||||
// fire custom event, works on IE
|
||||
const event = document.createEvent('Event');
|
||||
|
||||
event.initEvent('input', true, true);
|
||||
|
||||
textarea.dispatchEvent(event);
|
||||
}
|
||||
if (selectionStart != null && selectionEnd != null) {
|
||||
textarea.setSelectionRange(selectionStart, selectionEnd);
|
||||
}
|
||||
else {
|
||||
textarea.setSelectionRange(originalSelectionStart, textarea.selectionEnd);
|
||||
}
|
||||
};
|
225
extensions/markdown/js/src/forum/util/styles.js
Normal file
225
extensions/markdown/js/src/forum/util/styles.js
Normal file
@@ -0,0 +1,225 @@
|
||||
export function isMultipleLines(string) {
|
||||
return string.trim().split('\n').length > 1;
|
||||
}
|
||||
|
||||
export function repeat(string, n) {
|
||||
return Array(n + 1).join(string);
|
||||
}
|
||||
|
||||
export function wordSelectionStart(text, i) {
|
||||
let index = i;
|
||||
|
||||
while (text[index] && text[index - 1] != null && !text[index - 1].match(/\s/)) {
|
||||
index--;
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
export function wordSelectionEnd(text, i, multiline) {
|
||||
let index = i;
|
||||
const breakpoint = multiline ? /\n/ : /\s/;
|
||||
|
||||
while (text[index] && !text[index].match(breakpoint)) {
|
||||
index++;
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
export function expandSelectedText(textarea, prefixToUse, suffixToUse) {
|
||||
let multiline = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : false;
|
||||
|
||||
if (textarea.selectionStart === textarea.selectionEnd) {
|
||||
textarea.selectionStart = wordSelectionStart(textarea.value, textarea.selectionStart);
|
||||
textarea.selectionEnd = wordSelectionEnd(textarea.value, textarea.selectionEnd, multiline);
|
||||
} else {
|
||||
const expandedSelectionStart = textarea.selectionStart - prefixToUse.length;
|
||||
const expandedSelectionEnd = textarea.selectionEnd + suffixToUse.length;
|
||||
const beginsWithPrefix = textarea.value.slice(expandedSelectionStart, textarea.selectionStart) === prefixToUse;
|
||||
const endsWithSuffix = textarea.value.slice(textarea.selectionEnd, expandedSelectionEnd) === suffixToUse;
|
||||
|
||||
if (beginsWithPrefix && endsWithSuffix) {
|
||||
textarea.selectionStart = expandedSelectionStart;
|
||||
textarea.selectionEnd = expandedSelectionEnd;
|
||||
}
|
||||
}
|
||||
|
||||
return textarea.value.slice(textarea.selectionStart, textarea.selectionEnd);
|
||||
}
|
||||
|
||||
export function newlinesToSurroundSelectedText(textarea) {
|
||||
const beforeSelection = textarea.value.slice(0, textarea.selectionStart);
|
||||
const afterSelection = textarea.value.slice(textarea.selectionEnd);
|
||||
const breaksBefore = beforeSelection.match(/\n*$/);
|
||||
const breaksAfter = afterSelection.match(/^\n*/);
|
||||
const newlinesBeforeSelection = breaksBefore ? breaksBefore[0].length : 0;
|
||||
const newlinesAfterSelection = breaksAfter ? breaksAfter[0].length : 0;
|
||||
let newlinesToAppend;
|
||||
let newlinesToPrepend;
|
||||
|
||||
if (beforeSelection.match(/\S/) && newlinesBeforeSelection < 2) {
|
||||
newlinesToAppend = repeat('\n', 2 - newlinesBeforeSelection);
|
||||
}
|
||||
|
||||
if (afterSelection.match(/\S/) && newlinesAfterSelection < 2) {
|
||||
newlinesToPrepend = repeat('\n', 2 - newlinesAfterSelection);
|
||||
}
|
||||
|
||||
if (newlinesToAppend == null) {
|
||||
newlinesToAppend = '';
|
||||
}
|
||||
|
||||
if (newlinesToPrepend == null) {
|
||||
newlinesToPrepend = '';
|
||||
}
|
||||
|
||||
return {
|
||||
newlinesToAppend,
|
||||
newlinesToPrepend
|
||||
};
|
||||
}
|
||||
|
||||
export const blockStyle = (textarea, arg) => {
|
||||
let newlinesToAppend;
|
||||
let newlinesToPrepend;
|
||||
const { prefix, suffix, blockPrefix, blockSuffix, replaceNext, prefixSpace, scanFor, surroundWithNewlines } = arg;
|
||||
const originalSelectionStart = textarea.selectionStart;
|
||||
const originalSelectionEnd = textarea.selectionEnd;
|
||||
let selectedText = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd);
|
||||
let prefixToUse = isMultipleLines(selectedText) && blockPrefix.length > 0 ? `${blockPrefix}\n` : prefix;
|
||||
let suffixToUse = isMultipleLines(selectedText) && blockSuffix.length > 0 ? `\n${blockSuffix}` : suffix;
|
||||
|
||||
if (prefixSpace) {
|
||||
const beforeSelection = textarea.value[textarea.selectionStart - 1];
|
||||
if (textarea.selectionStart !== 0 && beforeSelection != null && !beforeSelection.match(/\s/)) {
|
||||
prefixToUse = ` ${prefixToUse}`;
|
||||
}
|
||||
}
|
||||
|
||||
selectedText = expandSelectedText(textarea, prefixToUse, suffixToUse, arg.multiline);
|
||||
let selectionStart = textarea.selectionStart;
|
||||
let selectionEnd = textarea.selectionEnd;
|
||||
const hasReplaceNext = replaceNext.length > 0 && suffixToUse.indexOf(replaceNext) > -1 && selectedText.length > 0;
|
||||
|
||||
if (surroundWithNewlines) {
|
||||
const ref = newlinesToSurroundSelectedText(textarea);
|
||||
newlinesToAppend = ref.newlinesToAppend;
|
||||
newlinesToPrepend = ref.newlinesToPrepend;
|
||||
prefixToUse = newlinesToAppend + prefix;
|
||||
suffixToUse += newlinesToPrepend;
|
||||
}
|
||||
|
||||
if (selectedText.startsWith(prefixToUse) && selectedText.endsWith(suffixToUse)) {
|
||||
const replacementText = selectedText.slice(prefixToUse.length, selectedText.length - suffixToUse.length);
|
||||
if (originalSelectionStart === originalSelectionEnd) {
|
||||
let position = originalSelectionStart - prefixToUse.length;
|
||||
position = Math.max(position, selectionStart);
|
||||
position = Math.min(position, selectionStart + replacementText.length);
|
||||
selectionStart = selectionEnd = position;
|
||||
}
|
||||
else {
|
||||
selectionEnd = selectionStart + replacementText.length;
|
||||
}
|
||||
return { text: replacementText, selectionStart, selectionEnd };
|
||||
}
|
||||
else if (!hasReplaceNext) {
|
||||
let replacementText = prefixToUse + selectedText + suffixToUse;
|
||||
selectionStart = originalSelectionStart + prefixToUse.length;
|
||||
selectionEnd = originalSelectionEnd + prefixToUse.length;
|
||||
const whitespaceEdges = selectedText.match(/^\s*|\s*$/g);
|
||||
if (arg.trimFirst && whitespaceEdges) {
|
||||
const leadingWhitespace = whitespaceEdges[0] || '';
|
||||
const trailingWhitespace = whitespaceEdges[1] || '';
|
||||
replacementText = leadingWhitespace + prefixToUse + selectedText.trim() + suffixToUse + trailingWhitespace;
|
||||
selectionStart += leadingWhitespace.length;
|
||||
selectionEnd -= trailingWhitespace.length;
|
||||
}
|
||||
return { text: replacementText, selectionStart, selectionEnd };
|
||||
}
|
||||
else if (scanFor.length > 0 && selectedText.match(scanFor)) {
|
||||
suffixToUse = suffixToUse.replace(replaceNext, selectedText);
|
||||
const replacementText = prefixToUse + suffixToUse;
|
||||
selectionStart = selectionEnd = selectionStart + prefixToUse.length;
|
||||
return { text: replacementText, selectionStart, selectionEnd };
|
||||
}
|
||||
else {
|
||||
const replacementText = prefixToUse + selectedText + suffixToUse;
|
||||
selectionStart = selectionStart + prefixToUse.length + selectedText.length + suffixToUse.indexOf(replaceNext);
|
||||
selectionEnd = selectionStart + replaceNext.length;
|
||||
return { text: replacementText, selectionStart, selectionEnd };
|
||||
}
|
||||
}
|
||||
|
||||
export const multilineStyle = (textarea, arg) => {
|
||||
const { prefix, suffix, surroundWithNewlines } = arg;
|
||||
let text = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd);
|
||||
let selectionStart = textarea.selectionStart;
|
||||
let selectionEnd = textarea.selectionEnd;
|
||||
const lines = text.split('\n');
|
||||
const undoStyle = lines.every(line => line.startsWith(prefix) && line.endsWith(suffix));
|
||||
if (undoStyle) {
|
||||
text = lines.map(line => line.slice(prefix.length, line.length - suffix.length)).join('\n');
|
||||
selectionEnd = selectionStart + text.length;
|
||||
}
|
||||
else {
|
||||
text = lines.map(line => prefix + line + suffix).join('\n');
|
||||
if (surroundWithNewlines) {
|
||||
const { newlinesToAppend, newlinesToPrepend } = newlinesToSurroundSelectedText(textarea);
|
||||
selectionStart += newlinesToAppend.length;
|
||||
selectionEnd = selectionStart + text.length;
|
||||
text = newlinesToAppend + text + newlinesToPrepend;
|
||||
}
|
||||
}
|
||||
return { text, selectionStart, selectionEnd };
|
||||
}
|
||||
|
||||
export const orderedList = (textarea) => {
|
||||
const orderedListRegex = /^\d+\.\s+/;
|
||||
const noInitialSelection = textarea.selectionStart === textarea.selectionEnd;
|
||||
let selectionEnd;
|
||||
let selectionStart;
|
||||
let text = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd);
|
||||
let textToUnstyle = text;
|
||||
let lines = text.split('\n');
|
||||
let startOfLine, endOfLine;
|
||||
if (noInitialSelection) {
|
||||
const linesBefore = textarea.value.slice(0, textarea.selectionStart).split(/\n/);
|
||||
startOfLine = textarea.selectionStart - linesBefore[linesBefore.length - 1].length;
|
||||
endOfLine = wordSelectionEnd(textarea.value, textarea.selectionStart, true);
|
||||
textToUnstyle = textarea.value.slice(startOfLine, endOfLine);
|
||||
}
|
||||
const linesToUnstyle = textToUnstyle.split('\n');
|
||||
const undoStyling = linesToUnstyle.every(line => orderedListRegex.test(line));
|
||||
if (undoStyling) {
|
||||
lines = linesToUnstyle.map(line => line.replace(orderedListRegex, ''));
|
||||
text = lines.join('\n');
|
||||
if (noInitialSelection && startOfLine && endOfLine) {
|
||||
const lengthDiff = linesToUnstyle[0].length - lines[0].length;
|
||||
selectionStart = selectionEnd = textarea.selectionStart - lengthDiff;
|
||||
textarea.selectionStart = startOfLine;
|
||||
textarea.selectionEnd = endOfLine;
|
||||
}
|
||||
}
|
||||
else {
|
||||
lines = (function () {
|
||||
let i;
|
||||
let len;
|
||||
let index;
|
||||
const results = [];
|
||||
for (index = i = 0, len = lines.length; i < len; index = ++i) {
|
||||
const line = lines[index];
|
||||
results.push(`${index + 1}. ${line}`);
|
||||
}
|
||||
return results;
|
||||
})();
|
||||
text = lines.join('\n');
|
||||
const { newlinesToAppend, newlinesToPrepend } = newlinesToSurroundSelectedText(textarea);
|
||||
selectionStart = textarea.selectionStart + newlinesToAppend.length;
|
||||
selectionEnd = selectionStart + text.length;
|
||||
if (noInitialSelection)
|
||||
selectionStart = selectionEnd;
|
||||
text = newlinesToAppend + text + newlinesToPrepend;
|
||||
}
|
||||
return { text, selectionStart, selectionEnd };
|
||||
}
|
Reference in New Issue
Block a user