UNPKG

quasar

Version:

Build high-performance VueJS user interfaces (SPA, PWA, SSR, Mobile and Desktop) in record time

544 lines (470 loc) 16.9 kB
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.js' import useFullscreen, { useFullscreenProps, useFullscreenEmits } from '../../composables/private/use-fullscreen.js' import useSplitAttrs from '../../composables/private/use-split-attrs.js' import { createComponent } from '../../utils/private/create.js' import { stopAndPrevent } from '../../utils/event.js' import extend from '../../utils/extend.js' import { shouldIgnoreKey } from '../../utils/private/key-composition.js' import { addFocusFn } from '../../utils/private/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), default () { return [ [ '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', 'mouseup', 'keyup', 'touchend', 'focus', 'blur', 'dropdownShow', 'dropdownHide', 'dropdownBeforeShow', 'dropdownBeforeHide', 'linkShow', 'linkHide' ], setup (props, { slots, emit, attrs }) { const { proxy, vnode } = getCurrentInstance() const { $q } = proxy const isDark = useDark(props, $q) const { inFullscreen, toggleFullscreen } = useFullscreen() const splitAttrs = useSplitAttrs(attrs, vnode) 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 // eslint-disable-line 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' } : (props.readonly === true ? { 'aria-readonly': '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 (e) { 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 !== null && 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)) ] 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 }) ]) } } })