├── .gitignore ├── src ├── project-data-deferred.json ├── library │ └── library.d.ts ├── pugs │ ├── link-editor.pug │ ├── group-editor.pug │ ├── style-editor.pug │ ├── global-editor.pug │ ├── board-editor.pug │ └── card-editor.pug ├── about.md ├── styles │ ├── base-style.css │ ├── editor-style.css │ └── core-style.css ├── scripts │ ├── main.js │ ├── domino.js │ ├── database.js │ ├── htmlui.js │ ├── editor │ │ ├── editor.js │ │ ├── card-editor.js │ │ └── cardstyle-editor.js │ ├── player.js │ ├── utility.js │ └── test.js ├── index.pug ├── font.css └── project-data.json ├── publish.cmd ├── watch.cmd ├── .gitattributes ├── jsconfig.json └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | -------------------------------------------------------------------------------- /src/project-data-deferred.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /publish.cmd: -------------------------------------------------------------------------------- 1 | call build 2 | neocities upload -d domino2 .\dist\index.html 3 | -------------------------------------------------------------------------------- /watch.cmd: -------------------------------------------------------------------------------- 1 | call build 2 | light-server-pug -o -s dist -w "./src/** # build.cmd" 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # tell github what's our code 2 | dist/* linguist-generated 3 | *.min.js linguist-vendored 4 | *.md linguist-detectable 5 | *.pug linguist-detectable 6 | -------------------------------------------------------------------------------- /src/library/library.d.ts: -------------------------------------------------------------------------------- 1 | export {} 2 | 3 | declare global { 4 | interface HTMLElement { 5 | replaceChildren(...nodes: (Node | string)[]): void; 6 | } 7 | } -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "esnext", 5 | "checkJs": true 6 | }, 7 | "include": ["src/scripts/**/*.js", "src/library/library.d.ts"] 8 | } -------------------------------------------------------------------------------- /src/pugs/link-editor.pug: -------------------------------------------------------------------------------- 1 | .sidebar-header 2 | div(data-path="link/prompt") 1 link selected 3 | .button-row 4 | button(data-action="link/deselect") deselect 5 | button(data-action="link/select") select cards 6 | button(data-action="link/delete") unlink 7 | .sidebar-panel 8 | h2 color 9 | input(type="color" data-path="link/color") 10 | -------------------------------------------------------------------------------- /src/pugs/group-editor.pug: -------------------------------------------------------------------------------- 1 | .sidebar-header 2 | div(data-path="group/prompt") 1 group selected 3 | .button-row 4 | button(data-action="group/deselect") deselect 5 | button(data-action="group/select") select cards 6 | button(data-action="group/delete") ungroup 7 | .sidebar-panel 8 | h2 color 9 | input(type="color" data-path="group/color") 10 | -------------------------------------------------------------------------------- /src/pugs/style-editor.pug: -------------------------------------------------------------------------------- 1 | .sidebar-header 2 | .button-row 3 | select(data-path="global-editor/card-styles/selected") 4 | option default card 5 | button(data-action="global-editor/card-style/new") new style 6 | .sidebar-panel 7 | h2 name 8 | .button-row 9 | input(type="text" data-path="global-editor/card-styles/selected/name") 10 | #card-style-fields 11 | h2 custom css 12 | textarea(data-path="global-editor/card-styles/selected/custom-css") 13 | .button-row 14 | button(data-action="global-editor/card-style/selected/duplicate") duplicate style 15 | button(data-action="global-editor/card-style/selected/delete" data-path="global-editor/card-style/selected/delete") delete style -------------------------------------------------------------------------------- /src/about.md: -------------------------------------------------------------------------------- 1 | ## about domino 2 | domino is a tool for collaging fragmented thoughts into a larger idea. 3 | it's essentially a tool for making a particular kind of mindmap and sharing it 4 | as a page on your website or itch.io. 5 | 6 | you can find out more about domino on [the release page][domino]. the source 7 | code for original releases can be found [on github][source]. 8 | 9 | i'm [mark wonnacott a.k.a candle][me] and i created domino as a 10 | [response to my frustration][0] trying to express myself in Emilie Reed's 11 | writing jams. 12 | 13 | ## thanks 14 | **Em Reed** for her writing jams--especially [Speculation Jam][1], 15 | [List Jam][2], [Alternative Ecologies Jam][3] which have deeply inspired me and 16 | also motivated the creation of this tool. 17 | 18 | **Max**, **Jazz**, **Ludonaut** for early testing and bug reports. 19 | 20 | [me]: https://twitter.com/ragzouken 21 | [domino]: https://kool.tools/domino/ 22 | [source]: https://github.com/Ragzouken/domino2 23 | [0]: https://kool.tools/2020/02/26/speculations.html 24 | [1]: https://itch.io/jam/speculation-jam 25 | [2]: https://itch.io/jam/list-jam 26 | [3]: https://itch.io/jam/alternative-ecologies-jam 27 | -------------------------------------------------------------------------------- /src/styles/base-style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* prevent touch gestures getting stolen by browser */ 3 | touch-action: none; 4 | } 5 | 6 | /* prevent weird highlights https://stackoverflow.com/questions/21003535/ */ 7 | .no-select { 8 | -webkit-tap-highlight-color: transparent; 9 | -webkit-touch-callout: none; 10 | -webkit-user-select: none; 11 | -khtml-user-select: none; 12 | -moz-user-select: none; 13 | -ms-user-select: none; 14 | user-select: none; 15 | } 16 | 17 | /* hidden elements should be hidden regardless of their display style */ 18 | [hidden] { display: none !important; } 19 | 20 | /* default to width/height including padding and border */ 21 | * { box-sizing: border-box; } 22 | 23 | /* used dynamically to prevent or cancel smooth transitions */ 24 | .skip-transition { transition: none !important; } 25 | 26 | /* make buttons inherit font */ 27 | button, select, option, input { font-family: inherit; font-size: inherit; } 28 | 29 | /* clickable things should use this cursor imo */ 30 | button, a, details summary { cursor: pointer; } 31 | 32 | /* crisp pixelart */ 33 | canvas, img { 34 | image-rendering: -moz-crisp-edges; 35 | image-rendering: -webkit-crisp-edges; 36 | image-rendering: pixelated; 37 | image-rendering: crisp-edges; 38 | } 39 | 40 | /* nicer details */ 41 | details summary > * { 42 | display: inline; 43 | } 44 | -------------------------------------------------------------------------------- /src/scripts/main.js: -------------------------------------------------------------------------------- 1 | const cellWidth = 256; 2 | const cellHeight = 160; 3 | 4 | const cellGap = 16; 5 | const cellWidth2 = 112; 6 | const cellHeight2 = 64; 7 | 8 | /** @type {PanningScene} */ 9 | let scene; 10 | 11 | async function start() { 12 | initui(); 13 | scene = new PanningScene(document.getElementById("scene")); 14 | 15 | const dataElement = ONE("#project-data"); 16 | const player = ONE("body").getAttribute("data-player") === "true"; 17 | 18 | /** @type {DominoDataProject} */ 19 | const data = JSON.parse(dataElement.innerHTML); 20 | 21 | await test(); 22 | data.details.id = nanoid(); 23 | dataManager.reset(data); 24 | invokeAction("global/center-focus"); 25 | 26 | console.log("player", player); 27 | if (player) { 28 | // data 29 | boardView.editable = false; 30 | } else { 31 | // data 32 | } 33 | } 34 | 35 | async function loadData() { 36 | console.log("data"); 37 | } 38 | 39 | async function loadDataDeferred() { 40 | console.log("deferred"); 41 | 42 | const font = ONE("#font"); 43 | const family = font.getAttribute("data-font-family"); 44 | 45 | const css = html("style", { id: "active-font" }); 46 | css.textContent = font.textContent; 47 | css.setAttribute("data-editor-only", undefined); 48 | document.head.appendChild(css); 49 | 50 | ONE(":root").style.fontFamily = family; 51 | } 52 | -------------------------------------------------------------------------------- /src/pugs/global-editor.pug: -------------------------------------------------------------------------------- 1 | #left-sidebar(data-block-clicks data-path="global-editor" hidden) 2 | .tab-bar 3 | button(data-action="global-editor/close") ◀ 4 | //button(data-tab-toggle="sidebar/about" data-tab-default) about 5 | button(data-tab-toggle="sidebar/board") board 6 | button(data-tab-toggle="sidebar/styles") styles 7 | button(data-tab-toggle="sidebar/selection" data-tab-default) selection 8 | .editor-page(data-tab-body="sidebar/about") 9 | include:markdown-it(html) /about.md 10 | .sidebar-page(data-tab-body="sidebar/board") 11 | include /pugs/board-editor.pug 12 | .sidebar-page(data-tab-body="sidebar/styles") 13 | include /pugs/style-editor.pug 14 | .sidebar-page(data-tab-body="sidebar/selection") 15 | div(hidden) 16 | button(data-tab-toggle="sidebar/selection/none" data-tab-default) 17 | button(data-tab-toggle="sidebar/selection/cards") 18 | button(data-tab-toggle="sidebar/selection/group") 19 | button(data-tab-toggle="sidebar/selection/link") 20 | .sidebar-page(data-tab-body="sidebar/selection/none") 21 | .sidebar-header nothing selected 22 | .sidebar-page(data-tab-body="sidebar/selection/cards") 23 | include /pugs/card-editor.pug 24 | .sidebar-page(data-tab-body="sidebar/selection/group") 25 | include /pugs/group-editor.pug 26 | .sidebar-page(data-tab-body="sidebar/selection/link") 27 | include /pugs/link-editor.pug 28 | -------------------------------------------------------------------------------- /src/pugs/board-editor.pug: -------------------------------------------------------------------------------- 1 | .sidebar-header 2 | details#saved-boards 3 | summary 4 | h2 switch board 5 | .sidebar-group 6 | div 7 | .button-row 8 | button(data-action="global/new") new board 9 | button(data-action="global/import") import board 10 | select(size="12" data-path="global/saves") 11 | .button-row 12 | button(data-action="global/saves/load") load 13 | button(data-action="global/saves/duplicate") duplicate 14 | button(data-action="global/saves/delete") delete 15 | .sidebar-panel 16 | h2 title 17 | .button-row 18 | input(type="text" data-path="global-editor/title") 19 | h2 initial card focus 20 | p copy the id of a card selection and paste it here to set where the viewer starts in the board 21 | .button-row 22 | input(type="text" data-path="global-editor/focus") 23 | button.shrink(data-action="global/center-focus") 🔍 24 | h2 background color 25 | .button-row 26 | input(type="color" data-path="global-editor/style/background-color") 27 | h2 publish 28 | .button-column 29 | button(data-action="project/export/html" title="export to standalone webpage") 📦 export to html 30 | button(data-action="project/publish/neocities" title="export html directly to neocities website") 😻 publish to neocities 31 | button#neocities-view(data-action="project/publish/neocities/view" title="view exported neocities page" disabled) 👀 view on neocities 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ANTI-CAPITALIST SOFTWARE LICENSE (v 1.4) 2 | 3 | Copyright © 2020 mark wonnacott 4 | 5 | This is anti-capitalist software, released for free use by individuals and organizations that do not operate by capitalist principles. 6 | 7 | Permission is hereby granted, free of charge, to any person or organization (the "User") obtaining a copy of this software and associated documentation files (the "Software"), to use, copy, modify, merge, distribute, and/or sell copies of the Software, subject to the following conditions: 8 | 9 | 1. The above copyright notice and this permission notice shall be included in all copies or modified versions of the Software. 10 | 11 | 2. The User is one of the following: 12 | a. An individual person, laboring for themselves 13 | b. A non-profit organization 14 | c. An educational institution 15 | d. An organization that seeks shared profit for all of its members, and allows non-members to set the cost of their labor 16 | 17 | 3. If the User is an organization with owners, then all owners are workers and all workers are owners with equal equity and/or equal vote. 18 | 19 | 4. If the User is an organization, then the User is not law enforcement or military, or working for or under either. 20 | 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT EXPRESS OR IMPLIED WARRANTY OF ANY KIND, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /src/pugs/card-editor.pug: -------------------------------------------------------------------------------- 1 | .sidebar-header 2 | div 3 | span(data-path="selection/prompt") 3 cards selected 4 | .button-row 5 | button(data-action="selection/cancel") deselect 6 | button(data-action="selection/copy-id") copy id 7 | button(data-action="selection/link" data-path="selection/link" ) link 8 | button(data-action="selection/group" data-path="selection/group" ) group 9 | button(data-action="selection/delete") delete 10 | .sidebar-panel 11 | .sidebar-group(data-path="card-editor/text") 12 | h2 text 13 | .button-row 14 | button(data-action="card-editor/text/bold") bold 15 | button(data-action="card-editor/text/italic") italic 16 | button(data-action="card-editor/text/strike") strike 17 | button(data-action="card-editor/text/header") header 18 | textarea(data-path="card-editor/text/value" spellcheck="true") 19 | .sidebar-group(data-path="card-editor/icons") 20 | h2 icons 21 | #card-editor-icons 22 | each row in [1, 2, 3, 4] 23 | input(data-path=`card-editor/icons/${row}/icon` type="text").icon-select 24 | input(data-path=`card-editor/icons/${row}/action` type="text").icon-action 25 | .sidebar-group(data-path="card-editor/style") 26 | h2 style 27 | select(data-path="card-editor/styles" size="3") 28 | .button-row 29 | button(data-action="card-editor/styles/edit") edit 30 | .sidebar-group(data-path="card-editor/image") 31 | h2 image 32 | .button-row 33 | button(data-action="card-editor/image/upload") upload image 34 | button(data-action="card-editor/image/remove") remove image 35 | textarea(data-path="card-editor/image/alt" placeholder="alt text" spellcheck="true") 36 | -------------------------------------------------------------------------------- /src/scripts/domino.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Object} DominoDataCardStyle 3 | * @property {string} id 4 | * @property {string} name 5 | * @property {Partial<{ 6 | * "custom-css": string, 7 | * "text-font": string, 8 | * "text-color": string, 9 | * "text-size": string, 10 | * "text-center": string, 11 | * "card-color": string, 12 | * }>} properties 13 | */ 14 | 15 | /** 16 | * @typedef {Object} DominoDataCardIcon 17 | * @property {string} icon 18 | * @property {string} action 19 | */ 20 | 21 | /** 22 | * @typedef {Object} DominoDataCard 23 | * @property {string} id 24 | * @property {Vector2} position 25 | * @property {Vector2} size 26 | * @property {string} text 27 | * @property {DominoDataCardIcon[]} icons 28 | * @property {string?} image 29 | * @property {string?} alttext 30 | * @property {string?} style 31 | */ 32 | 33 | /** 34 | * @typedef {Object} DominoDataGroup 35 | * @property {string[]} cards 36 | * @property {string} color 37 | */ 38 | 39 | /** 40 | * @typedef {Object} DominoDataLink 41 | * @property {string} cardA 42 | * @property {string} cardB 43 | * @property {string} color 44 | */ 45 | 46 | /** 47 | * @typedef {Object} DominoDataProjectDetails 48 | * @property {string} id 49 | * @property {string} name 50 | * @property {string} title 51 | * @property {string} focus 52 | */ 53 | 54 | /** 55 | * @typedef {Object} DominoDataSaveMetadata 56 | * @property {string} id 57 | * @property {string} title 58 | * @property {string} date 59 | */ 60 | 61 | /** 62 | * @typedef {Partial<{ 63 | * "custom-css": string, 64 | * "background-color": string, 65 | * }>} DominoDataBoardStyle 66 | */ 67 | 68 | /** 69 | * @typedef {Object} DominoDataProject 70 | * @property {DominoDataProjectDetails} details 71 | * @property {DominoDataCard[]} cards 72 | * @property {DominoDataGroup[]} groups 73 | * @property {DominoDataLink[]} links 74 | * @property {DominoDataCardStyle[]} cardStyles 75 | * @property {DominoDataBoardStyle} boardStyle 76 | */ 77 | -------------------------------------------------------------------------------- /src/scripts/database.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @template T 3 | * @param {IDBRequest} request 4 | * @returns {Promise} 5 | */ 6 | function promisfyRequest(request) { 7 | return new Promise((resolve, reject) => { 8 | request.onsuccess = () => resolve(request.result); 9 | request.onerror = () => reject(request.error); 10 | }); 11 | } 12 | 13 | /** 14 | * @param {IDBTransaction} transaction 15 | * @returns {Promise} 16 | */ 17 | function promisfyTransaction(transaction) { 18 | return new Promise((resolve, reject) => { 19 | transaction.oncomplete = () => resolve(); 20 | transaction.onabort = () => reject(transaction.error); 21 | transaction.onerror = () => reject(transaction.error); 22 | }); 23 | } 24 | 25 | async function openDatabase() { 26 | const request = indexedDB.open("domino2"); 27 | request.addEventListener("upgradeneeded", () => { 28 | request.result.createObjectStore("projects"); 29 | request.result.createObjectStore("projects-meta"); 30 | }); 31 | return promisfyRequest(request); 32 | } 33 | 34 | async function projectsStores(mode) { 35 | const db = await openDatabase(); 36 | const transaction = db.transaction(["projects", "projects-meta"], mode); 37 | const projects = transaction.objectStore("projects"); 38 | const meta = transaction.objectStore("projects-meta"); 39 | return { transaction, projects, meta }; 40 | } 41 | 42 | /** 43 | * @returns {Promise} 44 | */ 45 | async function listProjects() { 46 | const stores = await projectsStores("readonly"); 47 | return promisfyRequest(stores.meta.getAll()); 48 | } 49 | 50 | /** 51 | * @param {DominoDataProject} projectData 52 | * @returns {Promise} 53 | */ 54 | async function saveProject(projectData, key) { 55 | /** @type {DominoDataSaveMetadata} */ 56 | const meta = { 57 | id: projectData.details.id, 58 | title: projectData.details.title, 59 | date: (new Date()).toISOString(), 60 | } 61 | 62 | const stores = await projectsStores("readwrite"); 63 | stores.projects.put(projectData, key); 64 | stores.meta.put(meta, key); 65 | return promisfyTransaction(stores.transaction); 66 | } 67 | 68 | /** 69 | * @param {string} key 70 | * @returns {Promise} 71 | */ 72 | async function loadProject(key) { 73 | const stores = await projectsStores("readonly"); 74 | return promisfyRequest(stores.projects.get(key)); 75 | } 76 | 77 | /** 78 | * @param {string} key 79 | */ 80 | async function deleteProject(key) { 81 | const stores = await projectsStores("readwrite"); 82 | stores.projects.delete(key); 83 | stores.meta.delete(key); 84 | return promisfyTransaction(stores.transaction); 85 | } 86 | -------------------------------------------------------------------------------- /src/index.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title domino 2 5 | meta(charset="utf-8") 6 | meta(name="viewport", content="width=device-width, initial-scale=1, maximum-scale=1.0") 7 | link(rel="icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAXUlEQVQ4jWNkQANfHl77jy6GDHjktRiR+SgcQpqxGcKETcGRY+sZjhxbj5M/ElxACmBB5uByJj4w8F7AawC+0IeBQeYFYmIBXQ0LIQWEACO6wI7lrXjDwSOyGkUPAKUpOzwONiPPAAAAAElFTkSuQmCC") 8 | // styles 9 | style#base-style 10 | include styles/base-style.css 11 | style#core-style 12 | include styles/core-style.css 13 | style#editor-style 14 | include styles/editor-style.css 15 | style#card-styles 16 | // scripts 17 | script(id="database.js") 18 | include scripts/database.js 19 | script(id="utility.js") 20 | include scripts/utility.js 21 | script(id="htmlui.js") 22 | include scripts/htmlui.js 23 | script(id="domino.js") 24 | include scripts/domino.js 25 | script(id="test.js") 26 | include scripts/test.js 27 | script(id="editor/card-editor.js") 28 | include scripts/editor/card-editor.js 29 | script(id="editor/cardstyle-editor.js") 30 | include scripts/editor/cardstyle-editor.js 31 | script(id="editor/editor.js") 32 | include scripts/editor/editor.js 33 | script(id="player.js") 34 | include scripts/player.js 35 | script(id="main.js") 36 | include scripts/main.js 37 | body(onload="start()").no-select 38 | #frame 39 | #scene 40 | #svgs(data-empty) 41 | #cards(data-empty) 42 | #sidebar-toggle.toolbar-group.icon-bar 43 | .icon(data-action="global-editor/toggle" title="board settings") 🔨 44 | #toolbar(data-block-clicks) 45 | .toolbar-group.icon-bar 46 | #undo.icon.disabled(data-action="global/undo" title="undo") ↩ 47 | #redo.icon.disabled(data-action="global/redo" title="redo") ↪ 48 | #save.icon(data-action="project/save" title="save in browser") 💾 49 | .icon(data-action="global/view-saves" title="view saved boards") 📂 50 | 51 | .toolbar-group.icon-bar(data-path="picker" hidden) 52 | span click a card to link to 53 | .icon(data-action="picker/cancel" title="cancel picking") ❌ 54 | 55 | include pugs/global-editor.pug 56 | script#project-data(type="text/json") 57 | include project-data.json 58 | script loadData(); 59 | script#project-data-deferred(type="text/json") 60 | include project-data-deferred.json 61 | script#font(type="text/css" data-font-family="Lora") 62 | include font.css 63 | script loadDataDeferred(); 64 | -------------------------------------------------------------------------------- /src/scripts/htmlui.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {string} path 3 | * @returns {[string, string]} 4 | */ 5 | function pathToRootLeaf(path) { 6 | const parts = path.split('/'); 7 | const root = parts.slice(0, -1).join('/'); 8 | const leaf = parts.slice(-1)[0]; 9 | return [root, leaf]; 10 | } 11 | 12 | const toggleStates = new Map(); 13 | const actionHandlers = new Map(); 14 | const pathToElement = new Map(); 15 | 16 | /** 17 | * @template {keyof HTMLElementTagNameMap} K 18 | * @param {*} path 19 | * @param {K} tagName 20 | * @returns {HTMLElementTagNameMap[K]} 21 | */ 22 | function elementByPath(path, tagName) { 23 | /** @type {HTMLElementTagNameMap[K]} */ 24 | const element = pathToElement.get(path); 25 | if (element === undefined) 26 | throw Error(`No element at ${path}`); 27 | if (element.tagName.toLowerCase() !== tagName) 28 | throw Error(`Element at ${path} is ${element.tagName} not ${tagName}`); 29 | return element; 30 | } 31 | 32 | function setActionHandler(action, callback) { 33 | actionHandlers.set(action, callback); 34 | } 35 | 36 | function invokeAction(action) { 37 | const handler = actionHandlers.get(action); 38 | if (handler) handler(); 39 | } 40 | 41 | function switchTab(path) { 42 | elementByPath(`toggle:${path}`, "button").click(); 43 | } 44 | 45 | function initui() { 46 | const toggles = ALL("[data-tab-toggle]"); 47 | const bodies = ALL("[data-tab-body]"); 48 | const buttons = ALL("[data-action]"); 49 | 50 | const paths = ALL("[data-path]"); 51 | paths.forEach((element) => { 52 | const path = element.getAttribute("data-path"); 53 | pathToElement.set(path, element); 54 | }); 55 | 56 | buttons.forEach((element) => { 57 | const action = element.getAttribute("data-action"); 58 | 59 | element.addEventListener("click", (event) => { 60 | killEvent(event); 61 | invokeAction(action); 62 | }); 63 | }) 64 | 65 | function setGroupActiveTab(group, tab) { 66 | toggleStates.set(group, tab); 67 | toggles.forEach((element) => { 68 | const [group_, tab_] = pathToRootLeaf(element.getAttribute("data-tab-toggle")); 69 | if (group_ === group) element.classList.toggle("active", tab_ === tab); 70 | }); 71 | bodies.forEach((element) => { 72 | const [group_, tab_] = pathToRootLeaf(element.getAttribute("data-tab-body")); 73 | if (group_ === group) element.hidden = (tab_ !== tab); 74 | }); 75 | 76 | invokeAction(`hide:${group}`); 77 | invokeAction(`show:${group}/${tab}`); 78 | } 79 | 80 | toggles.forEach((element) => { 81 | const path = element.getAttribute("data-tab-toggle"); 82 | pathToElement.set("toggle:" + path, element); 83 | const [group, tab] = pathToRootLeaf(element.getAttribute("data-tab-toggle")); 84 | element.addEventListener('click', (event) => { 85 | killEvent(event); 86 | setGroupActiveTab(group, tab); 87 | }); 88 | }); 89 | 90 | bodies.forEach((element) => { 91 | element.hidden = true; 92 | }); 93 | 94 | ALL("[data-tab-default]").forEach((element) => element.click()); 95 | 96 | const clicks = ['pointerdown', 'click', 'wheel', 'dblclick']; 97 | ALL("[data-block-clicks]").forEach((element) => { 98 | for (let name of clicks) { 99 | element.addEventListener(name, (event) => event.stopPropagation()); 100 | } 101 | }); 102 | } 103 | -------------------------------------------------------------------------------- /src/styles/editor-style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: sans-serif; 3 | font-size: 1.1em; 4 | } 5 | 6 | .selection-flash { 7 | animation-name: selection-flash; 8 | animation-direction: alternate; 9 | animation-duration: .75s; 10 | animation-iteration-count: infinite; 11 | } 12 | 13 | @keyframes selection-flash { 14 | 0% { 15 | fill: black; 16 | stroke: black; 17 | border-color: black; 18 | } 19 | 20 | 100% { 21 | fill: white; 22 | stroke: white; 23 | border-color: white; 24 | } 25 | } 26 | 27 | .resize-handle { 28 | position: absolute; 29 | width: 32px; height: 32px; 30 | right: -1px; bottom: -1px; 31 | 32 | opacity: 50%; 33 | display: none; 34 | } 35 | 36 | .resize-handle > polygon { 37 | cursor: se-resize; 38 | } 39 | 40 | .selected .resize-handle { 41 | display: unset; 42 | } 43 | 44 | body:not([data-player]) .card-root:hover .resize-handle { 45 | display: unset; 46 | } 47 | 48 | .target { 49 | transition: transform .1s, width .1s, height .1s; 50 | } 51 | 52 | .card-selection { 53 | position: absolute; 54 | left: -16px; right: -16px; top: -16px; bottom: -16px; 55 | } 56 | 57 | .icon.disabled { 58 | cursor: not-allowed; 59 | opacity: .5; 60 | } 61 | 62 | .editor-blocker { 63 | position: absolute; 64 | width: 100%; height: 100%; 65 | margin: 0; padding: 0; 66 | 67 | background-color: rgba(0, 0, 0, 25%); 68 | } 69 | 70 | .editor-panel { 71 | position: absolute; 72 | left: 50%; bottom: 0; 73 | transform: translate(-50%, 0); 74 | 75 | background: var(--card-color); 76 | pointer-events: initial; 77 | cursor: initial; 78 | 79 | width: 400px; 80 | max-width: 100%; 81 | height: 300px; 82 | max-height: 50%; 83 | 84 | display: flex; 85 | flex-direction: column; 86 | 87 | left: 0; top: 0; bottom: 0; 88 | width: 420px; 89 | height: 100%; 90 | max-height: none; 91 | max-width: none; 92 | transform: none; 93 | } 94 | 95 | #content-input { 96 | flex: 1; 97 | } 98 | 99 | .full { 100 | position: absolute; 101 | width: 100%; height: 100%; 102 | margin: 0; padding: 0; 103 | } 104 | 105 | /* generic tabbed pages styles */ 106 | .tab-bar { 107 | display: grid; 108 | grid-auto-flow: column; 109 | color: white; 110 | background:black; 111 | } 112 | 113 | .tab-bar > * { 114 | padding: 1em; 115 | text-align: center; 116 | } 117 | 118 | .tab-bar > button { 119 | border: none; 120 | background: inherit; 121 | color: inherit; 122 | text-transform: uppercase; 123 | } 124 | 125 | .tab-bar > *.active { 126 | color: black; 127 | background: lightblue; 128 | } 129 | 130 | .tab-page { 131 | margin: 0; 132 | padding: 2em; 133 | overflow: auto; 134 | 135 | flex: 1; 136 | display: flex; 137 | flex-direction: column; 138 | } 139 | /* end of tabbed pages styles */ 140 | 141 | .editor-page { 142 | padding: 1em; 143 | height: 100%; 144 | display: flex; 145 | 146 | flex-direction: column; 147 | gap: 1em; 148 | 149 | overflow: auto; 150 | } 151 | 152 | /* editor icons page styles */ 153 | #card-editor-icons { 154 | display: grid; 155 | 156 | grid-template-columns: 3em auto; 157 | grid-template-rows: repeat(4, 1fr); 158 | 159 | column-gap: 1em; 160 | row-gap: 1em; 161 | } 162 | 163 | .icon-select { 164 | min-width: 3em; 165 | text-align: center; 166 | font-size: 1em; 167 | 168 | grid-column: 1; 169 | grid-column-start: 1; 170 | grid-column-end: 2; 171 | } 172 | 173 | .icon-action { 174 | font-size: 1em; 175 | font-family: monospace; 176 | 177 | grid-column-start: 2; 178 | grid-column-end: 3; 179 | } 180 | /* end of editor icons page styles */ 181 | 182 | textarea { 183 | flex: 1; 184 | min-height: 10em; 185 | } 186 | 187 | .button-row { 188 | display: flex; 189 | flex-direction: row; 190 | align-items: stretch; 191 | gap: 1em; 192 | } 193 | 194 | .button-row > button { 195 | flex: 1; 196 | white-space: nowrap; 197 | } 198 | 199 | .toolbar-group[data-path="global"] { 200 | background: rgb(255 255 255 / 50%); 201 | } 202 | 203 | .toolbar-group[data-path="selection"] { 204 | background: var(--selection-color); 205 | } 206 | 207 | .toolbar-group[data-path="group"] { 208 | background: var(--group-color); 209 | } 210 | 211 | .toolbar-group[data-path="link"] { 212 | background: var(--link-color); 213 | } 214 | 215 | .toolbar-group[data-path="picker"] { 216 | background: var(--link-color); 217 | } 218 | 219 | .toolbar-group[data-path="picker"] > span { 220 | font-size: .75em; 221 | white-space: nowrap; 222 | } 223 | 224 | .button-list { 225 | display: flex; 226 | flex-flow: row wrap; 227 | align-items: flex-start; 228 | align-content: flex-start; 229 | } 230 | 231 | [data-path="card-editor/styles"] { 232 | flex: 1; 233 | min-height: 10em; 234 | } 235 | 236 | .card-style-settings-row { 237 | display: flex; 238 | } 239 | 240 | #card-style-fields { 241 | display: flex; 242 | flex-direction: column; 243 | gap: 1em; 244 | } 245 | 246 | #card-style-fields > h2 { 247 | margin: 0; 248 | } 249 | 250 | body[data-player] #toolbar { 251 | display: none; 252 | } 253 | 254 | .button-column { 255 | display: flex; 256 | flex-direction: column; 257 | align-items: stretch; 258 | gap: 1em; 259 | } 260 | 261 | /* sidebar stuff */ 262 | .sidebar-page { 263 | height: 100%; 264 | overflow: auto; 265 | 266 | display: flex; 267 | flex-direction: column; 268 | } 269 | 270 | .sidebar-header { 271 | padding: 1em; 272 | 273 | display: flex; 274 | flex-direction: column; 275 | gap: 1em; 276 | 277 | background: lightblue; 278 | } 279 | 280 | .sidebar-panel { 281 | height: 100%; 282 | overflow: auto; 283 | 284 | padding: 1em; 285 | 286 | display: flex; 287 | flex-direction: column; 288 | gap: 1em; 289 | } 290 | 291 | .sidebar-group { 292 | display: flex; 293 | flex-direction: column; 294 | gap: 1em; 295 | } 296 | 297 | 298 | .button-row .shrink { 299 | flex: 0; 300 | } 301 | -------------------------------------------------------------------------------- /src/styles/core-style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --selection-color: rgb(106, 90, 205); 3 | --group-color: rgba(205, 90, 119, 0.5); 4 | --link-color: rgba(113, 205, 90, 0.5); 5 | 6 | --black-text-outline: 1px 1px 1px black, -1px 1px 1px black, -1px -1px 1px black, 1px -1px 1px black; 7 | 8 | --card-color: white; 9 | } 10 | 11 | html, body { 12 | width: 100vw; height: 100vh; 13 | margin: 0; padding: 0; 14 | } 15 | 16 | body { 17 | display: flex; 18 | flex-direction: row; 19 | } 20 | 21 | #frame { 22 | position: relative; overflow: hidden; /* clip things */ 23 | flex: 1; 24 | 25 | cursor: grab; 26 | 27 | background-color: rgb(183, 184, 176); 28 | } 29 | 30 | #scene { 31 | position: absolute; transform-origin: 0 0; 32 | width: 0; height: 0; 33 | 34 | /* i think this helps streamline the panning by telling the browser that it 35 | will be constantly moving 36 | https://stackoverflow.com/questions/26907265/css-will-change-how-to-use-it-how-it-works */ 37 | /* will-change: transform; */ 38 | 39 | /* transition: transform .1s linear; */ 40 | } 41 | 42 | .icon-bar { 43 | display: flex; 44 | flex-direction: row; 45 | align-items: center; 46 | 47 | gap: .5em; 48 | margin: 0; 49 | } 50 | 51 | .icon-bar > .icon { 52 | width: 2em; 53 | height: 2em; 54 | grid-row: 1; 55 | 56 | padding: 0; 57 | 58 | display: flex; 59 | align-items: center; 60 | justify-content: center; 61 | text-decoration: none; 62 | } 63 | 64 | .target { 65 | position: absolute; 66 | z-index: -1; 67 | 68 | /* tiny curve on corners */ 69 | border-radius: .5em; 70 | background: black; 71 | 72 | opacity: 50%; 73 | } 74 | 75 | .card-root.selected { 76 | box-shadow: 0 0 0 8px var(--selection-color); 77 | } 78 | 79 | /* main toolbar */ 80 | #toolbar { 81 | position: absolute; 82 | right: 0; bottom: 0; 83 | 84 | font-size: 1.5em; 85 | padding: .5em; 86 | 87 | display: flex; 88 | flex-direction: row; 89 | } 90 | 91 | /* main toolbar buttons */ 92 | #toolbar > .icon { 93 | background: rgb(240, 240, 240); 94 | border: 1px solid black; 95 | border-radius: 100%; 96 | cursor: pointer; 97 | } 98 | 99 | .toolbar-group { 100 | border-radius: 100em; 101 | 102 | display: flex; 103 | flex-direction: row; 104 | gap: .5em; 105 | margin: 0; 106 | padding: .5em; 107 | } 108 | 109 | .toolbar-group > .icon { 110 | background: rgb(240, 240, 240); 111 | border: 1px solid black; 112 | border-radius: 100%; 113 | cursor: pointer; 114 | } 115 | 116 | #svgs > svg { 117 | position: absolute; 118 | z-index: -3; 119 | pointer-events: none; 120 | mix-blend-mode: screen; 121 | } 122 | 123 | svg > * { 124 | pointer-events: initial; 125 | } 126 | 127 | #svgs > svg.link { 128 | stroke-width: 16px; 129 | z-index: -2; 130 | } 131 | 132 | svg.link line.selection-flash { 133 | stroke-width: 24px; 134 | } 135 | 136 | #left-sidebar { 137 | position: absolute; 138 | left: 0; top: 0; bottom: 0; 139 | width: 400px; 140 | 141 | background: var(--card-color); 142 | 143 | display: flex; 144 | flex-direction: column; 145 | } 146 | 147 | .editor-page > h1, h2, p { 148 | margin: 0; 149 | } 150 | 151 | .button-row > select, .button-row > input { 152 | flex: 1; 153 | } 154 | 155 | .button-row button[data-action="global-editor/card-style/new"] { 156 | flex: 0; 157 | } 158 | 159 | .settings-grid > span { 160 | grid-column-start: 2; 161 | grid-column-end: 3; 162 | } 163 | 164 | .settings-grid > input, .settings-grid > select { 165 | grid-column-start: 3; 166 | grid-column-end: 4; 167 | } 168 | 169 | .settings-grid { 170 | display: grid; 171 | 172 | column-gap: 1em; 173 | row-gap: 1em; 174 | 175 | grid-template-columns: auto auto minmax(0, 1fr); 176 | } 177 | 178 | .settings-grid .check { 179 | grid-column-start: 1; 180 | grid-column-end: 2; 181 | place-self: center; 182 | } 183 | 184 | .settings-grid input[type="checkbox"] { 185 | transform: scale(1.5); 186 | } 187 | 188 | /* CARDS */ 189 | 190 | .has-image { 191 | align-items: center; 192 | } 193 | 194 | .has-image .card-text { 195 | background-repeat: no-repeat; 196 | 197 | color: white; 198 | text-align: center; 199 | text-shadow: var(--black-text-outline); 200 | } 201 | 202 | .card-root { 203 | position: absolute; 204 | 205 | border-radius: 8px; 206 | box-shadow: 0 3px 0 var(--card-edge-color, black); 207 | } 208 | 209 | .card-body { 210 | position: relative; overflow: hidden; /* clipping */ 211 | width: 100%; height: 100%; margin: 0; 212 | display: flex; 213 | 214 | background-color: var(--card-color, white); 215 | border-radius: inherit; 216 | background-size: cover; 217 | background-position: center; 218 | } 219 | 220 | .card-text { 221 | width: 100%; 222 | padding: 16px; 223 | 224 | color: var(--text-color, black); 225 | font-size: var(--text-size, initial); 226 | font-family: var(--text-font, inherit); 227 | text-align: var(--text-align, inherit); 228 | } 229 | 230 | .card-text > h3 { 231 | margin: 0; 232 | margin-bottom: .5em; 233 | font-size: 1.5em; 234 | font-weight: bold; 235 | text-align: center; 236 | } 237 | 238 | .card-icon-bar { 239 | position: absolute; 240 | left: 50%; bottom: 0; 241 | transform: translate(-50%, 50%); 242 | 243 | display: flex; 244 | justify-content: space-evenly; 245 | 246 | padding: 1em; 247 | margin: 0; 248 | width: 100%; 249 | 250 | font-size: 1rem; 251 | 252 | pointer-events: none; 253 | } 254 | 255 | .card-icon-bar > a { 256 | width: 2em; 257 | height: 2em; 258 | 259 | padding: 0; 260 | 261 | display: flex; 262 | align-items: center; 263 | justify-content: center; 264 | text-decoration: none; 265 | 266 | pointer-events: initial; 267 | 268 | transition: transform .1s ease-in-out; 269 | } 270 | 271 | .card-icon-bar > *:hover { transform: scale(1.5); } 272 | .card-icon-bar > *:active { transform: scale(2); } 273 | /* don't animate cosmetic card icons */ 274 | .card-icon-bar > *.cosmetic { cursor: initial; transform: unset; } 275 | /* don't respond to blank card icons */ 276 | .card-icon-bar > *.blank { pointer-events: none; transform: unset; } 277 | 278 | 279 | #sidebar-toggle { 280 | position: absolute; 281 | left: 0; bottom: 0; 282 | font-size: 1.5em; 283 | padding: 1em; 284 | } 285 | -------------------------------------------------------------------------------- /src/scripts/editor/editor.js: -------------------------------------------------------------------------------- 1 | function saveAs(blob, name) { 2 | const element = document.createElement("a"); 3 | const url = window.URL.createObjectURL(blob); 4 | element.href = url; 5 | element.download = name; 6 | element.click(); 7 | window.URL.revokeObjectURL(url); 8 | }; 9 | 10 | /** @param {DominoDataProject} projectData */ 11 | function createStandalonePlayer(projectData) { 12 | const clone = /** @type {HTMLElement} */ (document.documentElement.cloneNode(true)); 13 | ALL("[data-empty]", clone).forEach((element) => element.replaceChildren()); 14 | ALL("[data-editor-only]", clone).forEach((element) => element.remove()); 15 | ONE("body", clone).setAttribute("data-player", "true"); 16 | ONE("title", clone).innerHTML = projectData.details.name; 17 | ONE("#project-data", clone).innerHTML = JSON.stringify(projectData); 18 | ONE('[data-path="global-editor"]', clone).hidden = true; 19 | ONE('#sidebar-toggle', clone).hidden = true; 20 | return clone; 21 | } 22 | 23 | const projectToHTML = () => { 24 | const clone = createStandalonePlayer(boardView.projectData); 25 | return clone.outerHTML; 26 | } 27 | 28 | setActionHandler("project/export/html", async () => { 29 | const name = boardView.projectData.details.name + ".html"; 30 | const blob = textToBlob(projectToHTML(), "text/html"); 31 | saveAs(blob, name); 32 | }); 33 | 34 | setActionHandler("global/import", async () => { 35 | const [file] = await pickFiles("text/html"); 36 | const text = await textFromFile(file); 37 | const html = await htmlFromText(text); 38 | 39 | const json = ONE("#project-data", html).innerHTML; 40 | const projectData = JSON.parse(json); 41 | projectData.details.id = nanoid(); 42 | dataManager.reset(projectData); 43 | }); 44 | 45 | setActionHandler("global/new", async () => { 46 | /** @type {DominoDataProject} */ 47 | const blank = { 48 | details: { 49 | id: nanoid(), 50 | title: "new project", 51 | name: "project", 52 | focus: "", 53 | }, 54 | cards: [], 55 | groups: [], 56 | links: [], 57 | cardStyles: COPY(boardView.projectData.cardStyles), 58 | boardStyle: COPY(boardView.projectData.boardStyle), 59 | } 60 | 61 | dataManager.reset(blank); 62 | }); 63 | 64 | async function refreshSaves() { 65 | const saves = await listProjects(); 66 | saves.sort((a, b) => (new Date(b.date)).getTime() - (new Date(a.date)).getTime()); 67 | 68 | const options = saves.map(({ title, date, id }) => { 69 | const date_ = new Date(date).toLocaleDateString(); 70 | const label = `${title} (${date_})`; 71 | return html("option", { value: id }, label); 72 | }); 73 | elementByPath("global/saves", "select").replaceChildren(...options); 74 | } 75 | 76 | setActionHandler("project/save", async () => { 77 | const timer = sleep(250); 78 | const saveButton = ONE("#save"); 79 | saveButton.disabled = true; 80 | saveButton.innerText = "⏳"; 81 | await saveProject(boardView.projectData, boardView.projectData.details.id); 82 | refreshSaves(); 83 | await timer; 84 | saveButton.disabled = false; 85 | saveButton.innerText = "💾" 86 | }); 87 | 88 | setActionHandler("global/saves/load", async () => { 89 | const id = elementByPath("global/saves", "select").value; 90 | const project = await loadProject(id); 91 | dataManager.reset(project); 92 | centerCards(project.cards); 93 | }); 94 | 95 | setActionHandler("global/saves/duplicate", async () => { 96 | const id = elementByPath("global/saves", "select").value; 97 | const project = COPY(await loadProject(id)); 98 | project.details.id = nanoid(); 99 | project.details.title += " (copy)"; 100 | await saveProject(project, project.details.id); 101 | refreshSaves(); 102 | }); 103 | 104 | setActionHandler("global/saves/delete", async () => { 105 | const id = elementByPath("global/saves", "select").value; 106 | await deleteProject(id); 107 | refreshSaves(); 108 | }); 109 | 110 | setActionHandler("show:sidebar/board", refreshSaves); 111 | 112 | setActionHandler("project/publish/neocities", async () => { 113 | const ready = new Promise((resolve, reject) => { 114 | const remove = listen(window, "message", (event) => { 115 | if (event.origin !== "https://kool.tools") return; 116 | remove(); 117 | resolve(); 118 | }); 119 | }); 120 | 121 | const success = new Promise((resolve, reject) => { 122 | const remove = listen(window, "message", (event) => { 123 | if (event.origin !== "https://kool.tools") return; 124 | 125 | if (event.data.error) { 126 | remove(); 127 | reject(event.data.error); 128 | } else if (event.data.url) { 129 | remove(); 130 | resolve(event.data.url); 131 | } 132 | }); 133 | }); 134 | 135 | const name = boardView.projectData.details.title.replace(/[^a-z0-9]/gi, '_').toLowerCase(); 136 | const popup = window.open( 137 | "https://kool.tools/neocities-publisher/index.html", 138 | "neocities publisher", 139 | "left=10,top=10,width=320,height=320"); 140 | const html = projectToHTML(); 141 | await ready; 142 | popup.postMessage({ name, html }, "https://kool.tools"); 143 | const url = await success; 144 | popup.close(); 145 | 146 | const viewButton = ONE("#neocities-view"); 147 | viewButton.disabled = false; 148 | viewButton.onclick = () => window.open(url); 149 | }); 150 | 151 | class LinkEditor { 152 | constructor() { 153 | this.colorInput = elementByPath("link/color", "input"); 154 | /** @type {DominoDataLink[]} */ 155 | this.links = []; 156 | 157 | this.colorInput.addEventListener("input", () => { 158 | dataManager.markDirty("links"); 159 | this.links.forEach((link) => { 160 | link.color = this.colorInput.value; 161 | boardView.linkToView.get(link).regenerateSVG(); 162 | }); 163 | }); 164 | } 165 | 166 | /** @param {DominoDataLink[]} links */ 167 | openLinks(links) { 168 | this.links = links; 169 | 170 | if (links.length === 1) this.colorInput.value = links[0].color; 171 | } 172 | 173 | close() { 174 | this.links = []; 175 | } 176 | } 177 | 178 | class GroupEditor { 179 | constructor() { 180 | this.colorInput = elementByPath("group/color", "input"); 181 | /** @type {DominoDataGroup[]} */ 182 | this.groups = []; 183 | 184 | this.colorInput.addEventListener("input", () => { 185 | dataManager.markDirty("group"); 186 | this.groups.forEach((group) => { 187 | group.color = this.colorInput.value; 188 | boardView.groupToView.get(group).regenerateSVG(); 189 | }); 190 | }); 191 | } 192 | 193 | /** @param {DominoDataGroup[]} groups */ 194 | openGroups(groups) { 195 | this.groups = groups; 196 | 197 | if (groups.length === 1) this.colorInput.value = groups[0].color; 198 | } 199 | 200 | close() { 201 | this.groups = []; 202 | } 203 | } 204 | 205 | -------------------------------------------------------------------------------- /src/scripts/editor/card-editor.js: -------------------------------------------------------------------------------- 1 | class CardEditor { 2 | constructor() { 3 | /** @type {DominoDataCard[]} */ 4 | this.cards = []; 5 | 6 | //this.container = elementByPath("card-editor", "div"); 7 | this.promptText = elementByPath("selection/prompt", "span"); 8 | 9 | this.textInput = elementByPath("card-editor/text/value", "textarea"); 10 | this.altTextInput = elementByPath("card-editor/image/alt", "textarea"); 11 | this.styleList = elementByPath("card-editor/styles", "select"); 12 | 13 | this.iconIconInputs = /** @type {HTMLInputElement[]} */ ([1, 2, 3, 4].map((i) => elementByPath(`card-editor/icons/${i}/icon`, "input"))); 14 | this.iconActionInputs = /** @type {HTMLInputElement[]} */ ([1, 2, 3, 4].map((i) => elementByPath(`card-editor/icons/${i}/action`, "input"))); 15 | 16 | [0, 1, 2, 3].forEach((i) => { 17 | this.iconIconInputs[i].addEventListener("input", () => { 18 | dataManager.markDirty(this.cards[0].id + "/icons"); 19 | this.pushData(); 20 | }); 21 | this.iconActionInputs[i].addEventListener("input", () => { 22 | dataManager.markDirty(this.cards[0].id + "/icons"); 23 | this.pushData(); 24 | }); 25 | }); 26 | 27 | this.textInput.addEventListener("input", () => { 28 | dataManager.markDirty(this.cards[0].id + "/text"); 29 | this.pushData(); 30 | }); 31 | 32 | this.altTextInput.addEventListener("input", () => { 33 | dataManager.markDirty(this.cards[0].id + "/alttext"); 34 | this.pushData(); 35 | }); 36 | 37 | setActionHandler("card-editor/image/upload", async () => { 38 | const [file] = await pickFiles("image/*"); 39 | if (!file) return; 40 | dataManager.makeCheckpoint(); 41 | this.cards[0].image = await fileToCompressedImageURL(file); 42 | this.pushData(); 43 | }); 44 | 45 | setActionHandler("card-editor/image/remove", () => { 46 | if (!this.cards[0].image) return; 47 | dataManager.makeCheckpoint(); 48 | this.cards[0].image = undefined; 49 | this.pushData(); 50 | }); 51 | 52 | setActionHandler("card-editor/text/bold", () => this.wrapSelectedText("**", "**")); 53 | setActionHandler("card-editor/text/italic", () => this.wrapSelectedText("*", "*")); 54 | setActionHandler("card-editor/text/strike", () => this.wrapSelectedText("~~", "~~")); 55 | setActionHandler("card-editor/text/header", () => this.wrapSelectedText("##", "##")); 56 | 57 | setActionHandler("card-editor/styles/edit", () => { 58 | cardStyleEditor.open(); 59 | cardStyleEditor.setSelectedStyle(this.styleList.value); 60 | switchTab("sidebar/styles"); 61 | }); 62 | 63 | this.styleList.addEventListener("change", () => { 64 | dataManager.makeCheckpoint(); 65 | this.pushData(); 66 | }); 67 | } 68 | 69 | /** @param {DominoDataCard[]} cards */ 70 | openMany(cards) { 71 | this.promptText.textContent = `${cards.length} cards selected`; 72 | 73 | elementByPath("card-editor/text", "div").hidden = cards.length > 1; 74 | elementByPath("card-editor/icons", "div").hidden = cards.length > 1; 75 | elementByPath("card-editor/image", "div").hidden = cards.length > 1; 76 | elementByPath("card-editor/style", "div").hidden = false; 77 | elementByPath("selection/link", "button").hidden = cards.length !== 1; 78 | elementByPath("selection/group", "button").hidden = cards.length === 1; 79 | this.cards = cards; 80 | this.pullData(); 81 | 82 | refreshDropdown( 83 | this.styleList, 84 | [{id: "default", name: "default"}, ...boardView.projectData.cardStyles], 85 | (style) => html("option", { value: style.id }, style.name), 86 | ); 87 | } 88 | 89 | close() { 90 | this.cards = []; 91 | 92 | this.promptText.textContent = `no cards selected`; 93 | elementByPath("card-editor/text", "div").hidden = true; 94 | elementByPath("card-editor/icons", "div").hidden = true; 95 | elementByPath("card-editor/image", "div").hidden = true; 96 | elementByPath("card-editor/style", "div").hidden = true; 97 | } 98 | 99 | pullData() { 100 | if (this.cards.length === 1) { 101 | const [card] = this.cards; 102 | 103 | this.textInput.value = card.text; 104 | this.altTextInput.value = card.alttext || ""; 105 | 106 | card.icons.slice(0, 4).forEach((icon, i) => { 107 | this.iconIconInputs[i].value = icon.icon; 108 | this.iconActionInputs[i].value = icon.action; 109 | }); 110 | 111 | [0, 1, 2, 3].forEach((i) => { 112 | const icon = card.icons[i] || { icon: "", action: "" }; 113 | 114 | this.iconIconInputs[i].value = icon.icon; 115 | this.iconActionInputs[i].value = icon.action; 116 | }); 117 | 118 | this.styleList.value = card.style ?? ""; 119 | } else { 120 | const styles = new Set(this.cards.map((card) => card.style)); 121 | const [style] = styles.size === 1 ? styles : [undefined]; 122 | this.styleList.value = style; 123 | } 124 | } 125 | 126 | pushData() { 127 | if (this.cards.length === 1) { 128 | const [card] = this.cards; 129 | card.text = this.textInput.value; 130 | card.icons = [0, 1, 2, 3].map((i) => { 131 | return { 132 | icon: this.iconIconInputs[i].value, 133 | action: this.iconActionInputs[i].value, 134 | }; 135 | }); 136 | card.alttext = this.altTextInput.value; 137 | card.style = this.styleList.value; 138 | } else { 139 | const style = this.styleList.value; 140 | if (style) this.cards.forEach((card) => card.style = style); 141 | } 142 | 143 | this.cards.forEach((card) => boardView.cardToView.get(card).regenerate()); 144 | } 145 | 146 | /** @param {ClipboardEvent} event */ 147 | async paste(event) { 148 | if (this.cards.length !== 1) return; 149 | const [card] = this.cards; 150 | 151 | const image = await dataTransferToImage(event.clipboardData); 152 | 153 | if (image) { 154 | dataManager.makeCheckpoint(); 155 | card.image = image; 156 | this.pushData(); 157 | killEvent(event); 158 | } 159 | } 160 | 161 | /** 162 | * @param {string} prefix 163 | * @param {string} suffix 164 | */ 165 | wrapSelectedText(prefix, suffix) { 166 | dataManager.markDirty(this.cards[0].id + "/text"); 167 | const start = this.textInput.selectionStart; 168 | const end = this.textInput.selectionEnd; 169 | const text = this.textInput.value.substring(start, end); 170 | const prev = this.textInput.value; 171 | const next = prev.slice(0, start) + prefix + text + suffix + prev.slice(end); 172 | 173 | this.textInput.value = next; 174 | this.pushData(); 175 | 176 | this.textInput.select(); 177 | this.textInput.setSelectionRange(start + prefix.length, end + prefix.length); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/scripts/player.js: -------------------------------------------------------------------------------- 1 | /** @param {DominoDataProject} projectData */ 2 | function repairProjectData(projectData) { 3 | const cardIds = new Set(projectData.cards.map((card) => card.id)); 4 | projectData.links = projectData.links.filter((link) => cardIds.has(link.cardA) && cardIds.has(link.cardB)); 5 | projectData.groups = projectData.groups.filter((group) => new Set([...group.cards, ...cardIds]).size === cardIds.size); 6 | } 7 | 8 | class DominoBoardView { 9 | constructor() { 10 | /** @type {Map} */ 11 | this.cardToView = new Map(); 12 | /** @type {Map} */ 13 | this.groupToView = new Map(); 14 | /** @type {Map} */ 15 | this.linkToView = new Map(); 16 | 17 | this.editable = true; 18 | } 19 | 20 | /** @param {DominoDataProject} projectData */ 21 | loadProject(projectData) { 22 | repairProjectData(projectData); 23 | 24 | this.clear(); 25 | this.projectData = projectData; 26 | 27 | this.projectData.cards.forEach((card) => { 28 | const view = new DominoCardView(scene); 29 | view.setCard(card); 30 | this.cardToView.set(card, view); 31 | }); 32 | 33 | refreshSVGs(); 34 | refreshCardStyles(); 35 | refreshBoardStyle(); 36 | } 37 | 38 | clear() { 39 | this.projectData = undefined; 40 | this.cardToView.forEach((view) => view.dispose()); 41 | this.groupToView.forEach((view) => view.dispose()); 42 | this.linkToView.forEach((view) => view.dispose()); 43 | this.cardToView.clear(); 44 | this.groupToView.clear(); 45 | this.linkToView.clear(); 46 | } 47 | } 48 | 49 | class TransformGesture { 50 | tryAddPointerDrag(event) { 51 | const drag = new PointerDrag(event); 52 | drag.events.on("pointermove", () => { 53 | 54 | }); 55 | } 56 | } 57 | 58 | class PanningScene { 59 | get hidden() { return this.container.hidden; } 60 | set hidden(value) { this.container.hidden = value; } 61 | 62 | /** 63 | * @param {HTMLElement} container 64 | */ 65 | constructor(container) { 66 | this.viewport = container.parentElement; 67 | this.container = container; 68 | this.transform = new DOMMatrix(); 69 | this.locked = false; 70 | 71 | this.pointerA = undefined; 72 | this.pointerB = undefined; 73 | let ratio = 1; 74 | 75 | this.viewport.addEventListener("pointerdown", (event) => { 76 | if (this.hidden || this.locked) return; 77 | killEvent(event); 78 | 79 | if (!this.pointerA) { 80 | // determine and save the relationship between mouse and scene 81 | // G = M1^ . S (scene relative to mouse) 82 | const mouse = this.mouseEventToViewportTransform(event); 83 | const grab = mouse.invertSelf().multiplySelf(this.transform); 84 | document.body.style.setProperty("cursor", "grabbing"); 85 | this.viewport.style.setProperty("cursor", "grabbing"); 86 | this.container.classList.toggle("skip-transition", true); 87 | 88 | ratio = 1; 89 | const drag = new PointerDrag(event); 90 | drag.events.on("pointermove", (event) => { 91 | // preserve the relationship between mouse and scene 92 | // D2 = M2 . G (drawing relative to scene) 93 | const mouse = this.mouseEventToViewportTransform(event); 94 | mouse.scaleSelf(ratio, ratio); 95 | this.transform = mouse.multiply(grab); 96 | this.refresh(); 97 | }); 98 | drag.events.on("pointerup", (event) => { 99 | document.body.style.removeProperty("cursor"); 100 | this.viewport.style.removeProperty("cursor"); 101 | this.container.classList.toggle("skip-transition", false); 102 | 103 | if (this.pointerB) this.pointerB.cancel(); 104 | 105 | this.pointerA = undefined; 106 | this.pointerB = undefined; 107 | }); 108 | drag.events.on("click", (event) => { 109 | deselectAll(); 110 | }); 111 | 112 | this.pointerA = drag; 113 | } else if (!this.pointerB) { 114 | const mouseB = this.mouseEventToViewportTransform(event); 115 | const mouseA = this.mouseEventToViewportTransform(this.pointerA.lastEvent); 116 | const dx = mouseB.e - mouseA.e; 117 | const dy = mouseB.f - mouseA.f; 118 | const initialD = Math.sqrt(dx*dx + dy*dy); 119 | 120 | this.pointerB = new PointerDrag(event); 121 | this.pointerB.events.on("pointermove", (event) => { 122 | const mouseB = this.mouseEventToViewportTransform(event); 123 | const mouseA = this.mouseEventToViewportTransform(this.pointerA.lastEvent); 124 | const dx = mouseB.e - mouseA.e; 125 | const dy = mouseB.f - mouseA.f; 126 | const currentD = Math.sqrt(dx*dx + dy*dy); 127 | ratio = currentD / initialD; 128 | }); 129 | this.pointerB.events.on("pointerup", () => { 130 | this.pointerB = undefined; 131 | }); 132 | } 133 | }); 134 | 135 | this.viewport.addEventListener('wheel', (event) => { 136 | if (this.hidden || this.locked) return; 137 | 138 | event.preventDefault(); 139 | 140 | const mouse = this.mouseEventToViewportTransform(event); 141 | const origin = (this.transform.inverse().multiply(mouse)).transformPoint(); 142 | 143 | const deltaY = event.deltaMode === 0 ? event.deltaY : event.deltaY * 33; 144 | 145 | const [minScale, maxScale] = [.25, 2]; 146 | const prevScale = getMatrixScale(this.transform).x; 147 | const [minDelta, maxDelta] = [minScale/prevScale, maxScale/prevScale]; 148 | const magnitude = Math.min(Math.abs(deltaY), 25); 149 | const exponent = Math.sign(deltaY) * magnitude * -.01; 150 | const deltaScale = clamp(Math.pow(2, exponent), minDelta, maxDelta); 151 | 152 | // prev * delta <= max -> delta <= max/prev 153 | this.transform.scaleSelf( 154 | deltaScale, deltaScale, deltaScale, 155 | origin.x, origin.y, origin.z, 156 | ); 157 | 158 | ratio *= deltaScale; 159 | this.refresh(); 160 | }); 161 | 162 | this.refresh(); 163 | } 164 | 165 | refresh() { 166 | setElementTransform(this.container, this.transform); 167 | } 168 | 169 | frameRect(rect, minScale=.25, maxScale=2) { 170 | const bounds = this.viewport.getBoundingClientRect(); 171 | 172 | // find scale that contains all width, all height, and is within limits 173 | const sx = bounds.width / rect.width; 174 | const sy = bounds.height / rect.height; 175 | const scale = clamp(Math.min(sx, sy), minScale, maxScale); 176 | 177 | // find translation that centers the rect in the viewport 178 | const ex = (1/scale - 1/sx) * bounds.width * .5; 179 | const ey = (1/scale - 1/sy) * bounds.height * .5; 180 | const [ox, oy] = [-rect.x + ex, -rect.y + ey]; 181 | 182 | this.transform = new DOMMatrix(); 183 | this.transform.scaleSelf(scale, scale); 184 | this.transform.translateSelf(ox, oy); 185 | this.refresh(); 186 | } 187 | 188 | mouseEventToViewportTransform(event) { 189 | const rect = this.viewport.getBoundingClientRect(); 190 | const [sx, sy] = [event.clientX - rect.x, event.clientY - rect.y]; 191 | const matrix = (new DOMMatrixReadOnly()).translate(sx, sy); 192 | return matrix; 193 | } 194 | 195 | mouseEventToSceneTransform(event) { 196 | const mouse = this.mouseEventToViewportTransform(event); 197 | mouse.preMultiplySelf(this.transform.inverse()); 198 | return mouse; 199 | } 200 | } 201 | 202 | function fakedownToTag(text, fd, tag) { 203 | const pattern = new RegExp(`${fd}([^${fd}]+)${fd}`, 'g'); 204 | return text.replace(pattern, `<${tag}>$1`); 205 | } 206 | 207 | function parseFakedown(text) { 208 | if (text.startsWith('`')) 209 | return `
${text.slice(1)}
`; 210 | text = text.replace(/([^-])--([^-])/g, '$1—$2'); 211 | text = fakedownToTag(text, '##\n?', 'h3'); 212 | text = fakedownToTag(text, '~~', 's'); 213 | text = fakedownToTag(text, '__', 'strong'); 214 | text = fakedownToTag(text, '\\*\\*', 'strong'); 215 | text = fakedownToTag(text, '_', 'em'); 216 | text = fakedownToTag(text, '\\*', 'em'); 217 | text = text.replace(/\n/g, '
'); 218 | return text; 219 | } 220 | -------------------------------------------------------------------------------- /src/scripts/utility.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * @typedef {Object} Vector2 5 | * @property {number} x 6 | * @property {number} y 7 | */ 8 | 9 | /** 10 | * @typedef {Object} Rect 11 | * @property {number} x 12 | * @property {number} y 13 | * @property {number} width 14 | * @property {number} height 15 | */ 16 | 17 | /** 18 | * @param {string} query 19 | * @param {ParentNode} element 20 | * @returns {HTMLElement} 21 | */ 22 | const ONE = (query, element = undefined) => (element || document).querySelector(query); 23 | /** 24 | * @param {string} query 25 | * @param {HTMLElement | Document} element 26 | * @returns {HTMLElement[]} 27 | */ 28 | const ALL = (query, element = undefined) => Array.from((element || document).querySelectorAll(query)); 29 | 30 | /** 31 | * @template T 32 | * @param {T} object 33 | * @returns {T} 34 | */ 35 | const COPY = (object) => JSON.parse(JSON.stringify(object)); 36 | 37 | // async equivalent of Function constructor 38 | const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor 39 | 40 | /** 41 | * @template T 42 | * @param {HTMLSelectElement} element 43 | * @param {T[]} data 44 | * @param {(data: T) => HTMLOptionElement} renderer 45 | */ 46 | function refreshDropdown(element, data, renderer) { 47 | const value = element.value; 48 | const options = data.map(renderer); 49 | element.replaceChildren(...options); 50 | element.value = value; 51 | } 52 | 53 | /** 54 | * @param {MouseEvent | Touch} event 55 | * @param {HTMLElement} element 56 | */ 57 | function eventToElementPixel(event, element) { 58 | const rect = element.getBoundingClientRect(); 59 | return [event.clientX - rect.x, event.clientY - rect.y]; 60 | } 61 | 62 | /** @param {Event} event */ 63 | function killEvent(event) { 64 | event.stopPropagation(); 65 | event.preventDefault(); 66 | } 67 | 68 | /** 69 | * @param {string} src 70 | * @returns {Promise} image 71 | */ 72 | async function loadImage(src) { 73 | return new Promise((resolve, reject) => { 74 | const image = document.createElement("img"); 75 | image.addEventListener("load", () => resolve(image)); 76 | image.src = src; 77 | }); 78 | } 79 | 80 | /** @param {HTMLImageElement} image */ 81 | function imageToRendering2D(image) { 82 | const rendering = createRendering2D(image.naturalWidth, image.naturalHeight); 83 | rendering.drawImage(image, 0, 0); 84 | return rendering; 85 | } 86 | 87 | /** 88 | * @template {any} T 89 | * @param {T[]} array 90 | * @param {T} value 91 | * @returns {boolean} 92 | */ 93 | function arrayDiscard(array, value) { 94 | const index = array.indexOf(value); 95 | if (index >= 0) array.splice(index, 1); 96 | return index >= 0; 97 | } 98 | 99 | /** 100 | * @template {keyof HTMLElementTagNameMap} K 101 | * @param {K} tagName 102 | * @param {*} attributes 103 | * @param {...(Node | string)} children 104 | * @returns {HTMLElementTagNameMap[K]} 105 | */ 106 | function html(tagName, attributes = {}, ...children) { 107 | const element = /** @type {HTMLElementTagNameMap[K]} */ (document.createElement(tagName)); 108 | Object.entries(attributes).forEach(([name, value]) => element.setAttribute(name, value)); 109 | children.forEach((child) => element.append(child)); 110 | return element; 111 | } 112 | 113 | /** 114 | * @template {keyof SVGElementTagNameMap} K 115 | * @param {K} tagName 116 | * @param {*} attributes 117 | * @param {...SVGElement} children 118 | * @returns {SVGElementTagNameMap[K]} 119 | */ 120 | function svg(tagName, attributes = {}, ...children) { 121 | const element = document.createElementNS("http://www.w3.org/2000/svg", tagName); 122 | Object.entries(attributes).forEach(([name, value]) => element.setAttributeNS(null, name, value)); 123 | children.forEach((child) => element.append(child)); 124 | return element; 125 | } 126 | 127 | // from https://github.com/ai/nanoid/blob/master/non-secure/index.js 128 | const urlAlphabet = 'ModuleSymbhasOwnPr-0123456789ABCDEFGHNRVfgctiUvz_KqYTJkLxpZXIjQW'; 129 | function nanoid(size = 21) { 130 | let id = ''; 131 | let i = size; 132 | while (i--) id += urlAlphabet[(Math.random() * 64) | 0]; 133 | return id 134 | } 135 | 136 | /** 137 | * @param {File} file 138 | * @return {Promise} 139 | */ 140 | async function textFromFile(file) { 141 | return new Promise((resolve, reject) => { 142 | const reader = new FileReader(); 143 | reader.onerror = reject; 144 | reader.onload = () => resolve(/** @type {string} */ (reader.result)); 145 | reader.readAsText(file); 146 | }); 147 | } 148 | 149 | /** 150 | * @param {File} file 151 | * @return {Promise} 152 | */ 153 | async function dataURLFromFile(file) { 154 | return new Promise((resolve, reject) => { 155 | const reader = new FileReader(); 156 | reader.onerror = reject; 157 | reader.onload = () => resolve(/** @type {string} */ (reader.result)); 158 | reader.readAsDataURL(file); 159 | }); 160 | } 161 | 162 | /** 163 | * @param {string} source 164 | */ 165 | async function htmlFromText(source) { 166 | const template = document.createElement('template'); 167 | template.innerHTML = source; 168 | return template.content; 169 | } 170 | 171 | /** 172 | * @param {string} text 173 | */ 174 | function textToBlob(text, type = "text/plain") { 175 | return new Blob([text], { type }); 176 | } 177 | 178 | /** 179 | * @param {string} accept 180 | * @param {boolean} multiple 181 | * @returns {Promise} 182 | */ 183 | async function pickFiles(accept = "*", multiple = false) { 184 | return new Promise((resolve) => { 185 | const fileInput = html("input", { type: "file", accept, multiple }); 186 | fileInput.addEventListener("change", () => resolve(Array.from(fileInput.files))); 187 | fileInput.click(); 188 | }); 189 | } 190 | 191 | function translationMatrix(translation) { 192 | const matrix = new DOMMatrix(); 193 | matrix.e = translation.x; 194 | matrix.f = translation.y; 195 | return matrix; 196 | } 197 | 198 | /** @param {DOMMatrix} matrix */ 199 | function getMatrixTranslation(matrix) { 200 | return { x: matrix.e, y: matrix.f }; 201 | } 202 | 203 | /** @param {DOMMatrix} matrix */ 204 | function getMatrixScale(matrix) { 205 | return { 206 | x: Math.sqrt(matrix.a*matrix.a + matrix.c*matrix.c), 207 | y: Math.sqrt(matrix.b*matrix.b + matrix.d*matrix.d), 208 | }; 209 | } 210 | 211 | /** 212 | * @param {number} value 213 | * @param {number} min 214 | * @param {number} max 215 | */ 216 | function clamp(value, min, max) { 217 | return Math.max(min, Math.min(max, value)); 218 | } 219 | 220 | /** 221 | * @param {Rect} rect 222 | * @param {number} padding 223 | */ 224 | function padRect(rect, padding) { 225 | rect.x -= padding; 226 | rect.y -= padding; 227 | rect.width += padding * 2; 228 | rect.height += padding * 2; 229 | return rect; 230 | } 231 | 232 | /** 233 | * @param {Rect} rect 234 | */ 235 | function getRectCenter(rect) { 236 | return { 237 | x: rect.x + rect.width * .5, 238 | y: rect.y + rect.height * .5, 239 | } 240 | } 241 | 242 | /** 243 | * @param {Rect} rect 244 | * @param {{ x: number, y: number }} point 245 | */ 246 | function rectContainsPoint(rect, point) { 247 | return point.x >= rect.x 248 | && point.y >= rect.y 249 | && point.x < rect.x + rect.width 250 | && point.y < rect.y + rect.height; 251 | } 252 | 253 | /** 254 | * @param {Rect} a 255 | * @param {Rect} b 256 | */ 257 | function rectsOverlap(a, b) { 258 | const outside = a.x + a.width > b.x 259 | || a.y + a.height > b.y 260 | || a.x > b.width + b.x 261 | || a.y > b.height + b.y; 262 | 263 | return !outside; 264 | } 265 | 266 | /** 267 | * @param {Rect[]} rects 268 | * @param {Rect} fallback 269 | * @returns {Rect} 270 | */ 271 | function boundRects(rects, fallback = { x: 0, y: 0, width: 0, height: 0 }) { 272 | const bounds = DOMRect.fromRect(rects[0] || fallback); 273 | rects.forEach((rect) => { 274 | const { x, y, width, height } = rect; 275 | let [top, left, bottom, right] = [y, x, y + height, x + width]; 276 | left = Math.min(bounds.left, left); 277 | top = Math.min(bounds.top, top); 278 | right = Math.max(bounds.right, right); 279 | bottom = Math.max(bounds.bottom, bottom); 280 | bounds.x = left; 281 | bounds.y = top; 282 | bounds.width = right - left; 283 | bounds.height = bottom - top; 284 | }); 285 | return bounds; 286 | } 287 | 288 | /** 289 | * @param {any} item 290 | * @param {any[]} array 291 | */ 292 | function removeItemFromArray(item, array) { 293 | const index = array.indexOf(item); 294 | array.splice(index, 1); 295 | } 296 | 297 | /** 298 | * 299 | * @param {string} original 300 | * @param {string} insert 301 | * @param {number} start 302 | * @param {number} end 303 | */ 304 | function insertText(original, insert, start, end) { 305 | const left = original.substring(0, start); 306 | const right = original.substring(end); 307 | return `${left}${insert}${right}`; 308 | } 309 | 310 | /** 311 | * @param {number} min 312 | * @param {number} max 313 | */ 314 | function range(min, max) { 315 | return Array.from(new Array(max-min+1), (x, i) => i + min); 316 | } 317 | 318 | /** 319 | * @param {number} min 320 | * @param {number} max 321 | */ 322 | function randomInt(min, max) { 323 | min = Math.ceil(min); 324 | max = Math.floor(max); 325 | return Math.floor(Math.random() * (max - min + 1) + min); 326 | } 327 | 328 | /** @param {number} milliseconds */ 329 | function sleep(milliseconds) { 330 | return new Promise(resolve => setTimeout(resolve, milliseconds)); 331 | } 332 | 333 | class EventEmitter { 334 | constructor() { 335 | this.listeners = {}; 336 | } 337 | 338 | on(event, listener) { 339 | if (this.listeners[event] === undefined) 340 | this.listeners[event] = []; 341 | this.listeners[event].push(listener); 342 | return () => this.off(event, listener); 343 | } 344 | 345 | off(event, listener) { 346 | const listeners = this.listeners[event] || []; 347 | const index = listeners.indexOf(listener); 348 | if (index !== -1) 349 | this.listeners[event].splice(index, 1); 350 | } 351 | 352 | emit(event, ...args) { 353 | const listeners = this.listeners[event] || []; 354 | [...listeners].forEach((listener) => listener(...args)); 355 | } 356 | 357 | once(event, listener) { 358 | const remove = this.on(event, (...args) => { 359 | remove(); 360 | listener(...args); 361 | }); 362 | return remove; 363 | } 364 | 365 | async wait(event, timeout = undefined) { 366 | return new Promise((resolve, reject) => { 367 | if (timeout) setTimeout(reject, timeout); 368 | this.once(event, resolve); 369 | }); 370 | } 371 | }; 372 | 373 | /** 374 | * @template {keyof WindowEventMap} K 375 | * @param {Window | Document | Element} element 376 | * @param {K} type 377 | * @param {(event: WindowEventMap[K]) => any} listener 378 | */ 379 | function listen(element, type, listener) { 380 | element.addEventListener(type, listener); 381 | return () => element.removeEventListener(type, listener); 382 | } 383 | -------------------------------------------------------------------------------- /src/scripts/editor/cardstyle-editor.js: -------------------------------------------------------------------------------- 1 | function deleteCardStyle(style) { 2 | arrayDiscard(boardView.projectData.cardStyles, style); 3 | boardView.projectData.cards.forEach((card) => { 4 | if (card.style === style.id) delete card.style; 5 | }); 6 | refreshCardStyles(); 7 | } 8 | 9 | // please don't tell anyone how i live.. 10 | class CardStyleEditorRow { 11 | /** 12 | * @param {string} name 13 | * @param {keyof DominoDataCardStyle["properties"]} key 14 | * @param {HTMLElement[]} inputs 15 | */ 16 | constructor(editor, name, key, ...inputs) { 17 | this.editor = editor; 18 | this.toggle = html("input", { type: "checkbox", class: "check" }); 19 | this.label = html("span", {}, name); 20 | 21 | this.name = name; 22 | this.key = key; 23 | 24 | this.elements = [this.toggle, this.label, ...inputs]; 25 | 26 | this.toggle.addEventListener("change", () => { 27 | inputs.forEach((input) => input.disabled = !this.toggle.checked); 28 | this.push(); 29 | }); 30 | 31 | inputs.forEach((input) => input.disabled = true); 32 | inputs.forEach((input) => input.addEventListener("input", () => this.push())); 33 | } 34 | 35 | push() { 36 | const style = this.editor.getSelectedStyle(); 37 | if (!style) return; 38 | this.pushData(style); 39 | refreshCardStyles(); 40 | } 41 | 42 | /** @param {DominoDataCardStyle} style */ 43 | pullData(style) { 44 | 45 | } 46 | 47 | pushData(style) { 48 | 49 | } 50 | } 51 | 52 | class CardStyleSize extends CardStyleEditorRow { 53 | constructor(editor, name, key, min, max, unit="px") { 54 | const input = html("input", { type: "range", min, max }); 55 | super(editor, name, key, input); 56 | this.input = input; 57 | this.unit = unit; 58 | } 59 | 60 | /** @param {DominoDataCardStyle} style */ 61 | pullData(style) { 62 | const value = style.properties[this.key]; 63 | 64 | if (value !== undefined) { 65 | this.toggle.checked = true; 66 | this.input.disabled = false; 67 | this.input.value = value.slice(0, -this.unit.length); 68 | } else { 69 | this.toggle.checked = false; 70 | this.input.disabled = true; 71 | } 72 | } 73 | 74 | /** @param {DominoDataCardStyle} style */ 75 | pushData(style) { 76 | style.properties[this.key] = this.toggle.checked 77 | ? this.input.value + this.unit 78 | : undefined; 79 | } 80 | } 81 | 82 | class CardStyleColor extends CardStyleEditorRow { 83 | constructor(editor, name, key, alpha=false) { 84 | const input = html("input", { type: "color" }); 85 | const slider = alpha ? html("input", { type: "range", min: "0", max: "255" }) : undefined; 86 | if (slider) { 87 | super(editor, name, key, input, slider); 88 | } else { 89 | super(editor, name, key, input); 90 | } 91 | this.input = input; 92 | this.slider = slider; 93 | } 94 | 95 | /** @param {DominoDataCardStyle} style */ 96 | pullData(style) { 97 | const value = style.properties[this.key]; 98 | 99 | if (value !== undefined) { 100 | this.toggle.checked = true; 101 | this.input.disabled = false; 102 | this.input.value = value.slice(0, 7); 103 | 104 | if (this.slider) { 105 | this.slider.value = parseInt(value.slice(-2), 16).toString(); 106 | this.slider.disabled = false; 107 | } 108 | } else { 109 | this.toggle.checked = false; 110 | this.input.disabled = true; 111 | if (this.slider) this.slider.disabled = true; 112 | } 113 | } 114 | 115 | /** @param {DominoDataCardStyle} style */ 116 | pushData(style) { 117 | const alpha = this.slider ? this.slider.valueAsNumber.toString(16) : "ff"; 118 | style.properties[this.key] = this.toggle.checked 119 | ? this.input.value + alpha 120 | : undefined; 121 | } 122 | } 123 | 124 | class CardStyleToggle extends CardStyleEditorRow { 125 | /** @param {DominoDataCardStyle} style */ 126 | pullData(style) { 127 | this.toggle.checked = style.properties[this.key] !== undefined; 128 | } 129 | 130 | /** @param {DominoDataCardStyle} style */ 131 | pushData(style) { 132 | style.properties[this.key] = this.toggle.checked ? "true" : undefined; 133 | } 134 | } 135 | 136 | class CardStyleSelect extends CardStyleEditorRow { 137 | constructor(editor, name, key) { 138 | const input = html("select", {}); 139 | super(editor, name, key, input); 140 | this.select = input; 141 | } 142 | 143 | /** @param {DominoDataCardStyle} style */ 144 | pullData(style) { 145 | const value = style.properties[this.key]; 146 | 147 | if (value !== undefined) { 148 | this.toggle.checked = true; 149 | this.select.disabled = false; 150 | this.select.value = value; 151 | } else { 152 | this.toggle.checked = false; 153 | this.select.disabled = true; 154 | } 155 | } 156 | 157 | /** @param {DominoDataCardStyle} style */ 158 | pushData(style) { 159 | style.properties[this.key] = this.toggle.checked 160 | ? this.select.value 161 | : undefined; 162 | } 163 | } 164 | 165 | class CardStyleEditor { 166 | constructor() { 167 | this.root = elementByPath("global-editor", "div"); 168 | 169 | const container = document.getElementById("card-style-fields"); 170 | 171 | this.titleInput = elementByPath("global-editor/title", "input"); 172 | this.focusInput = elementByPath("global-editor/focus", "input"); 173 | this.backgroundColorInput = elementByPath("global-editor/style/background-color", "input"); 174 | 175 | this.nameInput = elementByPath("global-editor/card-styles/selected/name", "input"); 176 | this.textFontRow = new CardStyleSelect(this, "font", "text-font"); 177 | 178 | this.rows = [ 179 | new CardStyleSize(this, "size", "text-size", 8, 64), 180 | new CardStyleColor(this, "color", "text-color"), 181 | new CardStyleToggle(this, "center", "text-center"), 182 | new CardStyleColor(this, "color", "card-color", true), 183 | this.textFontRow, 184 | ]; 185 | 186 | container.replaceChildren( 187 | html("h2", {}, "text"), 188 | html("div", { class: "settings-grid" }, 189 | ...this.textFontRow.elements, 190 | ...this.rows[0].elements, 191 | ...this.rows[1].elements, 192 | ...this.rows[2].elements, 193 | ), 194 | html("h2", {}, "card"), 195 | html("div", { class: "settings-grid" }, 196 | ...this.rows[3].elements, 197 | ), 198 | ); 199 | 200 | this.backgroundColorInput.addEventListener("input", () => { 201 | dataManager.markDirty("global/background-color"); 202 | boardView.projectData.boardStyle["background-color"] = this.backgroundColorInput.value; 203 | refreshBoardStyle(); 204 | }); 205 | 206 | this.focusInput.addEventListener("input", () => { 207 | dataManager.markDirty("global/focus"); 208 | boardView.projectData.details.focus = this.focusInput.value; 209 | }); 210 | 211 | this.nameInput.addEventListener("input", () => { 212 | const style = this.getSelectedStyle(); 213 | if (style) { 214 | dataManager.markDirty(this.selectedStyleId + "/name"); 215 | style.name = this.nameInput.value; 216 | } 217 | }); 218 | 219 | this.styleSelect = elementByPath("global-editor/card-styles/selected", "select"); 220 | this.styleSelect.addEventListener("change", () => { 221 | this.setSelectedStyle(this.styleSelect.value); 222 | }); 223 | 224 | this.customCssInput = elementByPath("global-editor/card-styles/selected/custom-css", "textarea"); 225 | this.customCssInput.addEventListener("input", () => { 226 | const style = this.getSelectedStyle(); 227 | if (style) { 228 | dataManager.markDirty(this.selectedStyleId + "/custom-css"); 229 | style.properties["custom-css"] = this.customCssInput.value; 230 | refreshCardStyles(); 231 | } 232 | }); 233 | 234 | this.titleInput.addEventListener("input", () => { 235 | dataManager.markDirty("global/title"); 236 | boardView.projectData.details.title = this.titleInput.value; 237 | }); 238 | 239 | setActionHandler("global-editor/open", () => this.open()); 240 | setActionHandler("global-editor/close", () => this.close()); 241 | setActionHandler("global-editor/toggle", () => this.toggle()); 242 | 243 | setActionHandler("global-editor/card-style/new", () => { 244 | dataManager.makeCheckpoint(); 245 | const style = { 246 | id: nanoid(), 247 | name: "new style", 248 | properties: {}, 249 | }; 250 | 251 | boardView.projectData.cardStyles.push(style); 252 | this.setSelectedStyle(style.id); 253 | }); 254 | 255 | setActionHandler("global-editor/card-style/selected/duplicate", () => { 256 | dataManager.makeCheckpoint(); 257 | const style = this.getSelectedStyle(); 258 | const copy = JSON.parse(JSON.stringify(style)); 259 | copy.id = nanoid(); 260 | copy.name += " (copy)"; 261 | boardView.projectData.cardStyles.push(copy); 262 | this.setSelectedStyle(copy.id); 263 | }); 264 | 265 | setActionHandler("global-editor/card-style/selected/delete", () => { 266 | if (boardView.projectData.cardStyles.length <= 1) return; 267 | dataManager.makeCheckpoint(); 268 | const style = this.getSelectedStyle(); 269 | deleteCardStyle(style); 270 | this.setSelectedStyle(boardView.projectData.cardStyles[0].id); 271 | }); 272 | } 273 | 274 | getSelectedStyle() { 275 | const styles = boardView.projectData.cardStyles; 276 | return styles.find((style) => style.id === this.selectedStyleId); 277 | } 278 | 279 | pullData() { 280 | this.titleInput.value = boardView.projectData.details.title; 281 | this.focusInput.value = boardView.projectData.details.focus; 282 | this.backgroundColorInput.value = boardView.projectData.boardStyle["background-color"] || "#b7b8b0"; 283 | 284 | refreshDropdown( 285 | this.styleSelect, 286 | boardView.projectData.cardStyles, 287 | (style) => html("option", { value: style.id }, style.name), 288 | ); 289 | 290 | if (!this.selectedStyleId) this.selectedStyleId = boardView.projectData.cardStyles[0].id; 291 | this.styleSelect.value = this.selectedStyleId; 292 | 293 | refreshDropdown( 294 | this.textFontRow.select, 295 | ["default", "monospace", "serif", "sans-serif", "cursive"], 296 | (font) => html("option", { value: font }, font), 297 | ); 298 | 299 | const style = this.getSelectedStyle(); 300 | if (!style) return; 301 | 302 | this.nameInput.value = style.name; 303 | this.rows.forEach((row) => row.pullData(style)); 304 | this.customCssInput.value = style.properties["custom-css"] || ""; 305 | 306 | const deleteButton = elementByPath("global-editor/card-style/selected/delete", "button"); 307 | deleteButton.disabled = boardView.projectData.cardStyles.length <= 1; 308 | } 309 | 310 | setSelectedStyle(styleId) { 311 | this.selectedStyleId = styleId; 312 | this.pullData(); 313 | } 314 | 315 | open() { 316 | this.root.hidden = false; 317 | this.pullData(); 318 | } 319 | 320 | close() { 321 | this.root.hidden = true; 322 | } 323 | 324 | toggle() { 325 | if (this.root.hidden) this.open(); 326 | else this.close(); 327 | } 328 | } 329 | -------------------------------------------------------------------------------- /src/font.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Lora'; 3 | font-style: normal; 4 | font-weight: 400; 5 | font-display: swap; 6 | src: url(data:font/woff2;base64,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) format('woff2'); 7 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 8 | } 9 | -------------------------------------------------------------------------------- /src/project-data.json: -------------------------------------------------------------------------------- 1 | {"details":{"id":"o63ufGrsIXyZMlc_EwWcd","title":"domino 2 intro","name":"project","focus":"#bph3jY70Zu2nGAUiVBgs5,KF1nvasQqE7g_2g8XQ2eV,nIZaJb3PWSiwlks_nkVaS,rz_M_aSTSY4q3R3BTlKK2,ilCMTXG2aPjbi4v1-QXRq,LPkRRSXE-D2D2RP13vQAF,ezGXhmVyZZBHP0XOrhPDC"},"cards":[{"id":"ilCMTXG2aPjbi4v1-QXRq","position":{"x":1024,"y":320},"size":{"x":2,"y":2},"text":"##domino 2##\na tool for collaging thoughts","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"yrKZvizTDGthzyGX_AA-X"},{"id":"KF1nvasQqE7g_2g8XQ2eV","position":{"x":1024,"y":80},"size":{"x":2,"y":2},"text":"##motivation##\nwhat domino is trying to do and why i created it","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"yrKZvizTDGthzyGX_AA-X"},{"id":"bph3jY70Zu2nGAUiVBgs5","position":{"x":640,"y":160},"size":{"x":2,"y":2},"text":"##technical details##\ninformation about how domino works and how it was made","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"yrKZvizTDGthzyGX_AA-X"},{"id":"ezGXhmVyZZBHP0XOrhPDC","position":{"x":640,"y":400},"size":{"x":2,"y":2},"text":"##source code##\nthe source code and build scripts for domino are available on github","icons":[{"icon":"🔗","action":"https://github.com/Ragzouken/domino2"},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"yrKZvizTDGthzyGX_AA-X"},{"id":"LPkRRSXE-D2D2RP13vQAF","position":{"x":1024,"y":560},"size":{"x":2,"y":2},"text":"##contact##\nyou can contact me on twitter, on mastodon, or by email","icons":[{"icon":"🐦","action":"https://twitter.com/ragzouken"},{"icon":"🐘","action":"https://cybre.space/@candle"},{"icon":"📧","action":"mailto:ragzouken+domino@gmail.com"},{"icon":"","action":""}],"alttext":"","style":"yrKZvizTDGthzyGX_AA-X"},{"id":"nIZaJb3PWSiwlks_nkVaS","position":{"x":1408,"y":160},"size":{"x":2,"y":2},"text":"##how to use##\nhow to make your own boards with domino","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"yrKZvizTDGthzyGX_AA-X"},{"id":"rz_M_aSTSY4q3R3BTlKK2","position":{"x":1408,"y":400},"size":{"x":2,"y":2},"text":"##future##\nideas and intentions for future versions of domino","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"yrKZvizTDGthzyGX_AA-X"},{"id":"hGU0_auJbxoaKOenkybsu","position":{"x":896,"y":-80},"size":{"x":2,"y":2},"text":"domino is a tool for collaging fragmentary thoughts and ideas without the friction of assembling them into linear writing","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"1HQnPB8fc0zpeLqCCGokQ"},{"id":"BQXX3hTfW0IRQxo_01WoG","position":{"x":1152,"y":-240},"size":{"x":2,"y":2},"text":"the format of domino sits somewhere between a mine map, a board of post-it notes, and a collage","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"1HQnPB8fc0zpeLqCCGokQ"},{"id":"utli8VjJ5Fd7OfY0ydAQZ","position":{"x":896,"y":-240},"size":{"x":2,"y":2},"text":"the name \"domino\" comes from being reminded of the game of dominoes where tiles with matching sides are chained together","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"1HQnPB8fc0zpeLqCCGokQ"},{"id":"yE3RYpXSZAIDxFxHYNmQp","position":{"x":1152,"y":-80},"size":{"x":2,"y":2},"text":"the idea is to transcribe a stream of thoughts into cards, and then arrange them spatially to imply connections and structure","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"1HQnPB8fc0zpeLqCCGokQ"},{"id":"btdH83zrBNTye2P0kKGvH","position":{"x":1024,"y":-400},"size":{"x":2,"y":2},"text":"##domino 1##\ncheck out the first iteration of domino for more context ","icons":[{"icon":"🔗","action":"https://kool.tools/domino"},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"yrKZvizTDGthzyGX_AA-X"},{"id":"3kL8WcUDCLXF0_buh6CWp","position":{"x":1024,"y":720},"size":{"x":2,"y":2},"text":"i get so much spam that i don't necessarily find every email that's sent to me.. poke me on twitter if you mail and i miss it","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"1HQnPB8fc0zpeLqCCGokQ"},{"id":"4MV1ajFAF1S9LBTd4gOTe","position":{"x":384,"y":320},"size":{"x":2,"y":2},"text":"domino is written in html/css/javascript and released as a single standalone html file","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"CyZclJHl2fVSpLw53gn1Q"},{"id":"KEeZAHKARQ-8_4YX5MUGJ","position":{"x":384,"y":160},"size":{"x":2,"y":2},"text":"the domino build process uses pug to generate the html including all the javascript and css files inline","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"CyZclJHl2fVSpLw53gn1Q"},{"id":"2ToRLnkYP8Rmkq8BF_Ooi","position":{"x":384,"y":480},"size":{"x":2,"y":2},"text":"the source is intended to be human \"readable\" but right now it's very poorly written and uncommented becasuse it's a prototype","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"1HQnPB8fc0zpeLqCCGokQ"},{"id":"C1k9Abhw4mH-JsfDnORdF","position":{"x":128,"y":400},"size":{"x":2,"y":2},"text":"i prefer to make tools using plain javascript without minification and bundling, and using few third party dependencies","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"1HQnPB8fc0zpeLqCCGokQ"},{"id":"Zf4M4UIt4RnBDAi4iyBJQ","position":{"x":128,"y":240},"size":{"x":2,"y":2},"text":"i want the tool to be \"user serviceable\", at least in the sense that the final page isn't obfuscated and making your own changes is straight forward","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"1HQnPB8fc0zpeLqCCGokQ"},{"id":"zJ1AJNaAVKQpTB28B4amo","position":{"x":512,"y":0},"size":{"x":2,"y":2},"text":"in domino 1, exported boards were also a fully functional copy of the domino editor, i have abandoned this for now","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"CyZclJHl2fVSpLw53gn1Q"},{"id":"RzjtPhgsKq_WTBu_tYNlp","position":{"x":512,"y":-160},"size":{"x":2,"y":2},"text":"it's not difficult to resurrect this in the future, but i think it was confusing to people trying to just read domino boards","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"1HQnPB8fc0zpeLqCCGokQ"},{"id":"wEq4jGSk8-67eqhCegEyI","position":{"x":128,"y":80},"size":{"x":2,"y":2},"text":"once again i'm using my \"low tech template\" for the build process","icons":[{"icon":"🔗","action":"https://kool.tools/blog/low-tech-template.html"},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"1HQnPB8fc0zpeLqCCGokQ"},{"id":"uq3iDCmk3423BJrOSFQUS","position":{"x":256,"y":-160},"size":{"x":2,"y":2},"text":"i will experiment with this idea in other projects probably. you can read about a related prototype in the link below","icons":[{"icon":"🧬","action":"https://kool.tools/exquisite/index.html"},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"1HQnPB8fc0zpeLqCCGokQ"},{"id":"YEsWeYeYaQcJsyhpnOh4E","position":{"x":1536,"y":560},"size":{"x":2,"y":2},"text":"i don't have any plans to support editing on mobile. i'm not sure it could be made a pleasant experience anyway","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"CyZclJHl2fVSpLw53gn1Q"},{"id":"Dpwf3dh_LxC8KJsv12oVp","position":{"x":1408,"y":720},"size":{"x":2,"y":2},"text":"let me know if you ever edited using domino 1 on mobile, and if you're interested in editing with domino 2 on mobile","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"1HQnPB8fc0zpeLqCCGokQ"},{"id":"dcjaVVJv_fcGWTUGCpUUU","position":{"x":1280,"y":560},"size":{"x":2,"y":2},"text":"if you find any bugs or have any feedback about usability or features, please let me know","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"vc_VZePmvF5KufvjyQGuX"},{"id":"_UYJ966mUpS0yql0ivNnl","position":{"x":1664,"y":400},"size":{"x":2,"y":2},"text":"font importer (upload or via google fonts)","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"CyZclJHl2fVSpLw53gn1Q"},{"id":"Uc-HEP4xesSDpD2nDljuo","position":{"x":1664,"y":720},"size":{"x":2,"y":2},"text":"more control over image embedding/compression","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"CyZclJHl2fVSpLw53gn1Q"},{"id":"revqdljgzvtE9TELKogsX","position":{"x":1792,"y":560},"size":{"x":2,"y":2},"text":"more sophisticated loading so cards can show up before all the images have fully downloaded","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"CyZclJHl2fVSpLw53gn1Q"},{"id":"_b_S1POoyLDRFYr_uNanO","position":{"x":1792,"y":0},"size":{"x":2,"y":2},"text":"##navigating##\nmoving around and reading a domino board","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"yrKZvizTDGthzyGX_AA-X"},{"id":"QBcYyOS6N7vjDg6J2poNa","position":{"x":1664,"y":-160},"size":{"x":2,"y":2},"text":"click and drag the background to pan freely around the board\n","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"CyZclJHl2fVSpLw53gn1Q"},{"id":"O6GMJgnfqRD2ienLd-9k8","position":{"x":1536,"y":0},"size":{"x":2,"y":2},"text":"a domino board is a collection of hand placed cards","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"CyZclJHl2fVSpLw53gn1Q"},{"id":"kDuhVgJWLAYrrxqJEEnKs","position":{"x":2048,"y":0},"size":{"x":2,"y":2},"text":"a card may have a row of icons that can be clicked on to either jump somewhere else in the board or open another website","icons":[{"icon":"🔨","action":"https://kool.tools"},{"icon":"🐞","action":"https://emreed.net"},{"icon":"🏠","action":"#ilCMTXG2aPjbi4v1-QXRq"},{"icon":"","action":""}],"alttext":"","style":"CyZclJHl2fVSpLw53gn1Q"},{"id":"BWIJeyFea2NsOxyCspbkm","position":{"x":1664,"y":160},"size":{"x":2,"y":2},"text":"you can zoom in and out with the mouse wheel (or pinching on touchscreen)","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"CyZclJHl2fVSpLw53gn1Q"},{"id":"FsO5wHZvt7pZdRUO93tbS","position":{"x":2176,"y":-160},"size":{"x":2,"y":2},"text":"##editing cards##\ncreating, manipulating, and editing cards in the board","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"yrKZvizTDGthzyGX_AA-X"},{"id":"u4iBdU2BPz2atCByL42Ib","position":{"x":2688,"y":160},"size":{"x":2,"y":2},"text":"##editing board##\nediting board title and style, and the styles shared by all cards","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"yrKZvizTDGthzyGX_AA-X"},{"id":"HPTwiQe45FK4g3IuiPhtq","position":{"x":1920,"y":-160},"size":{"x":2,"y":2},"text":"click and drag to move cards around","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"CyZclJHl2fVSpLw53gn1Q"},{"id":"n151gvoBpYqgU6lELNWG_","position":{"x":2304,"y":-320},"size":{"x":2,"y":2},"text":"click and drag the bottom right corner of a card to resize it","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"👉","action":""}],"alttext":"","style":"CyZclJHl2fVSpLw53gn1Q"},{"id":"UQ1nAYcgcsqBR-S7_sJbH","position":{"x":2048,"y":-320},"size":{"x":2,"y":2},"text":"click without dragging to select/deselect a card","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"CyZclJHl2fVSpLw53gn1Q"},{"id":"T6ah1WdbD3pRzBaKZ1xBA","position":{"x":2304,"y":0},"size":{"x":2,"y":2},"text":"double-click a card to open the editor sidebar with it selected, or double click empty space to create and edit a new card","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"CyZclJHl2fVSpLw53gn1Q"},{"id":"38OFSMRjV9ktiY-3spJ7d","position":{"x":1792,"y":-320},"size":{"x":2,"y":2},"text":"when dragging cards, all selected cards are moved at once","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"1HQnPB8fc0zpeLqCCGokQ"},{"id":"N2w2zAhMg9sBOri6Dl-7j","position":{"x":2560,"y":0},"size":{"x":2,"y":2},"text":"you can also open the editor sidebar using the 🔨 icon at the bottom left of the screen","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"CyZclJHl2fVSpLw53gn1Q"},{"id":"KmI9x-NJFGtumZIcj-2ZP","position":{"x":2432,"y":160},"size":{"x":2,"y":2},"text":"the **styles** tab of the 🔨 sidebar allows you to edit the styles shared between all cards","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"CyZclJHl2fVSpLw53gn1Q"},{"id":"e0VGafBwbvo_RxDZ9AQUA","position":{"x":2944,"y":-160},"size":{"x":2,"y":2},"text":"##saving##\nnew, save, load, import, export","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"yrKZvizTDGthzyGX_AA-X"},{"id":"cZTi_iCzK5InHRL41tZZV","position":{"x":2688,"y":-160},"size":{"x":2,"y":2},"text":"the 💾 button at the bottom right of the screen saves the board to your browser's internal storage","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"CyZclJHl2fVSpLw53gn1Q"},{"id":"FRDZgI2e4rpEBDNhzSbs4","position":{"x":2816,"y":0},"size":{"x":2,"y":2},"text":"to change to another board open the **switch board** section on the **board** tab of the 🔨 sidebar","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"CyZclJHl2fVSpLw53gn1Q"},{"id":"VqiFgGOuAX3QBRP6rQViV","position":{"x":3072,"y":0},"size":{"x":2,"y":2},"text":"you can export your board to a standalone web page (.html file) using the options in the **publish** section of the **board** tab of the 🔨 sidebar","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"CyZclJHl2fVSpLw53gn1Q"},{"id":"OTr6svqRli9cr9Rl9Dfb2","position":{"x":3200,"y":-160},"size":{"x":2,"y":2},"text":"if you have a neocities account and you trust my publisher app, there is a feature to publish directly to neocities","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"CyZclJHl2fVSpLw53gn1Q"},{"id":"NAyalAxArME7dhSV4kkaT","position":{"x":2432,"y":320},"size":{"x":2,"y":2},"text":"use the dropdown to select the style to edit, and the checkboxes to decide which elements will be controlled by the style","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"CyZclJHl2fVSpLw53gn1Q"},{"id":"PBW4GABHCJLvtLSTNFIS1","position":{"x":2432,"y":480},"size":{"x":2,"y":2},"text":"there's also a box for writing custom css that will apply to cards with that style","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"CyZclJHl2fVSpLw53gn1Q"},{"id":"z_P9VQENb9a4HG2EGai5V","position":{"x":2432,"y":640},"size":{"x":2,"y":2},"text":"you will need to poke around at the example styles and the card structure to make use of this feature","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"1HQnPB8fc0zpeLqCCGokQ"},{"id":"OM2eaSQ91s2tp3uo-aLl6","position":{"x":2944,"y":160},"size":{"x":2,"y":2},"text":"the board title and background color can be changed on the **board** tab of the 🔨 sidebar","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"CyZclJHl2fVSpLw53gn1Q"},{"id":"8F2iOY1RmGPeRWNLDTeC8","position":{"x":2176,"y":160},"size":{"x":2,"y":4},"text":"","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"image":"data:image/jpeg;base64,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","alttext":"","style":""},{"id":"xuQSW4FYqkYjXH0iA7knE","position":{"x":2304,"y":-720},"size":{"x":3,"y":4},"text":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":""},{"id":"elXnk9aTBvbVwXF1D4T5p","position":{"x":2048,"y":-640},"size":{"x":2,"y":2},"text":"with only one card selected, you can click **link** in the **selection** tab to add a line link to another card","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"CyZclJHl2fVSpLw53gn1Q"},{"id":"LF5kwwLV11WkD-aoWg645","position":{"x":2048,"y":-800},"size":{"x":2,"y":2},"text":"with multiple cards selected, you can click **group** to join them with a background box","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"CyZclJHl2fVSpLw53gn1Q"},{"id":"Jn-hokVWUs2dqqMLDu4Rs","position":{"x":1536,"y":-320},"size":{"x":2,"y":2},"text":"use middle click drag to pan without accidentally dragging cards","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"1HQnPB8fc0zpeLqCCGokQ"},{"id":"UgXxyz1TMaf-RO6OTleAd","position":{"x":1664,"y":-480},"size":{"x":2,"y":2},"text":"hold ctrl when beginning to drag cards to drag copies of the cards intead","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"1HQnPB8fc0zpeLqCCGokQ"},{"id":"xVcDfDlEg5u45QAC40qva","position":{"x":2816,"y":-320},"size":{"x":2,"y":2},"text":"you can also press ctrl+s at any time to save","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"1HQnPB8fc0zpeLqCCGokQ"},{"id":"AQwfo_1YEJoEdNEufH70j","position":{"x":2432,"y":-160},"size":{"x":2,"y":2},"text":"you can press the **delete** key to delete everything you have selected","icons":[{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""},{"icon":"","action":""}],"alttext":"","style":"1HQnPB8fc0zpeLqCCGokQ"}],"groups":[{"cards":["LF5kwwLV11WkD-aoWg645","elXnk9aTBvbVwXF1D4T5p","xuQSW4FYqkYjXH0iA7knE"],"color":"rgb(240 237 202)"}],"links":[{"cardA":"elXnk9aTBvbVwXF1D4T5p","cardB":"UQ1nAYcgcsqBR-S7_sJbH","color":"#90c5c6"}],"cardStyles":[{"id":"STYLE-HEADING","name":"heading","properties":{"text-size":"30px","text-center":true,"card-color":"#00000040","custom-css":".card-body { align-items: center; font-weight: bold; } .card-root { --card-edge-color: rgba(0, 0, 0, 25%); }"}},{"id":"STYLE-GRID","name":"grid","properties":{"custom-css":".card-body { line-height: 1.35em; background-image: linear-gradient(gainsboro 1px, transparent 1px), linear-gradient(90deg, gainsboro 1px, transparent 1px); background-size: 1.3em 1.3em; background-position: .75em .75em; }"}},{"id":"STYLE-TERMINAL","name":"terminal","properties":{"text-font":"monospace","text-color":"#00ff00ff","card-color":"#000000FF"}},{"id":"EeMLC4dJM4-iD3bhd2BWk","name":"disabled","properties":{"text-color":"#7d7d7dff","card-color":"#ffffff7c"}},{"id":"yrKZvizTDGthzyGX_AA-X","name":"heading 2","properties":{"text-size":"17px","card-color":"#ffffff76"}},{"id":"1HQnPB8fc0zpeLqCCGokQ","name":"context","properties":{"card-color":"#e5efd2ff","custom-css":""}},{"id":"CyZclJHl2fVSpLw53gn1Q","name":"information","properties":{"card-color":"#d2deefff","custom-css":""}},{"id":"vc_VZePmvF5KufvjyQGuX","name":"prompt","properties":{"card-color":"#efd6d2ff","custom-css":""}}],"boardStyle":{"background-color":"#e4decd"}} -------------------------------------------------------------------------------- /src/scripts/test.js: -------------------------------------------------------------------------------- 1 | /** @type {Set} */ 2 | const selectedCards = new Set(); 3 | /** @type {DominoDataGroup[]} */ 4 | const selectedGroups = []; 5 | /** @type {DominoDataLink[]} */ 6 | const selectedLinks = []; 7 | /** @type {DominoDataCard} */ 8 | let linking; 9 | /** @type {LinkEditor} */ 10 | let linkEditor; 11 | /** @type {GroupEditor} */ 12 | let groupEditor; 13 | /** @type {CardEditor} */ 14 | let cardEditor; 15 | /** @type {DominoBoardView} */ 16 | let boardView; 17 | /** @type {DominoProjectManager} */ 18 | let dataManager; 19 | 20 | /** @type {CardStyleEditor} */ 21 | let cardStyleEditor; 22 | 23 | async function test() { 24 | dataManager = new DominoProjectManager(); 25 | boardView = new DominoBoardView(); 26 | cardEditor = new CardEditor(); 27 | linkEditor = new LinkEditor(); 28 | groupEditor = new GroupEditor(); 29 | cardStyleEditor = new CardStyleEditor(); 30 | 31 | listen(scene.viewport, "dblclick", (event) => { 32 | if (!boardView.editable) return; 33 | 34 | killEvent(event); 35 | const transform = scene.mouseEventToSceneTransform(event); 36 | transform.e -= cellWidth/2; 37 | transform.f -= cellHeight/2; 38 | gridSnap(transform); 39 | const position = getMatrixTranslation(transform); 40 | 41 | const card = { 42 | id: nanoid(), 43 | position, 44 | size: { x: 2, y: 2 }, 45 | text: "new card :)", 46 | icons: [], 47 | } 48 | 49 | dataManager.makeCheckpoint(); 50 | insertCard(scene, card); 51 | deselectAll(); 52 | selectCard(card); 53 | invokeAction("global-editor/open"); 54 | switchTab("sidebar/selection"); 55 | }); 56 | 57 | setActionHandler("global/undo", () => dataManager.undo()); 58 | setActionHandler("global/redo", () => dataManager.redo()); 59 | 60 | setActionHandler("global/view-saves", () => { 61 | switchTab("sidebar/board"); 62 | cardStyleEditor.open(); 63 | ONE("#saved-boards").open = true; 64 | }); 65 | 66 | setActionHandler("project/reset", () => dataManager.reset(JSON.parse(ONE("#project-data").innerHTML))); 67 | 68 | setActionHandler("selection/copy-id", () => { 69 | const ids = Array.from(selectedCards).map((card) => card.id); 70 | navigator.clipboard.writeText('#' + ids.join(",")); 71 | }); 72 | setActionHandler("selection/group", groupSelection); 73 | setActionHandler("selection/link", beginLink); 74 | setActionHandler("selection/cancel", deselectCards); 75 | setActionHandler("selection/center", centerSelection); 76 | setActionHandler("selection/delete", () => { 77 | dataManager.makeCheckpoint(); 78 | Array.from(selectedCards).forEach((card) => deleteCard(card)); 79 | }); 80 | 81 | setActionHandler("group/delete", deleteSelectedGroup); 82 | setActionHandler("group/select", selectGroupCards); 83 | 84 | setActionHandler("link/delete", deleteSelectedLink); 85 | setActionHandler("link/select", selectLinkCards); 86 | 87 | setActionHandler("card-editor/close", closeEditor); 88 | 89 | setActionHandler("picker/cancel", () => { 90 | linking = undefined; 91 | updateToolbar(); 92 | }); 93 | 94 | setActionHandler("global/center-focus", () => { 95 | const data = boardView.projectData; 96 | if (data.details.focus) centerCards(getCardsByIds(data.details.focus.slice(1).split(","))); 97 | }); 98 | 99 | // image pasting 100 | window.addEventListener("paste", (event) => cardEditor.paste(event)); 101 | 102 | // hotkeys 103 | document.addEventListener("keydown", (event) => { 104 | const targetTag = event.target.tagName.toLowerCase(); 105 | const textedit = targetTag === "input" || targetTag === "textarea"; 106 | 107 | if (event.ctrlKey && event.key === "s") { 108 | invokeAction("project/save"); 109 | } else if (!textedit && event.key === "Escape") { 110 | deselectAll(); 111 | } else if (!textedit && event.key === "Delete") { 112 | if (selectedCards.size > 0) invokeAction("selection/delete"); 113 | if (selectedGroups.length > 0) invokeAction("group/delete"); 114 | if (selectedLinks.length > 0) invokeAction("link/delete"); 115 | } else if (!textedit && event.ctrlKey && event.key == "z") { 116 | dataManager.undo(); 117 | } else if (!textedit && event.ctrlKey && event.key == "y") { 118 | dataManager.redo(); 119 | } else { 120 | return; 121 | } 122 | 123 | killEvent(event); 124 | }); 125 | } 126 | 127 | /** @param {DominoDataGroup} group */ 128 | function getGroupCards(group) { 129 | const cards = new Set(group.cards); 130 | return boardView.projectData.cards.filter((card) => cards.has(card.id)); 131 | } 132 | 133 | /** @param {string[]} ids */ 134 | function getCardsByIds(ids) { 135 | const cards = new Set(ids); 136 | return boardView.projectData.cards.filter((card) => cards.has(card.id)); 137 | } 138 | 139 | function updateToolbar() { 140 | const selection = selectedCards.size > 0; 141 | const selectedGroup = selectedGroups.length > 0; 142 | const selectedLink = selectedLinks.length > 0; 143 | 144 | elementByPath("picker", "div").hidden = !linking; 145 | 146 | if (selection) switchTab("sidebar/selection/cards"); 147 | else if (selectedGroup) switchTab("sidebar/selection/group"); 148 | else if (selectedLink) switchTab("sidebar/selection/link"); 149 | else switchTab("sidebar/selection/none"); 150 | 151 | // selections 152 | const active = selectedGroups.length > 0 ? new Set(getGroupCards(selectedGroups[0])) : selectedCards; 153 | boardView.cardToView.forEach((view, card) => view.setSelected(active.has(card))); 154 | 155 | ONE("#undo").classList.toggle("disabled", !dataManager.canUndo); 156 | ONE("#redo").classList.toggle("disabled", !dataManager.canRedo); 157 | } 158 | 159 | /** 160 | * @param {PanningScene} scene 161 | * @param {DominoDataCard} card 162 | */ 163 | function insertCard(scene, card) { 164 | boardView.projectData.cards.push(card); 165 | const view = new DominoCardView(scene); 166 | view.setCard(card); 167 | boardView.cardToView.set(card, view); 168 | return view; 169 | } 170 | 171 | /** @param {DominoDataCard} card */ 172 | function deleteCard(card) { 173 | arrayDiscard(boardView.projectData.cards, card); 174 | 175 | Array.from(boardView.projectData.groups).forEach((group) => { 176 | if (arrayDiscard(group.cards, card.id)) refreshGroup(group); 177 | if (group.cards.length === 0) deleteGroup(group); 178 | }); 179 | Array.from(boardView.projectData.links).forEach((link) => { 180 | if (link.cardA === card.id || link.cardB === card.id) { 181 | deleteLink(link); 182 | } 183 | }); 184 | 185 | deselectCard(card); 186 | boardView.cardToView.get(card).rootElement.remove(); 187 | boardView.cardToView.delete(card); 188 | } 189 | 190 | function deselectAll() { 191 | deselectCards(); 192 | deselectGroup(); 193 | deselectLink(); 194 | updateToolbar(); 195 | } 196 | 197 | function deselectCards() { 198 | selectedCards.forEach((card) => boardView.cardToView.get(card).setSelected(false)); 199 | selectedCards.clear(); 200 | 201 | updateToolbar(); 202 | cardEditor.close(); 203 | } 204 | 205 | function deselectGroup() { 206 | selectedGroups.forEach((group) => boardView.groupToView.get(group).setHighlight(false)); 207 | selectedGroups.length = 0; 208 | updateToolbar(); 209 | groupEditor.close(); 210 | } 211 | 212 | function selectGroupCards() { 213 | getCardsByIds(selectedGroups[0].cards).forEach(selectCard); 214 | } 215 | 216 | function deleteSelectedGroup() { 217 | deleteGroup(selectedGroups.shift()); 218 | deselectGroup(); 219 | } 220 | 221 | function deleteGroup(group) { 222 | arrayDiscard(boardView.projectData.groups, group); 223 | boardView.groupToView.get(group).dispose(); 224 | boardView.groupToView.delete(group); 225 | } 226 | 227 | function deselectLink() { 228 | selectedLinks.forEach((link) => boardView.linkToView.get(link).setHighlight(false)); 229 | selectedLinks.length = 0; 230 | updateToolbar(); 231 | linkEditor.close(); 232 | } 233 | 234 | function selectLinkCards() { 235 | const cards = [selectedLinks[0].cardA, selectedLinks[0].cardB]; 236 | getCardsByIds(cards).forEach(selectCard); 237 | } 238 | 239 | function deleteSelectedLink() { 240 | dataManager.makeCheckpoint(); 241 | deleteLink(selectedLinks.shift()); 242 | deselectLink(); 243 | } 244 | 245 | function deleteLink(link) { 246 | arrayDiscard(boardView.projectData.links, link); 247 | boardView.linkToView.get(link).dispose(); 248 | boardView.linkToView.delete(link); 249 | } 250 | 251 | function closeEditor() { 252 | cardEditor.close(); 253 | } 254 | 255 | function selectCard(card) { 256 | if (selectedCards.size === 0) { 257 | switchTab("sidebar/selection"); 258 | } 259 | 260 | selectedCards.add(card); 261 | boardView.cardToView.get(card).setSelected(true); 262 | 263 | deselectGroup(); 264 | deselectLink(); 265 | updateToolbar(); 266 | 267 | cardEditor.openMany(Array.from(selectedCards)); 268 | } 269 | 270 | function deselectCard(card) { 271 | selectedCards.delete(card); 272 | boardView.cardToView.get(card).setSelected(false); 273 | 274 | updateToolbar(); 275 | cardEditor.openMany(Array.from(selectedCards)); 276 | } 277 | 278 | function beginLink() { 279 | if (selectedCards.size !== 1) return; 280 | linking = Array.from(selectedCards)[0]; 281 | updateToolbar(); 282 | } 283 | 284 | /** @param {DominoDataCard} card */ 285 | function selectCardToggle(card) { 286 | if (linking) { 287 | const link = { cardA: linking.id, cardB: card.id, color: 'black' }; 288 | dataManager.makeCheckpoint(); 289 | boardView.projectData.links.push(link); 290 | linking = undefined; 291 | refreshLink(link); 292 | selectLinks([link]); 293 | } else if (selectedGroups.length > 0) { 294 | const group = selectedGroups[0]; 295 | if (!arrayDiscard(group.cards, card.id)) group.cards.push(card.id); 296 | refreshGroup(group); 297 | updateToolbar(); 298 | } else { 299 | if (selectedCards.has(card)) deselectCard(card); 300 | else selectCard(card); 301 | } 302 | } 303 | 304 | function deselectAll() { 305 | deselectCards(); 306 | deselectGroup(); 307 | deselectLink(); 308 | } 309 | 310 | function cycleGroup() { 311 | const current = selectedGroups.shift(); 312 | selectedGroups.push(current); 313 | boardView.groupToView.get(current).setHighlight(false); 314 | boardView.groupToView.get(selectedGroups[0]).setHighlight(true); 315 | updateToolbar(); 316 | } 317 | 318 | /** @param {DominoDataGroup[]} groups */ 319 | function selectGroups(groups) { 320 | if (selectedGroups.length === 0) switchTab("sidebar/selection"); 321 | 322 | const combined = new Set([...groups, ...selectedGroups]); 323 | const same = combined.size === selectedGroups.length && combined.size === groups.length; 324 | 325 | if (same) { 326 | cycleGroup(); 327 | } else { 328 | const prev = selectedGroups[0]; 329 | 330 | deselectAll(); 331 | selectedGroups.push(...groups); 332 | boardView.groupToView.get(selectedGroups[0]).setHighlight(true); 333 | updateToolbar(); 334 | 335 | if (prev === selectedGroups[0]) cycleGroup(); 336 | } 337 | 338 | groupEditor.openGroups([selectedGroups[0]]); 339 | } 340 | 341 | function cycleLink() { 342 | const current = selectedLinks.shift(); 343 | selectedLinks.push(current); 344 | boardView.linkToView.get(current).setHighlight(false); 345 | boardView.linkToView.get(selectedLinks[0]).setHighlight(true); 346 | updateToolbar(); 347 | } 348 | 349 | /** @param {DominoDataLink[]} links */ 350 | function selectLinks(links) { 351 | if (selectedLinks.length === 0) switchTab("sidebar/selection"); 352 | 353 | const combined = new Set([...links, ...selectedLinks]); 354 | const same = combined.size === selectedLinks.length && combined.size === links.length; 355 | 356 | if (same) { 357 | cycleLink(); 358 | } else { 359 | const prev = selectedLinks[0]; 360 | 361 | deselectAll(); 362 | selectedLinks.push(...links); 363 | boardView.linkToView.get(selectedLinks[0]).setHighlight(true); 364 | updateToolbar(); 365 | 366 | if (prev === selectedLinks[0]) cycleLink(); 367 | } 368 | 369 | linkEditor.openLinks([selectedLinks[0]]); 370 | } 371 | 372 | function centerSelection() { 373 | centerCards(Array.from(selectedCards)); 374 | } 375 | 376 | function centerCards(cards) { 377 | scene.locked = true; 378 | animateElementTransform(scene.container, .2).then(() => scene.locked = false); 379 | const rect = boundCards(cards); 380 | padRect(rect, 64); 381 | scene.frameRect(rect, .25, 1); 382 | 383 | //if (cards.length === 1) window.location.replace("#" + cards[0].id); 384 | } 385 | 386 | function groupSelection() { 387 | dataManager.makeCheckpoint(); 388 | const cards = Array.from(selectedCards).map((card) => card.id); 389 | const color = `rgb(${randomInt(0, 255)} ${randomInt(0, 255)} ${randomInt(0, 255)})`; 390 | const group = { cards, color }; 391 | boardView.projectData.groups.push(group); 392 | refreshGroup(group); 393 | selectGroups([group]); 394 | } 395 | 396 | /** @type {Map} */ 397 | const svgToGroup = new Map(); 398 | /** @type {Map} */ 399 | const svgToLink = new Map(); 400 | 401 | function dragGroups(event) { 402 | dataManager.makeCheckpoint(); 403 | const overlapping = document.elementsFromPoint(event.clientX, event.clientY); 404 | const svgs = overlapping.map((overlap) => overlap.closest("svg")).filter((svg) => svg !== null); 405 | const groups = new Set(svgs.map((svg) => svgToGroup.get(svg)).filter((group) => group !== undefined)); 406 | 407 | groups.forEach((group) => { 408 | getCardsByIds(group.cards).forEach((card) => { 409 | boardView.cardToView.get(card).startDrag(event); 410 | }); 411 | }); 412 | selectGroups(Array.from(groups)); 413 | } 414 | 415 | function dragLinks(event) { 416 | dataManager.makeCheckpoint(); 417 | const overlapping = document.elementsFromPoint(event.clientX, event.clientY); 418 | const svgs = overlapping.map((overlap) => overlap.closest("svg")).filter((svg) => svg !== null); 419 | const links = new Set(svgs.map((svg) => svgToLink.get(svg)).filter((link) => link !== undefined)); 420 | 421 | links.forEach((link) => { 422 | getCardsByIds([link.cardA, link.cardB]).forEach((card) => { 423 | boardView.cardToView.get(card).startDrag(event); 424 | }); 425 | }); 426 | selectLinks(Array.from(links)); 427 | } 428 | 429 | function onCardMoved(card) { 430 | boardView.projectData.groups.forEach((group) => { 431 | const view = boardView.groupToView.get(group) || new DominoGroupView(group); 432 | boardView.groupToView.set(group, view); 433 | if (group.cards.includes(card.id)) view.regenerateSVG(); 434 | }); 435 | 436 | boardView.projectData.links.forEach((link) => { 437 | const view = boardView.linkToView.get(link) || new DominoLinkView(link); 438 | boardView.linkToView.set(link, view); 439 | if (link.cardA === card.id || link.cardB === card.id) view.regenerateSVG(); 440 | }); 441 | } 442 | 443 | function refreshGroup(group) { 444 | const view = boardView.groupToView.get(group) || new DominoGroupView(group); 445 | boardView.groupToView.set(group, view); 446 | view.regenerateSVG(); 447 | } 448 | 449 | function refreshLink(link) { 450 | const view = boardView.linkToView.get(link) || new DominoLinkView(link); 451 | boardView.linkToView.set(link, view); 452 | view.regenerateSVG(); 453 | } 454 | 455 | function refreshSVGs() { 456 | boardView.projectData.groups.forEach(refreshGroup); 457 | boardView.projectData.links.forEach(refreshLink); 458 | } 459 | 460 | const cardStyleVariables = ["card-color", "text-font", "text-size", "text-color"]; 461 | /** @param {DominoDataCardStyle} style */ 462 | function cardStyleToCss(style) { 463 | const declarations = []; 464 | cardStyleVariables.forEach((name) => { 465 | const value = style.properties[name]; 466 | if (value) declarations.push(`--${name}: ${value};`); 467 | }) 468 | 469 | if (style.properties["text-center"]) declarations.push("--text-align: center;"); 470 | 471 | const rules = [ 472 | `.style-${style.id} { ${declarations.join(" ")} }`, 473 | ]; 474 | 475 | if (style.properties["icon-hide-empty"]) { 476 | rules.push(`.style-${style.id} .blank { display: none; }`); 477 | } 478 | 479 | // custom css prefix 480 | if (style.properties["custom-css"]) { 481 | const doc = document.implementation.createHTMLDocument(""); 482 | const styleElement = document.createElement("style"); 483 | styleElement.textContent = style.properties["custom-css"]; 484 | doc.body.appendChild(styleElement); 485 | 486 | Array.from(styleElement.sheet.cssRules).forEach((rule) => { 487 | if (rule instanceof CSSStyleRule) { 488 | const selectors = rule.selectorText.split(","); 489 | const prefixed = selectors.map((selector) => { 490 | if (selector.startsWith(".card-root")) { 491 | return `.style-${style.id}${selector}`; 492 | } else { 493 | return `.style-${style.id} ${selector}`; 494 | } 495 | }).join(","); 496 | rule.selectorText = prefixed; 497 | rules.push(rule.cssText); 498 | } 499 | }); 500 | } 501 | 502 | return rules.join("\n"); 503 | } 504 | 505 | function refreshCardStyles() { 506 | const element = document.getElementById("card-styles"); 507 | const styles = boardView.projectData.cardStyles.map(cardStyleToCss); 508 | element.innerHTML = styles.join("\n"); 509 | cardStyleEditor.pullData(); 510 | } 511 | 512 | function refreshBoardStyle() { 513 | const style = boardView.projectData.boardStyle; 514 | const bgcolor = style["background-color"]; 515 | 516 | if (bgcolor) { 517 | ONE("#frame").style.setProperty("background-color", bgcolor); 518 | } 519 | } 520 | 521 | /** @param {DominoDataCard} card */ 522 | function boundCard(card) { 523 | return new DOMRect( 524 | card.position.x, 525 | card.position.y, 526 | gridSize(card.size.x, cellWidth2, cellGap), 527 | gridSize(card.size.y, cellHeight2, cellGap), 528 | ); 529 | } 530 | 531 | /** @param {DominoDataCard} card */ 532 | function cardCenter(card) { 533 | return getRectCenter(boundCard(card)); 534 | } 535 | 536 | /** @param {DominoDataCard[]} cards */ 537 | function boundCards(cards) { 538 | return boundRects(cards.map(boundCard)); 539 | } 540 | 541 | function gridSize(cells, cellWidth, cellGap) { 542 | return cellWidth + (cells - 1) * (cellWidth + cellGap); 543 | } 544 | 545 | function getCardFromId(cardId) { 546 | return boardView.projectData.cards.find((card) => card.id === cardId); 547 | } 548 | 549 | function runCardAction(action) { 550 | if (action.startsWith('#')) { 551 | const ids = action.slice(1).split(","); 552 | const cards = getCardsByIds(ids); 553 | 554 | if (cards.length > 0) { 555 | window.location.replace('#' + ids.join(",")); 556 | centerCards(cards); 557 | } 558 | } else if (action.startsWith('open:')) { 559 | window.open(action.slice(5)); 560 | } else if (action.length > 0) { 561 | window.open(action); 562 | } 563 | } 564 | 565 | class DominoLinkView { 566 | /** @param {DominoDataLink} link */ 567 | constructor(link) { 568 | this.link = link; 569 | this.root = svg("svg", { class: "link" }); 570 | this.selected = false; 571 | 572 | const background = document.getElementById("svgs"); 573 | background.appendChild(this.root); 574 | 575 | svgToLink.set(this.root, this.link); 576 | this.root.addEventListener("pointerdown", (event) => { 577 | if (!boardView.editable || event.button !== 0) return; 578 | killEvent(event); 579 | dragLinks(event); 580 | }); 581 | } 582 | 583 | dispose() { 584 | this.root.remove(); 585 | } 586 | 587 | setHighlight(value) { 588 | this.selected = value; 589 | 590 | if (this.selectElement) { 591 | this.selectElement.style.display = value ? "unset" : "none"; 592 | } 593 | } 594 | 595 | regenerateSVG() { 596 | while (this.root.children.length > 0) this.root.children[0].remove(); 597 | const [cardA, cardB] = [getCardFromId(this.link.cardA), getCardFromId(this.link.cardB)]; 598 | 599 | const { x, y, width, height } = boundCards([cardA, cardB]); 600 | const rect = { x, y, width, height }; 601 | 602 | const { x: x1, y: y1 } = cardCenter(cardA); 603 | const { x: x2, y: y2 } = cardCenter(cardB); 604 | const line = { x1, y1, x2, y2 }; 605 | 606 | padRect(rect, 8); 607 | const main = svg("line", { ...line, stroke: this.link.color }); 608 | 609 | //padRect(rect, 8); 610 | this.selectElement = svg("line", {...line, "class": "selection-flash" }); 611 | 612 | this.root.appendChild(this.selectElement); 613 | this.root.appendChild(main); 614 | 615 | { 616 | const rect = this.root.getBBox(); 617 | padRect(rect, 16); 618 | const { x, y, width, height } = rect; 619 | this.root.setAttributeNS(null, "width", width.toString()); 620 | this.root.setAttributeNS(null, "height", height.toString()); 621 | this.root.setAttributeNS(null, "viewBox", `${x} ${y} ${width} ${height}`); 622 | this.root.setAttributeNS(null, "transform", translationMatrix({ x, y }).toString()); 623 | } 624 | 625 | this.setHighlight(this.selected); 626 | } 627 | } 628 | 629 | class DominoGroupView { 630 | /** 631 | * @param {DominoDataGroup} group 632 | */ 633 | constructor(group) { 634 | this.group = group; 635 | this.root = svg("svg", { class: "group" }); 636 | this.selected = false; 637 | 638 | const background = document.getElementById("svgs"); 639 | background.appendChild(this.root); 640 | svgToGroup.set(this.root, this.group); 641 | this.root.addEventListener("pointerdown", (event) => { 642 | if (!boardView.editable || event.button !== 0) return; 643 | killEvent(event); 644 | dragGroups(event); 645 | }); 646 | } 647 | 648 | dispose() { 649 | this.root.remove(); 650 | } 651 | 652 | setHighlight(value) { 653 | this.selected = value; 654 | 655 | if (this.selectElement) { 656 | this.selectElement.style.display = value ? "unset" : "none"; 657 | } 658 | } 659 | 660 | regenerateSVG() { 661 | while (this.root.children.length > 0) this.root.children[0].remove(); 662 | 663 | const { x, y, width, height } = boundCards(getGroupCards(this.group)); 664 | const rect = { x, y, width, height }; 665 | 666 | padRect(rect, 8); 667 | const backing = svg("rect", { ...rect, rx: 16, fill: this.group.color }); 668 | 669 | padRect(rect, 8); 670 | this.selectElement = svg("rect", {...rect, rx: 24, fill: "gray", "class": "selection-flash" }); 671 | 672 | this.root.appendChild(this.selectElement); 673 | this.root.appendChild(backing); 674 | 675 | { 676 | const { x, y, width, height } = this.root.getBBox(); 677 | this.root.setAttributeNS(null, "width", width.toString()); 678 | this.root.setAttributeNS(null, "height", height.toString()); 679 | this.root.setAttributeNS(null, "viewBox", `${x} ${y} ${width} ${height}`); 680 | this.root.setAttributeNS(null, "transform", translationMatrix({ x, y }).toString()); 681 | } 682 | 683 | this.setHighlight(this.selected); 684 | } 685 | } 686 | 687 | /** 688 | * @param {DominoDataCard} card 689 | */ 690 | function duplicateCard(card) { 691 | const copy = COPY(card); 692 | copy.id = nanoid(); 693 | insertCard(scene, copy); 694 | return copy; 695 | } 696 | 697 | class DominoCardView { 698 | /** 699 | * @param {PanningScene} scene 700 | */ 701 | constructor(scene) { 702 | this.scene = scene; 703 | this.textElement = html("div", { class: "card-text" }); 704 | const resize = svg("svg", { class: "resize-handle" }, svg("polygon", { points: "0,32 32,32 32,0" })); 705 | this.bodyElement = html("div", { class: "card-body" }, this.textElement, resize); 706 | this.iconElements = [0, 1, 2, 3].map((i) => html("a")); 707 | const iconbar = html("div", { class: "card-icon-bar" }, ...this.iconElements); 708 | this.rootElement = html("div", { class: "card-root" }, this.bodyElement, iconbar); 709 | 710 | this.iconElements.forEach((icon, index) => { 711 | icon.addEventListener("click", (event) => this.onIconClicked(event, index)); 712 | icon.addEventListener('pointerdown', e => e.stopPropagation()); 713 | icon.addEventListener('dblclick', e => e.stopPropagation()); 714 | }); 715 | 716 | ONE("#cards").appendChild(this.rootElement); 717 | 718 | listen(resize.children[0], "pointerdown", (event) => { 719 | if (!boardView.editable || event.button !== 0) return; 720 | killEvent(event); 721 | this.startResize(event); 722 | }); 723 | 724 | listen(this.rootElement, "pointerdown", (event) => { 725 | if (!boardView.editable || event.button !== 0) return; 726 | killEvent(event); 727 | 728 | const duplicate = event.ctrlKey === true; 729 | const selected = selectedCards.has(this.card); 730 | const targets = selected ? Array.from(selectedCards) : [this.card]; 731 | const drags = []; 732 | 733 | if (duplicate) { 734 | dataManager.makeCheckpoint(); 735 | const copies = targets.map(duplicateCard); 736 | if (selected) { 737 | deselectAll(); 738 | copies.forEach(selectCard); 739 | } 740 | drags.push(...copies.map((card) => boardView.cardToView.get(card).startDrag(event))); 741 | drags[0].on("click", (event) => copies.map(deleteCard)); 742 | } else { 743 | dataManager.markDirty(`selected/position`); 744 | drags.push(...targets.map((card) => boardView.cardToView.get(card).startDrag(event))); 745 | } 746 | 747 | drags[0].on("click", (event) => selectCardToggle(this.card)); 748 | }); 749 | 750 | listen(this.rootElement, "dblclick", (event) => { 751 | killEvent(event); 752 | 753 | if (!boardView.editable) { 754 | centerCards([this.card]); 755 | } else { 756 | deselectCards(); 757 | selectCard(this.card); 758 | 759 | invokeAction("global-editor/open"); 760 | switchTab("sidebar/selection"); 761 | } 762 | }); 763 | } 764 | 765 | dispose() { 766 | this.rootElement.remove(); 767 | } 768 | 769 | /** 770 | * @param {DominoDataCard} card 771 | */ 772 | setCard(card) { 773 | this.card = card; 774 | this.regenerate(); 775 | } 776 | 777 | /** @param {string} value */ 778 | setCursor(value) { 779 | this.rootElement.style.cursor = value; 780 | } 781 | 782 | /** @param {DOMMatrix} transform */ 783 | setTransform(transform) { 784 | this.card.position = getMatrixTranslation(transform); 785 | setElementTransform(this.rootElement, transform); 786 | } 787 | 788 | /** @param {boolean} selected */ 789 | setSelected(selected) { 790 | this.rootElement.classList.toggle("selected", selected); 791 | } 792 | 793 | regenerate() { 794 | if (!this.card) return; 795 | setElementTransform(this.rootElement, translationMatrix(this.card.position)); 796 | this.textElement.innerHTML = parseFakedown(this.card.text); 797 | 798 | const bounds = boundCard(this.card); 799 | this.rootElement.style.width = `${bounds.width}px`; 800 | this.rootElement.style.height = `${bounds.height}px`; 801 | this.rootElement.setAttribute("class", "card-root " + "style-" + this.card.style ?? ""); 802 | this.setSelected(selectedCards.has(this.card)); 803 | 804 | this.card.icons.forEach((data, i) => { 805 | const element = this.iconElements[i]; 806 | element.innerHTML = data.icon; 807 | 808 | if (data.action === "") { 809 | element.removeAttribute("href"); 810 | } else { 811 | element.href = data.action; 812 | } 813 | 814 | element.classList.toggle('blank', data.icon === ''); 815 | element.classList.toggle('cosmetic', data.action === ''); 816 | }); 817 | 818 | if (this.card.image) { 819 | this.bodyElement.style.setProperty('background-image', `url(${this.card.image})`); 820 | } 821 | 822 | this.bodyElement.classList.toggle('has-image', !!this.card.image); 823 | } 824 | 825 | /** 826 | * @param {MouseEvent} event 827 | * @param {number} index 828 | */ 829 | onIconClicked(event, index) { 830 | killEvent(event); 831 | runCardAction(this.card.icons[index].action); 832 | } 833 | 834 | /** @param {PointerEvent} event */ 835 | startResize(event) { 836 | function fit(value, cellSize, cellGap) { 837 | let cells; 838 | for (cells = 1; gridSize(cells, cellSize, cellGap) < value - cellGap; ++cells); 839 | return cells; 840 | } 841 | 842 | const { x: x1, y: y1 } = getMatrixTranslation(this.scene.mouseEventToSceneTransform(event)); 843 | const [w1, h1] = [this.rootElement.clientWidth, this.rootElement.clientHeight]; 844 | 845 | // create target shadow 846 | const target = html("div", { class: "target" }); 847 | this.scene.container.appendChild(target); 848 | setElementTransform(target, translationMatrix(this.card.position)); 849 | 850 | const gesture = trackGesture(event); 851 | gesture.on("pointermove", (event) => { 852 | const { x: x2, y: y2 } = getMatrixTranslation(this.scene.mouseEventToSceneTransform(event)); 853 | const [dx, dy] = [x2 - x1, y2 - y1]; 854 | const [w2, h2] = [w1 + dx, h1 + dy]; 855 | 856 | this.card.size.x = Math.max(2, fit(w2, cellWidth2, cellGap)); 857 | this.card.size.y = Math.max(2, fit(h2, cellHeight2, cellGap)); 858 | 859 | this.rootElement.style.width = `${w2}px`; 860 | this.rootElement.style.height = `${h2}px`; 861 | 862 | const bounds = boundCard(this.card); 863 | target.style.width = `${bounds.width}px`; 864 | target.style.height = `${bounds.height}px`; 865 | onCardMoved(this.card); 866 | }); 867 | gesture.on("pointerup", (event) => { 868 | const bounds = boundCard(this.card); 869 | this.rootElement.style.width = `${bounds.width}px`; 870 | this.rootElement.style.height = `${bounds.height}px`; 871 | 872 | // snap card to grid 873 | animateElementSize(this.rootElement, .1).then(() => target.remove()); 874 | target.remove(); 875 | 876 | onCardMoved(this.card); 877 | }); 878 | gesture.emit("pointermove", event); 879 | } 880 | 881 | /** @param {PointerEvent} event */ 882 | startDrag(event) { 883 | // determine and save the relationship between mouse and element 884 | // G = M1^ . E (element relative to mouse) 885 | const mouse = this.scene.mouseEventToSceneTransform(event); 886 | const grab = mouse.invertSelf().multiplySelf(translationMatrix(this.card.position)); 887 | 888 | const initialPosition = this.card.position; 889 | 890 | // create target shadow 891 | const target = html("div", { class: "target" }); 892 | this.scene.container.appendChild(target); 893 | setElementTransform(target, translationMatrix(this.card.position)); 894 | const bounds = boundCard(this.card); 895 | target.style.width = `${bounds.width}px`; 896 | target.style.height = `${bounds.height}px`; 897 | 898 | const drag = trackGesture(event); 899 | drag.on("pointermove", (event) => { 900 | // preserve the relationship between mouse and element 901 | // D2 = M2 . G (drawing relative to scene) 902 | const mouse = this.scene.mouseEventToSceneTransform(event); 903 | const transform = mouse.multiply(grab); 904 | 905 | // card drags free from the grid 906 | this.setTransform(transform); 907 | // target shadow snaps to grid as card would 908 | gridSnap(transform); 909 | setElementTransform(target, transform); 910 | 911 | // TODO: this has gotta have a bigger system for updating 912 | // on drag and during snap animation etc 913 | onCardMoved(this.card); 914 | }); 915 | drag.on("pointerup", (event) => { 916 | const mouse = this.scene.mouseEventToSceneTransform(event); 917 | const transform = mouse.multiply(grab); 918 | 919 | // snap card to grid 920 | animateElementTransform(this.rootElement, .1).then(() => target.remove()); 921 | gridSnap(transform); 922 | this.setTransform(transform); 923 | 924 | this.setCursor("grab"); 925 | 926 | // TODO: 927 | onCardMoved(this.card); 928 | 929 | if (this.card.position.x === initialPosition.x && this.card.position.y === initialPosition.y) { 930 | dataManager.cancelDirty(`selected/position`); 931 | } else { 932 | dataManager.confirmDirty(`selected/position`); 933 | } 934 | }); 935 | 936 | this.setCursor("grabbing"); 937 | 938 | return drag; 939 | } 940 | } 941 | 942 | /** @param {DOMMatrix} transform */ 943 | function snap(transform, gx = 1, gy = gx) { 944 | transform.e = Math.round(transform.e / gx) * gx; 945 | transform.f = Math.round(transform.f / gy) * gy; 946 | } 947 | 948 | function gridSnap(transform) { 949 | return snap(transform, cellWidth/2, cellHeight/2); 950 | } 951 | 952 | /** 953 | * @param {HTMLElement} element 954 | * @param {DOMMatrixReadOnly} transform 955 | */ 956 | function setElementTransform(element, transform) { 957 | element.style.setProperty("transform", transform.toString()); 958 | } 959 | 960 | /** 961 | * @param {HTMLElement} element 962 | * @param {number} duration 963 | */ 964 | async function animateElementTransform(element, duration) { 965 | element.style.transition = `transform ${duration}s ease-in-out`; 966 | await sleep(duration * 1000); 967 | element.style.transition = "none"; 968 | } 969 | 970 | /** 971 | * @param {HTMLElement} element 972 | * @param {number} duration 973 | */ 974 | async function animateElementSize(element, duration) { 975 | element.style.transition = `width ${duration}s ease-in-out, height ${duration}s ease-in-out`; 976 | await sleep(duration * 1000); 977 | element.style.transition = "none"; 978 | } 979 | 980 | /** 981 | * @param {PointerEvent} event 982 | */ 983 | function trackGesture(event) { 984 | const emitter = new EventEmitter(); 985 | const pointer = event.pointerId; 986 | 987 | const clickMovementLimit = 5; 988 | let totalMovement = 0; 989 | 990 | const removes = [ 991 | listen(document, "pointerup", (event) => { 992 | if (event.pointerId !== pointer) return; 993 | 994 | removes.forEach((remove) => remove()); 995 | emitter.emit("pointerup", event); 996 | if (totalMovement <= clickMovementLimit) emitter.emit("click", event); 997 | }), 998 | listen(document, "pointermove", (event) => { 999 | if (event.pointerId !== pointer) return; 1000 | 1001 | totalMovement += Math.abs(event.movementX); 1002 | totalMovement += Math.abs(event.movementY); 1003 | emitter.emit("pointermove", event); 1004 | }), 1005 | ]; 1006 | 1007 | return emitter; 1008 | } 1009 | 1010 | class PointerDrag { 1011 | /** 1012 | * @param {PointerEvent} event 1013 | */ 1014 | constructor(event, { clickMovementLimit = 5 } = {}) { 1015 | this.events = new EventEmitter(); 1016 | this.pointerId = event.pointerId; 1017 | this.clickMovementLimit = 5; 1018 | this.totalMovement = 0; 1019 | 1020 | this.downEvent = event; 1021 | this.lastEvent = event; 1022 | 1023 | this.removes = [ 1024 | listen(document, "pointerup", (event) => { 1025 | if (event.pointerId !== this.pointerId) return; 1026 | 1027 | this.lastEvent = event; 1028 | this.removes.forEach((remove) => remove()); 1029 | this.events.emit("pointerup", event); 1030 | if (this.totalMovement <= clickMovementLimit) { 1031 | this.events.emit("click", event); 1032 | } 1033 | }), 1034 | listen(document, "pointermove", (event) => { 1035 | if (event.pointerId !== this.pointerId) return; 1036 | 1037 | this.totalMovement += Math.abs(event.movementX); 1038 | this.totalMovement += Math.abs(event.movementY); 1039 | this.lastEvent = event; 1040 | this.events.emit("pointermove", event); 1041 | }), 1042 | ]; 1043 | } 1044 | 1045 | cancel() { 1046 | this.removes.forEach((remove) => remove()); 1047 | } 1048 | } 1049 | 1050 | const imageSize = [512, 512]; 1051 | 1052 | async function fileToCompressedImageURL(file) { 1053 | const url = await dataURLFromFile(file); 1054 | const dataURL = await compressImageURL(url, 0.2, imageSize); 1055 | return dataURL; 1056 | } 1057 | 1058 | async function dataTransferToImage(dt) { 1059 | const files = filesFromDataTransfer(dt); 1060 | const element = elementFromDataTransfer(dt); 1061 | if (files.length > 0) { 1062 | return await fileToCompressedImageURL(files[0]); 1063 | } else if (element && element.nodeName === 'IMG') { 1064 | return await compressImageURL(element.src, .2, imageSize); 1065 | } 1066 | } 1067 | 1068 | function filesFromDataTransfer(dataTransfer) { 1069 | const clipboardFiles = 1070 | Array.from(dataTransfer.items || []) 1071 | .filter(item => item.kind === 'file') 1072 | .map(item => item.getAsFile()); 1073 | return clipboardFiles.concat(...(dataTransfer.files || [])); 1074 | } 1075 | 1076 | function elementFromDataTransfer(dataTransfer) { 1077 | const html = dataTransfer.getData('text/html'); 1078 | return html && stringToElement(html); 1079 | } 1080 | 1081 | async function compressImageURL(url, quality, size) { 1082 | const image = document.createElement("img"); 1083 | image.crossOrigin = "true"; 1084 | const canvas = document.createElement("canvas"); 1085 | 1086 | const [tw, th] = size; 1087 | canvas.width = tw; 1088 | canvas.height = th; 1089 | 1090 | return new Promise((resolve, reject) => { 1091 | image.onload = () => { 1092 | const scale = Math.min(tw / image.width, th / image.height); 1093 | canvas.width = image.width * scale; 1094 | canvas.height = image.height * scale; 1095 | const context = canvas.getContext('2d'); 1096 | context.drawImage(image, 0, 0, canvas.width, canvas.height); 1097 | const url = canvas.toDataURL('image/jpeg', quality); 1098 | 1099 | resolve(url); 1100 | }; 1101 | image.onerror = () => resolve(undefined); 1102 | image.src = url; 1103 | }); 1104 | } 1105 | 1106 | function stringToDocument(string) { 1107 | const template = document.createElement('template'); 1108 | template.innerHTML = string; 1109 | return template.content; 1110 | } 1111 | 1112 | function stringToElement(string) { 1113 | return stringToDocument(string).children[0]; 1114 | } 1115 | 1116 | class DominoProjectManager { 1117 | get data() { 1118 | return this.history[this.index]; 1119 | } 1120 | 1121 | get canUndo() { 1122 | return this.index > 0 || this.dirty; 1123 | } 1124 | 1125 | get canRedo() { 1126 | return this.index < this.history.length - 1 && !this.dirty; 1127 | } 1128 | 1129 | constructor() { 1130 | /** @type {DominoDataProject[]} */ 1131 | this.history = []; 1132 | this.index = -1; 1133 | this.historyLimit = 20; 1134 | this.dirty = undefined; 1135 | } 1136 | 1137 | /** 1138 | * @param {DominoDataProject} data 1139 | */ 1140 | reset(data) { 1141 | deselectAll(); 1142 | 1143 | this.history.length = 0; 1144 | this.history.push(data); 1145 | this.index = 0; 1146 | boardView.loadProject(this.data); 1147 | } 1148 | 1149 | markDirty(path="generic") { 1150 | if (path === this.dirty) return; 1151 | this.makeCheckpoint(); 1152 | this.dirty = path; 1153 | } 1154 | 1155 | cancelDirty(path) { 1156 | if (path !== this.dirty) return; 1157 | this.history.splice(this.index, 1); 1158 | this.index -= 1; 1159 | this.dirty = undefined; 1160 | updateToolbar(); 1161 | } 1162 | 1163 | confirmDirty(path) { 1164 | if (path !== this.dirty) return; 1165 | this.dirty = undefined; 1166 | updateToolbar(); 1167 | } 1168 | 1169 | makeCheckpoint() { 1170 | this.dirty = undefined; 1171 | this.history.length = this.index + 1; 1172 | 1173 | this.history[this.index] = COPY(boardView.projectData); 1174 | this.history.push(boardView.projectData); 1175 | 1176 | if (this.index < this.historyLimit) { 1177 | this.index += 1; 1178 | } else { 1179 | // delete earliest history 1180 | this.history.splice(0, 1); 1181 | } 1182 | 1183 | updateToolbar(); 1184 | } 1185 | 1186 | undo() { 1187 | if (!this.canUndo) return; 1188 | this.index -= 1; 1189 | deselectAll(); 1190 | boardView.loadProject(this.data); 1191 | this.dirty = undefined; 1192 | } 1193 | 1194 | redo() { 1195 | if (!this.canRedo) return; 1196 | this.index += 1; 1197 | deselectAll(); 1198 | boardView.loadProject(this.data); 1199 | this.dirty = undefined; 1200 | } 1201 | } 1202 | 1203 | async function downloadGoogleFont(url) { 1204 | const face = await fetch(url).then((r) => r.text()); 1205 | const [, srcURL] = face.match(/url\((http.*woff2)\)/); 1206 | const [, family] = face.match(/font-family: ['"](.*)["'];/); 1207 | const dataURL = await fetch(srcURL).then((r) => r.blob()).then(dataURLFromFile); 1208 | return { family, css: face.replace(srcURL, dataURL) }; 1209 | } 1210 | 1211 | async function replaceFont(url) { 1212 | const { family, css } = await downloadGoogleFont(url); 1213 | ONE("#active-font").textContent = css; 1214 | document.body.style.fontFamily = family; 1215 | } 1216 | --------------------------------------------------------------------------------