393 lines
8.6 KiB
JavaScript
393 lines
8.6 KiB
JavaScript
import { ref, computed, watch, nextTick } from 'vue'
|
|
import CodeMirror from 'codemirror'
|
|
import each from 'lodash/each'
|
|
import isNil from 'lodash/isNil'
|
|
import debounce from 'lodash/debounce'
|
|
|
|
import { useLocalization } from '@/composables/useLocalization'
|
|
|
|
const { __ } = useLocalization()
|
|
|
|
const defineMarkdownCommands = (
|
|
editor,
|
|
{ props, emit, isFocused, filesUploadingCount, filesUploadedCount, files }
|
|
) => {
|
|
const doc = editor.getDoc()
|
|
|
|
return {
|
|
setValue(value) {
|
|
doc.setValue(value)
|
|
this.refresh()
|
|
},
|
|
|
|
focus() {
|
|
isFocused.value = true
|
|
},
|
|
|
|
refresh() {
|
|
nextTick(() => editor.refresh())
|
|
},
|
|
|
|
insert(insertion) {
|
|
let cursor = doc.getCursor()
|
|
|
|
doc.replaceRange(insertion, {
|
|
line: cursor.line,
|
|
ch: cursor.ch,
|
|
})
|
|
},
|
|
|
|
insertAround(start, end) {
|
|
if (doc.somethingSelected()) {
|
|
const selection = doc.getSelection()
|
|
|
|
doc.replaceSelection(start + selection + end)
|
|
} else {
|
|
let cursor = doc.getCursor()
|
|
|
|
doc.replaceRange(start + end, {
|
|
line: cursor.line,
|
|
ch: cursor.ch,
|
|
})
|
|
|
|
doc.setCursor({
|
|
line: cursor.line,
|
|
ch: cursor.ch + start.length,
|
|
})
|
|
}
|
|
},
|
|
|
|
insertBefore(insertion, cursorOffset) {
|
|
if (doc.somethingSelected()) {
|
|
const selects = doc.listSelections()
|
|
selects.forEach(selection => {
|
|
const pos = [selection.head.line, selection.anchor.line].sort()
|
|
|
|
for (let i = pos[0]; i <= pos[1]; i++) {
|
|
doc.replaceRange(insertion, { line: i, ch: 0 })
|
|
}
|
|
|
|
doc.setCursor({ line: pos[0], ch: cursorOffset || 0 })
|
|
})
|
|
} else {
|
|
let cursor = doc.getCursor()
|
|
|
|
doc.replaceRange(insertion, {
|
|
line: cursor.line,
|
|
ch: 0,
|
|
})
|
|
doc.setCursor({
|
|
line: cursor.line,
|
|
ch: cursorOffset || 0,
|
|
})
|
|
}
|
|
},
|
|
|
|
uploadAttachment(file) {
|
|
if (!isNil(props.uploader)) {
|
|
filesUploadingCount.value = filesUploadingCount.value + 1
|
|
|
|
const placeholder = `![Uploading ${file.name}…]()`
|
|
|
|
this.insert(placeholder)
|
|
|
|
props.uploader(file, {
|
|
onCompleted: (path, url) => {
|
|
let value = doc.getValue()
|
|
value = value.replace(placeholder, ``)
|
|
|
|
doc.setValue(value)
|
|
emit('change', value)
|
|
|
|
filesUploadedCount.value = filesUploadedCount.value + 1
|
|
},
|
|
onFailure: error => {
|
|
filesUploadingCount.value = filesUploadingCount.value - 1
|
|
},
|
|
})
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
const defineMarkdownActions = (commands, { isEditable, isFullScreen }) => {
|
|
return {
|
|
bold() {
|
|
if (!isEditable) return
|
|
|
|
commands.insertAround('**', '**')
|
|
},
|
|
|
|
italicize() {
|
|
if (!isEditable) return
|
|
|
|
commands.insertAround('*', '*')
|
|
},
|
|
|
|
image() {
|
|
if (!isEditable) return
|
|
|
|
commands.insertBefore('', 2)
|
|
},
|
|
|
|
link() {
|
|
if (!isEditable) return
|
|
|
|
commands.insertAround('[', '](url)')
|
|
},
|
|
|
|
toggleFullScreen() {
|
|
isFullScreen.value = !isFullScreen.value
|
|
|
|
commands.refresh()
|
|
},
|
|
|
|
fullScreen() {
|
|
isFullScreen.value = true
|
|
|
|
commands.refresh()
|
|
},
|
|
|
|
exitFullScreen() {
|
|
isFullScreen.value = false
|
|
|
|
commands.refresh()
|
|
},
|
|
}
|
|
}
|
|
|
|
const defineMarkdownKeyMaps = (editor, actions) => {
|
|
const keyMaps = {
|
|
'Cmd-B': 'bold',
|
|
'Cmd-I': 'italicize',
|
|
'Cmd-Alt-I': 'image',
|
|
'Cmd-K': 'link',
|
|
F11: 'fullScreen',
|
|
Esc: 'exitFullScreen',
|
|
}
|
|
|
|
each(keyMaps, (action, map) => {
|
|
const realMap = map.replace(
|
|
'Cmd-',
|
|
CodeMirror.keyMap['default'] == CodeMirror.keyMap.macDefault
|
|
? 'Cmd-'
|
|
: 'Ctrl-'
|
|
)
|
|
|
|
editor.options.extraKeys[realMap] = actions[keyMaps[map]].bind(this)
|
|
})
|
|
}
|
|
|
|
const defineMarkdownEvents = (
|
|
editor,
|
|
commands,
|
|
{ props, emit, isFocused, files, filesUploadingCount, filesUploadedCount }
|
|
) => {
|
|
const doc = editor.getDoc()
|
|
|
|
const handlePasteFromClipboard = e => {
|
|
if (e.clipboardData && e.clipboardData.items) {
|
|
const items = e.clipboardData.items
|
|
|
|
for (let i = 0; i < items.length; i++) {
|
|
if (items[i].type.indexOf('image') !== -1) {
|
|
commands.uploadAttachment(items[i].getAsFile())
|
|
|
|
e.preventDefault()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const markdownFileRegex = /!\[[^\]]*\]\(([^\)]+)\)/gm
|
|
|
|
const getFileUrls = function (content) {
|
|
return [...content.matchAll(markdownFileRegex)]
|
|
.map(match => match[1])
|
|
.filter(url => {
|
|
try {
|
|
new URL(url)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
})
|
|
}
|
|
|
|
editor.on('focus', () => (isFocused.value = true))
|
|
editor.on('blur', () => (isFocused.value = false))
|
|
|
|
doc.on('change', (cm, changeObj) => {
|
|
if (changeObj.origin === 'setValue') {
|
|
return
|
|
}
|
|
|
|
emit('change', cm.getValue())
|
|
})
|
|
|
|
doc.on(
|
|
'change',
|
|
debounce((cm, changeObj) => {
|
|
const newFiles = getFileUrls(cm.getValue())
|
|
|
|
files.value
|
|
.filter(file => !newFiles.includes(file))
|
|
.filter((url, index, array) => array.indexOf(url) === index)
|
|
.forEach(file => emit('file-removed', file))
|
|
newFiles
|
|
.filter(url => !files.value.includes(url))
|
|
.filter((url, index, array) => array.indexOf(url) === index)
|
|
.forEach(file => emit('file-added', file))
|
|
files.value = newFiles
|
|
}, 1000)
|
|
)
|
|
|
|
editor.on('paste', (cm, event) => {
|
|
handlePasteFromClipboard(event)
|
|
})
|
|
|
|
watch(isFocused, (currentValue, oldValue) => {
|
|
if (currentValue === true && oldValue === false) {
|
|
editor.focus()
|
|
}
|
|
})
|
|
}
|
|
|
|
const bootstrap = (
|
|
theTextarea,
|
|
{
|
|
emit,
|
|
props,
|
|
isEditable,
|
|
isFocused,
|
|
isFullScreen,
|
|
filesUploadingCount,
|
|
filesUploadedCount,
|
|
files,
|
|
unmountMarkdownEditor,
|
|
}
|
|
) => {
|
|
const editor = CodeMirror.fromTextArea(theTextarea.value, {
|
|
tabSize: 4,
|
|
indentWithTabs: true,
|
|
lineWrapping: true,
|
|
mode: 'markdown',
|
|
viewportMargin: Infinity,
|
|
extraKeys: {
|
|
Enter: 'newlineAndIndentContinueMarkdownList',
|
|
},
|
|
readOnly: props.readonly,
|
|
})
|
|
|
|
const doc = editor.getDoc()
|
|
|
|
const commands = defineMarkdownCommands(editor, {
|
|
props,
|
|
emit,
|
|
isFocused,
|
|
filesUploadingCount,
|
|
filesUploadedCount,
|
|
files,
|
|
})
|
|
const actions = defineMarkdownActions(commands, { isEditable, isFullScreen })
|
|
|
|
defineMarkdownKeyMaps(editor, actions)
|
|
|
|
defineMarkdownEvents(editor, commands, {
|
|
props,
|
|
emit,
|
|
isFocused,
|
|
files,
|
|
filesUploadingCount,
|
|
filesUploadedCount,
|
|
})
|
|
|
|
commands.refresh()
|
|
|
|
return {
|
|
editor,
|
|
unmount: () => {
|
|
editor.toTextArea()
|
|
unmountMarkdownEditor()
|
|
},
|
|
actions: {
|
|
...commands,
|
|
...actions,
|
|
handle(context, action) {
|
|
if (!props.readonly) {
|
|
isFocused.value = true
|
|
actions[action].call(context)
|
|
}
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
export function useMarkdownEditing(emit, props) {
|
|
const isFullScreen = ref(false)
|
|
const isFocused = ref(false)
|
|
const previewContent = ref('')
|
|
const visualMode = ref('write')
|
|
const statusContent = ref(
|
|
__('Attach files by dragging & dropping, selecting or pasting them.')
|
|
)
|
|
const files = ref([])
|
|
const filesUploadingCount = ref(0)
|
|
const filesUploadedCount = ref(0)
|
|
|
|
const isEditable = computed(
|
|
() => props.readonly && visualMode.value == 'write'
|
|
)
|
|
|
|
const unmountMarkdownEditor = () => {
|
|
isFullScreen.value = false
|
|
isFocused.value = false
|
|
visualMode.value = 'write'
|
|
previewContent.value = ''
|
|
filesUploadingCount.value = 0
|
|
filesUploadedCount.value = 0
|
|
files.value = []
|
|
}
|
|
|
|
if (!isNil(props.uploader)) {
|
|
watch(
|
|
[filesUploadedCount, filesUploadingCount],
|
|
([currentFilesUploaded, currentFilesCount]) => {
|
|
if (currentFilesCount > currentFilesUploaded) {
|
|
statusContent.value = __('Uploading files... (:current/:total)', {
|
|
current: currentFilesUploaded,
|
|
total: currentFilesCount,
|
|
})
|
|
} else {
|
|
statusContent.value = __(
|
|
'Attach files by dragging & dropping, selecting or pasting them.'
|
|
)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
return {
|
|
createMarkdownEditor: (context, theTextarea) => {
|
|
return bootstrap.call(context, theTextarea, {
|
|
emit,
|
|
props,
|
|
isEditable,
|
|
isFocused,
|
|
isFullScreen,
|
|
filesUploadingCount,
|
|
filesUploadedCount,
|
|
files,
|
|
unmountMarkdownEditor,
|
|
})
|
|
},
|
|
isFullScreen,
|
|
isFocused,
|
|
isEditable,
|
|
visualMode,
|
|
previewContent,
|
|
statusContent,
|
|
files,
|
|
}
|
|
}
|