rpg-cards-ng/src/components/deck-card-editor.vue
koehr 2085e22688 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)
2020-04-03 00:24:57 +02:00

197 lines
4.8 KiB
Vue

<template>
<main>
<deck-card-editor-menu
:active="contentInFocus"
@action="editorAction"
v-model="menuState"
/>
<div
ref="content"
class="card-content"
:contenteditable="active"
@focus="start"
@click="syncMenuStateIfFocussed"
@keyup="syncMenuStateOnKeyPress"
@blur="stop"
>
<h2>card content</h2>
<hr />
<p><b>foo:</b> boom</p>
<p><b>bar:</b> blam</p>
<hr />
<p>Some description maybe?</p>
</div>
</main>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'
import DeckCardEditorMenu from '@/components/deck-card-editor-menu.vue'
import {
menuActionToCommand,
getActiveMarksAndBlocks,
State,
movementKeys,
controlSequenceKeys,
isRootNode,
isTextNode,
moveCaretToEOL
} from '@/editor'
@Component({
components: { DeckCardEditorMenu }
})
export default class DeckCardEditor extends Vue {
@Prop() public readonly active!: boolean
@Prop() public readonly content!: Card['content']
private contentInFocus = false
private defaultMenuState (): State {
return {
bold: false,
italic: false,
paragraph: true,
heading1: false,
heading2: false,
heading3: false,
bulletList: false,
spacer: false,
separator: false,
statBlock: false
}
}
private menuState = this.defaultMenuState()
private resetMenuState () {
this.menuState = this.defaultMenuState()
}
private setMenuState (marks: string[], block: string) {
this.resetMenuState()
marks.forEach(mark => { this.menuState[mark] = true })
if (block !== 'paragraph') {
this.menuState.paragraph = false
this.menuState[block] = true
}
}
private editorAction (action: string) {
const content = this.$refs.content as HTMLElement
content.focus()
const cmd = menuActionToCommand[action]
cmd()
this.$nextTick(() => this.syncMenuState())
}
private syncMenuState () {
const sel = window.getSelection()?.focusNode
if (!sel) return
const { marks, block } = getActiveMarksAndBlocks(sel as HTMLElement)
this.setMenuState(marks, block)
}
private syncMenuStateIfFocussed () {
if (this.contentInFocus) this.syncMenuState()
}
private syncMenuStateOnKeyPress (event: KeyboardEvent) {
// undo/redo/cut/paste
const isCtrlSq = event.ctrlKey && controlSequenceKeys.indexOf(event.key) >= 0
// arrow keys, enter, delete, etc
const isMove = movementKeys.indexOf(event.key) >= 0
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 <p> at it!"`)
document.execCommand('formatblock', false, 'P')
// Firefox behaves nicely and leaves the caret alone after surrounding
// the text node with a <p>. 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 () {
this.contentInFocus = true
this.syncMenuState()
// insert paragraphs instead of DIVs on enter
document.execCommand('defaultParagraphSeparator', false, 'p')
}
private stop () {
this.contentInFocus = false
}
}
</script>
<style>
.card-content p {
margin: 0;
line-height: 1.2;
}
.card-content ul {
list-style-position: inside;
margin: 0;
padding-left: .5em;
}
.card-content li > p {
display: inline;
}
.card-content h2 {
font-size: 1.4rem;
color: var(--highlight-color);
margin: 0;
font-weight: normal;
}
.card-content h3 {
font-size: 1.4rem;
color: var(--highlight-color);
margin: 0 0 .2em 0;
font-weight: normal;
font-variant: small-caps;
line-height: .9em;
border-bottom: 1px solid var(--highlight-color);
}
.card-content hr {
height: 0;
margin: .2em 0;
border: 2px solid var(--highlight-color);
}
.card-content hr.pointing-right {
height: 0;
margin: .2em 0;
border-style: solid;
border-width: 2px 0 2px 220px;
border-color: transparent transparent transparent var(--highlight-color);
}
.card-content hr.pointing-left {
height: 0;
margin: .2em 0;
border-style: solid;
border-width: 2px 220px 2px 0;
border-color: transparent var(--highlight-color) transparent transparent;
}
[contenteditable="true"] { outline: none; }
</style>