Merge pull request #58 from zero01101/bleeding-edge

Mostly Documentation and Development Tooling
This commit is contained in:
Victor Seiji Hariki 2022-11-28 22:37:29 -03:00 committed by GitHub
commit 64ff4e8f3e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 629 additions and 219 deletions

View file

@ -241,7 +241,7 @@ div.prompt-wrapper > textarea:focus {
border-bottom-right-radius: 5px;
}
.button-array > .button.tool {
.button.tool {
background-color: rgb(0, 0, 50);
color: rgb(255, 255, 255);
cursor: pointer;
@ -254,15 +254,15 @@ div.prompt-wrapper > textarea:focus {
margin-bottom: 5px;
}
.button-array > .button.tool:disabled {
.button.tool:disabled {
background-color: #666 !important;
cursor: default;
}
.button-array > .button.tool:hover {
.button.tool:hover {
background-color: rgb(30, 30, 80);
}
.button-array > .button.tool:active,
.button.tool:active,
.button.tool.active {
background-color: rgb(60, 60, 130);
}

38
js/commands.d.js Normal file
View file

@ -0,0 +1,38 @@
/**
* An object that represents an entry of the command in the history
*
* @typedef CommandEntry
* @property {string} id A unique ID generated for this entry
* @property {string} title The title passed to the command being run
* @property {() => void | Promise<void>} undo A method to undo whatever the command did
* @property {() => void | Promise<void>} redo A method to redo whatever undo did
* @property {{[key: string]: any}} state The state of the current command instance
*/
/**
* A command, which is run, then returns a CommandEntry object that can be used to manually undo/redo it
*
* @callback Command
* @param {string} title The title passed to the command being run
* @param {*} options A options object for the command
* @returns {Promise<CommandEntry>}
*/
/**
* A method for running a command (or redoing it)
*
* @callback CommandDoCallback
* @param {string} title The title passed to the command being run
* @param {*} options A options object for the command
* @param {{[key: string]: any}} state The state of the current command instance
* @returns {void | Promise<void>}
*/
/**
* A method for undoing a command
*
* @callback CommandUndoCallback
* @param {string} title The title passed to the command when it was run
* @param {{[key: string]: any}} state The state of the current command instance
* @returns {void | Promise<void>}
*/

View file

@ -4,122 +4,177 @@
const _commands_events = new Observer();
const commands = {
current: -1,
history: [],
undo(n = 1) {
for (var i = 0; i < n && this.current > -1; i++) {
this.history[this.current--].undo();
}
},
redo(n = 1) {
for (var i = 0; i < n && this.current + 1 < this.history.length; i++) {
this.history[++this.current].redo();
}
},
/** CommandNonExistentError */
class CommandNonExistentError extends Error {}
/**
* These are basic commands that can be done/undone
*
* They must contain a 'run' method that performs the action the first time,
* a 'undo' method that undoes that action and a 'redo' method that does the
* action again, but without requiring parameters. 'redo' is by default the
* same as 'run'.
*
* The 'run' and 'redo' functions will receive a 'options' parameter which will be
* forwarded directly to the operation, and a 'state' parameter that
* can be used to store state for undoing things.
*
* The 'state' object will be passed to the 'undo' function as well.
*/
createCommand(name, run, undo, redo = run) {
const command = function runWrapper(title, options) {
// Create copy of options and state object
const copy = {};
Object.assign(copy, options);
const state = {};
/** Global Commands Object */
const commands = makeReadOnly(
{
/** Current History Index Reader */
get current() {
return this._current;
},
/** Current History Index (private) */
_current: -1,
/**
* Command History (private)
*
* @type {CommandEntry[]}
*/
_history: [],
/** The types of commands we can run (private) */
_types: {},
const entry = {
id: guid(),
title,
state,
};
// Attempt to run command
try {
run(title, copy, state);
} catch (e) {
console.warn(`Error while running command '${name}' with options:`);
console.warn(copy);
console.warn(e);
return;
/**
* Undoes the last commands in the history
*
* @param {number} [n] Number of actions to undo
*/
async undo(n = 1) {
for (var i = 0; i < n && this.current > -1; i++) {
await this._history[this._current--].undo();
}
},
/**
* Redoes the next commands in the history
*
* @param {number} [n] Number of actions to redo
*/
async redo(n = 1) {
for (var i = 0; i < n && this.current + 1 < this._history.length; i++) {
await this._history[++this._current].redo();
}
},
/**
* Creates a basic command, that can be done and undone
*
* They must contain a 'run' method that performs the action for the first time,
* a 'undo' method that undoes that action and a 'redo' method that does the
* action again, but without requiring parameters. 'redo' is by default the
* same as 'run'.
*
* The 'run' and 'redo' functions will receive a 'options' parameter which will be
* forwarded directly to the operation, and a 'state' parameter that
* can be used to store state for undoing things.
*
* The 'state' object will be passed to the 'undo' function as well.
*
* @param {string} name Command identifier (name)
* @param {CommandDoCallback} run A method that performs the action for the first time
* @param {CommandUndoCallback} undo A method that reverses what the run method did
* @param {CommandDoCallback} redo A method that redoes the action after undone (default: run)
* @returns {Command}
*/
createCommand(name, run, undo, redo = run) {
const command = async function runWrapper(title, options) {
// Create copy of options and state object
const copy = {};
Object.assign(copy, options);
const state = {};
/** @type {CommandEntry} */
const entry = {
id: guid(),
title,
state,
};
// Attempt to run command
try {
console.debug(`[commands] Running '${title}'[${name}]`);
await run(title, copy, state);
} catch (e) {
console.warn(
`[commands] Error while running command '${name}' with options:`
);
console.warn(copy);
console.warn(e);
return;
}
const undoWrapper = () => {
console.debug(
`[commands] Undoing '${title}'[${name}], currently ${this._current}`
);
undo(title, state);
_commands_events.emit({
id: entry.id,
name,
action: "undo",
state,
current: this._current,
});
};
const redoWrapper = () => {
console.debug(
`[commands] Redoing '${title}'[${name}], currently ${this._current}`
);
redo(title, copy, state);
_commands_events.emit({
id: entry.id,
name,
action: "redo",
state,
current: this._current,
});
};
// Add to history
if (commands._history.length > commands._current + 1) {
commands._history.forEach((entry, index) => {
if (index >= commands._current + 1)
_commands_events.emit({
id: entry.id,
name,
action: "deleted",
state,
current: this._current,
});
});
commands._history.splice(commands._current + 1);
}
commands._history.push(entry);
commands._current++;
entry.undo = undoWrapper;
entry.redo = redoWrapper;
const undoWrapper = () => {
console.debug(`Undoing ${name}, currently ${commands.current}`);
undo(title, state);
_commands_events.emit({
id: entry.id,
name,
action: "undo",
action: "run",
state,
current: commands.current,
});
};
const redoWrapper = () => {
console.debug(`Redoing ${name}, currently ${commands.current}`);
redo(title, copy, state);
_commands_events.emit({
id: entry.id,
name,
action: "redo",
state,
current: commands.current,
current: commands._current,
});
return entry;
};
// Add to history
if (commands.history.length > commands.current + 1) {
commands.history.forEach((entry, index) => {
if (index >= commands.current + 1)
_commands_events.emit({
id: entry.id,
name,
action: "deleted",
state,
current: commands.current,
});
});
this._types[name] = command;
commands.history.splice(commands.current + 1);
}
commands.history.push(entry);
commands.current++;
entry.undo = undoWrapper;
entry.redo = redoWrapper;
_commands_events.emit({
id: entry.id,
name,
action: "run",
state,
current: commands.current,
});
return entry;
};
this.types[name] = command;
return command;
return command;
},
/**
* Runs a command
*
* @param {string} name The name of the command to run
* @param {string} title The display name of the command on the history panel view
* @param {any} options The options to be sent to the command to be run
*/
runCommand(name, title, options = null) {
if (!this._types[name])
throw new CommandNonExistentError(
`[commands] Command '${name}' does not exist`
);
this._types[name](title, options);
},
},
runCommand(name, title, options) {
this.types[name](title, options);
},
types: {},
};
"commands",
["_current"]
);
/**
* Draw Image Command, used to draw a Image to a context

120
js/input.d.js Normal file
View file

@ -0,0 +1,120 @@
/* Here are event types */
/**
* A base event type for input handlers
*
* @typedef InputEvent
* @property {HTMLElement} target The target for the event
* @property {MouseEvent | KeyboardEvent} evn An input event
* @property {number} timestamp The time an event was emmited
*/
/**
* A base event type for input
*/
// TODO: Implement event typing
/**
* An object for mouse event listeners
*
* @typedef OnClickEvent
*/
/* Here are mouse context types */
/**
* An object for mouse button event listeners.
*
* Drag events are use timing and radius to determine if they will be triggered
* Paint events are triggered on any mousedown, mousemove and mouseup circunstances
*
* @typedef MouseListenerBtnContext
* @property {Observer} onclick A click handler
* @property {Observer} ondclick A double click handler
*
* @property {Observer} ondragstart A drag start handler
* @property {Observer} ondrag A drag handler
* @property {Observer} ondragend A drag end handler
*
* @property {Observer} onpaintstart A paint start handler
* @property {Observer} onpaint A paint handler
* @property {Observer} onpaintend A paint end handler
*/
/**
* An object for mouse event listeners
*
* @typedef MouseListenerContext
* @property {Observer} onmousemove A mouse move handler
* @property {Observer} onwheel A mouse wheel handler
* @property {MouseListenerBtnContext} btn Button handlers
*/
/**
* This callback defines how event coordinateswill be transformed
* for this context. This function should set ctx.coords appropriately.
*
*
* @callback ContextMoveTransformer
* @param {MouseEvent} evn The mousemove event to be transformed
* @param {MouseContext} ctx The context object we are currently in
* @returns {void}
*/
/**
* A context for handling mouse coordinates and events
*
* @typedef MouseContext
* @property {string} id A unique identifier
* @property {string} name The key name
* @property {ContextMoveTransformer} onmove The coordinate transform callback
* @property {?HTMLElement} target The target
*/
/**
* An object for storing dragging information
*
* @typedef MouseCoordContextDragInfo
* @property {number} x X coordinate of drag start
* @property {number} y Y coordinate of drag start
* @property {HTMLElement} target Original element of drag
* @property {boolean} drag If we are in a drag
*/
/**
* An object for storing mouse coordinates in a context
*
* @typedef MouseCoordContext
* @property {{[key: string]: MouseCoordContextDragInfo}} dragging Information about mouse button drags
* @property {{x: number, y: number}} prev Previous mouse position
* @property {{x: number, y: number}} pos Current mouse position
*/
/* Here are keyboard-related types */
/**
* Stores key states
*
* @typedef KeyboardKeyState
* @property {boolean} pressed If the key is currently pressed or not
* @property {boolean} held If the key is currently held or not
* @property {?number} _hold_to A timeout for detecting key holding status
*/
/* Here are the shortcut types */
/**
* Keyboard shortcut callback
*
* @callback KeyboardShortcutCallback
* @param {KeyboardEvent} evn The keyboard event that triggered this shorcut
* @returns {void}
*/
/**
* Shortcut information
*
* @typedef KeyboardShortcut
* @property {string} id A unique identifier for this shortcut
*
* @property {boolean} ctrl Shortcut ctrl key state
* @property {boolean} alt Shortcut alt key state
* @property {boolean} shift Shortcut shift key state
*
* @property {KeyboardShortcutCallback} callback If the key is currently held or not
*/

View file

@ -10,31 +10,62 @@ const inputConfig = {
* Mouse input processing
*/
// Base object generator functions
function _mouse_observers() {
return {
// Simple click handler
onclick: new Observer(),
// Double click handler (will still trigger simple click handler as well)
ondclick: new Observer(),
// Drag handler
ondragstart: new Observer(),
ondrag: new Observer(),
ondragend: new Observer(),
// Paint handler (like drag handler, but with no delay); will trigger during clicks too
onpaintstart: new Observer(),
onpaint: new Observer(),
onpaintend: new Observer(),
};
function _mouse_observers(name = "generic_mouse_observer_array") {
return makeReadOnly(
{
// Simple click handler
onclick: new Observer(),
// Double click handler (will still trigger simple click handler as well)
ondclick: new Observer(),
// Drag handler
ondragstart: new Observer(),
ondrag: new Observer(),
ondragend: new Observer(),
// Paint handler (like drag handler, but with no delay); will trigger during clicks too
onpaintstart: new Observer(),
onpaint: new Observer(),
onpaintend: new Observer(),
},
name
);
}
/** Global Mouse Object */
const mouse = {
contexts: [],
/**
* Array of context objects
* @type {MouseContext[]}
*/
_contexts: [],
/**
* Timestamps of the button's last down event
* @type {Record<,number | null>}
*/
buttons: {},
coords: {},
/**
* Coordinate storage of mouse positions
* @type {{[ctxKey: string]: MouseCoordContext}}
*/
coords: makeWriteOnce({}, "mouse.coords"),
listen: {},
/**
* Listener storage for event observers
* @type {{[ctxKey: string]: MouseListenerContext}}
*/
listen: makeWriteOnce({}, "mouse.listen"),
// Register Context
/**
* Registers a new mouse context
*
* @param {string} name The key name of the context
* @param {ContextMoveTransformer} onmove The function to perform coordinate transform
* @param {object} options Extra options
* @param {HTMLElement} [options.target=null] Target filtering
* @param {Record<number, string>} [options.buttons={0: "left", 1: "middle", 2: "right"}] Custom button mapping
* @returns {MouseContext}
*/
registerContext: (name, onmove, options = {}) => {
// Options
defaultOpt(options, {
@ -43,6 +74,7 @@ const mouse = {
});
// Context information
/** @type {MouseContext} */
const context = {
id: guid(),
name,
@ -70,13 +102,16 @@ const mouse = {
mouse.listen[name] = {
onwheel: new Observer(),
onmousemove: new Observer(),
btn: {},
};
// Button specific items
Object.keys(options.buttons).forEach((index) => {
const button = options.buttons[index];
mouse.coords[name].dragging[button] = null;
mouse.listen[name][button] = _mouse_observers();
mouse.listen[name].btn[button] = _mouse_observers(
`mouse.listen[${name}].btn[${button}]`
);
});
// Add to context
@ -84,7 +119,7 @@ const mouse = {
context.listen = mouse.listen[name];
// Add to list
mouse.contexts.push(context);
mouse._contexts.push(context);
return context;
},
@ -98,9 +133,9 @@ window.onmousedown = (evn) => {
if (_double_click_timeout[evn.button]) {
// ondclick event
mouse.contexts.forEach(({target, name, buttons}) => {
mouse._contexts.forEach(({target, name, buttons}) => {
if ((!target || target === evn.target) && buttons[evn.button])
mouse.listen[name][buttons[evn.button]].ondclick.emit({
mouse.listen[name].btn[buttons[evn.button]].ondclick.emit({
target: evn.target,
buttonId: evn.button,
x: mouse.coords[name].pos.x,
@ -119,14 +154,14 @@ window.onmousedown = (evn) => {
// Set drag start timeout
_drag_start_timeout[evn.button] = setTimeout(() => {
mouse.contexts.forEach(({target, name, buttons}) => {
mouse._contexts.forEach(({target, name, buttons}) => {
const key = buttons[evn.button];
if (
(!target || target === evn.target) &&
!mouse.coords[name].dragging[key].drag &&
key
) {
mouse.listen[name][key].ondragstart.emit({
mouse.listen[name].btn[key].ondragstart.emit({
target: evn.target,
buttonId: evn.button,
x: mouse.coords[name].pos.x,
@ -143,7 +178,7 @@ window.onmousedown = (evn) => {
mouse.buttons[evn.button] = time;
mouse.contexts.forEach(({target, name, buttons}) => {
mouse._contexts.forEach(({target, name, buttons}) => {
const key = buttons[evn.button];
if ((!target || target === evn.target) && key) {
mouse.coords[name].dragging[key] = {};
@ -151,7 +186,7 @@ window.onmousedown = (evn) => {
Object.assign(mouse.coords[name].dragging[key], mouse.coords[name].pos);
// onpaintstart event
mouse.listen[name][key].onpaintstart.emit({
mouse.listen[name].btn[key].onpaintstart.emit({
target: evn.target,
buttonId: evn.button,
x: mouse.coords[name].pos.x,
@ -166,7 +201,7 @@ window.onmousedown = (evn) => {
window.onmouseup = (evn) => {
const time = performance.now();
mouse.contexts.forEach(({target, name, buttons}) => {
mouse._contexts.forEach(({target, name, buttons}) => {
const key = buttons[evn.button];
if (
(!target || target === evn.target) &&
@ -187,7 +222,7 @@ window.onmouseup = (evn) => {
time - mouse.buttons[evn.button] < inputConfig.clickTiming &&
dx * dx + dy * dy < inputConfig.clickRadius * inputConfig.clickRadius
)
mouse.listen[name][key].onclick.emit({
mouse.listen[name].btn[key].onclick.emit({
target: evn.target,
buttonId: evn.button,
x: mouse.coords[name].pos.x,
@ -197,7 +232,7 @@ window.onmouseup = (evn) => {
});
// onpaintend event
mouse.listen[name][key].onpaintend.emit({
mouse.listen[name].btn[key].onpaintend.emit({
target: evn.target,
initialTarget: mouse.coords[name].dragging[key].target,
buttonId: evn.button,
@ -211,7 +246,7 @@ window.onmouseup = (evn) => {
// ondragend event
if (mouse.coords[name].dragging[key].drag)
mouse.listen[name][key].ondragend.emit({
mouse.listen[name].btn[key].ondragend.emit({
target: evn.target,
initialTarget: mouse.coords[name].dragging[key].target,
buttonId: evn.button,
@ -235,7 +270,7 @@ window.onmouseup = (evn) => {
};
window.onmousemove = (evn) => {
mouse.contexts.forEach((context) => {
mouse._contexts.forEach((context) => {
const target = context.target;
const name = context.name;
@ -265,7 +300,7 @@ window.onmousemove = (evn) => {
dx * dx + dy * dy >=
inputConfig.clickRadius * inputConfig.clickRadius
) {
mouse.listen[name][key].ondragstart.emit({
mouse.listen[name].btn[key].ondragstart.emit({
target: evn.target,
buttonId: evn.button,
ix: mouse.coords[name].dragging[key].x,
@ -285,7 +320,7 @@ window.onmousemove = (evn) => {
mouse.coords[name].dragging[key] &&
mouse.coords[name].dragging[key].drag
)
mouse.listen[name][key].ondrag.emit({
mouse.listen[name].btn[key].ondrag.emit({
target: evn.target,
initialTarget: mouse.coords[name].dragging[key].target,
button: index,
@ -301,7 +336,7 @@ window.onmousemove = (evn) => {
// onpaint event
if (mouse.coords[name].dragging[key]) {
mouse.listen[name][key].onpaint.emit({
mouse.listen[name].btn[key].onpaint.emit({
target: evn.target,
initialTarget: mouse.coords[name].dragging[key].target,
button: index,
@ -323,7 +358,7 @@ window.onmousemove = (evn) => {
window.addEventListener(
"wheel",
(evn) => {
mouse.contexts.forEach(({name}) => {
mouse._contexts.forEach(({name}) => {
mouse.listen[name].onwheel.emit({
target: evn.target,
delta: evn.deltaY,
@ -361,20 +396,51 @@ mouse.registerContext(
/**
* Keyboard input processing
*/
// Base object generator functions
/** Global Keyboard Object */
const keyboard = {
/**
* Stores the key states for all keys
*
* @type {Record<string, KeyboardKeyState>}
*/
keys: {},
/**
* Checks if a key is pressed or not
*
* @param {string} code - The code of the key
* @returns {boolean}
*/
isPressed(code) {
return this.keys[key].pressed;
return this.keys[code].pressed;
},
/**
* Checks if a key is held or not
*
* @param {string} code - The code of the key
* @returns {boolean}
*/
isHeld(code) {
return !!this;
return this.keys[code].held;
},
/**
* Object storing shortcuts. Uses key as indexing for better performance.
* @type {Record<string, KeyboardShortcut[]>}
*/
shortcuts: {},
/**
* Adds a shortcut listener
*
* @param {object} shortcut Shortcut information
* @param {boolean} [shortcut.ctrl=false] If control must be pressed
* @param {boolean} [shortcut.alt=false] If alt must be pressed
* @param {boolean} [shortcut.shift=false] If shift must be pressed
* @param {string} shortcut.key The key code (evn.code) for the key pressed
* @param {KeyboardShortcutCallback} callback Will be called on shortcut detection
* @returns
*/
onShortcut(shortcut, callback) {
/**
* Adds a shortcut handler (shorcut must be in format: {ctrl?: bool, alt?: bool, shift?: bool, key: string (code)})
@ -384,23 +450,32 @@ const keyboard = {
this.shortcuts[shortcut.key] = [];
this.shortcuts[shortcut.key].push({
ctrl: shortcut.ctrl,
alt: shortcut.alt,
shift: shortcut.shift,
ctrl: !!shortcut.ctrl,
alt: !!shortcut.alt,
shift: !!shortcut.shift,
id: guid(),
callback,
});
return callback;
},
deleteShortcut(id, key = null) {
/**
* Deletes a shortcut (disables callback)
*
* @param {string | KeyboardShortcutCallback} shortcut A shortcut ID or its callback
* @param {string} [key=null] If you know the key code, to avoid searching all shortcuts
* @returns
*/
deleteShortcut(shortcut, key = null) {
if (key) {
this.shortcuts[key] = this.shortcuts[key].filter(
(v) => v.id !== id && v.callback !== id
(v) => v.id !== shortcut && v.callback !== shortcut
);
return;
}
this.shortcuts.keys().forEach((key) => {
this.shortcuts[key] = this.shortcuts[key].filter(
(v) => v.id !== id && v.callback !== id
(v) => v.id !== shortcut && v.callback !== shortcut
);
});
},

7
js/jsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es6"
},
"include": ["**/*.js"]
}

View file

@ -6,7 +6,7 @@ function makeDraggable(element) {
element.style.top = startbb.y + "px";
element.style.left = startbb.x + "px";
mouse.listen.window.left.onpaintstart.on((evn) => {
mouse.listen.window.btn.left.onpaintstart.on((evn) => {
if (
element.contains(evn.target) &&
evn.target.classList.contains("draggable")
@ -18,14 +18,14 @@ function makeDraggable(element) {
}
});
mouse.listen.window.left.onpaint.on((evn) => {
mouse.listen.window.btn.left.onpaint.on((evn) => {
if (dragging) {
element.style.top = evn.y - offset.y + "px";
element.style.left = evn.x - offset.x + "px";
}
});
mouse.listen.window.left.onpaintend.on((evn) => {
mouse.listen.window.btn.left.onpaintend.on((evn) => {
dragging = false;
});
}
@ -143,13 +143,13 @@ function createSlider(name, wrapper, options = {}) {
}
});
mouse.listen.window.left.onclick.on((evn) => {
mouse.listen.window.btn.left.onclick.on((evn) => {
if (evn.target === overEl) {
textEl.select();
}
});
mouse.listen.window.left.ondrag.on((evn) => {
mouse.listen.window.btn.left.ondrag.on((evn) => {
if (evn.target === overEl) {
setValue(
Math.max(

View file

@ -28,17 +28,15 @@
if (message.action === "run") {
Array.from(historyView.children).forEach((child) => {
if (
!commands.history.find((entry) => `hist-${entry.id}` === child.id)
!commands._history.find((entry) => `hist-${entry.id}` === child.id)
) {
console.log("Removing " + child.id);
historyView.removeChild(child);
}
});
}
commands.history.forEach((entry, index) => {
commands._history.forEach((entry, index) => {
if (!document.getElementById(`hist-${entry.id}`)) {
console.log("Inserting " + entry.id);
historyView.appendChild(
makeHistoryEntry(index, `hist-${entry.id}`, entry.title)
);

View file

@ -254,8 +254,8 @@ const dreamTool = () =>
// Start Listeners
mouse.listen.canvas.onmousemove.on(state.mousemovecb);
mouse.listen.canvas.left.onclick.on(state.dreamcb);
mouse.listen.canvas.right.onclick.on(state.erasecb);
mouse.listen.canvas.btn.left.onclick.on(state.dreamcb);
mouse.listen.canvas.btn.right.onclick.on(state.erasecb);
// Display Mask
setMask(state.invertMask ? "hold" : "clear");
@ -263,8 +263,8 @@ const dreamTool = () =>
(state, opt) => {
// Clear Listeners
mouse.listen.canvas.onmousemove.clear(state.mousemovecb);
mouse.listen.canvas.left.onclick.clear(state.dreamcb);
mouse.listen.canvas.right.onclick.clear(state.erasecb);
mouse.listen.canvas.btn.left.onclick.clear(state.dreamcb);
mouse.listen.canvas.btn.right.onclick.clear(state.erasecb);
// Hide Mask
setMask("none");
@ -336,8 +336,8 @@ const img2imgTool = () =>
// Start Listeners
mouse.listen.canvas.onmousemove.on(state.mousemovecb);
mouse.listen.canvas.left.onclick.on(state.dreamcb);
mouse.listen.canvas.right.onclick.on(state.erasecb);
mouse.listen.canvas.btn.left.onclick.on(state.dreamcb);
mouse.listen.canvas.btn.right.onclick.on(state.erasecb);
// Display Mask
setMask(state.invertMask ? "hold" : "clear");
@ -345,8 +345,8 @@ const img2imgTool = () =>
(state, opt) => {
// Clear Listeners
mouse.listen.canvas.onmousemove.clear(state.mousemovecb);
mouse.listen.canvas.left.onclick.clear(state.dreamcb);
mouse.listen.canvas.right.onclick.clear(state.erasecb);
mouse.listen.canvas.btn.left.onclick.clear(state.dreamcb);
mouse.listen.canvas.btn.right.onclick.clear(state.erasecb);
// Hide mask
setMask("none");

View file

@ -74,10 +74,10 @@ const maskBrushTool = () =>
// Start Listeners
mouse.listen.canvas.onmousemove.on(state.movecb);
mouse.listen.canvas.onwheel.on(state.wheelcb);
mouse.listen.canvas.left.onpaintstart.on(state.drawcb);
mouse.listen.canvas.left.onpaint.on(state.drawcb);
mouse.listen.canvas.right.onpaintstart.on(state.erasecb);
mouse.listen.canvas.right.onpaint.on(state.erasecb);
mouse.listen.canvas.btn.left.onpaintstart.on(state.drawcb);
mouse.listen.canvas.btn.left.onpaint.on(state.drawcb);
mouse.listen.canvas.btn.right.onpaintstart.on(state.erasecb);
mouse.listen.canvas.btn.right.onpaint.on(state.erasecb);
// Display Mask
setMask("neutral");
@ -86,10 +86,10 @@ const maskBrushTool = () =>
// Clear Listeners
mouse.listen.canvas.onmousemove.clear(state.movecb);
mouse.listen.canvas.onwheel.clear(state.wheelcb);
mouse.listen.canvas.left.onpaintstart.clear(state.drawcb);
mouse.listen.canvas.left.onpaint.clear(state.drawcb);
mouse.listen.canvas.right.onpaintstart.clear(state.erasecb);
mouse.listen.canvas.right.onpaint.clear(state.erasecb);
mouse.listen.canvas.btn.left.onpaintstart.clear(state.drawcb);
mouse.listen.canvas.btn.left.onpaint.clear(state.drawcb);
mouse.listen.canvas.btn.right.onpaintstart.clear(state.erasecb);
mouse.listen.canvas.btn.right.onpaint.clear(state.erasecb);
// Hide Mask
setMask("none");

View file

@ -9,12 +9,12 @@ const selectTransformTool = () =>
// Canvas left mouse handlers
mouse.listen.canvas.onmousemove.on(state.movecb);
mouse.listen.canvas.left.onclick.on(state.clickcb);
mouse.listen.canvas.left.ondragstart.on(state.dragstartcb);
mouse.listen.canvas.left.ondragend.on(state.dragendcb);
mouse.listen.canvas.btn.left.onclick.on(state.clickcb);
mouse.listen.canvas.btn.left.ondragstart.on(state.dragstartcb);
mouse.listen.canvas.btn.left.ondragend.on(state.dragendcb);
// Canvas right mouse handler
mouse.listen.canvas.right.onclick.on(state.cancelcb);
mouse.listen.canvas.btn.right.onclick.on(state.cancelcb);
// Keyboard click handlers
keyboard.listen.onkeyclick.on(state.keyclickcb);
@ -30,11 +30,11 @@ const selectTransformTool = () =>
(state, opt) => {
// Clear all those listeners and shortcuts we set up
mouse.listen.canvas.onmousemove.clear(state.movecb);
mouse.listen.canvas.left.onclick.clear(state.clickcb);
mouse.listen.canvas.left.ondragstart.clear(state.dragstartcb);
mouse.listen.canvas.left.ondragend.clear(state.dragendcb);
mouse.listen.canvas.btn.left.onclick.clear(state.clickcb);
mouse.listen.canvas.btn.left.ondragstart.clear(state.dragstartcb);
mouse.listen.canvas.btn.left.ondragend.clear(state.dragendcb);
mouse.listen.canvas.right.onclick.clear(state.cancelcb);
mouse.listen.canvas.btn.right.onclick.clear(state.cancelcb);
keyboard.listen.onkeyclick.clear(state.keyclickcb);
keyboard.listen.onkeydown.clear(state.keydowncb);

View file

@ -9,8 +9,8 @@ const stampTool = () =>
// Start Listeners
mouse.listen.canvas.onmousemove.on(state.movecb);
mouse.listen.canvas.left.onclick.on(state.drawcb);
mouse.listen.canvas.right.onclick.on(state.cancelcb);
mouse.listen.canvas.btn.left.onclick.on(state.drawcb);
mouse.listen.canvas.btn.right.onclick.on(state.cancelcb);
// For calls from other tools to paste image
if (opt && opt.image) {
@ -33,8 +33,8 @@ const stampTool = () =>
(state, opt) => {
// Clear Listeners
mouse.listen.canvas.onmousemove.clear(state.movecb);
mouse.listen.canvas.left.onclick.clear(state.drawcb);
mouse.listen.canvas.right.onclick.clear(state.cancelcb);
mouse.listen.canvas.btn.left.onclick.clear(state.drawcb);
mouse.listen.canvas.btn.right.onclick.clear(state.cancelcb);
// Deselect
state.selected = null;

View file

@ -1,33 +1,71 @@
/**
* Implementation of a simple Oberver Pattern for custom event handling
* Some type definitions before the actual code
*/
function Observer() {
this.handlers = new Set();
/**
* Represents a simple bounding box
*
* @typedef BoundingBox
* @type {Object}
* @property {number} x - Leftmost coordinate of the box
* @property {number} y - Topmost coordinate of the box
* @property {number} w - The bounding box Width
* @property {number} h - The bounding box Height
*/
/**
* A simple implementation of the Observer programming pattern
* @template [T=any] Message type
*/
class Observer {
/**
* List of handlers
* @type {Set<(msg: T) => void | Promise<void>>}
*/
_handlers = new Set();
/**
* Adds a observer to the events
*
* @param {(msg: T) => void | Promise<void>} callback The function to run when receiving a message
* @returns {(msg:T) => void | Promise<void>} The callback we received
*/
on(callback) {
this._handlers.add(callback);
return callback;
}
/**
* Removes a observer
*
* @param {(msg: T) => void | Promise<void>} callback The function used to register the callback
* @returns {boolean} Whether the handler existed
*/
clear(callback) {
return this._handlers.delete(callback);
}
/**
* Sends a message to all observers
*
* @param {T} msg The message to send to the observers
*/
async emit(msg) {
return Promise.all(
Array.from(this._handlers).map(async (handler) => {
try {
await handler(msg);
} catch (e) {
console.warn("Observer failed to run handler");
console.warn(e);
}
})
);
}
}
Observer.prototype = {
// Adds handler for this message
on(callback) {
this.handlers.add(callback);
return callback;
},
clear(callback) {
return this.handlers.delete(callback);
},
emit(msg) {
this.handlers.forEach(async (handler) => {
try {
await handler(msg);
} catch (e) {
console.warn("Observer failed to run handler");
console.warn(e);
}
});
},
};
/**
* Generates unique id
* Generates a simple UID in the format xxxx-xxxx-...-xxxx, with x being [0-9a-f]
*
* @param {number} [size] Number of quartets of characters to generate
* @returns {string} The new UID
*/
const guid = (size = 3) => {
const s4 = () => {
@ -43,17 +81,73 @@ const guid = (size = 3) => {
};
/**
* Default option set
* Assigns defaults to an option object passed to the function.
*
* @template T Object Type
*
* @param {T} options Original options object
* @param {T} defaults Default values to assign
*/
function defaultOpt(options, defaults) {
Object.keys(defaults).forEach((key) => {
if (options[key] === undefined) options[key] = defaults[key];
});
}
/** Custom error for attempt to set read-only objects */
class ProxyReadOnlySetError extends Error {}
/**
* Bounding box Calculation
* Makes a given object read-only; throws a ProxyReadOnlySetError exception if modification is attempted
*
* @template T Object Type
*
* @param {T} obj Object to be proxied
* @param {string} name Name for logging purposes
* @param {string[]} exceptions Parameters excepted from this restriction
* @returns {T} Proxied object, intercepting write attempts
*/
function makeReadOnly(obj, name = "read-only object", exceptions = []) {
return new Proxy(obj, {
set: (obj, prop, value) => {
if (!exceptions.some((v) => v === prop))
throw new ProxyReadOnlySetError(
`Tried setting the '${prop}' property on '${name}'`
);
obj[prop] = value;
},
});
}
/** Custom error for attempt to set write-once objects a second time */
class ProxyWriteOnceSetError extends Error {}
/**
* Makes a given object write-once; Attempts to overwrite an existing prop in the object will throw a ProxyWriteOnceSetError exception
*
* @template T Object Type
* @param {T} obj Object to be proxied
* @param {string} [name] Name for logging purposes
* @param {string[]} [exceptions] Parameters excepted from this restriction
* @returns {T} Proxied object, intercepting write attempts
*/
function makeWriteOnce(obj, name = "write-once object", exceptions = []) {
return new Proxy(obj, {
set: (obj, prop, value) => {
if (obj[prop] !== undefined && !exceptions.some((v) => v === prop))
throw new ProxyWriteOnceSetError(
`Tried setting the '${prop}' property on '${name}' after it was already set`
);
obj[prop] = value;
},
});
}
/**
* Snaps a single value to an infinite grid
*
* @param {number} i Original value to be snapped
* @param {boolean} scaled If grid will change alignment for odd scaleFactor values (default: true)
* @param {number} gridSize Size of the grid
* @returns an offset, in which [i + offset = (a location snapped to the grid)]
*/
function snap(i, scaled = true, gridSize = 64) {
// very cheap test proof of concept but it works surprisingly well
@ -74,6 +168,16 @@ function snap(i, scaled = true, gridSize = 64) {
return -snapOffset;
}
/**
* Gets a bounding box centered on a given set of coordinates. Supports grid snapping
*
* @param {number} cx - x-coordinate of the center of the box
* @param {number} cy - y-coordinate of the center of the box
* @param {number} w - the width of the box
* @param {height} h - the height of the box
* @param {number | null} gridSnap - The size of the grid to snap to
* @returns {BoundingBox} - A bounding box object centered at (cx, cy)
*/
function getBoundingBox(cx, cy, w, h, gridSnap = null) {
const offset = {x: 0, y: 0};
const box = {x: 0, y: 0};
@ -94,7 +198,10 @@ function getBoundingBox(cx, cy, w, h, gridSnap = null) {
}
/**
* Triggers Canvas Download
* Crops a given canvas to content, returning a new canvas object with the content in it.
*
* @param {HTMLCanvasElement} sourceCanvas Canvas to get a content crop from
* @returns {HTMLCanvasElement} A new canvas with the cropped part of the image
*/
function cropCanvas(sourceCanvas) {
var w = sourceCanvas.width;
@ -143,6 +250,15 @@ function cropCanvas(sourceCanvas) {
return cutCanvas;
}
/**
* Downloads the content of a canvas to the disk, or opens it
*
* @param {Object} options - Optional Information
* @param {boolean} [options.cropToContent] - If we wish to crop to content first (default: true)
* @param {HTMLCanvasElement} [options.canvas] - The source canvas (default: imgCanvas)
* @param {string} [options.filename] - The filename to save as (default: '[ISO date] [Hours] [Minutes] [Seconds] openOutpaint image.png').\
* If null, opens image in new tab.
*/
function downloadCanvas(options = {}) {
defaultOpt(options, {
cropToContent: true,
@ -156,7 +272,8 @@ function downloadCanvas(options = {}) {
});
var link = document.createElement("a");
link.download = options.filename;
link.target = "_blank";
if (options.filename) link.download = options.filename;
var croppedCanvas = options.cropToContent
? cropCanvas(options.canvas)