Merge pull request #98 from Hufe921/feature/content-block

Feature/content block
pr675
Hufe 3 years ago committed by GitHub
commit aef243e942
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -0,0 +1,41 @@
import Editor from '../../../src/editor'
describe('菜单-内容块', () => {
const url = 'http://localhost:3000/canvas-editor/'
beforeEach(() => {
cy.visit(url)
cy.get('canvas').first().as('canvas').should('have.length', 1)
})
it('内容块', () => {
cy.getEditor().then((editor: Editor) => {
editor.listener.saved = function (payload) {
const data = payload.data
expect(data[0].type).to.eq('block')
expect(data[0].block?.iframeBlock?.src).to.eq(url)
}
editor.command.executeSelectAll()
editor.command.executeBackspace()
cy.get('.menu-item__block').click()
cy.get('.dialog-option__item [name="width"]').type('500')
cy.get('.dialog-option__item [name="height"]').type('300')
cy.get('.dialog-option__item [name="value"]').type(url)
cy.get('.dialog-menu button').eq(1).click()
cy.get('@canvas').type('{ctrl}s')
})
})
})

@ -188,6 +188,9 @@
</ul>
</div>
</div>
<div class="menu-item__block" title="内容块">
<i></i>
</div>
</div>
<div class="menu-divider"></div>
<div class="menu-item">

@ -0,0 +1 @@
<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg"><g fill="#3D4757"><path d="M8.923 11v1h-2A2 2 0 015 10.55c.328-.15.638-.335.923-.55a1 1 0 001 1h2zm0-6h-2a1 1 0 00-1 1A4.997 4.997 0 005 5.45 2 2 0 016.923 4h2v1z"/><path d="M4 10a2 2 0 100-4 2 2 0 000 4zm0 1a3 3 0 110-6 3 3 0 010 6zm6-9a1 1 0 00-1 1v3a1 1 0 001 1h3a1 1 0 001-1V3a1 1 0 00-1-1h-3zm0-1h3a2 2 0 012 2v3a2 2 0 01-2 2h-3a2 2 0 01-2-2V3a2 2 0 012-2zm0 10a1 1 0 00-1 1v1a1 1 0 001 1h3a1 1 0 001-1v-1a1 1 0 00-1-1h-3zm0-1h3a2 2 0 012 2v1a2 2 0 01-2 2h-3a2 2 0 01-2-2v-1a2 2 0 012-2z"/></g></svg>

After

Width:  |  Height:  |  Size: 568 B

