Compare commits
13 Commits
master
...
own-rich-t
Author | SHA1 | Date |
---|---|---|
koehr | e259186d4c | 5 years ago |
koehr | e7b5cdcbde | 5 years ago |
koehr | 58d3970e8e | 5 years ago |
koehr | 4482fa2fcf | 5 years ago |
koehr | 35ef3505ce | 5 years ago |
koehr | 2085e22688 | 5 years ago |
koehr | 18e043baad | 5 years ago |
koehr | 8668838238 | 5 years ago |
koehr | bfa4ef8ba0 | 5 years ago |
koehr | 5986e599fd | 5 years ago |
koehr | 42dfa704d7 | 5 years ago |
koehr | e33adfe230 | 5 years ago |
koehr | 591530246b | 5 years ago |
@ -0,0 +1 @@
|
|||||||
|
/* /index.html 200
|
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 16 KiB |
After Width: | Height: | Size: 20 KiB |
After Width: | Height: | Size: 22 KiB |
After Width: | Height: | Size: 22 KiB |
After Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 1.4 KiB |
@ -0,0 +1,136 @@
|
|||||||
|
<template>
|
||||||
|
<menu class="menu-bar" :class="{ active }">
|
||||||
|
<button class="editor-button-bold" :class="{ active: value.bold }" @click="menuAction('bold')" />
|
||||||
|
<button class="editor-button-italic" :class="{ active: value.italic }" @click="menuAction('italic')" />
|
||||||
|
|
||||||
|
<button class="editor-button-paragraph" :class="{ active: value.paragraph }" @click="menuAction('paragraph')" />
|
||||||
|
<button class="editor-button-heading2" :class="{ active: value.heading2 }" @click="menuAction('heading2')" />
|
||||||
|
<button class="editor-button-heading3" :class="{ active: value.heading3 }" @click="menuAction('heading3')" />
|
||||||
|
|
||||||
|
<button class="editor-button-bullet-list" :class="{ active: value.bulletList }" @click="menuAction('bulletList')" />
|
||||||
|
<button class="editor-button-horizontal-rule" :class="{ active: value.separator}" @click="menuAction('separator')" />
|
||||||
|
|
||||||
|
<button class="editor-button-dropdown" :class="{ active: dropdownOpen }" @click="toggleDropdown" />
|
||||||
|
|
||||||
|
<div class="extended-menu" v-show="dropdownOpen">
|
||||||
|
<button class="extended-menu-button" @click="extMenuAction('statBlock')">Stat Block (DnD5e)</button>
|
||||||
|
<button class="extended-menu-button" @click="extMenuAction('boxes')">Empty Boxes</button>
|
||||||
|
</div>
|
||||||
|
</menu>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Prop, Vue } from 'vue-property-decorator'
|
||||||
|
import { blocks, marks, State } from '@/editor'
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export default class DeckCardEditorMenu extends Vue {
|
||||||
|
@Prop() public readonly active!: boolean
|
||||||
|
@Prop() public readonly value!: State
|
||||||
|
|
||||||
|
private dropdownOpen = false
|
||||||
|
|
||||||
|
private menuAction (name: string) {
|
||||||
|
const newState = { ...this.value }
|
||||||
|
|
||||||
|
if (blocks.indexOf(name) >= 0) { // blocks behave like radio buttons
|
||||||
|
blocks.forEach(block => {
|
||||||
|
newState[block] = false
|
||||||
|
})
|
||||||
|
newState[name] = true
|
||||||
|
} else if (marks.indexOf(name)) { // marks behave like checkboxes
|
||||||
|
newState[name] = !newState[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$emit('input', newState)
|
||||||
|
this.$emit('action', name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private toggleDropdown () {
|
||||||
|
this.dropdownOpen = !this.dropdownOpen
|
||||||
|
this.$emit('action', 'refocus')
|
||||||
|
}
|
||||||
|
|
||||||
|
private extMenuAction (name: string) {
|
||||||
|
this.menuAction(name)
|
||||||
|
this.dropdownOpen = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card-front > main > .menu-bar {
|
||||||
|
position: absolute;
|
||||||
|
width: 70%;
|
||||||
|
margin: -3rem 0 0 -1rem;
|
||||||
|
padding: .2rem 1rem;
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity .3s .2s, visibility .3s .2s;
|
||||||
|
background-color: var(--highlight-color);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.card-front > main > .menu-bar.active {
|
||||||
|
opacity: 1.0;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
.menu-bar > button {
|
||||||
|
position: relative;
|
||||||
|
width: 1.6rem;
|
||||||
|
height: 1.6rem;
|
||||||
|
margin: 0 .1rem;
|
||||||
|
background-color: #EEE;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 75%;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.menu-bar > button:after {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 1.6rem;
|
||||||
|
width: 1.6rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
.menu-bar > button.active {
|
||||||
|
background-color: #FF0;
|
||||||
|
}
|
||||||
|
.editor-button-bold { background-image: url(../assets/zondicons/format-bold.svg); }
|
||||||
|
.editor-button-italic { background-image: url(../assets/zondicons/format-italic.svg); }
|
||||||
|
.editor-button-bullet-list { background-image: url(../assets/zondicons/list-bullet.svg); }
|
||||||
|
|
||||||
|
.editor-button-heading2:after { content: 'H2'; }
|
||||||
|
.editor-button-heading3:after { content: 'H3'; }
|
||||||
|
.editor-button-paragraph:after { content: 'P'; }
|
||||||
|
.editor-button-horizontal-rule:after { content: '—'; }
|
||||||
|
|
||||||
|
.editor-button-stat-block:after { content: 'ST'; }
|
||||||
|
|
||||||
|
.menu-bar > button.editor-button-dropdown {
|
||||||
|
width: 3.6rem;
|
||||||
|
}
|
||||||
|
.menu-bar > button.editor-button-dropdown:after {
|
||||||
|
content: ' more ';
|
||||||
|
width: 90%;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extended-menu {
|
||||||
|
width: 100%;
|
||||||
|
height: 4rem;
|
||||||
|
padding-top: .5rem;
|
||||||
|
background: var(--highlight-color);
|
||||||
|
}
|
||||||
|
.extended-menu-button {
|
||||||
|
width: 97%;
|
||||||
|
height: 1.6rem;
|
||||||
|
margin: 0 .1rem;
|
||||||
|
background-color: #EEE;
|
||||||
|
color: black;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
</style>
|
@ -0,0 +1,197 @@
|
|||||||
|
<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]
|
||||||
|
if (cmd) 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>
|
@ -0,0 +1,33 @@
|
|||||||
|
import { getFocussedNode } from './node'
|
||||||
|
|
||||||
|
function applyRange (callback: (range: Range) => void) {
|
||||||
|
const range = document.createRange()
|
||||||
|
callback(range)
|
||||||
|
|
||||||
|
const sel = window.getSelection()
|
||||||
|
if (sel) {
|
||||||
|
sel.removeAllRanges()
|
||||||
|
sel.addRange(range)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function collapseRange (node: Node, toStart = false) {
|
||||||
|
applyRange(range => {
|
||||||
|
range.selectNode(node)
|
||||||
|
range.collapse(toStart)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveCaretToBOL () {
|
||||||
|
const node = getFocussedNode()
|
||||||
|
if (node) collapseRange(node, true)
|
||||||
|
}
|
||||||
|
export function moveCaretToEOL () {
|
||||||
|
const node = getFocussedNode()
|
||||||
|
if (node) collapseRange(node, false)
|
||||||
|
}
|
||||||
|
export function selectLine () {
|
||||||
|
const node = getFocussedNode()
|
||||||
|
if (node) {
|
||||||
|
applyRange(range => range.selectNodeContents(node))
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,41 @@
|
|||||||
|
export const movementKeys = [
|
||||||
|
'ArrowLeft',
|
||||||
|
'ArrowRight',
|
||||||
|
'ArrowUp',
|
||||||
|
'ArrowDown',
|
||||||
|
'Delete',
|
||||||
|
'Backspace',
|
||||||
|
'Enter',
|
||||||
|
'Home',
|
||||||
|
'End',
|
||||||
|
'PageUp',
|
||||||
|
'PageDown'
|
||||||
|
]
|
||||||
|
|
||||||
|
export const controlSequenceKeys = ['p', 'x', 'y', 'z', 'Z']
|
||||||
|
|
||||||
|
export const elementNameToMenuState: KV<string> = {
|
||||||
|
B: 'bold',
|
||||||
|
STRONG: 'bold',
|
||||||
|
I: 'italic',
|
||||||
|
EM: 'italic',
|
||||||
|
P: 'paragraph',
|
||||||
|
H1: 'heading1',
|
||||||
|
H2: 'heading2',
|
||||||
|
H3: 'heading3',
|
||||||
|
UL: 'bulletList',
|
||||||
|
OL: 'numberedList',
|
||||||
|
HR: 'separator'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const marks = ['bold', 'italic']
|
||||||
|
export const blocks = [
|
||||||
|
'paragraph',
|
||||||
|
'heading1',
|
||||||
|
'heading2',
|
||||||
|
'heading3',
|
||||||
|
'bulletList',
|
||||||
|
'spacer',
|
||||||
|
'separator',
|
||||||
|
'statBlock'
|
||||||
|
]
|
@ -0,0 +1,81 @@
|
|||||||
|
import { elementNameToMenuState, marks, blocks } from './constants'
|
||||||
|
|
||||||
|
export {
|
||||||
|
isRootNode,
|
||||||
|
isRootChild,
|
||||||
|
isElementNode,
|
||||||
|
isTextNode,
|
||||||
|
isEmptyTextNode,
|
||||||
|
getFocussedNode
|
||||||
|
} from './node'
|
||||||
|
|
||||||
|
export {
|
||||||
|
moveCaretToBOL,
|
||||||
|
moveCaretToEOL,
|
||||||
|
selectLine
|
||||||
|
} from './caret'
|
||||||
|
|
||||||
|
export type State = KV<boolean>
|
||||||
|
export {
|
||||||
|
movementKeys,
|
||||||
|
controlSequenceKeys,
|
||||||
|
marks,
|
||||||
|
blocks
|
||||||
|
} from './constants'
|
||||||
|
|
||||||
|
function simpleAction (cmd: string, arg?: string): () => boolean {
|
||||||
|
return () => {
|
||||||
|
return document.execCommand(cmd, false, arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const menuActionToCommand: KV<() => boolean> = {
|
||||||
|
paragraph: simpleAction('formatblock', 'P'),
|
||||||
|
heading1: simpleAction('formatblock', 'H1'),
|
||||||
|
heading2: simpleAction('formatblock', 'H2'),
|
||||||
|
heading3: simpleAction('formatblock', 'H3'),
|
||||||
|
bulletList: simpleAction('insertUnorderedList'),
|
||||||
|
numberedList: simpleAction('insertOrderedList'),
|
||||||
|
separator: simpleAction('insertHorizontalRule'),
|
||||||
|
bold: simpleAction('bold'),
|
||||||
|
italic: simpleAction('italic')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveMarksAndBlocks (el: HTMLElement): {
|
||||||
|
marks: string[];
|
||||||
|
block: string;
|
||||||
|
} {
|
||||||
|
let activeBlock = 'paragraph'
|
||||||
|
const activeMarks: string[] = []
|
||||||
|
|
||||||
|
const focussedEl = el.nodeName === '#text' ? el.parentElement : el
|
||||||
|
if (!focussedEl) return { marks: activeMarks, block: activeBlock }
|
||||||
|
|
||||||
|
const focussedState = elementNameToMenuState[focussedEl.nodeName]
|
||||||
|
if (!focussedState) return { marks: activeMarks, block: activeBlock }
|
||||||
|
|
||||||
|
if (blocks.indexOf(focussedState) >= 0) {
|
||||||
|
activeBlock = focussedState
|
||||||
|
return { marks: activeMarks, block: activeBlock }
|
||||||
|
}
|
||||||
|
|
||||||
|
let wrappingEl = focussedEl.parentElement
|
||||||
|
let wrappingState: string
|
||||||
|
|
||||||
|
if (marks.indexOf(focussedState) >= 0) {
|
||||||
|
activeMarks.push(focussedState)
|
||||||
|
|
||||||
|
while (wrappingEl) {
|
||||||
|
wrappingState = elementNameToMenuState[wrappingEl.nodeName]
|
||||||
|
if (marks.indexOf(wrappingState) < 0) {
|
||||||
|
if (blocks.indexOf(wrappingState) >= 0) activeBlock = wrappingState
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
activeMarks.push(wrappingState)
|
||||||
|
wrappingEl = wrappingEl.parentElement
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { marks: activeMarks, block: activeBlock }
|
||||||
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
const { TEXT_NODE, ELEMENT_NODE } = Node
|
||||||
|
export function getFocussedNode (): Node | null {
|
||||||
|
return window.getSelection()?.focusNode || null
|
||||||
|
}
|
||||||
|
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'
|
||||||
|
}
|