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
+
+
+
+
+
+
+
+
+
+
+
+
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