adds editorjs, fixes cards array reference bug
This commit is contained in:
parent
35743b54e7
commit
bf8c166a71
18 changed files with 998 additions and 49 deletions
|
@ -15,6 +15,8 @@
|
|||
"vue-router": "4.0.0-alpha.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@editorjs/editorjs": "^2.18.0",
|
||||
"@editorjs/list": "^1.5.0",
|
||||
"@vue/compiler-sfc": "3.0.0-beta.15",
|
||||
"copy-webpack-plugin": "^6.0.2",
|
||||
"css-loader": "^3.6.0",
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
.card-front, .card-back {
|
||||
.card-front {
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
}
|
||||
.card-front {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.card-front > header {
|
||||
|
@ -41,13 +39,6 @@
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-back {
|
||||
justify-content: center;
|
||||
}
|
||||
.card-back > .icon-wrapper {
|
||||
margin: 3em;
|
||||
}
|
||||
|
||||
.card-content .cdx-block {
|
||||
padding: 0;
|
||||
}
|
||||
|
|
|
@ -9,13 +9,12 @@
|
|||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import { KV, ICard } from '@/types'
|
||||
import { CardSize, defaultCardSize } from '@/consts'
|
||||
import { cardSizeToStyle } from '@/lib/card'
|
||||
import { KV } from '@/types'
|
||||
import { cardCSSVars } from '@/lib/card'
|
||||
import iconPath from '@/lib/iconPath'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Card',
|
||||
name: 'CardBack',
|
||||
props: {
|
||||
icon: String,
|
||||
color: String,
|
||||
|
@ -26,19 +25,8 @@ export default defineComponent({
|
|||
const icon = this.icon || 'plus'
|
||||
return iconPath(icon)
|
||||
},
|
||||
showBackSide (): boolean {
|
||||
return true
|
||||
},
|
||||
showFrontSide (): boolean {
|
||||
return false
|
||||
},
|
||||
cssVars (): KV<string> {
|
||||
const backgroundColor = this.color || 'transparent'
|
||||
const size = this.size as CardSize || defaultCardSize
|
||||
return {
|
||||
backgroundColor,
|
||||
...cardSizeToStyle(size)
|
||||
}
|
||||
return cardCSSVars(this.size, this.color)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@ -48,7 +36,7 @@ export default defineComponent({
|
|||
.card-back {
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
justify-content: space-evenly;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
line-height: 4rem;
|
||||
font-size: 2rem;
|
||||
|
@ -67,7 +55,7 @@ export default defineComponent({
|
|||
display: none;
|
||||
}
|
||||
.card-back > .icon-wrapper {
|
||||
width: 90%;
|
||||
width: 70%;
|
||||
margin: auto;
|
||||
}
|
||||
</style>
|
||||
|
|
68
src/components/CardFront.vue
Normal file
68
src/components/CardFront.vue
Normal file
|
@ -0,0 +1,68 @@
|
|||
<template>
|
||||
<section name="card-front" class="card card-front" :style="cssVars">
|
||||
<header>
|
||||
<h1>{{ card.name }}</h1>
|
||||
<img :src="iconPath" />
|
||||
</header>
|
||||
<main ref="cardEl" class="card-content" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from 'vue'
|
||||
import { KV } from '@/types'
|
||||
import { cardCSSVars } from '@/lib/card'
|
||||
import iconPath from '@/lib/iconPath'
|
||||
|
||||
import Editor from '@editorjs/editorjs'
|
||||
import List from '@editorjs/list'
|
||||
import { Heading, Delimiter, Charges, DnDStats } from '@/editor'
|
||||
|
||||
const editorjsConfig = {
|
||||
autofocus: false,
|
||||
hideToolbar: true,
|
||||
tools: {
|
||||
list: { class: List, inlineToolbar: false },
|
||||
heading: { class: Heading, inlineToolbar: false },
|
||||
delimiter: { class: Delimiter, inlineToolbar: false },
|
||||
charges: { class: Charges, inlineToolbar: false },
|
||||
dndstats: { class: DnDStats, inlineToolbar: false }
|
||||
},
|
||||
onReady: () => {
|
||||
console.log('editor is ready, what to do?')
|
||||
}
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CardFront',
|
||||
props: {
|
||||
card: Object,
|
||||
icon: String,
|
||||
color: String,
|
||||
size: String
|
||||
},
|
||||
setup (props) {
|
||||
const cardEl = ref(document.createElement('main'))
|
||||
const editor = new Editor({
|
||||
holder: cardEl.value,
|
||||
data: props.card?.content || {},
|
||||
...editorjsConfig
|
||||
})
|
||||
|
||||
console.log('card content', props.card?.content)
|
||||
|
||||
return { cardEl }
|
||||
},
|
||||
computed: {
|
||||
iconPath (): string {
|
||||
const icon = this.card?.icon || this.icon || 'plus'
|
||||
return iconPath(icon)
|
||||
},
|
||||
cssVars (): KV<string> {
|
||||
return cardCSSVars(this.size, this.color)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped src="@/assets/card.css" />
|
|
@ -1,13 +1,110 @@
|
|||
<template>
|
||||
<div :id="card && card.id" class="flip-card card" :style="cssVars">
|
||||
<section name="card-front" class="card-front" v-if="showFrontSide">
|
||||
<span>Front Side</span>
|
||||
</section>
|
||||
<section name="card-back" class="card-back" v-if="showBackSide">
|
||||
<div class="icon-wrapper">
|
||||
<img :src="iconPath" alt="card icon" />
|
||||
</div>
|
||||
<footer><slot name="back"></slot></footer>
|
||||
</section>
|
||||
<div class="flip-card card">
|
||||
<CardFront :card="card" :icon="icon" :color="color" :size="size" />
|
||||
<CardBack :icon="card.icon || icon" :color="card.color || color" :size="size">
|
||||
<button @click="$emit('click')">edit card</button>
|
||||
<button @click="$emit('delete')">delete card</button>
|
||||
</CardBack>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import CardFront from '@/components/CardFront.vue'
|
||||
import CardBack from '@/components/CardBack.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FlipCard',
|
||||
components: { CardFront, CardBack },
|
||||
props: {
|
||||
card: Object,
|
||||
icon: String,
|
||||
color: String,
|
||||
size: String
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.flip-card {
|
||||
position: relative;
|
||||
perspective: 600px;
|
||||
transition: transform .2s ease-out .4s;
|
||||
}
|
||||
.flip-card.card {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.flip-card.card > .card {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.flip-card > .active-background {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: -100vh;
|
||||
left: -100vw;
|
||||
width: 200vw;
|
||||
height: 200vh;
|
||||
background-color: #0008;
|
||||
}
|
||||
.flip-card.active {
|
||||
z-index: 1;
|
||||
}
|
||||
.flip-card.active > .active-background {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card-front, .card-back {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--highlight-color);
|
||||
transform: rotateX(0) rotateY(0);
|
||||
transform-style: preserve-3d;
|
||||
backface-visibility: hidden;
|
||||
transition: transform .4s ease-out;
|
||||
overflow: hidden;
|
||||
}
|
||||
.flip-card:not(.active):hover > .card-front {
|
||||
transform: rotateX(0) rotateY(179deg);
|
||||
}
|
||||
.flip-card:not(.active):hover > .card-back {
|
||||
z-index: 2;
|
||||
transform: rotateX(0) rotateY(0);
|
||||
}
|
||||
|
||||
.card-front {
|
||||
z-index: 1;
|
||||
}
|
||||
.card-front h1[contenteditable="true"] { text-decoration: underline dotted; }
|
||||
.card-front h1[contenteditable="true"]:focus { text-decoration: none; }
|
||||
|
||||
.card-back {
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
transform: rotateX(0) rotateY(-179deg);
|
||||
}
|
||||
.card-back button {
|
||||
width: 80%;
|
||||
margin: .1rem auto;
|
||||
}
|
||||
|
||||
.action-close {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
margin-top: -3rem;
|
||||
}
|
||||
|
||||
@media screen and (orientation:landscape) {
|
||||
.action-close {
|
||||
top: 3rem;
|
||||
right: -3rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
134
src/editor/charges.ts
Normal file
134
src/editor/charges.ts
Normal file
|
@ -0,0 +1,134 @@
|
|||
import { ContentlessBlock, BlockToolArgs } from './contentless-block'
|
||||
import icon from '../assets/editor/charges.svg.txt'
|
||||
import iconCircle from '../assets/editor/charges-circle.svg.txt'
|
||||
|
||||
const title = 'Charges'
|
||||
|
||||
interface ChargesData {
|
||||
variant: string;
|
||||
amount: number;
|
||||
size: number;
|
||||
stretch: boolean;
|
||||
}
|
||||
|
||||
class Charges extends ContentlessBlock {
|
||||
static MIN_SIZE = 1
|
||||
static MAX_SIZE = 5
|
||||
private _variant: string
|
||||
private _amount: number
|
||||
private _size: number
|
||||
private _stretch: boolean
|
||||
|
||||
constructor (args: BlockToolArgs) {
|
||||
super(args)
|
||||
this._settingButtons = [
|
||||
{ name: 'box', icon, action: (name: string) => this.setVariant(name) },
|
||||
{ name: 'more', icon: icon, action: () => this.increaseAmount() },
|
||||
{ name: 'bigger', icon: icon, action: () => this.increaseSize() },
|
||||
{ name: 'circle', icon: iconCircle, action: (name: string) => this.setVariant(name) },
|
||||
{ name: 'less', icon: icon, action: () => this.decreaseAmount() },
|
||||
{ name: 'smaller', icon: icon, action: () => this.decreaseSize() },
|
||||
{ name: 'toggle-stretch', icon: icon, action: () => this.toggleStretch() }
|
||||
]
|
||||
const { variant, amount, size, stretch } = (args.data || {}) as ChargesData
|
||||
|
||||
this._variant = variant || 'box'
|
||||
this._amount = amount || 5
|
||||
this._size = size || 1
|
||||
this._stretch = !(stretch === false)
|
||||
|
||||
this._element = this._render()
|
||||
}
|
||||
|
||||
private setVariant (variant: string) {
|
||||
if (this._variant === variant) return
|
||||
|
||||
const charges = Array.from(this._element.children)
|
||||
|
||||
charges.forEach(charge => {
|
||||
charge.classList.remove(`card-charge-${this._variant}`)
|
||||
charge.classList.add(`card-charge-${variant}`)
|
||||
})
|
||||
|
||||
this._variant = variant
|
||||
}
|
||||
|
||||
private toggleStretch () {
|
||||
if (this._stretch) this._element.classList.remove('card-charges-stretch')
|
||||
else this._element.classList.add('card-charges-stretch')
|
||||
this._stretch = !this._stretch
|
||||
}
|
||||
|
||||
private createCharge (): HTMLElement {
|
||||
const charge = document.createElement('DIV')
|
||||
charge.classList.add('card-charge', `card-charge-${this._variant}`, `card-charge-size-${this._size}`)
|
||||
return charge
|
||||
}
|
||||
|
||||
private increaseAmount () {
|
||||
this._element.appendChild(this.createCharge())
|
||||
this._amount++
|
||||
}
|
||||
|
||||
private decreaseAmount () {
|
||||
const child = this._element.lastElementChild
|
||||
if (child) {
|
||||
this._element.removeChild(child)
|
||||
this._amount--
|
||||
}
|
||||
}
|
||||
|
||||
private increaseSize () {
|
||||
if (this._size >= Charges.MAX_SIZE) return
|
||||
|
||||
const charges = Array.from(this._element.children)
|
||||
|
||||
charges.forEach(charge => {
|
||||
charge.classList.remove(`card-charge-size-${this._size}`)
|
||||
charge.classList.add(`card-charge-size-${this._size + 1}`)
|
||||
})
|
||||
|
||||
this._size++
|
||||
}
|
||||
|
||||
private decreaseSize () {
|
||||
if (this._size <= Charges.MIN_SIZE) return
|
||||
|
||||
const charges = Array.from(this._element.children)
|
||||
|
||||
charges.forEach(charge => {
|
||||
charge.classList.remove(`card-charge-size-${this._size}`)
|
||||
charge.classList.add(`card-charge-size-${this._size - 1}`)
|
||||
})
|
||||
|
||||
this._size--
|
||||
}
|
||||
|
||||
protected _render (): HTMLElement {
|
||||
const el = document.createElement('DIV')
|
||||
el.classList.add('card-charges-wrapper', this._CSS.block)
|
||||
|
||||
if (this._stretch) el.classList.add('card-charges-stretch')
|
||||
|
||||
for (let i = 0; i < this._amount; i++) {
|
||||
el.appendChild(this.createCharge())
|
||||
}
|
||||
|
||||
return el
|
||||
}
|
||||
|
||||
public save (): ChargesData {
|
||||
return {
|
||||
variant: this._variant,
|
||||
amount: this._amount,
|
||||
size: this._size,
|
||||
stretch: this._stretch
|
||||
}
|
||||
}
|
||||
|
||||
static get toolbox () {
|
||||
return { icon, title }
|
||||
}
|
||||
}
|
||||
|
||||
export default Charges
|
222
src/editor/content-block.ts
Normal file
222
src/editor/content-block.ts
Normal file
|
@ -0,0 +1,222 @@
|
|||
import {
|
||||
BlockTool,
|
||||
BlockToolData,
|
||||
ToolboxConfig,
|
||||
API,
|
||||
HTMLPasteEvent,
|
||||
ToolSettings,
|
||||
SanitizerConfig
|
||||
} from '@editorjs/editorjs'
|
||||
|
||||
export { HTMLPasteEvent } from '@editorjs/editorjs'
|
||||
|
||||
interface PasteConfig {
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface ContentBlockConfig extends ToolSettings {
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface ContentBlockSettingButton {
|
||||
name: string;
|
||||
icon: string;
|
||||
action: (name: string, event?: MouseEvent) => void; // action triggered by button
|
||||
isActive?: (name: string) => boolean; // determine if current button is active
|
||||
}
|
||||
|
||||
export type ContentBlockSettings = ContentBlockSettingButton[]
|
||||
|
||||
export interface ContentBlockArgs {
|
||||
api: API;
|
||||
config?: ContentBlockConfig;
|
||||
data?: BlockToolData;
|
||||
}
|
||||
|
||||
export interface CSSClasses {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export interface ContentBlockData extends BlockToolData {
|
||||
text?: string;
|
||||
}
|
||||
|
||||
type importFunction = (str: string) => ContentBlockData
|
||||
type exportFunction = (data: ContentBlockData) => string
|
||||
|
||||
export interface ConversionConfig {
|
||||
import: string | importFunction;
|
||||
export: string | exportFunction;
|
||||
}
|
||||
|
||||
export class ContentBlock implements BlockTool {
|
||||
// Default placeholder for Paragraph Tool
|
||||
static get DEFAULT_PLACEHOLDER (): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
static _supportedTags: string[] = []
|
||||
|
||||
static _toolboxConfig: ToolboxConfig = {
|
||||
icon: '<svg></svg>',
|
||||
title: 'UnnamedContentPlugin'
|
||||
}
|
||||
|
||||
protected _defaultPlaceholder (): string {
|
||||
return ContentBlock.DEFAULT_PLACEHOLDER
|
||||
}
|
||||
|
||||
protected api: API
|
||||
protected _element: HTMLElement
|
||||
protected _data: ContentBlockData
|
||||
protected _config: ContentBlockConfig
|
||||
protected _placeholder: string
|
||||
protected _CSS: CSSClasses = {}
|
||||
protected onKeyUp: (event: KeyboardEvent) => void
|
||||
protected _settingButtons: ContentBlockSettings = []
|
||||
|
||||
constructor ({ data, config, api }: ContentBlockArgs) {
|
||||
this.api = api
|
||||
this._config = config as ContentBlockConfig
|
||||
this._CSS.block = this.api.styles.block
|
||||
|
||||
this.onKeyUp = (event: KeyboardEvent) => this._onKeyUp(event)
|
||||
|
||||
// Placeholder it is first Block
|
||||
this._placeholder = config?.placeholder ? config.placeholder : this._defaultPlaceholder()
|
||||
this._data = data as ContentBlockData
|
||||
this._element = this._render()
|
||||
}
|
||||
|
||||
// Check if text content is empty and set empty string to inner html.
|
||||
// We need this because some browsers (e.g. Safari) insert <br> into empty contenteditanle elements
|
||||
_onKeyUp (event: KeyboardEvent) {
|
||||
if (event.code !== 'Backspace' && event.code !== 'Delete') return
|
||||
|
||||
if (this._element.textContent === '') {
|
||||
this._element.innerHTML = ''
|
||||
}
|
||||
}
|
||||
|
||||
// render tool view
|
||||
// whenever a redraw is needed the result is saved in this._element
|
||||
protected _render (): HTMLElement {
|
||||
const el = document.createElement('DIV')
|
||||
el.classList.add(this._CSS.block)
|
||||
el.dataset.placeholder = this._placeholder
|
||||
el.addEventListener('keyup', this.onKeyUp)
|
||||
el.innerHTML = this.data.text || ''
|
||||
el.contentEditable = 'true'
|
||||
|
||||
return el
|
||||
}
|
||||
|
||||
// Return Tool's view
|
||||
public render (): HTMLElement {
|
||||
return this._element
|
||||
}
|
||||
|
||||
// Method that specified how to merge two Text blocks.
|
||||
// Called by Editor.js by backspace at the beginning of the Block
|
||||
public merge (data: ContentBlockData) {
|
||||
this.data = {
|
||||
text: (this.data.text || '') + data.text
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Paragraph block data (by default checks for emptiness)
|
||||
public validate (savedData: ContentBlockData): boolean {
|
||||
if (!savedData.text) return false
|
||||
return savedData.text.trim() !== ''
|
||||
}
|
||||
|
||||
// Extract Tool's data from the view
|
||||
public save (toolsContent: HTMLElement): ContentBlockData {
|
||||
return {
|
||||
text: toolsContent.innerHTML
|
||||
}
|
||||
}
|
||||
|
||||
public get CSS (): CSSClasses {
|
||||
return this._CSS
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable Conversion Toolbar. Paragraph can be converted to/from other tools
|
||||
*/
|
||||
static get conversionConfig (): ConversionConfig {
|
||||
return {
|
||||
export: 'text', // to convert Paragraph to other block, use 'text' property of saved data
|
||||
import: 'text' // to covert other block's exported string to Paragraph, fill 'text' property of tool data
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitizer rules
|
||||
static get sanitize (): SanitizerConfig {
|
||||
return {
|
||||
text: { br: true }
|
||||
}
|
||||
}
|
||||
|
||||
get data (): ContentBlockData {
|
||||
const text = this._element?.innerHTML
|
||||
if (text !== undefined) this._data.text = text
|
||||
if (this._data.text === undefined) this._data.text = ''
|
||||
return this._data
|
||||
}
|
||||
|
||||
set data (data: ContentBlockData) {
|
||||
this._data = data || {}
|
||||
this._element.innerHTML = this._data.text || ''
|
||||
}
|
||||
|
||||
public renderSettings (): HTMLElement {
|
||||
const wrapper = document.createElement('DIV')
|
||||
|
||||
this._settingButtons.forEach(tune => {
|
||||
// make sure the settings button does something
|
||||
if (!tune.icon || typeof tune.action !== 'function') return
|
||||
|
||||
const { name, icon, action, isActive } = tune
|
||||
|
||||
const btn = document.createElement('SPAN')
|
||||
btn.classList.add(this.api.styles.settingsButton)
|
||||
|
||||
if (typeof isActive === 'function' && isActive(name)) {
|
||||
btn.classList.add(this.api.styles.settingsButtonActive)
|
||||
}
|
||||
btn.innerHTML = icon
|
||||
btn.addEventListener('click', event => action(name, event))
|
||||
|
||||
wrapper.appendChild(btn)
|
||||
})
|
||||
|
||||
return wrapper
|
||||
}
|
||||
|
||||
// Used by Editor.js paste handling API.
|
||||
// Provides configuration to handle the tools tags.
|
||||
static get pasteConfig (): PasteConfig {
|
||||
return {
|
||||
tags: this._supportedTags
|
||||
}
|
||||
}
|
||||
|
||||
// overwrite this if you need special handling of paste data
|
||||
protected pasteHandler (element: HTMLElement): ContentBlockData {
|
||||
return { text: element.innerText }
|
||||
}
|
||||
|
||||
// On paste callback fired from Editor.
|
||||
public onPaste (event: HTMLPasteEvent) {
|
||||
const element = event.detail.data
|
||||
this.data = this.pasteHandler(element)
|
||||
}
|
||||
|
||||
// Icon and title for displaying at the Toolbox
|
||||
static get toolbox (): ToolboxConfig {
|
||||
return this._toolboxConfig
|
||||
}
|
||||
}
|
||||
|
||||
export default ContentBlock
|
73
src/editor/contentless-block.ts
Normal file
73
src/editor/contentless-block.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
import { BlockTool, BlockToolData, ToolSettings, ToolboxConfig, API } from '@editorjs/editorjs'
|
||||
import { ContentBlockSettings, CSSClasses } from './content-block'
|
||||
|
||||
export interface BlockToolArgs {
|
||||
api: API;
|
||||
config?: ToolSettings;
|
||||
data?: BlockToolData;
|
||||
}
|
||||
|
||||
export class ContentlessBlock implements BlockTool {
|
||||
static get contentless () {
|
||||
return true
|
||||
}
|
||||
|
||||
protected api: API
|
||||
protected _element: HTMLElement
|
||||
protected _data: object
|
||||
protected _config: ToolSettings
|
||||
protected _CSS: CSSClasses = {}
|
||||
protected _settingButtons: ContentBlockSettings = []
|
||||
|
||||
constructor ({ data, config, api }: BlockToolArgs) {
|
||||
this.api = api
|
||||
this._config = config as ToolSettings
|
||||
this._data = data || {}
|
||||
this._CSS.block = this.api.styles.block
|
||||
this._element = this._render()
|
||||
}
|
||||
|
||||
protected _render (): HTMLElement {
|
||||
const el = document.createElement('DIV')
|
||||
el.classList.add(this._CSS.block)
|
||||
return el
|
||||
}
|
||||
|
||||
public render (): HTMLElement {
|
||||
return this._element
|
||||
}
|
||||
|
||||
public save (_toolsContent: HTMLElement): object {
|
||||
return {}
|
||||
}
|
||||
|
||||
public renderSettings (): HTMLElement {
|
||||
const wrapper = document.createElement('DIV')
|
||||
|
||||
this._settingButtons.forEach(tune => {
|
||||
// make sure the settings button does something
|
||||
if (!tune.icon || typeof tune.action !== 'function') return
|
||||
|
||||
const { name, icon, action, isActive } = tune
|
||||
|
||||
const btn = document.createElement('SPAN')
|
||||
btn.classList.add(this.api.styles.settingsButton)
|
||||
|
||||
if (typeof isActive === 'function' && isActive(name)) {
|
||||
btn.classList.add(this.api.styles.settingsButtonActive)
|
||||
}
|
||||
btn.innerHTML = icon
|
||||
btn.addEventListener('click', event => action(name, event))
|
||||
|
||||
wrapper.appendChild(btn)
|
||||
})
|
||||
|
||||
return wrapper
|
||||
}
|
||||
|
||||
static get toolbox (): ToolboxConfig {
|
||||
return { icon: '<svg></svg>', title: 'UnnamedPlugin' }
|
||||
}
|
||||
}
|
||||
|
||||
export default ContentlessBlock
|
53
src/editor/delimiter.ts
Normal file
53
src/editor/delimiter.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
import { ContentlessBlock, BlockToolArgs } from './contentless-block'
|
||||
import icon from '../assets/editor/delimiter.svg.txt'
|
||||
import iconR from '../assets/editor/delimiter_r.svg.txt'
|
||||
import iconL from '../assets/editor/delimiter_l.svg.txt'
|
||||
const title = 'Delimiter'
|
||||
|
||||
interface DelimiterData {
|
||||
variant: string;
|
||||
}
|
||||
|
||||
class Delimiter extends ContentlessBlock {
|
||||
private _variant = 'none'
|
||||
|
||||
constructor (args: BlockToolArgs) {
|
||||
super(args)
|
||||
this._settingButtons = [
|
||||
{ name: 'straight', icon, action: (name: string) => this.setDelimiterType(name) },
|
||||
{ name: 'pointing-left', icon: iconL, action: (name: string) => this.setDelimiterType(name) },
|
||||
{ name: 'pointing-right', icon: iconR, action: (name: string) => this.setDelimiterType(name) }
|
||||
]
|
||||
const { variant } = (args.data || {}) as DelimiterData
|
||||
if (variant) this.setDelimiterType(variant)
|
||||
}
|
||||
|
||||
private setDelimiterType (name: string) {
|
||||
this._element.classList.remove('pointing-left')
|
||||
this._element.classList.remove('pointing-right')
|
||||
this._variant = 'none'
|
||||
|
||||
if (name === 'pointing-left' || name === 'pointing-right') {
|
||||
this._variant = name
|
||||
this._element.classList.add(name)
|
||||
}
|
||||
}
|
||||
|
||||
protected _render (): HTMLElement {
|
||||
const el = document.createElement('HR')
|
||||
el.classList.add('card-delimiter', this._CSS.block)
|
||||
return el
|
||||
}
|
||||
|
||||
public save (): DelimiterData {
|
||||
return {
|
||||
variant: this._variant
|
||||
}
|
||||
}
|
||||
|
||||
static get toolbox () {
|
||||
return { icon, title }
|
||||
}
|
||||
}
|
||||
|
||||
export default Delimiter
|
106
src/editor/dnd-stats.ts
Normal file
106
src/editor/dnd-stats.ts
Normal file
|
@ -0,0 +1,106 @@
|
|||
import { ContentlessBlock, BlockToolArgs } from './contentless-block'
|
||||
import icon from '../assets/editor/charges-circle.svg.txt'
|
||||
|
||||
const title = 'DnDStats'
|
||||
|
||||
interface DnDStatsData {
|
||||
text: string;
|
||||
}
|
||||
|
||||
class DnDStats extends ContentlessBlock {
|
||||
static _toolboxConfig = { icon, title }
|
||||
private _stats = [10, 10, 10, 10, 10, 10]
|
||||
|
||||
constructor (args: BlockToolArgs) {
|
||||
super(args)
|
||||
this.data = args.data as DnDStatsData
|
||||
this._element = this._render()
|
||||
}
|
||||
|
||||
public get data () {
|
||||
return {
|
||||
text: this._stats.join(',')
|
||||
}
|
||||
}
|
||||
|
||||
public set data (data: DnDStatsData) {
|
||||
if (data.text === undefined) data.text = ''
|
||||
|
||||
const newStats = data.text.split(',')
|
||||
.map(x => parseInt(x, 10))
|
||||
.filter(x => !Number.isNaN(x))
|
||||
|
||||
while (newStats.length < 6) newStats.push(10) // fill missing stats
|
||||
|
||||
this._stats = newStats
|
||||
}
|
||||
|
||||
// creates a random four character long id
|
||||
private randomId (): string {
|
||||
const min = 46656 // '1000'
|
||||
const max = 1679615 /* 'zzzz' */ - 46656 /* '1000' */
|
||||
return (min + Math.floor(max * Math.random())).toString(36)
|
||||
}
|
||||
|
||||
private renderStatMod (value: number): string {
|
||||
const mod = Math.floor((value - 10) / 2.0)
|
||||
const sign = mod < 0 ? '' : '+'
|
||||
return ` (${sign}${mod})`
|
||||
}
|
||||
|
||||
private createStatBlock (title: string, value: number, changeHandler: (newValue: number) => void): HTMLElement {
|
||||
const id = `dnd-stat-${title}-${this.randomId()}`
|
||||
|
||||
const labelWrapper = document.createElement('label')
|
||||
const titleEl = document.createElement('span')
|
||||
const statInputEl = document.createElement('input')
|
||||
const statModEl = document.createElement('span')
|
||||
|
||||
// should allow focussing block with tab
|
||||
labelWrapper.setAttribute('z-index', '1')
|
||||
labelWrapper.classList.add('dnd-stat-block')
|
||||
labelWrapper.setAttribute('for', id)
|
||||
|
||||
titleEl.classList.add('dnd-stat-title')
|
||||
titleEl.innerText = title
|
||||
|
||||
statInputEl.id = id
|
||||
statInputEl.value = `${value}`
|
||||
statInputEl.addEventListener('input', () => {
|
||||
const value = parseInt(statInputEl.value, 10)
|
||||
statModEl.innerText = this.renderStatMod(value)
|
||||
changeHandler(value)
|
||||
})
|
||||
|
||||
statModEl.innerText = this.renderStatMod(value)
|
||||
|
||||
labelWrapper.appendChild(titleEl)
|
||||
labelWrapper.appendChild(statInputEl)
|
||||
labelWrapper.appendChild(statModEl)
|
||||
|
||||
return labelWrapper
|
||||
}
|
||||
|
||||
protected _render (): HTMLElement {
|
||||
const el = document.createElement('div')
|
||||
el.classList.add('card-dnd-stats')
|
||||
const stats = this._stats || [10, 10, 10, 10, 10, 10]
|
||||
const titles = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA']
|
||||
|
||||
stats.forEach((stat, i) => {
|
||||
const title = titles[i]
|
||||
const block = this.createStatBlock(title, stat, newValue => {
|
||||
this._stats[i] = newValue
|
||||
})
|
||||
el.appendChild(block)
|
||||
})
|
||||
|
||||
return el
|
||||
}
|
||||
|
||||
public save (): DnDStatsData {
|
||||
return this.data
|
||||
}
|
||||
}
|
||||
|
||||
export default DnDStats
|
159
src/editor/heading.ts
Normal file
159
src/editor/heading.ts
Normal file
|
@ -0,0 +1,159 @@
|
|||
import {
|
||||
ContentBlock,
|
||||
ContentBlockArgs,
|
||||
ContentBlockConfig,
|
||||
ContentBlockData
|
||||
} from './content-block'
|
||||
|
||||
import icon from '../assets/editor/header.svg.txt'
|
||||
import icon1 from '../assets/editor/header1.svg.txt'
|
||||
import icon2 from '../assets/editor/header2.svg.txt'
|
||||
import icon3 from '../assets/editor/header3.svg.txt'
|
||||
import icon4 from '../assets/editor/header4.svg.txt'
|
||||
import icon5 from '../assets/editor/header5.svg.txt'
|
||||
import icon6 from '../assets/editor/header6.svg.txt'
|
||||
|
||||
const title = 'Heading'
|
||||
|
||||
enum HeadingLevel {
|
||||
One = 1,
|
||||
Two = 2,
|
||||
Three = 3,
|
||||
Four = 4,
|
||||
Five = 5,
|
||||
Six = 6
|
||||
}
|
||||
|
||||
const icons = [null, icon1, icon2, icon3, icon4, icon5, icon6]
|
||||
|
||||
interface HeadingConfig extends ContentBlockConfig {
|
||||
placeholder?: string;
|
||||
levels?: HeadingLevel[];
|
||||
defaultLevel?: HeadingLevel;
|
||||
}
|
||||
|
||||
interface HeadingData extends ContentBlockData {
|
||||
text: string;
|
||||
level?: HeadingLevel;
|
||||
}
|
||||
|
||||
class Heading extends ContentBlock {
|
||||
static _supportedTags = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6']
|
||||
static _toolboxConfig = { icon, title }
|
||||
|
||||
protected _config: HeadingConfig
|
||||
private defaultLevel: HeadingLevel
|
||||
private currentLevel: HeadingLevel
|
||||
|
||||
constructor (args: ContentBlockArgs) {
|
||||
super(args)
|
||||
this._config = args.config as HeadingConfig
|
||||
|
||||
if (this._config.levels === undefined) {
|
||||
this._config.levels = [HeadingLevel.Two, HeadingLevel.Three]
|
||||
}
|
||||
if (this._config.defaultLevel === undefined) {
|
||||
this._config.defaultLevel = HeadingLevel.Two
|
||||
}
|
||||
if (this._config.levels.indexOf(this._config.defaultLevel) === -1) {
|
||||
console.warn('(ง\'̀-\'́)ง Heading Tool: the default level specified was not found in available levels')
|
||||
}
|
||||
this.defaultLevel = this._config.defaultLevel
|
||||
this.currentLevel = this.defaultLevel
|
||||
|
||||
// setting data will rerender the element with the right settings
|
||||
this.data = {
|
||||
level: this.currentLevel,
|
||||
text: (args.data as HeadingData).text || ''
|
||||
}
|
||||
|
||||
this._settingButtons = this._config.levels.map(level => {
|
||||
return {
|
||||
name: `H${level}`,
|
||||
icon: icons[level] || icon,
|
||||
action: (name: string) => this.setLevel(name),
|
||||
isActive: (name: string): boolean => this.isCurrentLevel(name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public get data (): HeadingData {
|
||||
return this._data as HeadingData
|
||||
}
|
||||
|
||||
public set data (data: HeadingData) {
|
||||
const currentData = this._data as HeadingData
|
||||
|
||||
if (data.level === undefined) data.level = currentData.level || this.defaultLevel
|
||||
if (data.text === undefined) data.text = currentData.text || ''
|
||||
|
||||
this._data = data
|
||||
this.currentLevel = data.level
|
||||
|
||||
const newHeader = this._render()
|
||||
if (this._element.parentNode) {
|
||||
this._element.parentNode.replaceChild(newHeader, this._element)
|
||||
}
|
||||
this._element = newHeader
|
||||
}
|
||||
|
||||
private isCurrentLevel (name: string): boolean {
|
||||
const currentLevel = `H${this.currentLevel}`
|
||||
return name === currentLevel
|
||||
}
|
||||
|
||||
private setLevel (name: string) {
|
||||
const level = parseInt(name[1], 10)
|
||||
this.data = { level, text: this._element.innerHTML }
|
||||
}
|
||||
|
||||
protected _render (): HTMLElement {
|
||||
const el = document.createElement(`H${this.currentLevel}`)
|
||||
el.innerHTML = this.data.text || ''
|
||||
el.classList.add(this._CSS.block)
|
||||
el.contentEditable = 'true'
|
||||
el.dataset.placeholder = this._config.placeholder || ''
|
||||
return el
|
||||
}
|
||||
|
||||
// Handle pasted H1-H6 tags to substitute with header tool
|
||||
protected pasteHandler (element: HTMLHeadingElement): HeadingData {
|
||||
const text = element.innerHTML
|
||||
let level = this.defaultLevel
|
||||
|
||||
const tagMatch = element.tagName.match(/H(\d)/)
|
||||
if (tagMatch) level = parseInt(tagMatch[1], 10)
|
||||
|
||||
// Fallback to nearest level when specified not available
|
||||
if (this._config.levels) {
|
||||
level = this._config.levels.reduce((prevLevel, currLevel) => {
|
||||
return Math.abs(currLevel - level) < Math.abs(prevLevel - level) ? currLevel : prevLevel
|
||||
})
|
||||
}
|
||||
|
||||
return { level, text }
|
||||
}
|
||||
|
||||
// Method that specified how to merge two Text blocks.
|
||||
// Called by Editor.js by backspace at the beginning of the Block
|
||||
public merge (data: HeadingData) {
|
||||
this.data = {
|
||||
text: this.data.text + (data.text || ''),
|
||||
level: this.data.level
|
||||
}
|
||||
}
|
||||
|
||||
// extract tools data from view
|
||||
public save (toolsContent: HTMLElement): HeadingData {
|
||||
return {
|
||||
text: toolsContent.innerHTML,
|
||||
level: this.currentLevel
|
||||
}
|
||||
}
|
||||
|
||||
static get sanitize () {
|
||||
return { level: {} }
|
||||
}
|
||||
}
|
||||
|
||||
export default Heading
|
4
src/editor/index.ts
Normal file
4
src/editor/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export { default as Delimiter } from './delimiter'
|
||||
export { default as Heading } from './heading'
|
||||
export { default as Charges } from './charges'
|
||||
export { default as DnDStats } from './dnd-stats'
|
|
@ -1,16 +1,34 @@
|
|||
import { CardSize } from '../consts'
|
||||
import { ICard } from '../types'
|
||||
import { CardSize, defaultCardSize } from '../consts'
|
||||
import { KV, ICard } from '../types'
|
||||
import randomId from './randomId'
|
||||
|
||||
export function defaultCard (): ICard {
|
||||
export function defaultCard (icon = 'robe', name = 'no title yet'): ICard {
|
||||
return {
|
||||
id: randomId(),
|
||||
name: 'no title yet',
|
||||
name,
|
||||
icon,
|
||||
tags: [],
|
||||
icon: 'robe',
|
||||
content: {
|
||||
time: Date.now(),
|
||||
blocks: [],
|
||||
blocks: [{
|
||||
type: 'heading',
|
||||
data: {
|
||||
text: 'Next Level RPG Card',
|
||||
level: 2
|
||||
}
|
||||
}, {
|
||||
type: 'delimiter',
|
||||
data: { variant: 'pointing-left' }
|
||||
}, {
|
||||
type: 'paragraph',
|
||||
data: { text: 'This card is a rich text editor so you can basically do whatever you want.' }
|
||||
}, {
|
||||
type: 'paragraph',
|
||||
data: { text: ' ' }
|
||||
}, {
|
||||
type: 'paragraph',
|
||||
data: { text: 'You see that delimiter over there? It seems to be wrong, or maybe you like it that way. In any way you can change it by clicking on it and then on the little tool button on the right.' }
|
||||
}],
|
||||
version: '2.17.0'
|
||||
}
|
||||
}
|
||||
|
@ -20,11 +38,12 @@ export function cardWHFromSize (size: CardSize): number[] {
|
|||
return size.split('x').map(v => parseFloat(v))
|
||||
}
|
||||
|
||||
export function cardSizeToStyle (size: CardSize): { width: string } {
|
||||
const [w, h] = cardWHFromSize(size)
|
||||
export function cardCSSVars (size?: CardSize | string, color?: string): KV<string> {
|
||||
const [w, h] = cardWHFromSize(size as CardSize || defaultCardSize)
|
||||
const ratio = w / h
|
||||
|
||||
return {
|
||||
backgroundColor: color || 'transparent',
|
||||
width: `calc(var(--card-height) * ${ratio})`
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ export const defaultDeckValues: IDeck = {
|
|||
|
||||
export function defaultDeck (): IDeck {
|
||||
const newDeck = { ...defaultDeckValues }
|
||||
newDeck.cards = [] // make sure not to copy a reference
|
||||
newDeck.id = randomId()
|
||||
return newDeck
|
||||
}
|
||||
|
|
8
src/shims.d.ts
vendored
8
src/shims.d.ts
vendored
|
@ -3,3 +3,11 @@ declare module "*.vue" {
|
|||
const Component: ReturnType<typeof defineComponent>
|
||||
export default Component
|
||||
}
|
||||
|
||||
declare module '*.txt' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
|
||||
declare module '@editorjs/paragraph'
|
||||
declare module '@editorjs/list'
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { reactive, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { State, KV } from '../types'
|
||||
import { DeckDB } from '../storage'
|
||||
import { defaultDeck } from '../lib/deck'
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
</header>
|
||||
|
||||
<section name="deck-cards" class="cards" :class="{ centered: deck.cards.length === 0 }">
|
||||
<CardBack v-for="card in deck.cards"
|
||||
<FlipCard v-for="card in deck.cards"
|
||||
:key="card.id"
|
||||
:id="card.id"
|
||||
:card="card"
|
||||
|
@ -37,12 +37,13 @@ import { useRoute } from 'vue-router'
|
|||
import { IDeck } from '@/types'
|
||||
import state from '@/state'
|
||||
import iconPath from '@/lib/iconPath'
|
||||
import FlipCard from '@/components/FlipCard.vue'
|
||||
import CardBack from '@/components/CardBack.vue'
|
||||
|
||||
const name = 'Deck'
|
||||
|
||||
export default defineComponent({
|
||||
components: { CardBack },
|
||||
components: { FlipCard, CardBack },
|
||||
setup () {
|
||||
const route = useRoute()
|
||||
|
||||
|
|
23
yarn.lock
23
yarn.lock
|
@ -28,6 +28,19 @@
|
|||
lodash "^4.17.13"
|
||||
to-fast-properties "^2.0.0"
|
||||
|
||||
"@editorjs/editorjs@^2.18.0":
|
||||
version "2.18.0"
|
||||
resolved "https://registry.yarnpkg.com/@editorjs/editorjs/-/editorjs-2.18.0.tgz#bde42183013f5ca98693e77986cc9d8b2c9a1244"
|
||||
integrity sha512-9OKs580JFKoXCAw7llb19E+qxY6QuzgDBq50cKbyOS1Lt+BglTq/zBdXxmRWNRTlCMxjTB1vgnq70+OjEyDSlw==
|
||||
dependencies:
|
||||
codex-notifier "^1.1.2"
|
||||
codex-tooltip "^1.0.1"
|
||||
|
||||
"@editorjs/list@^1.5.0":
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@editorjs/list/-/list-1.5.0.tgz#8675c82caa25f50744a4b072a7911163ce26bd74"
|
||||
integrity sha512-LzZuJwJ2HxCkuaPrp3zYdQGvMC8dzXjewqWEBZ9mpq0fVwBAse4o9QB2mWvJxZ93UtLqQE7f9vrbHotG2uW9Qg==
|
||||
|
||||
"@jimp/bmp@^0.9.8":
|
||||
version "0.9.8"
|
||||
resolved "https://registry.yarnpkg.com/@jimp/bmp/-/bmp-0.9.8.tgz#5933ab8fb359889bec380b0f7802163374933624"
|
||||
|
@ -1484,6 +1497,16 @@ code-point-at@^1.0.0:
|
|||
resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
|
||||
integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=
|
||||
|
||||
codex-notifier@^1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/codex-notifier/-/codex-notifier-1.1.2.tgz#a733079185f4c927fa296f1d71eb8753fe080895"
|
||||
integrity sha512-DCp6xe/LGueJ1N5sXEwcBc3r3PyVkEEDNWCVigfvywAkeXcZMk9K41a31tkEFBW0Ptlwji6/JlAb49E3Yrxbtg==
|
||||
|
||||
codex-tooltip@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/codex-tooltip/-/codex-tooltip-1.0.1.tgz#f6e4f39d81507f9c455b667f1287746d14ee8056"
|
||||
integrity sha512-1xLb1NZbxguNtf02xBRhDphq/EXvMMeEbY0ievjQTHqf8UjXsD41evGk9rqcbjpl+JOjNgtwnp1OaU/X/h6fhQ==
|
||||
|
||||
collection-visit@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0"
|
||||
|
|
Loading…
Add table
Reference in a new issue