diff --git a/lib/stylus/README.txt b/lib/stylus/README.txt new file mode 100644 index 0000000..e4da17e --- /dev/null +++ b/lib/stylus/README.txt @@ -0,0 +1,13 @@ + + +https://mattdesl.svbtle.com/drawing-lines-is-hard +http://perfectionkills.com/exploring-canvas-drawing-techniques/ + + + +https://github.com/mattdesl/polyline-normals + +var path = [ [0, 122], [0, 190], [90, 190] ] + +//get the normals as a closed loop +var normals = getNormals(path, true) diff --git a/lib/stylus/index.html b/lib/stylus/index.html new file mode 100644 index 0000000..51584c0 --- /dev/null +++ b/lib/stylus/index.html @@ -0,0 +1,73 @@ + + + + + + + + Stylus Functional Tests + + + + + + +
+ + Get a better browser, bro. + +
+ + + diff --git a/lib/stylus/main.js b/lib/stylus/main.js new file mode 100644 index 0000000..d3ff5ce --- /dev/null +++ b/lib/stylus/main.js @@ -0,0 +1,90 @@ +import PIXIApp from '../../lib/pixi/app.js' +import Button from '../../lib/pixi/button.js' +import ButtonGroup from '../../lib/pixi/buttongroup.js' +import Stylus from './stylus.js' + +class StylusApp extends PIXIApp { + + sceneFactory() { + return new Stylus(this.renderer) + } + + setup() { + let buttonColor = 0x666666 + super.setup() + + this.tools = new ButtonGroup({ + type: 'checkbox', + margin: 0, + x: 16, + y: 16, + fill: buttonColor, + buttons: [{icon: 'edit', + iconColorActive: 0xFFFF00, + action: (event, button) => this.toggleEditMode() }, + {icon: 'undo', + action: (event, button) => this.undo(button) }, + {icon: 'redo', + action: (event, button) => this.redo(button) }, + {icon: 'delete', + action: (event, button) => this.clear(button) } + ] + }) + this.scene.addChild(this.tools) + + let defaults = { icon: 'brightness_1', + action: (event, button) => this.selectColor(button), + fillAlpha: 0, + strokeAlpha: 0, + fillActiveAlpha: 0, + strokeActiveAlpha: 0} + + this.palette = new ButtonGroup( { + type: "radio", + x: 200, + y: 16, + margin: 0, + strokeAlpha: 0, + fill: buttonColor, + buttons: [ + Object.assign({}, defaults, { iconColor: 0x111111, + iconColorActive: 0x111111}), // tooltip: "Black", + Object.assign({}, defaults, { iconColor: 0xFFFF00, + iconColorActive: 0xFFFF00}), // tooltip: "Yellow", + Object.assign({}, defaults, { iconColor: 0x00FF00, + iconColorActive:0x00FF00}), // tooltip: "Green", + Object.assign({}, defaults, { iconColor: 0xFF00FF, + iconColorActive:0xFF00FF}) // tooltip: "Violet", + ] + }) + this.scene.addChild(this.palette) + } + + selectColor(button) { + this.scene.color = button.opts.iconColor + } + + undo(button) { + this.scene.undo() + setTimeout(() => { + button.active = false}, 200) + } + + redo(button) { + this.scene.redo() + setTimeout(() => { + button.active = false}, 200) + } + + clear(button) { + this.scene.clearAll() + setTimeout(() => { + button.active = false}, 200) + } +} + +const app = new StylusApp({ view: canvas }) +window.app = app +app.setup() +app.run() + diff --git a/lib/stylus/stylus.js b/lib/stylus/stylus.js new file mode 100644 index 0000000..a267416 --- /dev/null +++ b/lib/stylus/stylus.js @@ -0,0 +1,398 @@ +import Events from '../../node_modules/iwmlib/lib/events.js' +import { Angle } from '../../node_modules/iwmlib/lib/utils.js' + +class StylusCommand extends Object { + + constructor() { + super() + } + + do(stylus) { + stylus.commandStack.push(this) + } + + undo(stylus) { + stylus.undoCommandStack.push(this) + } + + redo(stylus) { + this.do(stylus) + } +} + +class StrokeCommand extends StylusCommand { + + constructor(stroke) { + super() + this.stroke = stroke + } + + do(stylus) { + if (this.stroke.length > 0) { + super.do(stylus) + stylus.stroke = [] + stylus.strokes.push(this.stroke) + stylus.redraw() + stylus.changed() + } + } + + undo(stylus) { + if (this.stroke.length > 0) { + super.undo(stylus) + stylus.strokes.pop() + stylus.redraw() + stylus.changed() + } + } +} + +class ClearCommand extends StylusCommand { + + do(stylus) { + // Clears the command stack + stylus.commandStack = [] + super.do(stylus) + this.strokes = stylus.strokes + stylus.stroke = [] + stylus.strokes = [] + stylus.redraw() + stylus.changed() + } + + undo(stylus) { + //super.undo(stylus) // Clear all is not redoable + stylus.stroke = [] + stylus.strokes = this.strokes + stylus.redraw() + stylus.changed() + } +} + + +export default class Stylus extends PIXI.Graphics { + + constructor({ width = window.innerWidth, + height = window.innerHeight, + interactive = true, + color = 0x000000, + tiltX = 0, + tiltY = 0, + backgroundAlpha = 1, + backgroundFill = 0xFFFFFF, + colorAlpha = 1, + captureEvents = true, + acceptMouseEvents = true } = {}) { + super() + this.wantedWidth = width + this.wantedHeight = height + this.backgroundAlpha = backgroundAlpha + this.backgroundFill = backgroundFill + this.colorAlpha = colorAlpha + this.color = color + this.interactive = interactive + this.debug = false + this.tiltX = tiltX // degrees -90 ... 90 + this.tiltY = tiltY // degrees -90 ... 90 + this.captureEvents = captureEvents + this.commandStack = [] + this.undoCommandStack = [] + this.strokes = [] + this.stroke = [] + if (captureEvents) + this.registerEventHandler(acceptMouseEvents) + this.drawBackground() + } + + drawBackground() { + this.clear() + this.beginFill(this.backgroundFill, this.backgroundAlpha) + this.drawRect(0, 0, this.wantedWidth, this.wantedHeight) + this.endFill() + } + + touchToPoint(t) { + return { x: t.clientX, y: t.clientY } + } + + isStylusPointer(event) { + let identifier = event.data.identifier + if (typeof (event.data.originalEvent.changedTouches) !== 'undefined') { + for (let touch of event.data.originalEvent.changedTouches) { + if (touch.identifier === identifier && touch.touchType === 'stylus') { + this.tiltX = Angle.radian2degree(touch.azimuthAngle) + this.tiltY = 90.0 - Angle.radian2degree(touch.altitudeAngle) + return true + } + } + } + // UO: Not tested since the Sprot delivered "mouse" events to Chrome + if (event.data.originalEvent.pointerType === 'pen') { + this.tiltX = event.data.originalEvent.tiltX + this.tiltY = event.data.originalEvent.tiltY + return true + } + return false + } + + isStylusTouch(event) { + let identifier = event.data.identifier + if (typeof (event.data.originalEvent.changedTouches) !== 'undefined') { + for (let touch of event.data.originalEvent.changedTouches) { + if (touch.identifier === identifier && touch.pointerType === 'touch') { + return true + } + } + } + return false + } + + getPointerID(event) { + let identifier = event.data.identifier + for (let touch of event.data.originalEvent.changedTouches) { + if (touch.identifier === identifier) { + return touch.pointerId + } + } + } + + registerEventHandler() { + window.addEventListener('keydown', (e) => { + switch (e.keyCode) { + case 38: // up arrow + this.tiltX += 5 + break + case 40: // down arrow + this.tiltX -= 5 + break + case 37: // left arrow + this.tiltY -= 5 + break + case 39: // right arrow + this.tiltY += 5 + break + } + if (this.debug) console.log("keydown", e.keyCode, this.tiltX, this.tiltY) + }) + + this.on('pointerdown', (e) => { + if (this.debug) console.log("pointerdown", e) + if (this.eventInside(e)) + this.startStroke(this.toStroke(e)) + }) + this.on('pointermove', (e) => { + if (Events.isPointerDown(e.data.originalEvent) || this.isStylusPointer(e) || this.isStylusTouch(e)) { + if (this.debug) console.log("pointermove", e, this.eventInside(e)) + if (this.eventInside(e)) + this.moveStroke(this.toStroke(e)) + } + }) + this.on('pointerup', (e) => { + if (this.debug) console.log("pointerup", e) + if (this.eventInside(e)) + this.endStroke(this.toStroke(e)) + }) + this.on('pointerleave', (e) => { + this.endStroke(this.toStroke(e)) + }) + this.on('pointercancel', (e) => { + this.endStroke(this.toStroke(e)) + }) + } + + undoable() { + return this.commandStack.length > 0 + } + + redoable() { + return this.undoCommandStack.length > 0 + } + + undo() { + if (this.undoable()) { + let cmd = this.commandStack.pop() + cmd.undo(this) + } + } + + redo() { + if (this.redoable()) { + let cmd = this.undoCommandStack.pop() + cmd.redo(this) + } + } + + eventInside(event) { + + let local = this.toLocal(event.data.global) + for (let child of this.children) { + let r = child.getBounds() + if (r.contains(local.x, local.y)) { + console.log("Child touched") + return false + } + } + if (local.x < 0 || local.x > this.wantedWidth) + return false + if (local.y < 0 || local.y > this.wantedHeight) + return false + event.stopPropagation() + if (this.debug) console.log("stopPropagation", event) + if (event.data.originalEvent.claimedByScatter) { + return false + } + return true + } + + toLocalPoint(event) { + return this.toLocal(event.data.global) + } + + toStroke(event) { + let local = this.toLocalPoint(event) + let x = Math.max(0, Math.min(local.x, this.wantedWidth)) + let y = Math.max(0, Math.min(local.y, this.wantedHeight)) + let desc = { + x, y, + pressure: event.pressure || null, + tiltX: this.tiltX, tiltY: this.tiltY, + color: this.color + } + return desc + } + + startStroke(info) { + this.stroke = [info] + this.redraw() + } + + moveStroke(info) { + this.stroke.push(info) + this.redraw() + } + + endStroke(info) { + if (this.stroke.length > 1) { + let cmd = new StrokeCommand(this.stroke) + cmd.do(this) + } + } + + tiltToLineWidth(value) { + return Math.round(Math.abs(value / 10) + 1) + } + + drawStroke(stroke) { + if (stroke.length) { + let start = stroke[0] + this.beginFill(0, 0) + this.moveTo(start.x, start.y) + for (let i = 1; i < stroke.length; i++) { + let info = stroke[i] + this.lineStyle(this.tiltToLineWidth(info.tiltY), + info.color, this.colorAlpha) + this.lineTo(info.x, info.y) + } + this.endFill() + } + } + + drawTouch(point) { + this.beginFill(0, 0) + this.drawCircle(point.x, point.y, 22) + this.endFill() + } + + drawStrokes() { + this.drawBackground() + this.lineStyle(1.0, 0xFF0000, 1) + for (let stroke of this.iterStrokes()) { + this.drawStroke(stroke) + } + } + + redraw() { + this.drawStrokes() + } + + // Can be overwritten if different levels of strokes are necessary + *iterStrokes() { + for (let stroke of this.strokes) { + yield stroke + } + yield this.stroke + } + + changed() { + // Can be overwritten + } + + clearAll() { + let cmd = new ClearCommand() + cmd.do(this) + } + + normalizeInfo(info) { + let { x, y, pressure, tiltX, tiltY, color } = info + x /= this.wantedWidth + y /= this.wantedHeight + return { x, y, pressure, tiltX, tiltY, color } + } + + denormalizeInfo(info) { + let { x, y, pressure, tiltX, tiltY, color } = info + x = x * this.wantedWidth + y = y * this.wantedHeight + return { x, y, pressure, tiltX, tiltY, color } + } + + // Convert strokes into an object that can be stored in an Indexed DB. + // Returns normalized strokes + toObject() { + let result = [] + for (let stroke of this.strokes) { + let normalized = [] + for (let info of stroke) { + normalized.push(this.normalizeInfo(info)) + } + result.push(normalized) + } + return result + } + + // Read normalized strokes from an object from an Indexed DB. + fromObject(normalizedStrokes) { + this.strokes = [] + for (let stroke of normalizedStrokes) { + let denormalized = [] + for (let info of stroke) { + denormalized.push(this.denormalizeInfo(info)) + } + this.strokes.push(denormalized) + } + } + + // Convert strokes into a JSON object that can be stored in an Indexed DB + toJSON() { + return JSON.stringify(this.toObject()) + } + + // Convert strokes from a JSON + fromJSON(json) { + this.fromObject(JSON.parse(json)) + } + + // Returns a set of used colors + usedColors() { + let used = new Set() + for (let info of this.stroke) { + used.add(info.color) + } + for (let stroke of this.strokes) { + for (let info of stroke) { + used.add(info.color) + } + } + return used.values() + } +} diff --git a/lib/stylus/thumbnail.png b/lib/stylus/thumbnail.png new file mode 100644 index 0000000..e2c7b2b Binary files /dev/null and b/lib/stylus/thumbnail.png differ