save card content, add charges component
This commit is contained in:
parent
fb5b5add6e
commit
600af1679a
17 changed files with 529 additions and 269 deletions
1
src/assets/editor/charges-circle.svg.txt
Normal file
1
src/assets/editor/charges-circle.svg.txt
Normal file
|
@ -0,0 +1 @@
|
|||
<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><circle r="7" cx="7" cy="7" stroke="black" stroke-width="2" fill="none" /></svg>
|
After Width: | Height: | Size: 164 B |
1
src/assets/editor/charges.svg.txt
Normal file
1
src/assets/editor/charges.svg.txt
Normal file
|
@ -0,0 +1 @@
|
|||
<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><rect width="14" height="14" stroke="black" stroke-width="2" fill="none" /></svg>
|
After Width: | Height: | Size: 165 B |
1
src/assets/editor/list.svg.txt
Normal file
1
src/assets/editor/list.svg.txt
Normal file
|
@ -0,0 +1 @@
|
|||
<svg width="17" height="13" viewBox="0 0 17 13" xmlns="http://www.w3.org/2000/svg"> <path d="M5.625 4.85h9.25a1.125 1.125 0 0 1 0 2.25h-9.25a1.125 1.125 0 0 1 0-2.25zm0-4.85h9.25a1.125 1.125 0 0 1 0 2.25h-9.25a1.125 1.125 0 0 1 0-2.25zm0 9.85h9.25a1.125 1.125 0 0 1 0 2.25h-9.25a1.125 1.125 0 0 1 0-2.25zm-4.5-5a1.125 1.125 0 1 1 0 2.25 1.125 1.125 0 0 1 0-2.25zm0-4.85a1.125 1.125 0 1 1 0 2.25 1.125 1.125 0 0 1 0-2.25zm0 9.85a1.125 1.125 0 1 1 0 2.25 1.125 1.125 0 0 1 0-2.25z"/></svg>
|
After Width: | Height: | Size: 488 B |
1
src/assets/editor/list_ordered.svg.txt
Normal file
1
src/assets/editor/list_ordered.svg.txt
Normal file
|
@ -0,0 +1 @@
|
|||
<svg width="17" height="13" viewBox="0 0 17 13" xmlns="http://www.w3.org/2000/svg"><path d="M5.819 4.607h9.362a1.069 1.069 0 0 1 0 2.138H5.82a1.069 1.069 0 1 1 0-2.138zm0-4.607h9.362a1.069 1.069 0 0 1 0 2.138H5.82a1.069 1.069 0 1 1 0-2.138zm0 9.357h9.362a1.069 1.069 0 0 1 0 2.138H5.82a1.069 1.069 0 0 1 0-2.137zM1.468 4.155V1.33c-.554.404-.926.606-1.118.606a.338.338 0 0 1-.244-.104A.327.327 0 0 1 0 1.59c0-.107.035-.184.105-.234.07-.05.192-.114.369-.192.264-.118.475-.243.633-.373.158-.13.298-.276.42-.438a3.94 3.94 0 0 1 .238-.298C1.802.019 1.872 0 1.975 0c.115 0 .208.042.277.127.07.085.105.202.105.351v3.556c0 .416-.15.624-.448.624a.421.421 0 0 1-.32-.127c-.08-.085-.121-.21-.121-.376zm-.283 6.664h1.572c.156 0 .275.03.358.091a.294.294 0 0 1 .123.25.323.323 0 0 1-.098.238c-.065.065-.164.097-.296.097H.629a.494.494 0 0 1-.353-.119.372.372 0 0 1-.126-.28c0-.068.027-.16.081-.273a.977.977 0 0 1 .178-.268c.267-.264.507-.49.722-.678.215-.188.368-.312.46-.371.165-.11.302-.222.412-.334.109-.112.192-.226.25-.344a.786.786 0 0 0 .085-.345.6.6 0 0 0-.341-.553.75.75 0 0 0-.345-.08c-.263 0-.47.11-.62.329-.02.029-.054.107-.101.235a.966.966 0 0 1-.16.295c-.059.069-.145.103-.26.103a.348.348 0 0 1-.25-.094.34.34 0 0 1-.099-.258c0-.132.031-.27.093-.413.063-.143.155-.273.279-.39.123-.116.28-.21.47-.282.189-.072.411-.107.666-.107.307 0 .569.045.786.137a1.182 1.182 0 0 1 .618.623 1.18 1.18 0 0 1-.096 1.083 2.03 2.03 0 0 1-.378.457c-.128.11-.344.282-.646.517-.302.235-.509.417-.621.547a1.637 1.637 0 0 0-.148.187z"/></svg>
|
After Width: | Height: | Size: 1.5 KiB |
1
src/assets/editor/list_unordered.svg.txt
Normal file
1
src/assets/editor/list_unordered.svg.txt
Normal file
|
@ -0,0 +1 @@
|
|||
<svg width="17" height="13" viewBox="0 0 17 13" xmlns="http://www.w3.org/2000/svg"> <path d="M5.625 4.85h9.25a1.125 1.125 0 0 1 0 2.25h-9.25a1.125 1.125 0 0 1 0-2.25zm0-4.85h9.25a1.125 1.125 0 0 1 0 2.25h-9.25a1.125 1.125 0 0 1 0-2.25zm0 9.85h9.25a1.125 1.125 0 0 1 0 2.25h-9.25a1.125 1.125 0 0 1 0-2.25zm-4.5-5a1.125 1.125 0 1 1 0 2.25 1.125 1.125 0 0 1 0-2.25zm0-4.85a1.125 1.125 0 1 1 0 2.25 1.125 1.125 0 0 1 0-2.25zm0 9.85a1.125 1.125 0 1 1 0 2.25 1.125 1.125 0 0 1 0-2.25z"/></svg>
|
After Width: | Height: | Size: 488 B |
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<main :id="id" class="card-content"></main>
|
||||
<main ref="cardEl" class="card-content"></main>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
|
@ -7,7 +7,7 @@ import { Component, Prop, Vue } from 'vue-property-decorator'
|
|||
|
||||
import Editor from '@editorjs/editorjs'
|
||||
import List from '@editorjs/list'
|
||||
import { Heading, Delimiter } from '@/editor'
|
||||
import { Heading, Delimiter, Charges } from '@/editor'
|
||||
|
||||
@Component
|
||||
export default class DeckCardEditor extends Vue {
|
||||
|
@ -23,16 +23,25 @@ export default class DeckCardEditor extends Vue {
|
|||
|
||||
private mounted () {
|
||||
this.editor = new Editor({
|
||||
holderId: this.id,
|
||||
holder: this.$refs.cardEl as HTMLElement,
|
||||
autofocus: false,
|
||||
tools: {
|
||||
// header: Heading,
|
||||
list: { class: List, inlineToolbar: true },
|
||||
heading: { class: Heading, inlineToolbar: true },
|
||||
delimiter: { class: Delimiter, inlineToolbar: false },
|
||||
heading: { class: Heading, inlineToolbar: true }
|
||||
charges: { class: Charges, inlineToolbar: false }
|
||||
},
|
||||
// data: {},
|
||||
placeholder: 'Click here to write your card.'
|
||||
data: this.content,
|
||||
placeholder: 'Click here to write your card.',
|
||||
onChange: () => {
|
||||
console.log('editor change, saving')
|
||||
this.editor.save().then(value => {
|
||||
this.$emit('change', { field: 'content', value })
|
||||
}).catch(error => {
|
||||
console.error('error saving data', error)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -45,7 +54,7 @@ export default class DeckCardEditor extends Vue {
|
|||
|
||||
.card-content .ce-paragraph, .card-content p {
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.card-content ul {
|
||||
|
@ -94,5 +103,28 @@ export default class DeckCardEditor extends Vue {
|
|||
border-width: 2px 220px 2px 0;
|
||||
border-color: transparent var(--highlight-color) transparent transparent;
|
||||
}
|
||||
.card-content .cdx-list__item {
|
||||
padding: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.card-content .card-charges-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
min-height: 1em;
|
||||
}
|
||||
.card-content .card-charges-wrapper.card-charges-stretch { justify-content: space-around; }
|
||||
.card-content .card-charges-wrapper > .card-charge {
|
||||
width: 1.0em;
|
||||
height: 1.0em;
|
||||
border: 2px solid var(--highlight-color);
|
||||
margin: .5em .2em;
|
||||
}
|
||||
.card-content .card-charges-wrapper > .card-charge-circle { border-radius: 100%; }
|
||||
.card-content .card-charges-wrapper > .card-charge-size-1 { width: 1.0em; height: 1.0em; }
|
||||
.card-content .card-charges-wrapper > .card-charge-size-2 { width: 1.2em; height: 1.2em; }
|
||||
.card-content .card-charges-wrapper > .card-charge-size-3 { width: 1.4em; height: 1.4em; }
|
||||
.card-content .card-charges-wrapper > .card-charge-size-4 { width: 1.6em; height: 1.6em; }
|
||||
.card-content .card-charges-wrapper > .card-charge-size-5 { width: 1.8em; height: 1.8em; }
|
||||
[contenteditable="true"] { outline: none; }
|
||||
</style>
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
:card-id="card.id"
|
||||
:active="isSelection"
|
||||
:content="card.content"
|
||||
@input="$emit('edit', $event)"
|
||||
@change="$emit('edit', $event)"
|
||||
/>
|
||||
</section>
|
||||
<section name="card-back" class="card-back">
|
||||
|
|
137
src/editor/charges.ts
Normal file
137
src/editor/charges.ts
Normal file
|
@ -0,0 +1,137 @@
|
|||
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)
|
||||
console.log('new charges', 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())
|
||||
}
|
||||
|
||||
console.log('rendered', this._amount, 'charges', el)
|
||||
|
||||
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
|
|
@ -38,7 +38,15 @@ export interface CSSClasses {
|
|||
}
|
||||
|
||||
export interface ContentBlockData extends BlockToolData {
|
||||
text: string;
|
||||
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 {
|
||||
|
@ -97,7 +105,7 @@ export class ContentBlock implements BlockTool {
|
|||
el.classList.add(this._CSS.block)
|
||||
el.dataset.placeholder = this._placeholder
|
||||
el.addEventListener('keyup', this.onKeyUp)
|
||||
el.innerHTML = this.data.text
|
||||
el.innerHTML = this.data.text || ''
|
||||
el.contentEditable = 'true'
|
||||
|
||||
return el
|
||||
|
@ -112,7 +120,7 @@ export class ContentBlock implements BlockTool {
|
|||
// Called by Editor.js by backspace at the beginning of the Block
|
||||
public merge (data: ContentBlockData) {
|
||||
this.data = {
|
||||
text: this.data.text + data.text
|
||||
text: (this.data.text || '') + data.text
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -129,20 +137,17 @@ export class ContentBlock implements BlockTool {
|
|||
}
|
||||
}
|
||||
|
||||
// On paste callback fired from Editor.
|
||||
public onPaste (event: HTMLPasteEvent) {
|
||||
this.data = {
|
||||
text: event.detail.data.innerHTML
|
||||
}
|
||||
public get CSS (): CSSClasses {
|
||||
return this._CSS
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable Conversion Toolbar. Paragraph can be converted to/from other tools
|
||||
*/
|
||||
static get conversionConfig () {
|
||||
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
|
||||
import: 'text' // to covert other block's exported string to Paragraph, fill 'text' property of tool data
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -197,6 +202,17 @@ export class ContentBlock implements BlockTool {
|
|||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
|
@ -4,7 +4,13 @@ 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 = [
|
||||
|
@ -12,13 +18,17 @@ class Delimiter extends ContentlessBlock {
|
|||
{ 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)
|
||||
}
|
||||
}
|
||||
|
@ -29,6 +39,12 @@ class Delimiter extends ContentlessBlock {
|
|||
return el
|
||||
}
|
||||
|
||||
public save (): DelimiterData {
|
||||
return {
|
||||
variant: this._variant
|
||||
}
|
||||
}
|
||||
|
||||
static get toolbox () {
|
||||
return { icon, title }
|
||||
}
|
||||
|
|
|
@ -2,8 +2,7 @@ import {
|
|||
ContentBlock,
|
||||
ContentBlockArgs,
|
||||
ContentBlockConfig,
|
||||
ContentBlockData,
|
||||
HTMLPasteEvent
|
||||
ContentBlockData
|
||||
} from './content-block'
|
||||
|
||||
import icon from '../assets/editor/header.svg.txt'
|
||||
|
@ -33,7 +32,7 @@ interface HeadingConfig extends ContentBlockConfig {
|
|||
defaultLevel?: HeadingLevel;
|
||||
}
|
||||
|
||||
interface HeaderData extends ContentBlockData {
|
||||
interface HeadingData extends ContentBlockData {
|
||||
text: string;
|
||||
level?: HeadingLevel;
|
||||
}
|
||||
|
@ -65,7 +64,7 @@ class Heading extends ContentBlock {
|
|||
// setting data will rerender the element with the right settings
|
||||
this.data = {
|
||||
level: this.currentLevel,
|
||||
text: (args.data as HeaderData).text || ''
|
||||
text: (args.data as HeadingData).text || ''
|
||||
}
|
||||
|
||||
this._settingButtons = this._config.levels.map(level => {
|
||||
|
@ -78,12 +77,12 @@ class Heading extends ContentBlock {
|
|||
})
|
||||
}
|
||||
|
||||
public get data () {
|
||||
return this._data
|
||||
public get data (): HeadingData {
|
||||
return this._data as HeadingData
|
||||
}
|
||||
|
||||
public set data (data: HeaderData) {
|
||||
const currentData = this._data as HeaderData
|
||||
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 || ''
|
||||
|
@ -118,12 +117,11 @@ class Heading extends ContentBlock {
|
|||
}
|
||||
|
||||
// Handle pasted H1-H6 tags to substitute with header tool
|
||||
public onPaste (event: HTMLPasteEvent) {
|
||||
const content = event.detail.data
|
||||
const text = content.innerHTML
|
||||
protected pasteHandler (element: HTMLHeadingElement): HeadingData {
|
||||
const text = element.innerHTML
|
||||
let level = this.defaultLevel
|
||||
|
||||
const tagMatch = content.tagName.match(/H(\d)/)
|
||||
const tagMatch = element.tagName.match(/H(\d)/)
|
||||
if (tagMatch) level = parseInt(tagMatch[1], 10)
|
||||
|
||||
// Fallback to nearest level when specified not available
|
||||
|
@ -133,12 +131,12 @@ class Heading extends ContentBlock {
|
|||
})
|
||||
}
|
||||
|
||||
this.data = { level, text }
|
||||
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: HeaderData) {
|
||||
public merge (data: HeadingData) {
|
||||
this.data = {
|
||||
text: this.data.text + (data.text || ''),
|
||||
level: this.data.level
|
||||
|
@ -146,7 +144,7 @@ class Heading extends ContentBlock {
|
|||
}
|
||||
|
||||
// extract tools data from view
|
||||
public save (toolsContent: HTMLElement): HeaderData {
|
||||
public save (toolsContent: HTMLElement): HeadingData {
|
||||
return {
|
||||
text: toolsContent.innerHTML,
|
||||
level: this.currentLevel
|
||||
|
|
|
@ -1,207 +0,0 @@
|
|||
import { ToolConstructable, ToolConfig, HTMLPasteEvent } from '@editorjs/editorjs'
|
||||
import { BlockToolExt, BlockToolArgs } from './block-tool'
|
||||
|
||||
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'
|
||||
|
||||
interface PasteConfig {
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
enum HeadingLevel {
|
||||
One = 1,
|
||||
Two = 2,
|
||||
Three = 3,
|
||||
Four = 4,
|
||||
Five = 5,
|
||||
Six = 6
|
||||
}
|
||||
|
||||
interface HeaderConfig extends ToolConfig {
|
||||
placeholder?: string;
|
||||
levels?: HeadingLevel[];
|
||||
defaultLevel?: HeadingLevel;
|
||||
}
|
||||
|
||||
interface HeaderData {
|
||||
text?: string;
|
||||
level?: HeadingLevel;
|
||||
}
|
||||
|
||||
class Heading extends BlockToolExt {
|
||||
protected _config: HeaderConfig
|
||||
protected settingsButtons: HTMLElement[] = []
|
||||
private levels: HeadingLevel[]
|
||||
private defaultLevel: HeadingLevel
|
||||
private currentLevel: HeadingLevel
|
||||
private icons: Map<HeadingLevel, string> = new Map([
|
||||
[HeadingLevel.One, icon1],
|
||||
[HeadingLevel.Two, icon2],
|
||||
[HeadingLevel.Three, icon3],
|
||||
[HeadingLevel.Four, icon4],
|
||||
[HeadingLevel.Five, icon5],
|
||||
[HeadingLevel.Six, icon6]
|
||||
])
|
||||
|
||||
constructor (args: BlockToolArgs) {
|
||||
super(args)
|
||||
this._config = args.config as HeaderConfig
|
||||
|
||||
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.levels = this._config.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 HeaderData).text || ''
|
||||
}
|
||||
}
|
||||
|
||||
public renderSettings (): HTMLElement {
|
||||
const wrapper = document.createElement('DIV')
|
||||
|
||||
this.levels.forEach(level => {
|
||||
const { settingsButton, settingsButtonActive } = this._CSS
|
||||
const btn = document.createElement('SPAN')
|
||||
btn.classList.add(settingsButton)
|
||||
btn.dataset.level = `${level}`
|
||||
btn.innerHTML = this.icons.get(level) || icon
|
||||
|
||||
if (this.currentLevel === level) btn.classList.add(settingsButtonActive)
|
||||
|
||||
btn.addEventListener('click', () => this.setLevel(level))
|
||||
wrapper.appendChild(btn)
|
||||
this.settingsButtons.push(btn)
|
||||
})
|
||||
|
||||
return wrapper
|
||||
}
|
||||
|
||||
public get data (): HeaderData {
|
||||
return this._data as HeaderData
|
||||
}
|
||||
|
||||
public set data (data: HeaderData) {
|
||||
const currentData = this._data as HeaderData
|
||||
|
||||
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 setLevel (level: HeadingLevel) {
|
||||
this.data = { level, text: this._element.innerHTML }
|
||||
}
|
||||
|
||||
protected get _CSS () {
|
||||
return {
|
||||
block: this.api.styles.block,
|
||||
settingsButton: this.api.styles.settingsButton,
|
||||
settingsButtonActive: this.api.styles.settingsButtonActive,
|
||||
wrapper: 'card-heading'
|
||||
}
|
||||
}
|
||||
|
||||
protected _render (): HTMLElement {
|
||||
console.log('render', `H${this.currentLevel}`, this.data)
|
||||
|
||||
const el = document.createElement(`H${this.currentLevel}`)
|
||||
el.innerHTML = this.data.text || ''
|
||||
el.classList.add(this._CSS.wrapper, this._CSS.block)
|
||||
el.contentEditable = 'true'
|
||||
el.dataset.placeholder = this._config.placeholder || ''
|
||||
return el
|
||||
}
|
||||
|
||||
// Handle pasted H1-H6 tags to substitute with header tool
|
||||
onPaste (event: HTMLPasteEvent) {
|
||||
const content = event.detail.data
|
||||
const text = content.innerHTML
|
||||
let level = this.defaultLevel
|
||||
|
||||
const tagMatch = content.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
|
||||
})
|
||||
}
|
||||
|
||||
this.data = { level, text }
|
||||
}
|
||||
|
||||
// Used by Editor.js paste handling API.
|
||||
// Provides configuration to handle H1-H6 tags.
|
||||
static get pasteConfig (): PasteConfig {
|
||||
return {
|
||||
tags: ['H1', 'H2', 'H3', 'H4', 'H5', 'H6']
|
||||
}
|
||||
}
|
||||
|
||||
// Method that specified how to merge two Text blocks.
|
||||
// Called by Editor.js by backspace at the beginning of the Block
|
||||
public merge (data: HeaderData) {
|
||||
this.data = {
|
||||
text: this.data.text + (data.text || ''),
|
||||
level: this.data.level
|
||||
}
|
||||
}
|
||||
|
||||
// validate text block data
|
||||
validate (blockData: HeaderData): boolean {
|
||||
if (!blockData.text) return false
|
||||
return blockData.text.trim() !== ''
|
||||
}
|
||||
|
||||
// extract tools data from view
|
||||
save (toolsContent: HTMLElement): HeaderData {
|
||||
return {
|
||||
text: toolsContent.innerHTML,
|
||||
level: this.currentLevel
|
||||
}
|
||||
}
|
||||
|
||||
// Allow Heading to be converted to/from other blocks
|
||||
static get conversionConfig () {
|
||||
return {
|
||||
export: 'text', // use 'text' property for other blocks
|
||||
import: 'text' // fill 'text' property from other block's export string
|
||||
}
|
||||
}
|
||||
|
||||
static get sanitize (): object {
|
||||
return { level: {} }
|
||||
}
|
||||
|
||||
static get toolbox () {
|
||||
return { icon, title }
|
||||
}
|
||||
}
|
||||
|
||||
export default Heading as ToolConstructable
|
|
@ -1,2 +1,4 @@
|
|||
export { default as Delimiter } from './delimiter'
|
||||
export { default as Heading } from './heading'
|
||||
export { default as List } from './list'
|
||||
export { default as Charges } from './charges'
|
||||
|
|
254
src/editor/list.ts
Normal file
254
src/editor/list.ts
Normal file
|
@ -0,0 +1,254 @@
|
|||
import {
|
||||
ContentBlock,
|
||||
ContentBlockArgs,
|
||||
ContentBlockConfig,
|
||||
ContentBlockData,
|
||||
ConversionConfig
|
||||
} from './content-block'
|
||||
|
||||
import icon from '../assets/editor/list.svg.txt'
|
||||
import iconUL from '../assets/editor/list_unordered.svg.txt'
|
||||
import iconOL from '../assets/editor/list_ordered.svg.txt'
|
||||
|
||||
const title = 'List'
|
||||
|
||||
enum ListStyle {
|
||||
Ordered = 'OL',
|
||||
Unordered = 'UL'
|
||||
}
|
||||
const icons = {
|
||||
[ListStyle.Ordered]: iconOL,
|
||||
[ListStyle.Unordered]: iconUL
|
||||
}
|
||||
|
||||
interface ListConfig extends ContentBlockConfig {
|
||||
placeholder?: string;
|
||||
styles: ListStyle[];
|
||||
defaultStyle: ListStyle;
|
||||
}
|
||||
|
||||
interface ListData extends ContentBlockData {
|
||||
style: ListStyle;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
interface ListConversionConfig extends ConversionConfig {
|
||||
import: (str: string) => ListData;
|
||||
export: (data: ListData) => string;
|
||||
}
|
||||
|
||||
class List extends ContentBlock {
|
||||
static _supportedTags = ['UL', 'OL', 'LI']
|
||||
static _toolboxConfig = { icon, title }
|
||||
|
||||
protected _config: ListConfig
|
||||
private defaultStyle: ListStyle
|
||||
private currentStyle: ListStyle
|
||||
|
||||
constructor (args: ContentBlockArgs) {
|
||||
super(args)
|
||||
this._config = args.config as ListConfig
|
||||
|
||||
if (this._config.styles === undefined) {
|
||||
this._config.styles = [ListStyle.Unordered, ListStyle.Ordered]
|
||||
}
|
||||
|
||||
if (this._config.defaultStyle === undefined) {
|
||||
this._config.defaultStyle = ListStyle.Unordered
|
||||
}
|
||||
if (this._config.styles.indexOf(this._config.defaultStyle) === -1) {
|
||||
console.warn('(ง\'̀-\'́)ง List Tool: the default style specified was not found in available styles')
|
||||
}
|
||||
this.defaultStyle = this._config.defaultStyle
|
||||
this.currentStyle = this.defaultStyle
|
||||
|
||||
this.data = {
|
||||
style: this.currentStyle,
|
||||
items: (args.data as ListData).items
|
||||
}
|
||||
|
||||
this._settingButtons = this._config.styles.map(listStyle => {
|
||||
return {
|
||||
name: listStyle,
|
||||
icon: icons[listStyle] || icon,
|
||||
action: (name: string) => this.setStyle(name as ListStyle),
|
||||
isActive: (name: string): boolean => name === this.currentStyle
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public get data (): ListData {
|
||||
// first render
|
||||
if (!this._element) return this._data as ListData
|
||||
|
||||
const items = this.queryItems()
|
||||
const data = this._data as ListData
|
||||
data.items = Array.from(items).map(item => item.innerText)
|
||||
return data
|
||||
}
|
||||
|
||||
public set data (data: ListData) {
|
||||
const currentData = this._data as ListData
|
||||
|
||||
if (data.style === undefined) data.style = currentData.style || this.defaultStyle
|
||||
if (data.items === undefined) data.items = currentData.items || []
|
||||
|
||||
this._data = data
|
||||
this.currentStyle = data.style
|
||||
|
||||
const newList = this._render()
|
||||
if (this._element.parentNode) {
|
||||
this._element.parentNode.replaceChild(newList, this._element)
|
||||
}
|
||||
this._element = newList
|
||||
}
|
||||
|
||||
private setStyle (style: ListStyle) {
|
||||
this.data = { style, items: this.data.items || [] }
|
||||
}
|
||||
|
||||
protected _render (): HTMLElement {
|
||||
const el = document.createElement(this.currentStyle)
|
||||
el.classList.add('cdx-list', this._CSS.block)
|
||||
el.contentEditable = 'true'
|
||||
el.dataset.placeholder = this._config.placeholder || ''
|
||||
|
||||
const items = this.data.items
|
||||
|
||||
if (!items || !items.length) {
|
||||
const li = document.createElement('LI')
|
||||
li.innerText = ''
|
||||
li.classList.add('cdx-list__item')
|
||||
el.appendChild(li)
|
||||
} else {
|
||||
items.forEach(item => {
|
||||
const li = document.createElement('LI')
|
||||
li.innerText = item
|
||||
li.classList.add('cdx-list__item')
|
||||
el.appendChild(li)
|
||||
})
|
||||
}
|
||||
|
||||
el.addEventListener('keydown', event => {
|
||||
// on pressing Enter
|
||||
if (event.keyCode === 13) return this.getOutOfList(event)
|
||||
// on pressing Backspace
|
||||
if (event.keyCode === 8) return this.backspace(event)
|
||||
}, false)
|
||||
|
||||
return el
|
||||
}
|
||||
|
||||
private queryItems (): NodeListOf<HTMLLIElement> {
|
||||
return this._element.querySelectorAll('.cdx-list__item')
|
||||
}
|
||||
|
||||
private get currentItem (): Element | undefined {
|
||||
const selection = window.getSelection()
|
||||
if (selection === null) return
|
||||
|
||||
let currentNode = selection.anchorNode
|
||||
if (currentNode === null) return
|
||||
|
||||
if (currentNode.nodeType !== Node.ELEMENT_NODE) {
|
||||
currentNode = currentNode.parentNode
|
||||
}
|
||||
|
||||
return (currentNode as HTMLElement).closest('.cdx-list__item') || undefined
|
||||
}
|
||||
|
||||
// leave list by pressing enter on an empty list item
|
||||
private getOutOfList (event: KeyboardEvent) {
|
||||
const items = this.queryItems()
|
||||
if (items.length < 2) return
|
||||
|
||||
const lastItem = items[items.length - 1]
|
||||
const currentItem = this.currentItem
|
||||
|
||||
// prevent generation of new li if last li is empty
|
||||
if (currentItem === lastItem && !lastItem.innerText.trim().length) {
|
||||
if (!currentItem.parentElement) return // somethings really wrong
|
||||
currentItem.parentElement.removeChild(currentItem)
|
||||
this.api.blocks.insertNewBlock()
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
public backspace (event: KeyboardEvent) {
|
||||
const items = this.queryItems()
|
||||
const firstItem = items[0]
|
||||
|
||||
if (firstItem === undefined) return
|
||||
if (items.length < 2 && !firstItem.innerText.trim()) event.preventDefault()
|
||||
}
|
||||
|
||||
public selectItem (event: KeyboardEvent) {
|
||||
event.preventDefault()
|
||||
|
||||
const selection = window.getSelection()
|
||||
if (selection === null) return // no selection, no problem
|
||||
|
||||
const currentNode = selection.anchorNode?.parentNode
|
||||
if (currentNode === null) return
|
||||
|
||||
const currentItem = (currentNode as Element).closest('.cdx-list__item')
|
||||
if (currentItem === null) return
|
||||
|
||||
const range = new Range()
|
||||
|
||||
range.selectNodeContents(currentItem)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
protected pasteHandler (element: HTMLUListElement | HTMLOListElement | HTMLLIElement): ListData {
|
||||
const tag = element.tagName
|
||||
const style = tag === ListStyle.Ordered ? ListStyle.Ordered : ListStyle.Unordered
|
||||
const data: ListData = { style, items: [] }
|
||||
|
||||
if (tag === 'LI') { // does this really happen?
|
||||
data.items.push(element.innerText)
|
||||
} else {
|
||||
const items = Array.from(element.querySelectorAll('LI'))
|
||||
data.items = items.map(item => (item as HTMLElement).innerText).filter(item => !!item.trim())
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// allow to use native enter behavior
|
||||
static get enableLineBreaks (): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
static get sanitize () {
|
||||
return {
|
||||
style: {},
|
||||
items: {
|
||||
br: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static get conversionConfig (): ListConversionConfig {
|
||||
return {
|
||||
export: data => {
|
||||
return data.items.join(' * ')
|
||||
},
|
||||
import: str => {
|
||||
return {
|
||||
items: [str],
|
||||
style: ListStyle.Unordered
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public save (): ListData {
|
||||
console.log('saving list', this.data)
|
||||
return this.data
|
||||
}
|
||||
}
|
||||
|
||||
export default List
|
|
@ -45,6 +45,10 @@ export function defaultCard (): Card {
|
|||
count: 1,
|
||||
tags: [],
|
||||
icon: 'robe',
|
||||
content: []
|
||||
content: {
|
||||
time: Date.now(),
|
||||
blocks: [],
|
||||
version: '2.17.0'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
20
src/types.d.ts
vendored
20
src/types.d.ts
vendored
|
@ -2,19 +2,15 @@ interface KV<V> {
|
|||
[key: string]: V;
|
||||
}
|
||||
|
||||
interface TextNode {
|
||||
type: 'text';
|
||||
text: string;
|
||||
interface ContentBlock {
|
||||
type: string;
|
||||
data: object;
|
||||
}
|
||||
|
||||
interface SimpleNode {
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface ContentNode {
|
||||
type: string;
|
||||
content: (ContentNode | TextNode | SimpleNode)[];
|
||||
attrs?: object;
|
||||
interface CardContent {
|
||||
time: number;
|
||||
blocks: ContentBlock[];
|
||||
version: string;
|
||||
}
|
||||
|
||||
interface Card {
|
||||
|
@ -23,7 +19,7 @@ interface Card {
|
|||
count: number;
|
||||
tags: string[];
|
||||
icon: string;
|
||||
content: (ContentNode | SimpleNode)[];
|
||||
content: CardContent;
|
||||
backIcon?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
|
|
@ -86,22 +86,29 @@ export default class DeckView extends Vue {
|
|||
if (this.deck === null) return
|
||||
|
||||
const newCard = defaultCard()
|
||||
newCard.content = [{
|
||||
type: 'heading',
|
||||
attrs: { level: 2 },
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'feel free to edit this card'
|
||||
}]
|
||||
}, {
|
||||
type: 'horizontal_rule'
|
||||
}, {
|
||||
type: 'paragraph',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'This is a rich-text editor, so you can basically do whatever you want.'
|
||||
}]
|
||||
}]
|
||||
newCard.content = {
|
||||
time: Date.now(),
|
||||
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'
|
||||
}
|
||||
this.deck.cards.push(newCard)
|
||||
this.$storage.persist()
|
||||
|
||||
|
|
Loading…
Reference in a new issue