@ -6,9 +6,11 @@ export interface IDialogData {
label?: string;
name: string;
value?: string;
options?: { label: string; value: string; }[];
placeholder?: string;
width?: number;
height?: number;
required?: boolean;
}
export interface IDialogConfirm {
@ -29,7 +31,7 @@ export class Dialog {
private options: IDialogOptions
private mask: HTMLDivElement | null
private container: HTMLDivElement | null
private inputList: (HTMLInputElement | HTMLTextAreaElement)[]
private inputList: (HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement)[]
constructor(options: IDialogOptions) {
this.options = options
@ -83,10 +85,21 @@ export class Dialog {
const optionName = document.createElement('span')
optionName.append(document.createTextNode(option.label))
optionItemContainer.append(optionName)
if (option.required) {
optionName.classList.add('dialog-option__item--require')
}
}
// 选项输入框
let optionInput: HTMLInputElement | HTMLTextAreaElement
if (option.type === 'textarea') {
let optionInput: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
if (option.type === 'select') {
optionInput = document.createElement('select')
option.options?.forEach(item => {
const optionItem = document.createElement('option')
optionItem.value = item.value
optionItem.label = item.label
optionInput.append(optionItem)
})
} else if (option.type === 'textarea') {
optionInput = document.createElement('textarea')
} else {
optionInput = document.createElement('input')
@ -100,7 +113,9 @@ export class Dialog {
}
optionInput.name = option.name
optionInput.value = option.value || ''
optionInput.placeholder = option.placeholder || ''
if (!(optionInput instanceof HTMLSelectElement)) {
optionInput.placeholder = option.placeholder || ''
}
optionItemContainer.append(optionInput)
optionContainer.append(optionItemContainer)
this.inputList.push(optionInput)

@ -61,10 +61,12 @@
margin-right: 12px;
font-size: 14px;
color: #3d4757;
position: relative;
}
.dialog-option__item input,
.dialog-option__item textarea {
.dialog-option__item textarea,
.dialog-option__item select {
width: 276px;
height: 30px;
border-radius: 2px;
@ -83,6 +85,14 @@
border-color: #4991f2;
}
.dialog-option__item--require::before {
content: "*";
color: #f56c6c;
margin-right: 4px;
position: absolute;
left: -8px;
}
.dialog-menu {
display: flex;
align-items: center;

@ -0,0 +1,8 @@
.block-item {
position: absolute;
z-index: 0;
overflow: hidden;
border-radius: 8px;
background-color: #ffffff;
border: 1px solid rgb(235 236 240);
}

@ -5,6 +5,7 @@
left: 0;
right: 0;
position: absolute;
z-index: 1;
color: #606266;
background: #ffffff;
border-radius: 4px;

@ -1,5 +1,6 @@
@import './control/select.css';
@import './date/datePicker.css';
@import './block/block.css';
.inputarea {
width: 0;
@ -442,6 +443,7 @@
padding: 16px;
white-space: nowrap;
position: absolute;
z-index: 1;
text-align: center;
display: none;
}

@ -430,7 +430,9 @@ export class CommandAdapt {
trList
}
// 格式化element
formatElementList([element])
formatElementList([element], {
editorOptions: this.options
})
const curIndex = startIndex + 1
if (startIndex === endIndex) {
elementList.splice(curIndex, 0, element)

@ -46,6 +46,7 @@ import { WorkerManager } from '../worker/WorkerManager'
import { Previewer } from './particle/previewer/Previewer'
import { DateParticle } from './particle/date/DateParticle'
import { IMargin } from '../../interface/Margin'
import { BlockParticle } from './particle/block/BlockParticle'
export class Draw {
@ -86,6 +87,7 @@ export class Draw {
private superscriptParticle: SuperscriptParticle
private subscriptParticle: SubscriptParticle
private checkboxParticle: CheckboxParticle
private blockParticle: BlockParticle
private control: Control
private workerManager: WorkerManager
@ -138,6 +140,7 @@ export class Draw {
this.superscriptParticle = new SuperscriptParticle()
this.subscriptParticle = new SubscriptParticle()
this.checkboxParticle = new CheckboxParticle(this)
this.blockParticle = new BlockParticle(this)
this.control = new Control(this)
new ScrollObserver(this)
@ -707,6 +710,11 @@ export class Draw {
metrics.height = defaultSize * scale
metrics.boundingBoxDescent = 0
metrics.boundingBoxAscent = metrics.height
} else if (element.type === ElementType.BLOCK) {
metrics.width = element.width! * scale
metrics.height = element.height! * scale
metrics.boundingBoxDescent = metrics.height
metrics.boundingBoxAscent = 0
} else {
// 设置上下标真实字体尺寸
const size = element.size || this.options.defaultSize
@ -739,6 +747,7 @@ export class Draw {
const preElement = elementList[i - 1]
if (
preElement?.type === ElementType.TABLE
|| preElement?.type === ElementType.BLOCK
|| preElement?.imgDisplay === ImageDisplay.INLINE
|| element.imgDisplay === ImageDisplay.INLINE
|| curRow.width + metrics.width > innerWidth
@ -880,6 +889,9 @@ export class Draw {
// 如果是两端对齐因canvas目前不支持letterSpacing需单独绘制文本
this.textParticle.record(ctx, element, x, y + offsetY)
this._drawRichText(ctx)
} else if (element.type === ElementType.BLOCK) {
this._drawRichText(ctx)
this.blockParticle.render(pageNo, element, x, y)
} else {
this.textParticle.record(ctx, element, x, y + offsetY)
}
@ -972,13 +984,19 @@ export class Draw {
return { x, y, index }
}
private _clearPage(pageNo: number) {
const ctx = this.ctxList[pageNo]
const pageDom = this.pageList[pageNo]
ctx.clearRect(0, 0, pageDom.width, pageDom.height)
this.blockParticle.clear()
}
private _drawPage(positionList: IElementPosition[], rowList: IRow[], pageNo: number) {
const { pageMode } = this.options
const margins = this.getMargins()
const innerWidth = this.getInnerWidth()
const ctx = this.ctxList[pageNo]
const pageDom = this.pageList[pageNo]
ctx.clearRect(0, 0, pageDom.width, pageDom.height)
this._clearPage(pageNo)
// 绘制背景
this.background.render(ctx)
// 绘制页边距

@ -0,0 +1,67 @@
import { ElementType } from '../../../../dataset/enum/Element'
import { IRowElement } from '../../../../interface/Row'
import { Draw } from '../../Draw'
import { BaseBlock } from './modules/BaseBlock'
export class BlockParticle {
private draw: Draw
private container: HTMLDivElement
private blockContainer: HTMLDivElement
private blockMap: Map<string, BaseBlock>
constructor(draw: Draw) {
this.draw = draw
this.container = draw.getContainer()
this.blockMap = new Map()
this.blockContainer = this._createBlockContainer()
this.container.append(this.blockContainer)
}
private _createBlockContainer(): HTMLDivElement {
const blockContainer = document.createElement('div')
blockContainer.classList.add('block-container')
return blockContainer
}
public getDraw(): Draw {
return this.draw
}
public getBlockContainer(): HTMLDivElement {
return this.blockContainer
}
public render(pageNo: number, element: IRowElement, x: number, y: number) {
const id = element.id!
const cacheBlock = this.blockMap.get(id)
if (cacheBlock) {
cacheBlock.setClientRects(pageNo, x, y)
} else {
const newBlock = new BaseBlock(this, element)
newBlock.render()
newBlock.setClientRects(pageNo, x, y)
this.blockMap.set(id, newBlock)
}
}
public clear() {
if (!this.blockMap.size) return
const elementList = this.draw.getElementList()
const blockElementIds: string[] = []
for (let e = 0; e < elementList.length; e++) {
const element = elementList[e]
if (element.type === ElementType.BLOCK) {
blockElementIds.push(element.id!)
}
}
this.blockMap.forEach(block => {
const id = block.getBlockElement().id!
if (!blockElementIds.includes(id)) {
block.remove()
this.blockMap.delete(id)
}
})
}
}

@ -0,0 +1,63 @@
import { BlockType } from '../../../../../dataset/enum/Block'
import { IRowElement } from '../../../../../interface/Row'
import { Draw } from '../../../Draw'
import { BlockParticle } from '../BlockParticle'
import { IFrameBlock } from './IFrameBlock'
import { VideoBlock } from './VideoBlock'
export class BaseBlock {
private draw: Draw
private element: IRowElement
private block: IFrameBlock | VideoBlock | null
private blockContainer: HTMLDivElement
private blockItem: HTMLDivElement
constructor(blockParticle: BlockParticle, element: IRowElement) {
this.draw = blockParticle.getDraw()
this.blockContainer = blockParticle.getBlockContainer()
this.element = element
this.block = null
this.blockItem = this._createBlockItem()
this.blockContainer.append(this.blockItem)
}
public getBlockElement(): IRowElement {
return this.element
}
private _createBlockItem(): HTMLDivElement {
const blockItem = document.createElement('div')
blockItem.classList.add('block-item')
return blockItem
}
public render() {
const block = this.element.block!
if (block.type === BlockType.IFRAME) {
this.block = new IFrameBlock(this.element)
this.block.render(this.blockItem)
} else if (block.type === BlockType.VIDEO) {
this.block = new VideoBlock(this.element)
this.block.render(this.blockItem)
}
}
public setClientRects(pageNo: number, x: number, y: number) {
const scale = this.draw.getOptions().scale
const height = this.draw.getHeight()
const pageGap = this.draw.getPageGap()
const preY = pageNo * (height + pageGap)
// 尺寸
this.blockItem.style.width = `${this.element.width! * scale}px`
this.blockItem.style.height = `${this.element.height! * scale}px`
// 位置
this.blockItem.style.left = `${x}px`
this.blockItem.style.top = `${preY + y}px`
}
public remove() {
this.blockItem.remove()
}
}

@ -0,0 +1,28 @@
import { IRowElement } from '../../../../../interface/Row'
export class IFrameBlock {
private static readonly sandbox = [
'allow-forms',
'allow-scripts',
'allow-same-origin',
'allow-popups'
]
private element: IRowElement
constructor(element: IRowElement) {
this.element = element
}
public render(blockItemContainer: HTMLDivElement) {
const block = this.element.block!
const iframe = document.createElement('iframe')
iframe.sandbox.add(...IFrameBlock.sandbox)
iframe.style.border = 'none'
iframe.style.width = '100%'
iframe.style.height = '100%'
iframe.src = block.iframeBlock?.src || ''
blockItemContainer.append(iframe)
}
}

@ -0,0 +1,23 @@
import { IRowElement } from '../../../../../interface/Row'
export class VideoBlock {
private element: IRowElement
constructor(element: IRowElement) {
this.element = element
}
public render(blockItemContainer: HTMLDivElement) {
const block = this.element.block!
const video = document.createElement('video')
video.style.width = '100%'
video.style.height = '100%'
video.style.objectFit = 'contain'
video.src = block.videoBlock?.src || ''
video.controls = true
video.crossOrigin = 'anonymous'
blockItemContainer.append(video)
}
}

@ -50,7 +50,8 @@ export const EDITOR_ELEMENT_ZIP_ATTR: Array<keyof IElement> = [
'valueList',
'control',
'checkbox',
'dateFormat'
'dateFormat',
'block'
]
export const TEXTLIKE_ELEMENT_TYPE: ElementType[] = [

@ -0,0 +1,4 @@
export enum BlockType {
IFRAME = 'iframe',
VIDEO = 'video'
}

@ -11,5 +11,6 @@ export enum ElementType {
CHECKBOX = 'checkbox',
LATEX = 'latex',
TAB = 'tab',
DATE = 'date'
DATE = 'date',
BLOCK = 'block'
}

@ -26,6 +26,8 @@ import { DeepRequired } from './interface/Common'
import { INavigateInfo } from './core/draw/interactive/Search'
import { Shortcut } from './core/shortcut/Shortcut'
import { KeyMap } from './dataset/enum/KeyMap'
import { BlockType } from './dataset/enum/Block'
import { IBlock } from './interface/Block'
export default class Editor {
@ -125,7 +127,8 @@ export {
PageMode,
ImageDisplay,
Command,
KeyMap
KeyMap,
BlockType
}
// 对外类型
@ -136,5 +139,6 @@ export type {
IContextMenuContext,
IRegisterContextMenu,
IWatermark,
INavigateInfo
INavigateInfo,
IBlock
}

@ -0,0 +1,15 @@
import { BlockType } from '../dataset/enum/Block'
export interface IIFrameBlock {
src: string;
}
export interface IVideoBlock {
src: string;
}
export interface IBlock {
type: BlockType;
iframeBlock?: IIFrameBlock;
videoBlock?: IVideoBlock;
}

@ -1,6 +1,7 @@
import { ControlComponent, ImageDisplay } from '../dataset/enum/Control'
import { ElementType } from '../dataset/enum/Element'
import { RowFlex } from '../dataset/enum/Row'
import { IBlock } from './Block'
import { ICheckbox } from './Checkbox'
import { IControl } from './Control'
import { IColgroup } from './table/Colgroup'
@ -78,6 +79,10 @@ export interface IImageElement {
imgDisplay?: ImageDisplay
}
export interface IBlockElement {
block?: IBlock;
}
export type IElement = IElementBasic
& IElementStyle
& ITable
@ -89,6 +94,7 @@ export type IElement = IElementBasic
& ILaTexElement
& IDateElement
& IImageElement
& IBlockElement
export interface IElementMetrics {
width: number;

@ -9,10 +9,10 @@ import { ControlComponent, ControlType } from '../dataset/enum/Control'
interface IFormatElementListOption {
isHandleFirstElement?: boolean;
editorOptions?: Required<IEditorOption>;
editorOptions: Required<IEditorOption>;
}
export function formatElementList(elementList: IElement[], options: IFormatElementListOption = {}) {
export function formatElementList(elementList: IElement[], options: IFormatElementListOption) {
const { isHandleFirstElement, editorOptions } = <IFormatElementListOption>{
isHandleFirstElement: true,
...options
@ -240,6 +240,13 @@ export function formatElementList(elementList: IElement[], options: IFormatEleme
if (el.type === ElementType.IMAGE) {
el.id = getUUID()
}
if (el.type === ElementType.BLOCK) {
el.id = getUUID()
if (!el.width) {
const { editorOptions: { width, margins } } = options
el.width = width - margins[1] - margins[3]
}
}
if (el.type === ElementType.LATEX) {
const { svg, width, height } = LaTexParticle.convertLaTextToSVG(el.value)
el.width = el.width || width

@ -1,7 +1,7 @@
import { data, options } from './mock'
import './style.css'
import prism from 'prismjs'
import Editor, { Command, ControlType, EditorMode, ElementType, IElement, KeyMap, PageMode } from './editor'
import Editor, { BlockType, Command, ControlType, EditorMode, ElementType, IBlock, IElement, KeyMap, PageMode } from './editor'
import { Dialog } from './components/dialog/Dialog'
import { formatPrismToken } from './utils/prism'
import { Signature } from './components/signature/Signature'
@ -273,11 +273,13 @@ window.onload = function () {
type: 'text',
label: '文本',
name: 'name',
required: true,
placeholder: '请输入文本'
}, {
type: 'text',
label: '链接',
name: 'url',
required: true,
placeholder: '请输入链接'
}],
onConfirm: (payload) => {
@ -340,16 +342,19 @@ window.onload = function () {
type: 'text',
label: '内容',
name: 'data',
required: true,
placeholder: '请输入内容'
}, {
type: 'color',
label: '颜色',
name: 'color',
required: true,
value: '#AEB5C0'
}, {
type: 'number',
label: '字体大小',
name: 'size',
required: true,
value: '120'
}],
onConfirm: (payload) => {
@ -435,6 +440,7 @@ window.onload = function () {
type: 'text',
label: '占位符',
name: 'placeholder',
required: true,
placeholder: '请输入占位符'
}, {
type: 'text',
@ -469,6 +475,7 @@ window.onload = function () {
type: 'text',
label: '占位符',
name: 'placeholder',
required: true,
placeholder: '请输入占位符'
}, {
type: 'text',
@ -479,6 +486,7 @@ window.onload = function () {
type: 'textarea',
label: '值集',
name: 'valueSets',
required: true,
height: 100,
placeholder: `请输入值集JSON\n[{\n"value":"有",\n"code":"98175"\n}]`
}],
@ -514,6 +522,7 @@ window.onload = function () {
type: 'textarea',
label: '值集',
name: 'valueSets',
required: true,
height: 100,
placeholder: `请输入值集JSON\n[{\n"value":"有",\n"code":"98175"\n}]`
}],
@ -612,6 +621,77 @@ window.onload = function () {
}])
}
const blockDom = document.querySelector<HTMLDivElement>('.menu-item__block')!
blockDom.onclick = function () {
console.log('block')
new Dialog({
title: '内容块',
data: [{
type: 'select',
label: '类型',
name: 'type',
value: 'iframe',
required: true,
options: [{
label: '网址',
value: 'iframe'
}, {
label: '视频',
value: 'video'
}]
}, {
type: 'number',
label: '宽度',
name: 'width',
placeholder: '请输入宽度(默认页面内宽度)'
}, {
type: 'number',
label: '高度',
name: 'height',
required: true,
placeholder: '请输入高度'
}, {
type: 'textarea',
label: '地址',
height: 100,
name: 'value',
required: true,
placeholder: '请输入地址'
}],
onConfirm: (payload) => {
const type = payload.find(p => p.name === 'type')?.value
if (!type) return
const value = payload.find(p => p.name === 'value')?.value
if (!value) return
const width = payload.find(p => p.name === 'width')?.value
const height = payload.find(p => p.name === 'height')?.value
if (!height) return
const block: IBlock = {
type: <BlockType>type
}
if (block.type === BlockType.IFRAME) {
block.iframeBlock = {
src: value
}
} else if (block.type === BlockType.VIDEO) {
block.videoBlock = {
src: value
}
}
const blockElement: IElement = {
type: ElementType.BLOCK,
value: '',
height: Number(height),
block
}
if (width) {
blockElement.width = Number(width)
}
instance.command.executeInsertElementList([blockElement])
}
})
}
// 5. | 搜索&替换 | 打印 |
const searchCollapseDom = document.querySelector<HTMLDivElement>('.menu-item__search__collapse')!
const searchInputDom = document.querySelector<HTMLInputElement>('.menu-item__search__collapse__search input')!
@ -732,24 +812,28 @@ window.onload = function () {
type: 'text',
label: '上边距',
name: 'top',
required: true,
value: `${topMargin}`,
placeholder: '请输入上边距'
}, {
type: 'text',
label: '下边距',
name: 'bottom',
required: true,
value: `${bottomMargin}`,
placeholder: '请输入下边距'
}, {
type: 'text',
label: '左边距',
name: 'left',
required: true,
value: `${leftMargin}`,
placeholder: '请输入左边距'
}, {
type: 'text',
label: '右边距',
name: 'right',
required: true,
value: `${rightMargin}`,
placeholder: '请输入右边距'
}],

@ -463,6 +463,10 @@ ul {
width: 150px;
}
.menu-item__block i {
background-image: url('./assets/images/block.svg');
}
.menu-item .menu-item__control .options {
width: 55px;
}

Loading…
Cancel
Save