From 738dce488475df99df523d470cb44344b91ba5fd Mon Sep 17 00:00:00 2001 From: koehr Date: Fri, 3 Apr 2020 00:24:57 +0200 Subject: [PATCH] Fixes problems with bogus root level text nodes Depending on the browser in different situations the root node itself is selected and new text ends up in a text node on root level instead of a new paragraph. This happens in: * Firefox: after inserting a closed block like a horizontal rule * Chromium: after inserting or selecting such a closed block Now instead of inserting a paragraph directly after inserting an HR, the editor simply checks for normal text input inside the root node and wraps the newly written text with a paragraph (and moves the caret to the end of the paragraph because chromium moves it to the beginning of the line) --- src/components/deck-card-editor.vue | 28 +++++++++++++++++++++++++--- src/editor/caret.ts | 19 +++++++++++++++++++ src/editor/index.ts | 23 ++++++++++++++--------- src/editor/node.ts | 18 ++++++++++++++++++ 4 files changed, 76 insertions(+), 12 deletions(-) create mode 100644 src/editor/caret.ts create mode 100644 src/editor/node.ts diff --git a/src/components/deck-card-editor.vue b/src/components/deck-card-editor.vue index 96f6349..d69a8a2 100644 --- a/src/components/deck-card-editor.vue +++ b/src/components/deck-card-editor.vue @@ -33,7 +33,10 @@ import { getActiveMarksAndBlocks, State, movementKeys, - controlSequenceKeys + controlSequenceKeys, + isRootNode, + isTextNode, + moveCaretToEOL } from '@/editor' @Component({ @@ -76,7 +79,6 @@ export default class DeckCardEditor extends Vue { } private editorAction (action: string) { - console.log('action', action) const content = this.$refs.content as HTMLElement content.focus() @@ -104,7 +106,27 @@ export default class DeckCardEditor extends Vue { // arrow keys, enter, delete, etc const isMove = movementKeys.indexOf(event.key) >= 0 - if (isCtrlSq || isMove) this.syncMenuState() + if (isCtrlSq || isMove) { + return this.syncMenuState() + } else if (!event.ctrlKey && event.key.length === 1) { + // this should capture all normal typable letters and numbers + // TODO: this needs to be done on text pasting as well + + // some browsers create bogus root level text nodes, so whenever + // something is typed in such a root level node, we simply wrap it with + // a paragraph + const sel = window.getSelection()?.focusNode + if (sel && isTextNode(sel) && isRootNode(sel.parentElement as HTMLElement)) { + console.debug(`Typed letter "${event.key} into root node, throwing a

at it!"`) + document.execCommand('formatblock', false, 'P') + + // Firefox behaves nicely and leaves the caret alone after surrounding + // the text node with a

. Unlike Chromium that moves the caret to + // the beginning of the new paragraph. To mitigate that, we set the + // caret to end-of-line manually. + moveCaretToEOL() + } + } } private start () { diff --git a/src/editor/caret.ts b/src/editor/caret.ts new file mode 100644 index 0000000..0dcf253 --- /dev/null +++ b/src/editor/caret.ts @@ -0,0 +1,19 @@ +function collapseRange (node: Node, toStart = false) { + const range = document.createRange() + range.selectNode(node) + range.collapse(toStart) + const sel = window.getSelection() + if (sel) { + sel.removeAllRanges() + sel.addRange(range) + } +} + +export function moveCaretToBOL () { + const node = window.getSelection()?.focusNode + if (node) collapseRange(node, true) +} +export function moveCaretToEOL () { + const node = window.getSelection()?.focusNode + if (node) collapseRange(node, false) +} diff --git a/src/editor/index.ts b/src/editor/index.ts index 8f23a94..09028fe 100644 --- a/src/editor/index.ts +++ b/src/editor/index.ts @@ -1,5 +1,18 @@ import { elementNameToMenuState, marks, blocks } from './constants' +export { + isRootNode, + isRootChild, + isElementNode, + isTextNode, + isEmptyTextNode +} from './node' + +export { + moveCaretToBOL, + moveCaretToEOL +} from './caret' + export type State = KV export { movementKeys, @@ -14,14 +27,6 @@ function simpleAction (cmd: string, arg?: string): () => boolean { } } -function insertHorizontalRule (): () => boolean { - return () => { - const hr = document.execCommand('insertHorizontalRule') - const p = document.execCommand('formatblock', false, 'P') - return hr && p - } -} - export const menuActionToCommand: KV<() => boolean> = { paragraph: simpleAction('formatblock', 'P'), heading1: simpleAction('formatblock', 'H1'), @@ -29,7 +34,7 @@ export const menuActionToCommand: KV<() => boolean> = { heading3: simpleAction('formatblock', 'H3'), bulletList: simpleAction('insertUnorderedList'), numberedList: simpleAction('insertOrderedList'), - separator: insertHorizontalRule(), + separator: simpleAction('insertHorizontalRule'), bold: simpleAction('bold'), italic: simpleAction('italic') } diff --git a/src/editor/node.ts b/src/editor/node.ts new file mode 100644 index 0000000..7af5223 --- /dev/null +++ b/src/editor/node.ts @@ -0,0 +1,18 @@ +const { TEXT_NODE, ELEMENT_NODE } = Node + +export function isTextNode ({ nodeType }: Node): boolean { + return nodeType === TEXT_NODE +} +export function isElementNode ({ nodeType }: Node): boolean { + return nodeType === ELEMENT_NODE +} +export function isEmptyTextNode (node: Node): boolean { + return isTextNode(node) && (node as CharacterData).data === '' +} +export function isRootNode (node: Node): boolean { + return (node as HTMLElement).contentEditable === 'true' +} +export function isRootChild (node: Node): boolean { + // TODO: maybe use a data attribute or something for saver identification + return node.parentElement?.contentEditable === 'true' +}