diff --git a/src/mask-editor/README.md b/src/mask-editor/README.md new file mode 100644 index 0000000..c2728e6 --- /dev/null +++ b/src/mask-editor/README.md @@ -0,0 +1,48 @@ +# Luna Mask Editor + +Image mask editing. + +## Demo + +https://luna.liriliri.io/?path=/story/mask-editor + +## Install + +Add the following script and style to your page. + +```html + + + + + +``` + +You can also get it on npm. + +```bash +npm install luna-mask-editor luna-painter luna-toolbar --save +``` + +```javascript +import 'luna-toolbar/luna-toolbar.css' +import 'luna-painter/luna-painter.css' +import LunaMaskEditor from 'luna-mask-editor' +``` + +## Usage + +```javascript +const container = document.getElementById('container') +const maskEditor = new LunaMaskEditor(container) +``` + +## Configuration + +* image(string): Image src. + +## Api + +### getCanvas(): HTMLCanvasElement + +Get a canvas with mask drawn. diff --git a/src/mask-editor/index.ts b/src/mask-editor/index.ts index 36b35ad..8a0b298 100644 --- a/src/mask-editor/index.ts +++ b/src/mask-editor/index.ts @@ -1,5 +1,5 @@ import Component, { IComponentOptions } from '../share/Component' -import LunaPainter from 'luna-painter' +import LunaPainter, { Layer, Zoom } from 'luna-painter' import debounce from 'licia/debounce' import Color from 'licia/Color' @@ -19,8 +19,15 @@ export interface IOptions extends IComponentOptions { export default class MaskEditor extends Component { private painter: LunaPainter private canvas: HTMLCanvasElement + private blackCanvas: HTMLCanvasElement + private blackCtx: CanvasRenderingContext2D + private ctx: CanvasRenderingContext2D + private maskBrush: MaskBrush + private baseLayer: Layer + private drawingLayer: Layer constructor(container: HTMLElement, options: IOptions) { super(container, { compName: 'mask-editor' }, options) + this.initOptions(options) this.initTpl() @@ -30,47 +37,82 @@ export default class MaskEditor extends Component { tools: [], } ) + painter.addTool('paintBucket', new MaskPaintBucket(painter)) painter.addTool('eraser', new MaskEraser(painter)) - const maskBrush = new MaskBrush(painter) - painter.addTool('brush', maskBrush) + this.maskBrush = new MaskBrush(painter) + painter.addTool('brush', this.maskBrush) painter.useTool('brush') this.painter = painter + this.addSubComponent(this.painter) - const image = new Image() - image.onload = function () { - const ctx = painter.getActiveLayer().getContext() - ctx.drawImage(image, 0, 0, image.width, image.height) - const idx = painter.addLayer() - painter.activateLayer(idx) - const layer = painter.getActiveLayer() - layer.opacity = 80 - maskBrush.on('optionChange', (name, val) => { - if (name === 'layerOpacity') { - layer.opacity = val - painter.renderCanvas() - } - }) - painter.renderCanvas() - } - image.src = options.image + this.baseLayer = painter.getActiveLayer() + const idx = painter.addLayer() + painter.activateLayer(idx) + this.drawingLayer = painter.getActiveLayer() + this.drawingLayer.opacity = 80 + painter.renderCanvas() - this.addSubComponent(this.painter) + this.canvas = document.createElement('canvas') + this.ctx = this.canvas.getContext('2d')! + this.blackCanvas = document.createElement('canvas') + this.blackCtx = this.blackCanvas.getContext('2d')! this.bindEvent() + + this.loadImage() } /** Get a canvas with mask drawn. */ getCanvas() { return this.canvas } + private loadImage() { + const { painter } = this + + const image = new Image() + image.onload = () => { + const { width, height } = image + painter.setOption({ + width, + height, + }) + + const ctx = this.baseLayer.getContext() + ctx.drawImage(image, 0, 0, width, height) + painter.renderCanvas() + const zoom = painter.getTool('zoom') as Zoom + zoom.fitScreen() + + this.renderMask() + } + image.src = this.options.image + } + private renderMask() { + const { canvas, ctx, blackCanvas, blackCtx, painter } = this + const { width, height } = painter.getCanvas() + + blackCanvas.width = width + blackCanvas.height = height + blackCtx.fillStyle = '#000000' + blackCtx.fillRect(0, 0, width, height) + blackCtx.globalCompositeOperation = 'destination-in' + blackCtx.drawImage(painter.getActiveLayer().getCanvas(), 0, 0) + + canvas.width = width + canvas.height = height + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, width, height) + ctx.drawImage(blackCanvas, 0, 0) + } private initTpl() { this.$container.html(this.c('
')) } private bindEvent() { - const { painter } = this + const { painter, maskBrush } = this painter.on( 'canvasRender', debounce(() => { + this.renderMask() this.emit('change') }, 20) ) @@ -79,7 +121,7 @@ export default class MaskEditor extends Component { const c = new Color(color) const rgb = Color.parse(c.toRgb()).val - const ctx = painter.getActiveLayer().getContext() + const ctx = this.drawingLayer.getContext() const canvas = ctx.canvas const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height) const { data } = imageData @@ -91,6 +133,19 @@ export default class MaskEditor extends Component { ctx.putImageData(imageData, 0, 0) painter.renderCanvas() }) + + maskBrush.on('optionChange', (name, val) => { + if (name === 'layerOpacity') { + this.drawingLayer.opacity = val + painter.renderCanvas() + } + }) + + this.on('optionChange', (name) => { + if (name === 'image') { + this.loadImage() + } + }) } } @@ -145,3 +200,10 @@ class MaskEraser extends LunaPainter.Eraser { }) } } + +class MaskPaintBucket extends LunaPainter.PaintBucket { + constructor(painter: LunaPainter) { + super(painter) + this.options.tolerance = 180 + } +} diff --git a/src/mask-editor/package.json b/src/mask-editor/package.json index fcf05c7..ff755cd 100644 --- a/src/mask-editor/package.json +++ b/src/mask-editor/package.json @@ -7,6 +7,7 @@ "toolbar", "painter" ], - "style": false + "style": false, + "react": true } } diff --git a/src/mask-editor/react.tsx b/src/mask-editor/react.tsx new file mode 100644 index 0000000..2692c0f --- /dev/null +++ b/src/mask-editor/react.tsx @@ -0,0 +1,33 @@ +import { CSSProperties, FC, useEffect, useRef } from 'react' +import MaskEditor, { IOptions } from './index' +import { useNonInitialEffect } from '../share/hooks' + +interface IMaskEditorProps extends IOptions { + style?: CSSProperties + onCreate?: (maskEditor: MaskEditor) => void +} + +const LunaMaskEditor: FC = (props) => { + const maskEditorRef = useRef(null) + const maskEditor = useRef() + + useEffect(() => { + const { image } = props + maskEditor.current = new MaskEditor(maskEditorRef.current!, { + image, + }) + props.onCreate && props.onCreate(maskEditor.current) + + return () => maskEditor.current?.destroy() + }, []) + + useNonInitialEffect(() => { + if (maskEditor.current) { + maskEditor.current.setOption('image', props.image) + } + }, [props.image]) + + return
+} + +export default LunaMaskEditor diff --git a/src/mask-editor/story.js b/src/mask-editor/story.js index bb06bc8..db80b46 100644 --- a/src/mask-editor/story.js +++ b/src/mask-editor/story.js @@ -1,22 +1,98 @@ import MaskEditor from 'luna-mask-editor.js' import story from '../share/story' import $ from 'licia/$' +import h from 'licia/h' +import readme from './README.md' +import LunaMaskEditor from './react' +import { text } from '@storybook/addon-knobs' +import { useRef } from 'react' -const def = story('mask-editor', (container) => { - $(container).css({ - width: '100%', - maxWidth: 1200, - height: 600, - margin: '0 auto', - }) +const def = story( + 'mask-editor', + (wrapper) => { + $(wrapper).html('') - const maskEditor = new MaskEditor(container, { - image: 'https://res.liriliri.io/luna/pic1.jpg', - }) + const container = h('div') + + $(container).css({ + width: '100%', + maxWidth: 1200, + height: 600, + margin: '0 auto', + }) + wrapper.appendChild(container) + + const maskContainer = h('div') + $(maskContainer).css({ + border: '1px solid #eee', + width: '100%', + maxWidth: 1200, + margin: '0 auto', + fontSize: 0, + marginTop: '10px', + }) + wrapper.appendChild(maskContainer) + + const { image } = createKnobs() + const maskEditor = new MaskEditor(container, { + image, + }) + + onCreate(maskEditor, maskContainer) - return maskEditor -}) + return maskEditor + }, + { + readme, + source: __STORY__, + ReactComponent({ theme }) { + const maskContainer = useRef(null) + const { image } = createKnobs() + + return ( + <> + { + if (maskContainer.current) { + onCreate(maskEditor, maskContainer.current) + } + }} + /> +
+ + ) + }, + } +) + +function createKnobs() { + const image = text('Image', 'https://res.liriliri.io/luna/pic1.jpg') + + return { + image, + } +} + +function onCreate(maskEditor, maskContainer) { + const canvas = maskEditor.getCanvas() + maskContainer.appendChild(canvas) + $(canvas).css({ + maxWidth: '100%', + }) +} export default def -export const { maskEditor } = def +export const { maskEditor: html, react } = def diff --git a/src/painter/index.ts b/src/painter/index.ts index e748344..216799b 100644 --- a/src/painter/index.ts +++ b/src/painter/index.ts @@ -16,6 +16,7 @@ import { Tool, } from './tools' import { duplicateCanvas } from './util' +import isHidden from 'licia/isHidden' const $document = $(document as any) @@ -41,6 +42,9 @@ export interface IOptions extends IComponentOptions { export default class Painter extends Component { static Brush = Brush static Eraser = Eraser + static PaintBucket = PaintBucket + static Zoom = Zoom + static Hand = Hand private $toolBox: $.$ private $canvas: $.$ private $viewport: $.$ @@ -114,6 +118,7 @@ export default class Painter extends Component { this.bindEvent() this.resetViewport() this.useTool(this.options.tool) + zoom.fitScreen() hand.centerCanvas() } @@ -214,6 +219,10 @@ export default class Painter extends Component { layer.getContext().drawImage(tempCanvas, x, y, oldWidth, oldHeight) }) + const zoom = this.getTool('zoom') as Zoom + const ratio = zoom.getRatio() + zoom.zoomTo(ratio, false) + this.renderCanvas() } private initTpl() { @@ -261,9 +270,9 @@ export default class Painter extends Component { $viewport .on(drag('start'), this.onViewportDragStart) + .on(drag('move'), this.onViewportMouseMove) .on('click', this.onViewportClick) .on('mouseenter', this.onViewportMouseEnter) - .on('mousemove', this.onViewportMouseMove) .on('mouseleave', this.onViewportMouseLeave) $toolBox @@ -329,6 +338,10 @@ export default class Painter extends Component { this.currentTool.onClick(e.origEvent) } private onResize = () => { + if (isHidden(this.container)) { + return + } + this.resetViewport() const { $canvas, viewport } = this @@ -375,6 +388,8 @@ export class Layer { } } +export * from './tools' + if (typeof module !== 'undefined') { exportCjs(module, Painter) } diff --git a/src/painter/react.tsx b/src/painter/react.tsx index d6462cb..1113a60 100644 --- a/src/painter/react.tsx +++ b/src/painter/react.tsx @@ -1,5 +1,6 @@ import { CSSProperties, FC, useEffect, useRef } from 'react' import Painter, { IOptions } from './index' +import { useNonInitialEffect } from '../share/hooks' interface IPainterProps extends IOptions { style?: CSSProperties @@ -12,7 +13,6 @@ const LunaPainter: FC = (props) => { useEffect(() => { const { width, height, tool } = props - console.log(width) painter.current = new Painter(painterRef.current!, { width, height, @@ -23,6 +23,18 @@ const LunaPainter: FC = (props) => { return () => painter.current?.destroy() }, []) + useNonInitialEffect(() => { + if (painter.current) { + painter.current.setOption('width', props.width) + } + }, [props.width]) + + useNonInitialEffect(() => { + if (painter.current) { + painter.current.setOption('height', props.height) + } + }, [props.height]) + return
} diff --git a/src/painter/story.js b/src/painter/story.js index 116f65d..f6fb6b4 100644 --- a/src/painter/story.js +++ b/src/painter/story.js @@ -64,17 +64,17 @@ function onCreate(painter) { } function createKnobs() { - const width = number('Width', 512, { + const width = number('Width', 3000, { range: true, min: 128, - max: 2048, + max: 4000, step: 2, }) - const height = number('Height', 512, { + const height = number('Height', 2000, { range: true, min: 128, - max: 2048, + max: 4000, step: 2, }) diff --git a/src/painter/tools/Brush.ts b/src/painter/tools/Brush.ts index 9fd7341..88f315b 100644 --- a/src/painter/tools/Brush.ts +++ b/src/painter/tools/Brush.ts @@ -65,6 +65,7 @@ export default class Brush extends Tool { this.drawOptions = drawOptions as Required this.generateBrush() this.draw(this.x, this.y) + this.painter.renderCanvas() }) } onDragMove(e: any) { @@ -92,6 +93,7 @@ export default class Brush extends Tool { } this.draw(x, y) + this.painter.renderCanvas() } onDragEnd(e: any) { if (!this.isDrawing) { @@ -136,7 +138,6 @@ export default class Brush extends Tool { const startX = size > 1 ? x - Math.floor((size - 1) / 2) : x const startY = size > 1 ? y - Math.round((size - 1) / 2) : y drawCtx.drawImage(this.brushCavnas, startX, startY) - this.painter.renderCanvas() } protected renderToolbar() { super.renderToolbar() diff --git a/src/painter/tools/PaintBucket.ts b/src/painter/tools/PaintBucket.ts index 666b6e2..598ae1d 100644 --- a/src/painter/tools/PaintBucket.ts +++ b/src/painter/tools/PaintBucket.ts @@ -2,6 +2,7 @@ import Tool from './Tool' import Color from 'licia/Color' import isEqual from 'licia/isEqual' import Painter from '../' +import { colorDistance } from '../util' export default class PaintBucket extends Tool { constructor(painter: Painter) { @@ -108,14 +109,3 @@ export default class PaintBucket extends Tool { }) } } - -function colorDistance(color1: number[], color2: number[]) { - const r = Math.abs(color1[0] - color2[0]) - const g = Math.abs(color1[1] - color2[1]) - const b = Math.abs(color1[2] - color2[2]) - if (color1.length === 4 && color2.length === 4) { - const a = Math.abs(color1[3] - color2[3]) - return Math.round((r + b + g + a) / 4) - } - return Math.round((r + b + g) / 3) -} diff --git a/src/painter/tools/Pencil.ts b/src/painter/tools/Pencil.ts index 0e37a9b..4f33309 100644 --- a/src/painter/tools/Pencil.ts +++ b/src/painter/tools/Pencil.ts @@ -57,6 +57,7 @@ export default class Pencil extends Tool { }) this.drawOptions = drawOptions as Required this.draw(this.x, this.y) + this.painter.renderCanvas() }) } onDragMove(e: any) { @@ -83,6 +84,7 @@ export default class Pencil extends Tool { } this.draw(x, y) + this.painter.renderCanvas() } onDragEnd(e: any) { if (!this.isDrawing) { @@ -145,7 +147,6 @@ export default class Pencil extends Tool { const startX = size > 1 ? x - Math.floor((size - 1) / 2) : x const startY = size > 1 ? y - Math.round((size - 1) / 2) : y drawCtx.fillRect(startX, startY, size, size) - this.painter.renderCanvas() } private commitDraw(ctx: CanvasRenderingContext2D) { const { drawCanvas } = this diff --git a/src/painter/util.ts b/src/painter/util.ts index f2150f1..ea812f1 100644 --- a/src/painter/util.ts +++ b/src/painter/util.ts @@ -10,3 +10,19 @@ export function duplicateCanvas( } return result } + +const ratio3 = 255 / Math.sqrt(255 * 255 * 3) +const ratio4 = 255 / Math.sqrt(255 * 255 * 4) + +export function colorDistance(color1: number[], color2: number[]) { + const r = Math.abs(color1[0] - color2[0]) + const g = Math.abs(color1[1] - color2[1]) + const b = Math.abs(color1[2] - color2[2]) + if (color1.length === 4 && color2.length === 4) { + const a = Math.abs(color1[3] - color2[3]) + if (a !== 0) { + return Math.sqrt(r * r + g * g + b * b + a * a) * ratio4 + } + } + return Math.sqrt(r * r + g * g + b * b) * ratio3 +}