add nova
This commit is contained in:
392
nova/resources/js/composables/useMarkdownEditing.js
Normal file
392
nova/resources/js/composables/useMarkdownEditing.js
Normal file
@@ -0,0 +1,392 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user