
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)
197 lines
4.8 KiB
Vue
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>
|