quasar
Version:
Build high-performance VueJS user interfaces (SPA, PWA, SSR, Mobile and Desktop) in record time
758 lines (684 loc) • 18.9 kB
JavaScript
import {
h,
ref,
computed,
watch,
onMounted,
onBeforeUnmount,
nextTick,
getCurrentInstance
} from 'vue'
import Caret from './editor-caret.js'
import { getToolbar, getFonts, getLinkEditor } from './editor-utils.js'
import useDark, {
useDarkProps
} from '../../composables/private.use-dark/use-dark.js'
import useFullscreen, {
useFullscreenProps,
useFullscreenEmits
} from '../../composables/private.use-fullscreen/use-fullscreen.js'
import useSplitAttrs from '../../composables/use-split-attrs/use-split-attrs.js'
import { createComponent } from '../../utils/private.create/create.js'
import { stopAndPrevent } from '../../utils/event/event.js'
import extend from '../../utils/extend/extend.js'
import { shouldIgnoreKey } from '../../utils/private.keyboard/key-composition.js'
import { addFocusFn } from '../../utils/private.focus/focus-manager.js'
export default createComponent({
name: 'QEditor',
props: {
...useDarkProps,
...useFullscreenProps,
modelValue: {
type: String,
required: true
},
readonly: Boolean,
disable: Boolean,
minHeight: {
type: String,
default: '10rem'
},
maxHeight: String,
height: String,
definitions: Object,
fonts: Object,
placeholder: String,
toolbar: {
type: Array,
validator: v => v.length === 0 || v.every(group => group.length),
// long line on purpose for API validation purposes:
// oxfmt-ignore
default: () => [['left', 'center', 'right', 'justify'], ['bold', 'italic', 'underline', 'strike'], ['undo', 'redo']]
},
toolbarColor: String,
toolbarBg: String,
toolbarTextColor: String,
toolbarToggleColor: {
type: String,
default: 'primary'
},
toolbarOutline: Boolean,
toolbarPush: Boolean,
toolbarRounded: Boolean,
paragraphTag: {
type: String,
validator: v => ['div', 'p'].includes(v),
default: 'div'
},
contentStyle: Object,
contentClass: [Object, Array, String],
square: Boolean,
flat: Boolean,
dense: Boolean
},
emits: [
...useFullscreenEmits,
'update:modelValue',
'keydown',
'click',
'focus',
'blur',
'dropdownShow',
'dropdownHide',
'dropdownBeforeShow',
'dropdownBeforeHide',
'linkShow',
'linkHide'
],
setup(props, { slots, emit }) {
const { proxy } = getCurrentInstance()
const { $q } = proxy
const isDark = useDark(props, $q)
const { inFullscreen, toggleFullscreen } = useFullscreen()
const splitAttrs = useSplitAttrs()
const rootRef = ref(null)
const contentRef = ref(null)
const editLinkUrl = ref(null)
const isViewingSource = ref(false)
const editable = computed(() => !props.readonly && !props.disable)
let defaultFont, offsetBottom
let lastEmit = props.modelValue
if (__QUASAR_SSR_SERVER__ !== true) {
document.execCommand(
'defaultParagraphSeparator',
false,
props.paragraphTag
)
defaultFont = window.getComputedStyle(document.body).fontFamily
}
const toolbarBackgroundClass = computed(() =>
props.toolbarBg ? ` bg-${props.toolbarBg}` : ''
)
const buttonProps = computed(() => {
const flat = props.toolbarOutline !== true && props.toolbarPush !== true
return {
type: 'a',
flat,
noWrap: true,
outline: props.toolbarOutline,
push: props.toolbarPush,
rounded: props.toolbarRounded,
dense: true,
color: props.toolbarColor,
disable: !editable.value,
size: 'sm'
}
})
const buttonDef = computed(() => {
const e = $q.lang.editor,
i = $q.iconSet.editor
return {
bold: { cmd: 'bold', icon: i.bold, tip: e.bold, key: 66 },
italic: { cmd: 'italic', icon: i.italic, tip: e.italic, key: 73 },
strike: {
cmd: 'strikeThrough',
icon: i.strikethrough,
tip: e.strikethrough,
key: 83
},
underline: {
cmd: 'underline',
icon: i.underline,
tip: e.underline,
key: 85
},
unordered: {
cmd: 'insertUnorderedList',
icon: i.unorderedList,
tip: e.unorderedList
},
ordered: {
cmd: 'insertOrderedList',
icon: i.orderedList,
tip: e.orderedList
},
subscript: {
cmd: 'subscript',
icon: i.subscript,
tip: e.subscript,
htmlTip: 'x<subscript>2</subscript>'
},
superscript: {
cmd: 'superscript',
icon: i.superscript,
tip: e.superscript,
htmlTip: 'x<superscript>2</superscript>'
},
link: {
cmd: 'link',
disable: eVm => eVm.caret && !eVm.caret.can('link'),
icon: i.hyperlink,
tip: e.hyperlink,
key: 76
},
fullscreen: {
cmd: 'fullscreen',
icon: i.toggleFullscreen,
tip: e.toggleFullscreen,
key: 70
},
viewsource: {
cmd: 'viewsource',
icon: i.viewSource,
tip: e.viewSource
},
quote: {
cmd: 'formatBlock',
param: 'BLOCKQUOTE',
icon: i.quote,
tip: e.quote,
key: 81
},
left: { cmd: 'justifyLeft', icon: i.left, tip: e.left },
center: { cmd: 'justifyCenter', icon: i.center, tip: e.center },
right: { cmd: 'justifyRight', icon: i.right, tip: e.right },
justify: { cmd: 'justifyFull', icon: i.justify, tip: e.justify },
print: {
type: 'no-state',
cmd: 'print',
icon: i.print,
tip: e.print,
key: 80
},
outdent: {
type: 'no-state',
disable: eVm => eVm.caret && !eVm.caret.can('outdent'),
cmd: 'outdent',
icon: i.outdent,
tip: e.outdent
},
indent: {
type: 'no-state',
disable: eVm => eVm.caret && !eVm.caret.can('indent'),
cmd: 'indent',
icon: i.indent,
tip: e.indent
},
removeFormat: {
type: 'no-state',
cmd: 'removeFormat',
icon: i.removeFormat,
tip: e.removeFormat
},
hr: {
type: 'no-state',
cmd: 'insertHorizontalRule',
icon: i.hr,
tip: e.hr
},
undo: {
type: 'no-state',
cmd: 'undo',
icon: i.undo,
tip: e.undo,
key: 90
},
redo: {
type: 'no-state',
cmd: 'redo',
icon: i.redo,
tip: e.redo,
key: 89
},
h1: {
cmd: 'formatBlock',
param: 'H1',
icon: i.heading1 || i.heading,
tip: e.heading1,
htmlTip: `<h1 class="q-ma-none">${e.heading1}</h1>`
},
h2: {
cmd: 'formatBlock',
param: 'H2',
icon: i.heading2 || i.heading,
tip: e.heading2,
htmlTip: `<h2 class="q-ma-none">${e.heading2}</h2>`
},
h3: {
cmd: 'formatBlock',
param: 'H3',
icon: i.heading3 || i.heading,
tip: e.heading3,
htmlTip: `<h3 class="q-ma-none">${e.heading3}</h3>`
},
h4: {
cmd: 'formatBlock',
param: 'H4',
icon: i.heading4 || i.heading,
tip: e.heading4,
htmlTip: `<h4 class="q-ma-none">${e.heading4}</h4>`
},
h5: {
cmd: 'formatBlock',
param: 'H5',
icon: i.heading5 || i.heading,
tip: e.heading5,
htmlTip: `<h5 class="q-ma-none">${e.heading5}</h5>`
},
h6: {
cmd: 'formatBlock',
param: 'H6',
icon: i.heading6 || i.heading,
tip: e.heading6,
htmlTip: `<h6 class="q-ma-none">${e.heading6}</h6>`
},
p: {
cmd: 'formatBlock',
param: props.paragraphTag,
icon: i.heading,
tip: e.paragraph
},
code: {
cmd: 'formatBlock',
param: 'PRE',
icon: i.code,
htmlTip: `<code>${e.code}</code>`
},
'size-1': {
cmd: 'fontSize',
param: '1',
icon: i.size1 || i.size,
tip: e.size1,
htmlTip: `<font size="1">${e.size1}</font>`
},
'size-2': {
cmd: 'fontSize',
param: '2',
icon: i.size2 || i.size,
tip: e.size2,
htmlTip: `<font size="2">${e.size2}</font>`
},
'size-3': {
cmd: 'fontSize',
param: '3',
icon: i.size3 || i.size,
tip: e.size3,
htmlTip: `<font size="3">${e.size3}</font>`
},
'size-4': {
cmd: 'fontSize',
param: '4',
icon: i.size4 || i.size,
tip: e.size4,
htmlTip: `<font size="4">${e.size4}</font>`
},
'size-5': {
cmd: 'fontSize',
param: '5',
icon: i.size5 || i.size,
tip: e.size5,
htmlTip: `<font size="5">${e.size5}</font>`
},
'size-6': {
cmd: 'fontSize',
param: '6',
icon: i.size6 || i.size,
tip: e.size6,
htmlTip: `<font size="6">${e.size6}</font>`
},
'size-7': {
cmd: 'fontSize',
param: '7',
icon: i.size7 || i.size,
tip: e.size7,
htmlTip: `<font size="7">${e.size7}</font>`
}
}
})
const buttons = computed(() => {
const userDef = props.definitions || {}
const def =
props.definitions || props.fonts
? extend(
true,
{},
buttonDef.value,
userDef,
getFonts(
defaultFont,
$q.lang.editor.defaultFont,
$q.iconSet.editor.font,
props.fonts
)
)
: buttonDef.value
return props.toolbar.map(group =>
group.map(token => {
if (token.options) {
return {
type: 'dropdown',
icon: token.icon,
label: token.label,
size: 'sm',
dense: true,
fixedLabel: token.fixedLabel,
fixedIcon: token.fixedIcon,
highlight: token.highlight,
list: token.list,
options: token.options.map(item => def[item])
}
}
const obj = def[token]
if (obj) {
return obj.type === 'no-state' ||
(userDef[token] &&
(obj.cmd === void 0 ||
(buttonDef.value[obj.cmd] &&
buttonDef.value[obj.cmd].type === 'no-state')))
? obj
: Object.assign({ type: 'toggle' }, obj)
} else {
return {
type: 'slot',
slot: token
}
}
})
)
})
const eVm = {
$q,
props,
slots,
emit,
// caret (will get injected after mount)
inFullscreen,
toggleFullscreen,
runCmd,
isViewingSource,
editLinkUrl,
toolbarBackgroundClass,
buttonProps,
contentRef,
buttons,
setContent
}
watch(
() => props.modelValue,
v => {
if (lastEmit !== v) {
lastEmit = v
setContent(v, true)
}
}
)
watch(editLinkUrl, v => {
emit(`link${v ? 'Show' : 'Hide'}`)
})
const hasToolbar = computed(
() => props.toolbar && props.toolbar.length !== 0
)
const keys = computed(() => {
const k = {},
add = btn => {
if (btn.key) {
k[btn.key] = {
cmd: btn.cmd,
param: btn.param
}
}
}
buttons.value.forEach(group => {
group.forEach(token => {
if (token.options) {
token.options.forEach(add)
} else {
add(token)
}
})
})
return k
})
const innerStyle = computed(() =>
inFullscreen.value
? props.contentStyle
: [
{
minHeight: props.minHeight,
height: props.height,
maxHeight: props.maxHeight
},
props.contentStyle
]
)
const classes = computed(
() =>
`q-editor q-editor--${isViewingSource.value === true ? 'source' : 'default'}` +
(props.disable === true ? ' disabled' : '') +
(inFullscreen.value === true ? ' fullscreen column' : '') +
(props.square === true ? ' q-editor--square no-border-radius' : '') +
(props.flat === true ? ' q-editor--flat' : '') +
(props.dense === true ? ' q-editor--dense' : '') +
(isDark.value === true ? ' q-editor--dark q-dark' : '')
)
const innerClass = computed(() => [
props.contentClass,
'q-editor__content',
{
col: inFullscreen.value,
'overflow-auto': inFullscreen.value || props.maxHeight
}
])
const attributes = computed(() =>
props.disable === true ? { 'aria-disabled': 'true' } : {}
)
function onInput() {
if (contentRef.value !== null) {
const prop = `inner${isViewingSource.value === true ? 'Text' : 'HTML'}`
const val = contentRef.value[prop]
if (val !== props.modelValue) {
lastEmit = val
emit('update:modelValue', val)
}
}
}
function onKeydown(e) {
emit('keydown', e)
if (e.ctrlKey !== true || shouldIgnoreKey(e) === true) {
refreshToolbar()
return
}
const key = e.keyCode
const target = keys.value[key]
if (target !== void 0) {
const { cmd, param } = target
stopAndPrevent(e)
runCmd(cmd, param, false)
}
}
function onClick(e) {
refreshToolbar()
emit('click', e)
}
function onBlur(e) {
if (contentRef.value !== null) {
const { scrollTop, scrollHeight } = contentRef.value
offsetBottom = scrollHeight - scrollTop
}
eVm.caret.save()
emit('blur', e)
}
function onFocus(e) {
nextTick(() => {
if (contentRef.value !== null && offsetBottom !== void 0) {
contentRef.value.scrollTop =
contentRef.value.scrollHeight - offsetBottom
}
})
emit('focus', e)
}
function onFocusin(e) {
const root = rootRef.value
if (
root !== null &&
root.contains(e.target) === true &&
(e.relatedTarget === null || root.contains(e.relatedTarget) !== true)
) {
const prop = `inner${isViewingSource.value === true ? 'Text' : 'HTML'}`
eVm.caret.restorePosition(contentRef.value[prop].length)
refreshToolbar()
}
}
function onFocusout(e) {
const root = rootRef.value
if (
root !== null &&
root.contains(e.target) === true &&
(e.relatedTarget === null || root.contains(e.relatedTarget) !== true)
) {
eVm.caret.savePosition()
refreshToolbar()
}
}
function onPointerStart() {
offsetBottom = void 0
}
function onSelectionchange() {
eVm.caret.save()
}
function setContent(v, restorePosition) {
if (contentRef.value !== null) {
if (restorePosition === true) {
eVm.caret.savePosition()
}
const prop = `inner${isViewingSource.value === true ? 'Text' : 'HTML'}`
contentRef.value[prop] = v
if (restorePosition === true) {
eVm.caret.restorePosition(contentRef.value[prop].length)
refreshToolbar()
}
}
}
function runCmd(cmd, param, update = true) {
focus()
eVm.caret.restore()
eVm.caret.apply(cmd, param, () => {
focus()
eVm.caret.save()
if (update) {
refreshToolbar()
}
})
}
function refreshToolbar() {
setTimeout(() => {
editLinkUrl.value = null
proxy.$forceUpdate()
}, 1)
}
function focus() {
addFocusFn(() => {
contentRef.value?.focus({ preventScroll: true })
})
}
function getContentEl() {
return contentRef.value
}
onMounted(() => {
eVm.caret = proxy.caret = new Caret(contentRef.value, eVm)
setContent(props.modelValue)
refreshToolbar()
document.addEventListener('selectionchange', onSelectionchange)
})
onBeforeUnmount(() => {
document.removeEventListener('selectionchange', onSelectionchange)
})
// expose public methods
Object.assign(proxy, {
runCmd,
refreshToolbar,
focus,
getContentEl
})
return () => {
let toolbars
if (hasToolbar.value) {
const bars = [
h(
'div',
{
key: 'qedt_top',
class:
'q-editor__toolbar row no-wrap scroll-x' +
toolbarBackgroundClass.value
},
getToolbar(eVm)
)
]
if (editLinkUrl.value !== null) {
bars.push(
h(
'div',
{
key: 'qedt_btm',
class:
'q-editor__toolbar row no-wrap items-center scroll-x' +
toolbarBackgroundClass.value
},
getLinkEditor(eVm)
)
)
}
toolbars = h(
'div',
{
key: 'toolbar_ctainer',
class: 'q-editor__toolbars-container'
},
bars
)
}
return h(
'div',
{
ref: rootRef,
class: classes.value,
style: { height: inFullscreen.value === true ? '100%' : null },
...attributes.value,
onFocusin,
onFocusout
},
[
toolbars,
h('div', {
ref: contentRef,
style: innerStyle.value,
class: innerClass.value,
contenteditable: editable.value,
placeholder: props.placeholder,
...(__QUASAR_SSR_SERVER__ ? { innerHTML: props.modelValue } : {}),
...splitAttrs.listeners.value,
onInput,
onKeydown,
onClick,
onBlur,
onFocus,
// clean saved scroll position
onMousedown: onPointerStart,
onTouchstartPassive: onPointerStart
})
]
)
}
}
})