├── .gitignore ├── LICENSE.txt ├── README.md ├── documentation ├── DocumentationPlugin.d.ts ├── editors │ └── documentation │ │ ├── index.pug │ │ ├── index.styl │ │ └── index.ts ├── index.d.ts ├── package-lock.json ├── package.json ├── public │ ├── editors │ │ └── documentation │ │ │ ├── icon.svg │ │ │ └── manifest.json │ └── locales │ │ ├── en │ │ └── plugin.json │ │ ├── pt-BR │ │ └── plugin.json │ │ ├── ru │ │ └── plugin.json │ │ ├── sv │ │ └── plugin.json │ │ ├── th │ │ └── plugin.json │ │ └── zh-TW │ │ └── plugin.json └── tsconfig.json ├── home ├── editors │ └── main │ │ ├── index.pug │ │ ├── index.styl │ │ ├── index.ts │ │ └── links.ts ├── index.d.ts ├── package.json ├── public │ ├── editors │ │ └── main │ │ │ ├── icon.svg │ │ │ └── manifest.json │ └── locales │ │ ├── en │ │ ├── home.json │ │ └── plugin.json │ │ ├── it │ │ ├── home.json │ │ └── plugin.json │ │ ├── pt-BR │ │ ├── home.json │ │ └── plugin.json │ │ ├── ru │ │ ├── home.json │ │ └── plugin.json │ │ └── zh-TW │ │ ├── home.json │ │ └── plugin.json └── tsconfig.json ├── settings ├── editors │ └── settings │ │ ├── index.pug │ │ ├── index.styl │ │ └── index.ts ├── index.d.ts ├── package.json ├── public │ ├── editors │ │ └── settings │ │ │ ├── icon.svg │ │ │ └── manifest.json │ └── locales │ │ ├── en │ │ ├── plugin.json │ │ └── settingsEditors.json │ │ ├── it │ │ ├── plugin.json │ │ └── settingsEditors.json │ │ ├── pt-BR │ │ ├── plugin.json │ │ └── settingsEditors.json │ │ └── ru │ │ ├── plugin.json │ │ └── settingsEditors.json ├── settingsEditors │ └── SettingsEditorPlugin.d.ts └── tsconfig.json ├── textEditorWidget ├── data │ ├── TextEditorSettingsResource.ts │ ├── index.ts │ └── textEditorUserSettings.ts ├── index.d.ts ├── operational-transform.d.ts ├── package-lock.json ├── package.json ├── public │ └── locales │ │ ├── en │ │ └── settingsEditors.json │ │ └── pt-BR │ │ └── settingsEditors.json ├── settingsEditors │ ├── TextEditorSettingsEditor.ts │ └── index.ts ├── tsconfig.json ├── widget.d.ts ├── widget │ ├── widget.styl │ └── widget.ts └── widgetGulpfile.js └── three ├── helpers.d.ts ├── helpers ├── GridHelper.ts ├── SelectionBoxRenderer.ts ├── TransformControls.ts ├── TransformGizmos.ts ├── TransformMarker.ts └── index.ts ├── index.d.ts ├── main.d.ts ├── main ├── Camera.ts ├── Camera2DControls.ts ├── Camera3DControls.ts └── main.ts ├── mainGulpfile.js ├── package-lock.json ├── package.json └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/*.html 3 | **/*.css 4 | **/*.js 5 | !**/*Gulpfile.js 6 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Superpowers is distributed under the ISC license, 2 | in the hope of making it as useful as possible for everyone. 3 | https://en.wikipedia.org/wiki/ISC_license 4 | 5 | We are a welcoming community and we'd love to have you contributing! 6 | https://github.com/superpowers 7 | 8 | ------------------------------------------------------------------------------ 9 | 10 | Copyright © 2014-2016, Sparklin Labs 11 | 12 | Permission to use, copy, modify, and/or distribute this software for any 13 | purpose with or without fee is hereby granted, provided that the above 14 | copyright notice and this permission notice appear in all copies. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 17 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 18 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 19 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 20 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION 21 | OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN 22 | CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Common Superpowers plugins 2 | 3 | These plugins are used by most [Superpowers](http://superpowers-html5.com/) systems. 4 | -------------------------------------------------------------------------------- /documentation/DocumentationPlugin.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace SupClient { 2 | // Dummy boolean because we need to register something, 3 | // but really, the plugin's name is all we need. 4 | export type DocumentationPlugin = { isFirstSection: boolean }; 5 | } 6 | -------------------------------------------------------------------------------- /documentation/editors/documentation/index.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title= t("plugin:editors.documentation.title") 5 | link(rel="stylesheet",href="/styles/reset.css") 6 | link(rel="stylesheet",href="/styles/dialogs.css") 7 | link(rel="stylesheet",href="index.css") 8 | 9 | body 10 | nav 11 | ul 12 | 13 | main 14 | 15 | script(src="/SupCore.js") 16 | script(src="/SupClient.js") 17 | script(src="index.js") 18 | -------------------------------------------------------------------------------- /documentation/editors/documentation/index.styl: -------------------------------------------------------------------------------- 1 | body 2 | display flex 3 | 4 | nav 5 | width 250px 6 | overflow-y auto 7 | position fixed 8 | top 0 9 | background #ddd 10 | bottom 0 11 | 12 | ul 13 | list-style none 14 | margin 0 15 | padding 0.5em 16 | 17 | li 18 | padding 0 19 | margin 0 20 | 21 | a 22 | display block 23 | color #444 24 | padding 0.25em 0.5em 25 | text-decoration none 26 | 27 | &:hover 28 | background #ccc 29 | 30 | &:active 31 | color #fff 32 | 33 | &.active 34 | background #444 35 | color #eee 36 | 37 | main 38 | margin-left 250px 39 | padding 1em 40 | flex 1 41 | 42 | article 43 | flex 1 44 | display none 45 | &.active { display: block; } 46 | overflow-y auto 47 | 48 | > *:first-child 49 | margin-top 0 50 | 51 | h1 52 | text-transform uppercase 53 | font-size 2em 54 | h2 55 | font-size 1.5em -------------------------------------------------------------------------------- /documentation/editors/documentation/index.ts: -------------------------------------------------------------------------------- 1 | import * as async from "async"; 2 | import * as marked from "marked"; 3 | 4 | let data: { 5 | projectClient: SupClient.ProjectClient; 6 | }; 7 | 8 | const socket = SupClient.connect(SupClient.query.project); 9 | socket.on("welcome", onWelcome); 10 | socket.on("disconnect", SupClient.onDisconnected); 11 | 12 | let loaded = false; 13 | let initialSection: string; 14 | window.addEventListener("message", (event: any) => { 15 | if (event.data.type === "setState") { 16 | if (!loaded) initialSection = event.data.state.section; 17 | else openDocumentation(event.data.state.section); 18 | } 19 | }); 20 | 21 | function onWelcome() { 22 | data = { projectClient: new SupClient.ProjectClient(socket), }; 23 | 24 | loadPlugins(); 25 | } 26 | 27 | function loadPlugins() { 28 | SupClient.fetch(`/systems/${SupCore.system.id}/plugins.json`, "json", (err: Error, pluginsInfo: SupCore.PluginsInfo) => { 29 | async.each(pluginsInfo.list, (pluginName, cb) => { 30 | const pluginPath = `/systems/${SupCore.system.id}/plugins/${pluginName}`; 31 | SupClient.loadScript(`${pluginPath}/bundles/documentation.js`, cb); 32 | }, (err) => { setupDocs(); }); 33 | }); 34 | } 35 | 36 | const navListElt = document.querySelector("nav ul"); 37 | const mainElt = document.querySelector("main"); 38 | 39 | if (SupApp != null) { 40 | mainElt.addEventListener("click", (event) => { 41 | const target = event.target as HTMLAnchorElement; 42 | if (target.tagName !== "A") return; 43 | 44 | event.preventDefault(); 45 | SupApp.openLink(target.href); 46 | }); 47 | } 48 | 49 | function openDocumentation(name: string) { 50 | (navListElt.querySelector("li a.active") as HTMLAnchorElement).classList.remove("active"); 51 | (mainElt.querySelector("article.active") as HTMLElement).classList.remove("active"); 52 | navListElt.querySelector(`[data-name=${name}]`).classList.add("active"); 53 | document.getElementById(`documentation-${name}`).classList.add("active"); 54 | } 55 | 56 | function setupDocs() { 57 | const docs = SupClient.getPlugins("documentation"); 58 | if (docs == null) { 59 | mainElt.textContent = "This system doesn't have any documentation included."; 60 | return; 61 | } 62 | 63 | const languageCode = SupClient.cookies.get("supLanguage"); 64 | const liEltsByTranslatedName: { [translatedName: string]: HTMLLIElement } = {}; 65 | 66 | async.each(Object.keys(docs), (name, cb) => { 67 | const liElt = document.createElement("li"); 68 | const anchorElt = document.createElement("a"); 69 | anchorElt.dataset["name"] = name; 70 | anchorElt.href = `#${name}`; 71 | liElt.appendChild(anchorElt); 72 | 73 | const articleElt = document.createElement("article"); 74 | articleElt.id = `documentation-${name}`; 75 | mainElt.appendChild(articleElt); 76 | 77 | function onDocumentationLoaded(content: string) { 78 | articleElt.innerHTML = marked(content); 79 | 80 | const translatedName = articleElt.firstElementChild.textContent; 81 | anchorElt.textContent = translatedName; 82 | 83 | if (docs[name].content.isFirstSection) navListElt.appendChild(liElt); 84 | else liEltsByTranslatedName[translatedName] = liElt; 85 | 86 | if (SupApp == null) { 87 | const linkElts = articleElt.querySelectorAll("a") as any as HTMLAnchorElement[]; 88 | for (const linkElt of linkElts) linkElt.target = "_blank"; 89 | } 90 | cb(null); 91 | } 92 | 93 | const pluginPath = SupClient.getPlugins("documentation")[name].path; 94 | SupClient.fetch(`${pluginPath}/documentation/${name}.${languageCode}.md`, "text", (err, data) => { 95 | if (err != null) { 96 | SupClient.fetch(`${pluginPath}/documentation/${name}.en.md`, "text", (err, data) => { 97 | onDocumentationLoaded(data); 98 | }); 99 | return; 100 | } 101 | onDocumentationLoaded(data); 102 | }); 103 | }, () => { 104 | const sortedNames = Object.keys(liEltsByTranslatedName).sort((a, b) => { return (a.toLowerCase() < b.toLowerCase()) ? -1 : 1; }); 105 | for (const name of sortedNames) navListElt.appendChild(liEltsByTranslatedName[name]); 106 | 107 | navListElt.addEventListener("click", (event: any) => { 108 | if (event.target.tagName !== "A") return; 109 | openDocumentation(event.target.dataset["name"]); 110 | }); 111 | 112 | (navListElt.querySelector("li a")).classList.add("active"); 113 | (mainElt.querySelector("article")).classList.add("active"); 114 | loaded = true; 115 | if (initialSection != null) openDocumentation(initialSection); 116 | }); 117 | } 118 | -------------------------------------------------------------------------------- /documentation/index.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /documentation/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "superpowers-common-documentation-plugin", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@types/marked": { 8 | "version": "0.6.2", 9 | "resolved": "https://registry.npmjs.org/@types/marked/-/marked-0.6.2.tgz", 10 | "integrity": "sha512-yl4Y+AXghz2VWsrIK0rXJpYYcKI1sIbaLoa+ByHq7WFHZfuN0+iJMtgn0zIXh1/DbKn6BIbp9oHuHZjEIb7QlQ==", 11 | "dev": true 12 | }, 13 | "marked": { 14 | "version": "0.6.1", 15 | "resolved": "https://registry.npmjs.org/marked/-/marked-0.6.1.tgz", 16 | "integrity": "sha512-+H0L3ibcWhAZE02SKMqmvYsErLo4EAVJxu5h3bHBBDvvjeWXtl92rGUSBYHL2++5Y+RSNgl8dYOAXcYe7lp1fA==" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /documentation/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "superpowers-common-documentation-plugin", 3 | "description": "Documentation plugin for Superpowers, the HTML5 app for real-time collaborative projects", 4 | "version": "1.0.0", 5 | "license": "ISC", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/superpowers/superpowers-common-plugins.git" 9 | }, 10 | "scripts": { 11 | "build": "gulp --gulpfile=../../../../../scripts/pluginGulpfile.js --cwd=." 12 | }, 13 | "dependencies": { 14 | "marked": "^0.6.1" 15 | }, 16 | "devDependencies": { 17 | "@types/marked": "^0.6.2" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /documentation/public/editors/documentation/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 21 | 23 | 44 | 51 | 52 | 54 | 55 | 57 | image/svg+xml 58 | 60 | 61 | 62 | 63 | 64 | 69 | 76 | 83 | 88 | 95 | 102 | 108 | 115 | 124 | 133 | 139 | 148 | 154 | 155 | 156 | -------------------------------------------------------------------------------- /documentation/public/editors/documentation/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "pinned": false 3 | } -------------------------------------------------------------------------------- /documentation/public/locales/en/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "editors": { 3 | "documentation": { 4 | "title": "Documentation" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /documentation/public/locales/pt-BR/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "editors": { 3 | "documentation": { 4 | "title": "Documentação" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /documentation/public/locales/ru/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "editors": { 3 | "documentation": { 4 | "title": "Документация" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /documentation/public/locales/sv/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "editors": { 3 | "documentation": { 4 | "title": "Dokumentation" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /documentation/public/locales/th/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "editors": { 3 | "documentation": { 4 | "title": "เอกสารคู่มือ" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /documentation/public/locales/zh-TW/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "editors": { 3 | "documentation": { 4 | "title": "文件" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /documentation/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "noImplicitAny": true, 6 | "rootDir": "./", 7 | "typeRoots": [ "../../../../../node_modules/@types" ] 8 | }, 9 | "exclude": [ 10 | "node_modules", 11 | "typings" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /home/editors/main/index.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | link(rel="stylesheet", href="/styles/reset.css") 5 | link(rel="stylesheet", href="index.css") 6 | title Home 7 | body 8 | .main 9 | .project-info 10 | // TODO: If in browser, add button to add to/open in Superpowers 11 | .chat 12 | ol 13 | .chat-input 14 | textarea(placeholder= t("home:links.chatPlaceholder"),maxlength=300) 15 | .sidebar 16 | .links 17 | ul 18 | li 19 | a(href="http://superpowers-html5.com/",target="_blank")= t("home:links.officialWebsite") 20 | li 21 | a(href="http://docs.superpowers-html5.com/en/development/roadmap",target="_blank")= t("home:links.devRoadmap") 22 | li 23 | a(href="http://itch.io/engine/superpowers/community",target="_blank")= t("home:links.communityForums") 24 | .members 25 | ul 26 | 27 | script(src="/SupCore.js") 28 | script(src="/SupClient.js") 29 | script(src="index.js") 30 | -------------------------------------------------------------------------------- /home/editors/main/index.styl: -------------------------------------------------------------------------------- 1 | body 2 | display flex 3 | 4 | .main 5 | flex 1 6 | display flex 7 | flex-flow column 8 | 9 | .project-info 10 | .project-name 11 | font-size 2em 12 | 13 | .chat 14 | flex 1 15 | flex-basis 0 16 | overflow-y scroll 17 | padding 0.5em 18 | word-wrap break-word 19 | 20 | ol 21 | list-style none 22 | margin 0 23 | padding 0 24 | white-space pre-wrap 25 | -webkit-user-select text 26 | -moz-user-select text 27 | user-select text 28 | cursor initial 29 | 30 | li 31 | margin-bottom 0.25em 32 | padding 0.125em 33 | 34 | &.day-separator 35 | padding 0 36 | text-align center 37 | text-transform uppercase 38 | position relative 39 | color #666 40 | 41 | > hr 42 | height 1px 43 | margin 0.5em 0 44 | border none 45 | background #ccc 46 | 47 | > div 48 | position absolute 49 | top -0.5em 50 | left 0 51 | right 0 52 | 53 | > div > div 54 | display inline-block 55 | padding 0 1em 56 | background #fff 57 | 58 | .timestamp 59 | color #888 60 | font-size smaller 61 | padding-right 0.5em 62 | 63 | .author 64 | font-weight bold 65 | 66 | .chat-input 67 | border-top 1px solid #ccc 68 | padding 0.5em 69 | background #eee 70 | line-height 0 71 | 72 | .chat-input textarea 73 | width 100% 74 | resize none 75 | line-height 1.25 76 | border 1px solid #ccc 77 | padding 0.5em 78 | font-size 14px 79 | 80 | .sidebar { 81 | width: 200px; 82 | border-left: 1px solid #ddd; 83 | 84 | display: flex; 85 | flex-flow: column; 86 | 87 | ol, ul { 88 | list-style: none; 89 | padding: 0; 90 | margin: 0; 91 | } 92 | } 93 | 94 | .sidebar .links { 95 | background: #eee; 96 | padding: 1em; 97 | border-bottom: 1px solid #ddd; 98 | } 99 | 100 | .sidebar .members { 101 | flex: 1 1 0; 102 | padding: 1em; 103 | } 104 | -------------------------------------------------------------------------------- /home/editors/main/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import "./links"; 3 | 4 | let data: { room: SupCore.Data.Room; }; 5 | let socket: SocketIOClient.Socket; 6 | 7 | const ui = { 8 | chatHistoryContainer: document.querySelector(".chat"), 9 | chatHistory: document.querySelector(".chat ol"), 10 | roomUsers: document.querySelector(".members ul") 11 | }; 12 | 13 | function start() { 14 | socket = SupClient.connect(SupClient.query.project); 15 | socket.on("connect", onConnected); 16 | socket.on("disconnect", SupClient.onDisconnected); 17 | 18 | // Chat 19 | document.querySelector(".chat-input textarea").addEventListener("keydown", onChatInputKeyDown); 20 | document.querySelector(".chat").addEventListener("click", onLinkClicked); 21 | } 22 | 23 | function onConnected() { 24 | data = {}; 25 | // FIXME Add support in ProjectClient? 26 | socket.emit("sub", "rooms", "home", onRoomReceived); 27 | socket.on("edit:rooms", onRoomEdited); 28 | } 29 | 30 | function onRoomReceived(err: string, room: any) { 31 | data.room = new SupCore.Data.Room(room); 32 | 33 | for (const roomUser of data.room.pub.users) appendRoomUser(roomUser); 34 | 35 | for (const entry of data.room.pub.history) appendHistoryEntry(entry); 36 | scrollToBottom(); 37 | } 38 | 39 | let onRoomCommands: any = {}; 40 | function onRoomEdited(id: string, command: string, ...args: any[]) { 41 | Object.getPrototypeOf(data.room)[`client_${command}`].apply(data.room, args); 42 | if (onRoomCommands[command] != null) onRoomCommands[command].apply(data.room, args); 43 | } 44 | 45 | function scrollToBottom() { 46 | setTimeout(() => { ui.chatHistoryContainer.scrollTop = ui.chatHistoryContainer.scrollHeight; }, 0); 47 | } 48 | 49 | // Firefox 41 loses the scroll position when going back to the tab 50 | // so we'll manually restore it when the tab is activated 51 | let savedScrollTop = 0; 52 | 53 | ui.chatHistoryContainer.addEventListener("scroll", (event) => { 54 | savedScrollTop = ui.chatHistoryContainer.scrollTop; 55 | }); 56 | 57 | window.addEventListener("message", (event) => { 58 | if (event.data.type === "activate") { 59 | setTimeout(() => { ui.chatHistoryContainer.scrollTop = savedScrollTop; }, 0); 60 | } 61 | }); 62 | 63 | const appendDaySeparator = (date: Date) => { 64 | const separatorElt = document.createElement("li"); 65 | separatorElt.className = "day-separator"; 66 | 67 | separatorElt.appendChild(document.createElement("hr")); 68 | 69 | const dateDiv = document.createElement("div"); 70 | separatorElt.appendChild(dateDiv); 71 | 72 | const dateInnerDiv = document.createElement("div"); 73 | dateInnerDiv.textContent = date.toDateString(); 74 | dateDiv.appendChild(dateInnerDiv); 75 | 76 | ui.chatHistory.appendChild(separatorElt); 77 | }; 78 | 79 | let previousDay: string; 80 | interface Entry { 81 | author: string; 82 | text: string; 83 | timestamp: number; 84 | } 85 | 86 | const addressRegex = new RegExp("^(http[s]?:\\/\\/(www\\.)?|ftp:\\/\\/(www\\.)?|www\\.){1}([0-9A-Za-z-\\.@:%_\+~#=]+)+((\\.[a-zA-Z]{2,3})+)(/(.)*)?(\\?(.)*)?"); 87 | function appendHistoryEntry(entry: Entry) { 88 | const date = new Date(entry.timestamp); 89 | const day = date.toDateString(); 90 | if (previousDay !== day) { 91 | appendDaySeparator(date); 92 | previousDay = day; 93 | } 94 | 95 | const entryElt = document.createElement("li"); 96 | 97 | const timestampSpan = document.createElement("span"); 98 | timestampSpan.className = "timestamp"; 99 | const time = `00${date.getHours()}`.slice(-2) + ":" + `00${date.getMinutes()}`.slice(-2); 100 | timestampSpan.textContent = time; 101 | entryElt.appendChild(timestampSpan); 102 | 103 | const authorSpan = document.createElement("span"); 104 | authorSpan.className = "author"; 105 | authorSpan.textContent = entry.author; 106 | entryElt.appendChild(authorSpan); 107 | 108 | const addressTest = addressRegex.exec(entry.text); 109 | if (addressTest != null) { 110 | const beforeAddress = entry.text.slice(0, addressTest.index); 111 | const beforeTextSpan = document.createElement("span"); 112 | beforeTextSpan.className = "text"; 113 | beforeTextSpan.textContent = `: ${beforeAddress}`; 114 | entryElt.appendChild(beforeTextSpan); 115 | 116 | const addressTextLink = document.createElement("a"); 117 | addressTextLink.className = "text"; 118 | addressTextLink.textContent = addressTest[0]; 119 | addressTextLink.href = addressTest[0]; 120 | entryElt.appendChild(addressTextLink); 121 | 122 | const afterAddress = entry.text.slice(addressTest.index + addressTest[0].length); 123 | const afterTextSpan = document.createElement("span"); 124 | afterTextSpan.className = "text"; 125 | afterTextSpan.textContent = afterAddress; 126 | entryElt.appendChild(afterTextSpan); 127 | 128 | } else { 129 | const textSpan = document.createElement("span"); 130 | textSpan.className = "text"; 131 | textSpan.textContent = `: ${entry.text}`; 132 | entryElt.appendChild(textSpan); 133 | } 134 | 135 | ui.chatHistory.appendChild(entryElt); 136 | } 137 | 138 | onRoomCommands.appendMessage = (entry: Entry) => { 139 | if (window.parent != null) window.parent.postMessage({ type: "chat", content: `${entry.author}: ${entry.text}` }, window.location.origin); 140 | appendHistoryEntry(entry); 141 | scrollToBottom(); 142 | }; 143 | 144 | function appendRoomUser(roomUser: { id: string; connectionCount: number; }) { 145 | const roomUserElt = document.createElement("li"); 146 | roomUserElt.dataset["userId"] = roomUser.id; 147 | roomUserElt.textContent = roomUser.id; 148 | ui.roomUsers.appendChild(roomUserElt); 149 | } 150 | 151 | onRoomCommands.join = (roomUser: { id: string; connectionCount: number; }) => { 152 | if (roomUser.connectionCount === 1) appendRoomUser(roomUser); 153 | }; 154 | 155 | onRoomCommands.leave = (roomUserId: string) => { 156 | if (data.room.users.byId[roomUserId] == null) { 157 | const roomUserElt = ui.roomUsers.querySelector(`li[data-user-id=${roomUserId}]`); 158 | roomUserElt.parentElement.removeChild(roomUserElt); 159 | } 160 | }; 161 | 162 | function onChatInputKeyDown(event: any) { 163 | if (event.keyCode !== 13 || event.shiftKey) return; 164 | event.preventDefault(); 165 | if (!socket.connected) return; 166 | 167 | socket.emit("edit:rooms", "home", "appendMessage", this.value, (err: string) => { 168 | if (err != null) { new SupClient.Dialogs.InfoDialog(err); return; } 169 | }); 170 | 171 | this.value = ""; 172 | } 173 | 174 | function onLinkClicked(event: MouseEvent) { 175 | const anchorElt = event.target as HTMLAnchorElement; 176 | if (anchorElt.tagName === "A") { 177 | event.preventDefault(); 178 | 179 | if (SupApp != null) SupApp.openLink(anchorElt.href); 180 | else window.open(anchorElt.href, "_blank"); 181 | } 182 | } 183 | 184 | SupClient.i18n.load([{ root: path.join(window.location.pathname, "../.."), name: "home" }], start); 185 | -------------------------------------------------------------------------------- /home/editors/main/links.ts: -------------------------------------------------------------------------------- 1 | if (SupApp != null) { 2 | document.querySelector(".sidebar .links").addEventListener("click", (event: any) => { 3 | if (event.target.tagName !== "A") return; 4 | 5 | event.preventDefault(); 6 | SupApp.openLink(event.target.href); 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /home/index.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /home/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "superpowers-common-home-plugin", 3 | "description": "Home plugin for Superpowers, the HTML5 app for real-time collaborative projects", 4 | "version": "1.0.0", 5 | "license": "ISC", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/superpowers/superpowers-common-plugins.git" 9 | }, 10 | "scripts": { 11 | "build": "gulp --gulpfile=../../../../../scripts/pluginGulpfile.js --cwd=." 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /home/public/editors/main/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 21 | 23 | 41 | 48 | 49 | 51 | 52 | 54 | image/svg+xml 55 | 57 | 58 | 59 | 60 | 61 | 66 | 69 | 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /home/public/editors/main/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "pinned": true 3 | } 4 | -------------------------------------------------------------------------------- /home/public/locales/en/home.json: -------------------------------------------------------------------------------- 1 | { 2 | "links": { 3 | "title": "Links", 4 | "officialWebsite": "Official website", 5 | "devRoadmap": "Development roadmap", 6 | "communityForums": "Community forums", 7 | "chatPlaceholder": "Type here to chat" 8 | }, 9 | "onlineMembers": "Online members" 10 | } -------------------------------------------------------------------------------- /home/public/locales/en/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "editors": { 3 | "main": { 4 | "title": "Home" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /home/public/locales/it/home.json: -------------------------------------------------------------------------------- 1 | { 2 | "links": { 3 | "title": "Link", 4 | "officialWebsite": "Website ufficiale", 5 | "devRoadmap": "Roadmap di sviluppo", 6 | "communityForums": "Forum della community", 7 | "chatPlaceholder": "Scrivi qui per chattare" 8 | }, 9 | "onlineMembers": "Membri online" 10 | } 11 | -------------------------------------------------------------------------------- /home/public/locales/it/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "editors": { 3 | "main": { 4 | "title": "Home" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /home/public/locales/pt-BR/home.json: -------------------------------------------------------------------------------- 1 | { 2 | "links": { 3 | "title": "Links", 4 | "officialWebsite": "Site Oficial", 5 | "devRoadmap": "Roteiro de Desenvolvimento", 6 | "skypeGroupChat": "Conversa em Grupo no Skype: ", 7 | "forums": "Fóruns: ", 8 | "chatPlaceholder": "Digite aqui para conversar" 9 | }, 10 | "onlineMembers": "Membros Online" 11 | } -------------------------------------------------------------------------------- /home/public/locales/pt-BR/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "editors": { 3 | "main": { 4 | "title": "Home" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /home/public/locales/ru/home.json: -------------------------------------------------------------------------------- 1 | { 2 | "links": { 3 | "title": "Ссылки", 4 | "officialWebsite": "Официальный сайт", 5 | "devRoadmap": "План разработки", 6 | "communityForums": "Форумы сообщества", 7 | "chatPlaceholder": "Чат" 8 | }, 9 | "onlineMembers": "Учасники" 10 | } 11 | -------------------------------------------------------------------------------- /home/public/locales/ru/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "editors": { 3 | "main": { 4 | "title": "Главная" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /home/public/locales/zh-TW/home.json: -------------------------------------------------------------------------------- 1 | { 2 | "links": { 3 | "title": "連結", 4 | "officialWebsite": "官方網站", 5 | "devRoadmap": "開發規劃", 6 | "skypeGroupChat": "Skype 群組聊天: ", 7 | "forums": "論壇: ", 8 | "chatPlaceholder": "輸入聊天訊息" 9 | }, 10 | "onlineMembers": "線上成員" 11 | } -------------------------------------------------------------------------------- /home/public/locales/zh-TW/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "editors": { 3 | "main": { 4 | "title": "首頁" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /home/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "noImplicitAny": true, 6 | "rootDir": "./", 7 | "typeRoots": [ "../../../../../node_modules/@types" ] 8 | }, 9 | "exclude": [ 10 | "node_modules", 11 | "typings" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /settings/editors/settings/index.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title= t("plugin:editors.settings.title") 5 | link(rel="stylesheet",href="/styles/reset.css") 6 | link(rel="stylesheet",href="/styles/treeView.css") 7 | link(rel="stylesheet",href="/styles/dialogs.css") 8 | link(rel="stylesheet",href="index.css") 9 | 10 | body 11 | main 12 | 13 | script(src="/SupCore.js") 14 | script(src="/SupClient.js") 15 | script(src="index.js") 16 | -------------------------------------------------------------------------------- /settings/editors/settings/index.styl: -------------------------------------------------------------------------------- 1 | body 2 | display flex 3 | 4 | main 5 | flex 1 6 | 7 | > header 8 | font-size 2em 9 | background-color #eee 10 | color #666 11 | padding 0.25em 0.5em 12 | text-transform uppercase 13 | border-bottom 1px solid #aaa 14 | &:not(:first-child) { border-top: 1px solid #aaa; } 15 | 16 | > div 17 | display flex 18 | flex-wrap wrap 19 | 20 | section 21 | padding 1em 22 | width calc(500px + 2em) 23 | 24 | header 25 | font-size 1.5em 26 | margin-bottom 0.5em 27 | 28 | section table 29 | width 500px 30 | border-collapse collapse 31 | font-size 12px 32 | 33 | th, td 34 | border 1px solid #ccc 35 | 36 | th 37 | text-align left 38 | white-space nowrap 39 | overflow-x hidden 40 | text-overflow ellipsis 41 | font-weight normal 42 | background #eee 43 | padding 0 0.5em 44 | 45 | td input, td select, td textarea 46 | width 100% 47 | margin 0 48 | padding 0.5em 0.25em 49 | border none 50 | 51 | td select 52 | padding 0.25em 0 53 | 54 | td input[type=checkbox] 55 | width auto 56 | margin 0.5em 57 | cursor pointer 58 | 59 | td input[type=color] 60 | padding 0 61 | 62 | td 63 | .inputs 64 | display flex 65 | 66 | input:not(:last-of-type) 67 | border-right 1px solid #ccc 68 | 69 | .list input:not(:last-of-type) 70 | border-bottom 1px solid #ccc 71 | 72 | td input.color 73 | font-family "Consolas", monospace 74 | -------------------------------------------------------------------------------- /settings/editors/settings/index.ts: -------------------------------------------------------------------------------- 1 | import * as async from "async"; 2 | 3 | let data: { 4 | projectClient: SupClient.ProjectClient; 5 | }; 6 | 7 | const socket = SupClient.connect(SupClient.query.project); 8 | 9 | // NOTE: Listening for "welcome" rather than "connect" 10 | // because SupCore.system.id is only set after "welcome" 11 | socket.on("welcome", onWelcome); 12 | socket.on("disconnect", SupClient.onDisconnected); 13 | 14 | function onWelcome() { 15 | data = { projectClient: new SupClient.ProjectClient(socket) }; 16 | loadPlugins(); 17 | } 18 | 19 | function loadPlugins() { 20 | const i18nFiles: SupClient.i18n.File[] = []; 21 | 22 | SupClient.fetch(`/systems/${SupCore.system.id}/plugins.json`, "json", (err: Error, pluginsInfo: SupCore.PluginsInfo) => { 23 | for (const pluginName of pluginsInfo.list) { 24 | const root = `/systems/${SupCore.system.id}/plugins/${pluginName}`; 25 | i18nFiles.push({ root, name: "settingsEditors" }); 26 | } 27 | 28 | async.parallel([ 29 | (cb) => { 30 | SupClient.i18n.load(i18nFiles, cb); 31 | }, (cb) => { 32 | async.each(pluginsInfo.list, (pluginName, cb) => { 33 | const pluginPath = `/systems/${SupCore.system.id}/plugins/${pluginName}`; 34 | async.each(["data", "settingsEditors"], (name, cb) => { 35 | SupClient.loadScript(`${pluginPath}/bundles/${name}.js`, cb); 36 | }, cb); 37 | }, cb); 38 | } 39 | ], setupSettings); 40 | }); 41 | } 42 | 43 | function setupSettings() { 44 | const mainElt = document.querySelector("main") as HTMLDivElement; 45 | 46 | const plugins = SupClient.getPlugins("settingsEditors"); 47 | const sortedNames = Object.keys(plugins); 48 | sortedNames.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); 49 | 50 | const createSection = (namespace: string) => { 51 | const header = SupClient.html("header", { parent: mainElt, textContent: SupClient.i18n.t(`settingsEditors:namespaces.${namespace}`) }); 52 | const root = SupClient.html("div", `namespace-${namespace}`, { parent: mainElt }); 53 | 54 | return { header, root }; 55 | }; 56 | 57 | // Create general section first so we are sure it is displayed above 58 | const generalSection = createSection("general"); 59 | 60 | for (const name of sortedNames) { 61 | const namespace = plugins[name].content.namespace; 62 | let sectionRootElt = mainElt.querySelector(`div.namespace-${namespace}`) as HTMLDivElement; 63 | if (sectionRootElt == null) sectionRootElt = createSection(namespace).root; 64 | 65 | const sectionElt = SupClient.html("section", { parent: sectionRootElt }); 66 | 67 | const headerElt = SupClient.html("header", { parent: sectionElt }); 68 | SupClient.html("a", { parent: headerElt, textContent: SupClient.i18n.t(`settingsEditors:${name}.label`), id: name }); 69 | 70 | const editorContentElt = SupClient.html("div", { parent: sectionElt }); 71 | 72 | const settingEditorClass = plugins[name].content.editor; 73 | new settingEditorClass(editorContentElt, data.projectClient); 74 | } 75 | 76 | // Remove general section if it's empty 77 | if (generalSection.root.children.length === 0) { 78 | mainElt.removeChild(generalSection.header); 79 | mainElt.removeChild(generalSection.root); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /settings/index.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /settings/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "superpowers-common-settings-plugin", 3 | "description": "Settings plugin for Superpowers, the HTML5 app for real-time collaborative projects", 4 | "version": "1.0.0", 5 | "license": "ISC", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/superpowers/superpowers-common-plugins.git" 9 | }, 10 | "scripts": { 11 | "build": "gulp --gulpfile=../../../../../scripts/pluginGulpfile.js --cwd=." 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /settings/public/editors/settings/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 21 | 23 | 46 | 53 | 54 | 56 | 57 | 59 | image/svg+xml 60 | 62 | 63 | 64 | 65 | 66 | 71 | 78 | 83 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /settings/public/editors/settings/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "pinned": false 3 | } 4 | -------------------------------------------------------------------------------- /settings/public/locales/en/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "editors": { 3 | "settings": { 4 | "title": "Settings" 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /settings/public/locales/en/settingsEditors.json: -------------------------------------------------------------------------------- 1 | { 2 | "namespaces": { 3 | "general": "General", 4 | "editors": "Editors" 5 | } 6 | } -------------------------------------------------------------------------------- /settings/public/locales/it/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "editors": { 3 | "settings": { 4 | "title": "Impostazioni" 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /settings/public/locales/it/settingsEditors.json: -------------------------------------------------------------------------------- 1 | { 2 | "namespaces": { 3 | "general": "Generale", 4 | "editors": "Editor" 5 | } 6 | } -------------------------------------------------------------------------------- /settings/public/locales/pt-BR/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "editors": { 3 | "settings": { 4 | "title": "Configurações" 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /settings/public/locales/pt-BR/settingsEditors.json: -------------------------------------------------------------------------------- 1 | { 2 | "namespaces": { 3 | "general": "Geral", 4 | "editors": "Editores" 5 | } 6 | } -------------------------------------------------------------------------------- /settings/public/locales/ru/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "editors": { 3 | "settings": { 4 | "title": "Настройки" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /settings/public/locales/ru/settingsEditors.json: -------------------------------------------------------------------------------- 1 | { 2 | "namespaces": { 3 | "general": "Основное", 4 | "editors": "Редакторы" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /settings/settingsEditors/SettingsEditorPlugin.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace SupClient { 2 | export interface SettingsEditor { 3 | new(container: HTMLDivElement, projectClient: SupClient.ProjectClient): any; 4 | } 5 | 6 | export interface SettingsEditorPlugin { 7 | namespace: string; 8 | editor: SettingsEditor; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /settings/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "noImplicitAny": true, 6 | "rootDir": "./", 7 | "typeRoots": [ "../../../../../node_modules/@types" ] 8 | }, 9 | "exclude": [ 10 | "node_modules", 11 | "typings" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /textEditorWidget/data/TextEditorSettingsResource.ts: -------------------------------------------------------------------------------- 1 | interface TextEditorSettingsResourcePub { 2 | tabSize: number; 3 | softTab: boolean; 4 | } 5 | 6 | export default class TextEditorSettingsResource extends SupCore.Data.Base.Resource { 7 | 8 | static schema: SupCore.Data.Schema = { 9 | tabSize: { type: "number", min: 1, mutable: true }, 10 | softTab: { type: "boolean", mutable: true }, 11 | }; 12 | 13 | pub: TextEditorSettingsResourcePub; 14 | 15 | constructor(id: string, pub: any, server: ProjectServer) { 16 | super(id, pub, TextEditorSettingsResource.schema, server); 17 | } 18 | 19 | init(callback: Function) { 20 | this.pub = { 21 | tabSize: 2, 22 | softTab: true 23 | }; 24 | 25 | super.init(callback); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /textEditorWidget/data/index.ts: -------------------------------------------------------------------------------- 1 | import TextEditorSettingsResource from "./TextEditorSettingsResource"; 2 | 3 | SupCore.system.data.registerResource("textEditorSettings", TextEditorSettingsResource); 4 | -------------------------------------------------------------------------------- /textEditorWidget/data/textEditorUserSettings.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | 3 | const storageKey = "superpowers.common.textEditorWidget"; 4 | 5 | const item = window.localStorage.getItem(storageKey); 6 | export let pub: { 7 | formatVersion: number; 8 | keyMap: string; 9 | theme: string; 10 | [key: string]: any; 11 | } = item != null ? JSON.parse(item) : { 12 | formatVersion: 2, 13 | keyMap: "sublime", 14 | theme: "default" 15 | }; 16 | 17 | if (pub.formatVersion === 1) { 18 | pub.formatVersion = 2; 19 | edit("theme", "default"); 20 | } 21 | 22 | export const emitter = new EventEmitter(); 23 | 24 | window.addEventListener("storage", (event) => { 25 | if (event.key !== storageKey) return; 26 | 27 | const oldPub = pub; 28 | pub = JSON.parse(event.newValue); 29 | 30 | if (oldPub.keyMap !== pub.keyMap) emitter.emit("keyMap"); 31 | if (oldPub.theme !== pub.theme) emitter.emit("theme"); 32 | }); 33 | 34 | export function edit(key: string, value: any) { 35 | pub[key] = value; 36 | window.localStorage.setItem(storageKey, JSON.stringify(pub)); 37 | } 38 | -------------------------------------------------------------------------------- /textEditorWidget/index.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /textEditorWidget/operational-transform.d.ts: -------------------------------------------------------------------------------- 1 | declare module "operational-transform" { 2 | class Document { 3 | text: string; 4 | operations: TextOperation[]; 5 | 6 | constructor(text: string, revisionId: number); 7 | apply(operation: TextOperation, revision: number): TextOperation; 8 | getRevisionId(): number; 9 | } 10 | 11 | class TextOperation { 12 | userId: string; 13 | ops: TextOp[]; 14 | 15 | baseLength: number; 16 | targetLength: number; 17 | 18 | constructor(userId?: string); 19 | serialize(): OperationData; 20 | deserialize(data: OperationData): boolean; 21 | retain(amount: number): void; 22 | insert(text: string): void; 23 | delete(text: string): void; 24 | apply(text: string): string; 25 | invert(): TextOperation; 26 | clone(): TextOperation; 27 | equal(otherOperation: TextOperation): boolean; 28 | compose(otherOperation: TextOperation): TextOperation; 29 | transform(otherOperation: TextOperation): TextOperation[]; 30 | gotPriority(otherId: string): boolean; 31 | } 32 | 33 | class TextOp { 34 | type: string; 35 | attributes: any; 36 | 37 | constructor(type: string, attributes: any); 38 | } 39 | } 40 | 41 | interface OperationData { 42 | userId: string; 43 | ops: Array<{type: string; attributes: any}>; 44 | } 45 | -------------------------------------------------------------------------------- /textEditorWidget/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "superpowers-common-text-editor-widget-plugin", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@types/codemirror": { 8 | "version": "0.0.34", 9 | "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-0.0.34.tgz", 10 | "integrity": "sha1-lGRIYH5Ama+F7KiDXRpP8zyY0hk=", 11 | "dev": true 12 | }, 13 | "codemirror": { 14 | "version": "5.30.0", 15 | "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.30.0.tgz", 16 | "integrity": "sha512-pfJV/7fLAUUenuGK3iANkQu1AxNLuWpeF7HV6YFDjSBMp53F8FTa2F6oPs9NKAHFweT2m08usmXUIA+7sohdew==", 17 | "dev": true 18 | }, 19 | "operational-transform": { 20 | "version": "0.2.3", 21 | "resolved": "https://registry.npmjs.org/operational-transform/-/operational-transform-0.2.3.tgz", 22 | "integrity": "sha1-zzJ3QxK0u5pGR465Sfpqyq3V1Ag=" 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /textEditorWidget/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "superpowers-common-text-editor-widget-plugin", 3 | "description": "Collaborative text editor widget for Superpowers, the HTML5 app for real-time collaborative projects", 4 | "version": "1.0.0", 5 | "license": "ISC", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/superpowers/superpowers-common-plugins.git" 9 | }, 10 | "scripts": { 11 | "build": "gulp --gulpfile=../../../../../scripts/pluginGulpfile.js --cwd=. --silent && gulp --gulpfile=widgetGulpfile.js" 12 | }, 13 | "dependencies": { 14 | "operational-transform": "^0.2.3" 15 | }, 16 | "devDependencies": { 17 | "@types/codemirror": "0.0.34", 18 | "codemirror": "^5.16.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /textEditorWidget/public/locales/en/settingsEditors.json: -------------------------------------------------------------------------------- 1 | { 2 | "TextEditor": { 3 | "label": "Text Editor", 4 | "tabSize": "Tab size", 5 | "useSoftTab": "Use soft tab", 6 | "keyMap": "Key map", 7 | "theme": "Theme" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /textEditorWidget/public/locales/pt-BR/settingsEditors.json: -------------------------------------------------------------------------------- 1 | { 2 | "TextEditor": { 3 | "label": "Editor de Texto", 4 | "tabSize": "Tamanho do Tab", 5 | "useSoftTab": "Usar Soft Tab" 6 | } 7 | } -------------------------------------------------------------------------------- /textEditorWidget/settingsEditors/TextEditorSettingsEditor.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | 4 | import TextEditorSettingsResource from "../data/TextEditorSettingsResource"; 5 | import * as textEditorUserSettings from "../data/textEditorUserSettings"; 6 | 7 | const modes = fs.readdirSync(path.join(__dirname, "../node_modules/codemirror/theme")); 8 | 9 | export default class TextEditorSettingsEditor { 10 | resource: TextEditorSettingsResource; 11 | 12 | tabSizeField: HTMLInputElement; 13 | softTabField: HTMLInputElement; 14 | keyMapField: HTMLSelectElement; 15 | themeField: HTMLSelectElement; 16 | 17 | constructor(container: HTMLDivElement, projectClient: SupClient.ProjectClient) { 18 | const { tbody } = SupClient.table.createTable(container); 19 | 20 | // Project settings 21 | const tabSizeRow = SupClient.table.appendRow(tbody, SupClient.i18n.t("settingsEditors:TextEditor.tabSize")); 22 | this.tabSizeField = SupClient.table.appendNumberField(tabSizeRow.valueCell, "", { min: 1 }); 23 | this.tabSizeField.addEventListener("change", (event: any) => { 24 | projectClient.editResource("textEditorSettings", "setProperty", "tabSize", parseInt(event.target.value, 10)); 25 | }); 26 | 27 | const softTabRow = SupClient.table.appendRow(tbody, SupClient.i18n.t("settingsEditors:TextEditor.useSoftTab")); 28 | this.softTabField = SupClient.table.appendBooleanField(softTabRow.valueCell, true); 29 | this.softTabField.addEventListener("change", (event: any) => { 30 | projectClient.editResource("textEditorSettings", "setProperty", "softTab", event.target.checked); 31 | }); 32 | 33 | projectClient.subResource("textEditorSettings", this); 34 | 35 | // User settings 36 | const keyMapRow = SupClient.table.appendRow(tbody, SupClient.i18n.t("settingsEditors:TextEditor.keyMap")); 37 | this.keyMapField = SupClient.table.appendSelectBox(keyMapRow.valueCell, { "sublime": "Sublime", "emacs": "Emacs", "vim": "Vim" }, textEditorUserSettings.pub.keyMap); 38 | this.keyMapField.addEventListener("change", (event: any) => { 39 | textEditorUserSettings.edit("keyMap", event.target.value); 40 | }); 41 | 42 | textEditorUserSettings.emitter.addListener("keyMap", () => { 43 | this.keyMapField.value = textEditorUserSettings.pub.keyMap; 44 | }); 45 | 46 | const themeRow = SupClient.table.appendRow(tbody, SupClient.i18n.t("settingsEditors:TextEditor.theme")); 47 | const themeValues: { [value: string]: string } = { "default": "default" }; 48 | for (const mode of modes) { 49 | const modeNoExtension = mode.slice(0, mode.length - 4); 50 | themeValues[modeNoExtension] = modeNoExtension; 51 | } 52 | this.themeField = SupClient.table.appendSelectBox(themeRow.valueCell, themeValues, textEditorUserSettings.pub.theme); 53 | this.themeField.addEventListener("change", (event: any) => { 54 | textEditorUserSettings.edit("theme", event.target.value); 55 | }); 56 | 57 | textEditorUserSettings.emitter.addListener("theme", () => { 58 | this.themeField.value = textEditorUserSettings.pub.theme; 59 | }); 60 | } 61 | 62 | onResourceReceived = (resourceId: string, resource: TextEditorSettingsResource) => { 63 | this.resource = resource; 64 | 65 | this.tabSizeField.value = resource.pub.tabSize.toString(); 66 | this.softTabField.checked = resource.pub.softTab; 67 | } 68 | 69 | onResourceEdited = (resourceId: string, command: string, propertyName: string) => { 70 | switch (propertyName) { 71 | case "tabSize": this.tabSizeField.value = this.resource.pub.tabSize.toString(); break; 72 | case "softTab": this.softTabField.checked = this.resource.pub.softTab; break; 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /textEditorWidget/settingsEditors/index.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import TextEditorSettingsEditor from "./TextEditorSettingsEditor"; 4 | 5 | SupClient.registerPlugin("settingsEditors", "TextEditor", { 6 | namespace: "editors", 7 | editor: TextEditorSettingsEditor 8 | }); 9 | -------------------------------------------------------------------------------- /textEditorWidget/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "noImplicitAny": true, 6 | "rootDir": "./", 7 | "typeRoots": [ "../../../../../node_modules/@types" ] 8 | }, 9 | "exclude": [ 10 | "node_modules", 11 | "typings" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /textEditorWidget/widget.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | interface EditCallback { 5 | (text: string, origin: string): void; 6 | } 7 | interface SendOperationCallback { 8 | (operation: OperationData): void; 9 | } 10 | 11 | interface TextEditorWidgetOptions { 12 | extraKeys?: { [name: string]: string|Function }; 13 | editCallback?: EditCallback; 14 | mode: string; 15 | sendOperationCallback: SendOperationCallback; 16 | } 17 | 18 | declare class TextEditorWidget { 19 | codeMirrorInstance: CodeMirror.EditorFromTextArea; 20 | clientId: number; 21 | 22 | constructor(projectClient: SupClient.ProjectClient, clientId: string, textArea: HTMLTextAreaElement, options: TextEditorWidgetOptions); 23 | setText(text: string): void; 24 | receiveEditText(operationData: OperationData): void; 25 | clear(): void; 26 | } 27 | -------------------------------------------------------------------------------- /textEditorWidget/widget/widget.styl: -------------------------------------------------------------------------------- 1 | @require "../node_modules/codemirror/lib/codemirror.css" 2 | @require "../node_modules/codemirror/addon/dialog/dialog.css" 3 | @require "../node_modules/codemirror/addon/hint/show-hint.css" 4 | @require "../node_modules/codemirror/addon/fold/foldgutter.css" 5 | // @require "../node_modules/codemirror/theme/monokai.css" 6 | 7 | .text-editor-container 8 | position relative 9 | flex 1 10 | 11 | textarea 12 | opacity 0 13 | 14 | .text-editor + .CodeMirror 15 | position absolute 16 | top 0 17 | left 0 18 | right 0 19 | bottom 0 20 | height auto 21 | font-size 14px 22 | 23 | .text-editor + .CodeMirror, .CodeMirror-hints 24 | font-family "Consolas", monospace 25 | 26 | .CodeMirror-hints { max-width: none; } 27 | -------------------------------------------------------------------------------- /textEditorWidget/widget/widget.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import TextEditorSettingsResource from "../data/TextEditorSettingsResource"; 4 | import * as textEditorUserSettings from "../data/textEditorUserSettings"; 5 | 6 | import * as OT from "operational-transform"; 7 | 8 | import * as CodeMirror from "codemirror"; 9 | (window as any).CodeMirror = CodeMirror; 10 | 11 | /* tslint:disable */ 12 | require("codemirror/addon/search/search"); 13 | require("codemirror/addon/search/searchcursor"); 14 | require("codemirror/addon/edit/closebrackets"); 15 | require("codemirror/addon/comment/comment"); 16 | require("codemirror/addon/hint/show-hint"); 17 | require("codemirror/addon/selection/active-line"); 18 | require("codemirror/addon/fold/foldcode"); 19 | require("codemirror/addon/fold/foldgutter"); 20 | require("codemirror/addon/fold/brace-fold"); 21 | require("codemirror/addon/fold/comment-fold"); 22 | require("codemirror/addon/fold/indent-fold"); 23 | 24 | require("codemirror/keymap/sublime"); 25 | require("codemirror/keymap/emacs"); 26 | require("codemirror/keymap/vim"); 27 | /* tslint:enable */ 28 | 29 | class TextEditorWidget { 30 | private textEditorResource: TextEditorSettingsResource; 31 | codeMirrorInstance: CodeMirror.EditorFromTextArea; 32 | 33 | private editCallback: EditCallback; 34 | private sendOperationCallback: SendOperationCallback; 35 | 36 | clientId: string; 37 | private tmpCodeMirrorDoc = new CodeMirror.Doc(""); 38 | private texts: string[] = []; 39 | 40 | private undoTimeout: number; 41 | private undoStack: OT.TextOperation[] = []; 42 | private undoQuantityByAction: number[] = []; 43 | private redoStack: OT.TextOperation[] = []; 44 | private redoQuantityByAction: number[] = []; 45 | 46 | private sentOperation: OT.TextOperation; 47 | private pendingOperation: OT.TextOperation; 48 | 49 | private useSoftTab = true; 50 | 51 | private linkElt = SupClient.html("link", { parent: document.head, rel: "stylesheet" }); 52 | 53 | constructor(projectClient: SupClient.ProjectClient, clientId: string, textArea: HTMLTextAreaElement, options: TextEditorWidgetOptions) { 54 | const extraKeys: { [name: string]: string|Function|boolean } = { 55 | "F9": () => { /* Disable line re-ordering */ }, 56 | "Ctrl-T": false, 57 | "Tab": (cm: any) => { 58 | if (cm.getSelection() !== "") cm.execCommand("indentMore"); 59 | else { 60 | if (this.useSoftTab) cm.execCommand("insertSoftTab"); 61 | else cm.execCommand("insertTab"); 62 | } 63 | }, 64 | "Ctrl-Z": () => { this.undo(); }, 65 | "Cmd-Z": () => { this.undo(); }, 66 | "Shift-Ctrl-Z": () => { this.redo(); }, 67 | "Shift-Cmd-Z": () => { this.redo(); }, 68 | "Ctrl-Y": () => { this.redo(); }, 69 | "Cmd-Y": () => { this.redo(); }, 70 | "Alt-F": "findPersistent" 71 | }; 72 | if (options.extraKeys != null) { 73 | for (const keyName in options.extraKeys) { 74 | extraKeys[keyName] = options.extraKeys[keyName]; 75 | } 76 | } 77 | 78 | this.editCallback = options.editCallback; 79 | this.sendOperationCallback = options.sendOperationCallback; 80 | 81 | this.codeMirrorInstance = CodeMirror.fromTextArea(textArea, { 82 | lineNumbers: true, 83 | gutters: ["line-error-gutter", "CodeMirror-linenumbers", "CodeMirror-foldgutter"], 84 | indentWithTabs: false, indentUnit: 2, tabSize: 2, 85 | extraKeys: extraKeys, 86 | keyMap: textEditorUserSettings.pub.keyMap, 87 | viewportMargin: Infinity, 88 | mode: options.mode, 89 | readOnly: true 90 | }); 91 | 92 | this.updateTheme(); 93 | 94 | this.codeMirrorInstance.setOption("matchBrackets", true); 95 | this.codeMirrorInstance.setOption("styleActiveLine", true); 96 | this.codeMirrorInstance.setOption("autoCloseBrackets", true); 97 | this.codeMirrorInstance.setOption("foldGutter", true); 98 | 99 | this.codeMirrorInstance.on("changes", this.edit); 100 | this.codeMirrorInstance.on("beforeChange", this.beforeChange); 101 | 102 | this.setupAppMenu(); 103 | 104 | this.clientId = clientId; 105 | projectClient.subResource("textEditorSettings", this); 106 | 107 | textEditorUserSettings.emitter.addListener("keyMap", () => { 108 | this.codeMirrorInstance.setOption("keyMap", textEditorUserSettings.pub.keyMap); 109 | }); 110 | 111 | textEditorUserSettings.emitter.addListener("theme", () => { 112 | this.updateTheme(); 113 | }); 114 | } 115 | 116 | private setupAppMenu() { 117 | if (SupApp == null) return; 118 | 119 | const menu = SupApp.createMenu(); 120 | menu.append(SupApp.createMenuItem({ 121 | label: SupClient.i18n.t("common:actions.cut"), 122 | accelerator: "CmdOrCtrl+X", 123 | click: () => { document.execCommand("cut"); } 124 | })); 125 | menu.append(SupApp.createMenuItem({ 126 | label: SupClient.i18n.t("common:actions.copy"), 127 | accelerator: "CmdOrCtrl+C", 128 | click: () => { document.execCommand("copy"); } 129 | })); 130 | menu.append(SupApp.createMenuItem({ 131 | label: SupClient.i18n.t("common:actions.paste"), 132 | accelerator: "CmdOrCtrl+V", 133 | click: () => { document.execCommand("paste"); } 134 | })); 135 | 136 | const win = SupApp.getCurrentWindow(); 137 | 138 | this.codeMirrorInstance.getWrapperElement().addEventListener("contextmenu", (event) => { 139 | event.preventDefault(); 140 | menu.popup({ window: win }); 141 | return false; 142 | }); 143 | } 144 | 145 | private updateTheme() { 146 | if (textEditorUserSettings.pub.theme === "default") { 147 | this.linkElt.href = ""; 148 | this.codeMirrorInstance.setOption("theme", textEditorUserSettings.pub.theme); 149 | } else { 150 | this.linkElt.href = `../../../../common/textEditorWidget/codemirror/theme/${textEditorUserSettings.pub.theme}.css`; 151 | this.codeMirrorInstance.setOption("theme", textEditorUserSettings.pub.theme); 152 | } 153 | } 154 | 155 | setText(text: string) { 156 | this.undoStack.length = 0; 157 | this.undoQuantityByAction.length = 0; this.undoQuantityByAction.push(0); 158 | this.redoStack.length = 0; 159 | this.redoQuantityByAction.length = 0; this.redoQuantityByAction.push(0); 160 | 161 | this.codeMirrorInstance.getDoc().setValue(text); 162 | this.codeMirrorInstance.setOption("readOnly", false); 163 | 164 | this.codeMirrorInstance.focus(); 165 | } 166 | 167 | beforeChange = (instance: CodeMirror.Editor, change: any) => { 168 | if (change.origin === "setValue" || change.origin === "network") return; 169 | const lastText = instance.getDoc().getValue(); 170 | if (lastText !== this.texts[this.texts.length - 1]) this.texts.push(lastText); 171 | } 172 | 173 | edit = (instance: CodeMirror.Editor, changes: CodeMirror.EditorChange[]) => { 174 | if (this.editCallback != null) 175 | this.editCallback(this.codeMirrorInstance.getDoc().getValue(), (changes[0]).origin); 176 | 177 | let undoRedo = false; 178 | let operationToSend: OT.TextOperation; 179 | for (let changeIndex = 0; changeIndex < changes.length; changeIndex++) { 180 | const change = changes[changeIndex]; 181 | 182 | // Modification from an other person 183 | if (change.origin === "setValue" || change.origin === "network") continue; 184 | 185 | this.tmpCodeMirrorDoc.setValue(this.texts[changeIndex]); 186 | 187 | const operation = new OT.TextOperation(this.clientId); 188 | for (let line = 0; line < change.from.line; line++) operation.retain(this.tmpCodeMirrorDoc.getLine(line).length + 1); 189 | operation.retain(change.from.ch); 190 | 191 | let offset = 0; 192 | if (change.removed.length !== 1 || change.removed[0] !== "") { 193 | for (let index = 0; index < change.removed.length; index++) { 194 | const text = change.removed[index]; 195 | if (index !== 0) { 196 | operation.delete("\n"); 197 | offset += 1; 198 | } 199 | 200 | operation.delete(text); 201 | offset += text.length; 202 | } 203 | } 204 | 205 | if (change.text.length !== 1 || change.text[0] !== "") { 206 | for (let index = 0; index < change.text.length; index++) { 207 | if (index !== 0) operation.insert("\n"); 208 | operation.insert(change.text[index]); 209 | } 210 | } 211 | 212 | const beforeLength = (operation.ops[0].attributes.amount != null) ? operation.ops[0].attributes.amount : 0; 213 | operation.retain(this.tmpCodeMirrorDoc.getValue().length - beforeLength - offset); 214 | 215 | if (operationToSend == null) operationToSend = operation.clone(); 216 | else operationToSend = operationToSend.compose(operation); 217 | 218 | if (change.origin === "undo" || change.origin === "redo") undoRedo = true; 219 | } 220 | 221 | this.texts.length = 0; 222 | if (operationToSend == null) return; 223 | 224 | if (!undoRedo) { 225 | if (this.undoTimeout != null) { 226 | clearTimeout(this.undoTimeout); 227 | this.undoTimeout = null; 228 | } 229 | 230 | this.undoStack.push(operationToSend.clone().invert()); 231 | this.undoQuantityByAction[this.undoQuantityByAction.length - 1] += 1; 232 | if (this.undoQuantityByAction[this.undoQuantityByAction.length - 1] > 20) this.undoQuantityByAction.push(0); 233 | else { 234 | this.undoTimeout = window.setTimeout(() => { 235 | this.undoTimeout = null; 236 | this.undoQuantityByAction.push(0); 237 | }, 500); 238 | } 239 | 240 | this.redoStack.length = 0; 241 | this.redoQuantityByAction.length = 0; 242 | } 243 | 244 | if (this.sentOperation == null) { 245 | this.sendOperationCallback(operationToSend.serialize()); 246 | 247 | this.sentOperation = operationToSend; 248 | } else { 249 | if (this.pendingOperation != null) this.pendingOperation = this.pendingOperation.compose(operationToSend); 250 | else this.pendingOperation = operationToSend; 251 | } 252 | } 253 | 254 | receiveEditText(operationData: OperationData) { 255 | if (this.clientId === operationData.userId) { 256 | if (this.pendingOperation != null) { 257 | this.sendOperationCallback(this.pendingOperation.serialize()); 258 | 259 | this.sentOperation = this.pendingOperation; 260 | this.pendingOperation = null; 261 | } else this.sentOperation = null; 262 | return; 263 | } 264 | 265 | // Transform operation and local changes 266 | let operation = new OT.TextOperation(); 267 | operation.deserialize(operationData); 268 | 269 | if (this.sentOperation != null) { 270 | [this.sentOperation, operation] = this.sentOperation.transform(operation); 271 | 272 | if (this.pendingOperation != null) [this.pendingOperation, operation] = this.pendingOperation.transform(operation); 273 | } 274 | this.undoStack = transformStack(this.undoStack, operation); 275 | this.redoStack = transformStack(this.redoStack, operation); 276 | 277 | this.applyOperation(operation.clone(), "network", false); 278 | } 279 | 280 | applyOperation(operation: OT.TextOperation, origin: string, moveCursor: boolean) { 281 | let cursorPosition = 0; 282 | let line = 0; 283 | for (const op of operation.ops) { 284 | switch (op.type) { 285 | case "retain": { 286 | while (true) { 287 | if (op.attributes.amount <= this.codeMirrorInstance.getDoc().getLine(line).length - cursorPosition) break; 288 | 289 | op.attributes.amount -= this.codeMirrorInstance.getDoc().getLine(line).length + 1 - cursorPosition; 290 | cursorPosition = 0; 291 | line++; 292 | } 293 | 294 | cursorPosition += op.attributes.amount; 295 | } 296 | break; 297 | 298 | case "insert": { 299 | const cursor = this.codeMirrorInstance.getDoc().getCursor(); 300 | 301 | const texts = op.attributes.text.split("\n"); 302 | for (let textIndex = 0; textIndex < texts.length; textIndex++) { 303 | let text = texts[textIndex]; 304 | if (textIndex !== texts.length - 1) text += "\n"; 305 | (this.codeMirrorInstance).replaceRange(text, { line, ch: cursorPosition }, null, origin); 306 | cursorPosition += text.length; 307 | 308 | if (textIndex !== texts.length - 1) { 309 | cursorPosition = 0; 310 | line++; 311 | } 312 | } 313 | 314 | if (line === cursor.line && cursorPosition === cursor.ch) { 315 | if (!operation.gotPriority(this.clientId)) { 316 | for (let i = 0; i < op.attributes.text.length; i++) (this.codeMirrorInstance).execCommand("goCharLeft"); 317 | } 318 | } 319 | 320 | if (moveCursor) (this.codeMirrorInstance).setCursor(line, cursorPosition); 321 | // use this way insted ? this.codeMirrorInstance.getDoc().setCursor({ line, ch: cursorPosition }); 322 | } 323 | break; 324 | 325 | case "delete": { 326 | const texts = op.attributes.text.split("\n"); 327 | 328 | for (let textIndex = 0; textIndex < texts.length; textIndex++) { 329 | const text = texts[textIndex]; 330 | if (texts[textIndex + 1] != null) (this.codeMirrorInstance).replaceRange("", { line, ch: cursorPosition }, { line: line + 1, ch: 0 }, origin); 331 | else (this.codeMirrorInstance).replaceRange("", { line, ch: cursorPosition }, { line, ch: cursorPosition + text.length }, origin); 332 | 333 | if (moveCursor) (this.codeMirrorInstance).setCursor(line, cursorPosition); 334 | } 335 | break; 336 | } 337 | } 338 | } 339 | } 340 | 341 | undo() { 342 | if (this.undoStack.length === 0) return; 343 | 344 | if (this.undoQuantityByAction[this.undoQuantityByAction.length - 1] === 0) this.undoQuantityByAction.pop(); 345 | const undoQuantityByAction = this.undoQuantityByAction[this.undoQuantityByAction.length - 1]; 346 | 347 | for (let i = 0; i < undoQuantityByAction; i++) { 348 | const operationToUndo = this.undoStack[this.undoStack.length - 1]; 349 | this.applyOperation(operationToUndo.clone(), "undo", true); 350 | 351 | this.undoStack.pop(); 352 | this.redoStack.push(operationToUndo.invert()); 353 | } 354 | 355 | if (this.undoTimeout != null) { 356 | clearTimeout(this.undoTimeout); 357 | this.undoTimeout = null; 358 | } 359 | 360 | this.redoQuantityByAction.push(this.undoQuantityByAction[this.undoQuantityByAction.length - 1]); 361 | this.undoQuantityByAction[this.undoQuantityByAction.length - 1] = 0; 362 | } 363 | 364 | redo() { 365 | if (this.redoStack.length === 0) return; 366 | 367 | const redoQuantityByAction = this.redoQuantityByAction[this.redoQuantityByAction.length - 1]; 368 | for (let i = 0; i < redoQuantityByAction; i++) { 369 | const operationToRedo = this.redoStack[this.redoStack.length - 1]; 370 | this.applyOperation(operationToRedo.clone(), "undo", true); 371 | 372 | this.redoStack.pop(); 373 | this.undoStack.push(operationToRedo.invert()); 374 | } 375 | 376 | if (this.undoTimeout != null) { 377 | clearTimeout(this.undoTimeout); 378 | this.undoTimeout = null; 379 | 380 | this.undoQuantityByAction.push(this.redoQuantityByAction[this.redoQuantityByAction.length - 1]); 381 | } 382 | else this.undoQuantityByAction[this.undoQuantityByAction.length - 1] = this.redoQuantityByAction[this.redoQuantityByAction.length - 1]; 383 | 384 | this.undoQuantityByAction.push(0); 385 | this.redoQuantityByAction.pop(); 386 | } 387 | 388 | clear() { 389 | if (this.undoTimeout != null) clearTimeout(this.undoTimeout); 390 | } 391 | 392 | onResourceReceived = (resourceId: string, resource: TextEditorSettingsResource) => { 393 | this.textEditorResource = resource; 394 | 395 | this.codeMirrorInstance.setOption("tabSize", resource.pub.tabSize); 396 | this.codeMirrorInstance.setOption("indentUnit", resource.pub.tabSize); 397 | this.codeMirrorInstance.setOption("indentWithTabs", !resource.pub.softTab); 398 | this.useSoftTab = resource.pub.softTab; 399 | } 400 | 401 | onResourceEdited = (resourceId: string, command: string, propertyName: string) => { 402 | switch (propertyName) { 403 | case "tabSize": 404 | this.codeMirrorInstance.setOption("tabSize", this.textEditorResource.pub.tabSize); 405 | this.codeMirrorInstance.setOption("indentUnit", this.textEditorResource.pub.tabSize); 406 | break; 407 | case "softTab": 408 | this.useSoftTab = this.textEditorResource.pub.softTab; 409 | this.codeMirrorInstance.setOption("indentWithTabs", !this.textEditorResource.pub.softTab); 410 | break; 411 | } 412 | } 413 | } 414 | export = TextEditorWidget; 415 | 416 | function transformStack(stack: OT.TextOperation[], operation: OT.TextOperation) { 417 | if (stack.length === 0) return stack; 418 | 419 | const newStack: OT.TextOperation[] = []; 420 | for (let i = stack.length - 1; i > 0; i--) { 421 | const pair = stack[i].transform(operation); 422 | newStack.push(pair[0]); 423 | operation = pair[1]; 424 | } 425 | return newStack.reverse(); 426 | } 427 | -------------------------------------------------------------------------------- /textEditorWidget/widgetGulpfile.js: -------------------------------------------------------------------------------- 1 | const gulp = require("gulp"); 2 | const tasks = [ "stylus", "copy-cm-modes", "copy-cm-themes" ]; 3 | 4 | // Stylus 5 | const stylus = require("gulp-stylus"); 6 | const concatCss = require("gulp-concat-css"); 7 | 8 | gulp.task("stylus", () => gulp.src("./widget/widget.styl").pipe(stylus({ errors: true, compress: true })).pipe(concatCss("widget.css")).pipe(gulp.dest("./public/"))); 9 | 10 | // Browserify 11 | const browserify = require("browserify"); 12 | const source = require("vinyl-source-stream"); 13 | 14 | function makeBrowserify(src, dest, output) { 15 | gulp.task(`${output}-browserify`, () => { 16 | return browserify(src, { standalone: "TextEditorWidget" }) 17 | .transform("brfs").bundle() 18 | .pipe(source(`${output}.js`)) 19 | .pipe(gulp.dest(dest)); 20 | }); 21 | tasks.push(`${output}-browserify`); 22 | } 23 | 24 | makeBrowserify("./widget/widget.js", "./public/", "widget"); 25 | 26 | // Copy CodeMirror modes 27 | gulp.task("copy-cm-modes", () => gulp.src([ "node_modules/codemirror/mode/**/*" ]).pipe(gulp.dest("public/codemirror/mode"))); 28 | 29 | // Copy CodeMirror themes 30 | gulp.task("copy-cm-themes", () => gulp.src([ "node_modules/codemirror/theme/**/*" ]).pipe(gulp.dest("public/codemirror/theme"))); 31 | 32 | // All 33 | gulp.task("default", gulp.parallel(tasks)); 34 | -------------------------------------------------------------------------------- /three/helpers.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare namespace SupTHREE { 4 | export class GridHelper { 5 | constructor(root: THREE.Object3D, size: number, step: number, opacity?: number); 6 | setup(size: number, step: number, opacity: number): this; 7 | setVisible(visible: boolean): this; 8 | } 9 | 10 | export class SelectionBoxRenderer { 11 | constructor(root: THREE.Object3D); 12 | setTarget(target: THREE.Object3D): this; 13 | move(): this; 14 | resize(): this; 15 | hide(): this; 16 | } 17 | 18 | export class TransformControls extends THREE.Object3D { 19 | translationSnap: number; 20 | rotationSnap: number; 21 | root: THREE.Object3D; 22 | 23 | constructor(scene: THREE.Scene, threeCamera: SupTHREE.Camera, canvas: HTMLCanvasElement); 24 | dispose(): void; 25 | 26 | setVisible(visible: boolean): this; 27 | attach(object: THREE.Object3D): this; 28 | detach(): this; 29 | 30 | update(): void; 31 | getMode(): string; 32 | setMode(mode: string): this; 33 | setSpace(space: string): this; 34 | enable(): this; 35 | disable(): this; 36 | } 37 | 38 | export class TransformMarker { 39 | constructor(root: THREE.Object3D); 40 | move(target: THREE.Object3D): this; 41 | hide(): this; 42 | } 43 | 44 | type ColorName = "white"|"red"|"green"|"blue"|"yellow"|"cyan"|"magenta"; 45 | export class GizmoMaterial extends THREE.MeshBasicMaterial { 46 | constructor(parameters?: THREE.MeshBasicMaterialParameters); 47 | 48 | setColor(colorName: ColorName): void; 49 | highlight(highlighted: boolean): void; 50 | setDisabled(disabled: boolean): void; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /three/helpers/GridHelper.ts: -------------------------------------------------------------------------------- 1 | export default class GridHelper { 2 | private gridHelper: THREE.GridHelper; 3 | 4 | constructor(private root: THREE.Object3D, size: number, step: number, opacity: number = 0.25) { 5 | this.setup(size, step, opacity); 6 | } 7 | 8 | setup(size: number, step: number, opacity: number) { 9 | if (this.gridHelper != null) { 10 | this.root.remove(this.gridHelper); 11 | this.gridHelper.geometry.dispose(); 12 | this.gridHelper.material.dispose(); 13 | } 14 | 15 | const divisions = Math.ceil(size / step); 16 | const actualSize = divisions * step; 17 | 18 | this.gridHelper = new THREE.GridHelper(actualSize, divisions, 0xffffff, 0xffffff); 19 | this.gridHelper.material.transparent = true; 20 | this.gridHelper.material.opacity = opacity; 21 | 22 | this.root.add(this.gridHelper); 23 | this.gridHelper.updateMatrixWorld(false); 24 | 25 | return this; 26 | } 27 | 28 | setVisible(visible: boolean) { 29 | this.gridHelper.visible = visible; 30 | return this; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /three/helpers/SelectionBoxRenderer.ts: -------------------------------------------------------------------------------- 1 | export default class SelectionBoxRenderer { 2 | private mesh: THREE.Mesh; 3 | private target: THREE.Object3D; 4 | 5 | constructor(root: THREE.Object3D) { 6 | this.mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial({ color: 0x00ffff, side: THREE.BackSide })); 7 | root.add(this.mesh); 8 | } 9 | 10 | setTarget(target: THREE.Object3D) { 11 | this.target = target; 12 | this.mesh.visible = true; 13 | this.move(); 14 | this.resize(); 15 | return this; 16 | } 17 | 18 | move() { 19 | this.mesh.position.copy(this.target.getWorldPosition()); 20 | this.mesh.quaternion.copy(this.target.getWorldQuaternion()); 21 | this.mesh.updateMatrixWorld(false); 22 | return this; 23 | } 24 | 25 | resize() { 26 | const vec = new THREE.Vector3(); 27 | const box = new THREE.Box3(); 28 | const inverseTargetMatrixWorld = new THREE.Matrix4().compose(this.target.getWorldPosition(), this.target.getWorldQuaternion(), { x: 1, y: 1, z: 1 } as THREE.Vector3); 29 | 30 | inverseTargetMatrixWorld.getInverse(inverseTargetMatrixWorld); 31 | 32 | this.target.traverse((node: THREE.Mesh) => { 33 | const geometry = node.geometry; 34 | 35 | if (geometry != null) { 36 | node.updateMatrixWorld(false); 37 | 38 | if (geometry instanceof THREE.Geometry) { 39 | const vertices = geometry.vertices; 40 | 41 | for (let i = 0, il = vertices.length; i < il; i++) { 42 | vec.copy(vertices[i]).applyMatrix4(node.matrixWorld).applyMatrix4(inverseTargetMatrixWorld); 43 | box.expandByPoint(vec); 44 | } 45 | 46 | } else if (geometry instanceof THREE.BufferGeometry && (geometry.attributes as any)["position"] != null) { 47 | const positions: Float32Array = (geometry.attributes as any)["position"].array; 48 | 49 | for (let i = 0, il = positions.length; i < il; i += 3) { 50 | vec.set(positions[i], positions[i + 1], positions[i + 2]); 51 | vec.applyMatrix4(node.matrixWorld).applyMatrix4(inverseTargetMatrixWorld); 52 | box.expandByPoint(vec); 53 | } 54 | } 55 | } 56 | }); 57 | 58 | const size = box.getSize(); 59 | const thickness = 0.1; 60 | this.mesh.scale.copy(size).add(new THREE.Vector3(thickness, thickness, thickness)); 61 | this.mesh.updateMatrixWorld(false); 62 | return this; 63 | } 64 | 65 | hide() { 66 | this.mesh.visible = false; 67 | return this; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /three/helpers/TransformControls.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * https://github.com/mrdoob/three.js/blob/master/examples/js/controls/TransformControls.js 3 | * Rewritten in TypeScript and modified by bilou84 4 | */ 5 | 6 | import { TransformGizmo, TransformGizmoTranslate, TransformGizmoRotate, TransformGizmoScale, TransformGizmoResize } from "./TransformGizmos"; 7 | 8 | const ray = new THREE.Raycaster(); 9 | const pointerVector = new THREE.Vector2(); 10 | 11 | const point = new THREE.Vector3(); 12 | const offset = new THREE.Vector3(); 13 | 14 | const rotation = new THREE.Vector3(); 15 | const offsetRotation = new THREE.Vector3(); 16 | 17 | const lookAtMatrix = new THREE.Matrix4(); 18 | const eye = new THREE.Vector3(); 19 | 20 | const tempMatrix = new THREE.Matrix4(); 21 | const tempVector = new THREE.Vector3(); 22 | const tempQuaternion = new THREE.Quaternion(); 23 | const unitX = new THREE.Vector3(1, 0, 0); 24 | const unitY = new THREE.Vector3(0, 1, 0); 25 | const unitZ = new THREE.Vector3(0, 0, 1); 26 | 27 | const quaternionXYZ = new THREE.Quaternion(); 28 | const quaternionX = new THREE.Quaternion(); 29 | const quaternionY = new THREE.Quaternion(); 30 | const quaternionZ = new THREE.Quaternion(); 31 | const quaternionE = new THREE.Quaternion(); 32 | 33 | const oldPosition = new THREE.Vector3(); 34 | const oldScale = new THREE.Vector3(); 35 | const oldRotationMatrix = new THREE.Matrix4(); 36 | 37 | const parentRotationMatrix = new THREE.Matrix4(); 38 | const parentScale = new THREE.Vector3(); 39 | 40 | const worldPosition = new THREE.Vector3(); 41 | const worldRotation = new THREE.Euler(); 42 | const worldRotationMatrix = new THREE.Matrix4(); 43 | const camPosition = new THREE.Vector3(); 44 | const camRotation = new THREE.Euler(); 45 | 46 | export default class TransformControls extends THREE.Object3D { 47 | visible = false; 48 | translationSnap: number; 49 | rotationSnap: number; 50 | root = new THREE.Object3D(); 51 | 52 | private target: THREE.Object3D; 53 | private externVisible = true; 54 | private size = 1; 55 | private axis: string; 56 | private mode: "translate"|"rotate"|"scale"|"resize" = "translate"; 57 | private space = "local"; 58 | private dragging = false; 59 | private disabled = true; 60 | private gizmo: { [name: string]: TransformGizmo; } = { 61 | "translate": new TransformGizmoTranslate(), 62 | "rotate": new TransformGizmoRotate(), 63 | "scale": new TransformGizmoScale(), 64 | "resize": new TransformGizmoResize() 65 | }; 66 | 67 | private changeEvent = { type: "change", target: null as any }; 68 | private mouseDownEvent = { type: "mouseDown", target: null as any }; 69 | private mouseUpEvent = { type: "mouseUp", mode: this.mode, target: null as any }; 70 | private objectChangeEvent = { type: "objectChange", target: null as any }; 71 | 72 | constructor(scene: THREE.Scene, private camera: SupTHREE.Camera, private domElement: HTMLElement) { 73 | super(); 74 | 75 | scene.add(this); 76 | scene.add(this.root); 77 | 78 | for (const type in this.gizmo) { 79 | const gizmoObj = this.gizmo[type]; 80 | gizmoObj.visible = (type === this.mode); 81 | this.add(gizmoObj); 82 | } 83 | 84 | this.enable(); 85 | } 86 | 87 | dispose() { 88 | this.disable(); 89 | } 90 | 91 | setVisible(visible: boolean) { 92 | this.externVisible = visible; 93 | this.visible = this.externVisible && this.target != null; 94 | return this; 95 | } 96 | 97 | attach(object: THREE.Object3D) { 98 | this.target = object; 99 | this.visible = this.externVisible; 100 | this.update(); 101 | return this; 102 | } 103 | 104 | detach() { 105 | this.target = null; 106 | this.visible = false; 107 | this.axis = null; 108 | return this; 109 | } 110 | 111 | getMode() { return this.mode; } 112 | 113 | setMode(mode: "translate"|"rotate"|"scale"|"resize") { 114 | this.mode = mode; 115 | 116 | for (const type in this.gizmo) this.gizmo[type].visible = (type === mode); 117 | 118 | if (this.target == null) return this; 119 | this.update(); 120 | this.dispatchEvent(this.changeEvent); 121 | return this; 122 | } 123 | 124 | setSize(size: number) { 125 | this.size = size; 126 | if (this.target == null) return this; 127 | 128 | this.update(); 129 | this.dispatchEvent(this.changeEvent); 130 | return this; 131 | } 132 | 133 | setSpace(space: string) { 134 | this.space = space; 135 | if (this.target == null) return this; 136 | 137 | this.update(); 138 | this.dispatchEvent(this.changeEvent); 139 | return this; 140 | } 141 | 142 | enable() { 143 | if (!this.disabled) return this; 144 | 145 | this.domElement.addEventListener("mousedown", this.onPointerDown, false); 146 | this.domElement.addEventListener("touchstart", this.onPointerDown, false); 147 | 148 | this.domElement.addEventListener("mousemove", this.onPointerHover, false); 149 | this.domElement.addEventListener("touchmove", this.onPointerHover, false); 150 | 151 | this.domElement.addEventListener("mousemove", this.onPointerMove, false); 152 | this.domElement.addEventListener("touchmove", this.onPointerMove, false); 153 | 154 | this.domElement.addEventListener("mouseup", this.onPointerUp, false); 155 | this.domElement.addEventListener("mouseout", this.onPointerUp, false); 156 | this.domElement.addEventListener("touchend", this.onPointerUp, false); 157 | this.domElement.addEventListener("touchcancel", this.onPointerUp, false); 158 | this.domElement.addEventListener("touchleave", this.onPointerUp, false); 159 | 160 | this.dragging = false; 161 | this.disabled = false; 162 | for (const gizmoName in this.gizmo) this.gizmo[gizmoName].setDisabled(false); 163 | 164 | return this; 165 | } 166 | 167 | disable() { 168 | if (this.disabled) return this; 169 | 170 | this.domElement.removeEventListener("mousedown", this.onPointerDown); 171 | this.domElement.removeEventListener("touchstart", this.onPointerDown); 172 | 173 | this.domElement.removeEventListener("mousemove", this.onPointerHover); 174 | this.domElement.removeEventListener("touchmove", this.onPointerHover); 175 | 176 | this.domElement.removeEventListener("mousemove", this.onPointerMove); 177 | this.domElement.removeEventListener("touchmove", this.onPointerMove); 178 | 179 | this.domElement.removeEventListener("mouseup", this.onPointerUp); 180 | this.domElement.removeEventListener("mouseout", this.onPointerUp); 181 | this.domElement.removeEventListener("touchend", this.onPointerUp); 182 | this.domElement.removeEventListener("touchcancel", this.onPointerUp); 183 | this.domElement.removeEventListener("touchleave", this.onPointerUp); 184 | 185 | this.dragging = false; 186 | this.disabled = true; 187 | for (const gizmoName in this.gizmo) this.gizmo[gizmoName].setDisabled(true); 188 | 189 | return this; 190 | } 191 | 192 | update(copyTarget = true) { 193 | if (this.target == null) return; 194 | 195 | if (copyTarget) { 196 | this.root.position.copy(this.target.getWorldPosition()); 197 | this.root.quaternion.copy(this.target.getWorldQuaternion()); 198 | 199 | const width = this.target.userData.width; 200 | const height = this.target.userData.height; 201 | const depth = this.target.userData.depth; 202 | 203 | if (this.mode === "resize") { 204 | this.root.scale.x = Math.abs(width); 205 | this.root.scale.y = Math.abs(height); 206 | this.root.scale.z = Math.abs(depth); 207 | } else { 208 | this.root.scale.x = (width < 0 ? -1 : 1) * this.target.scale.x; 209 | this.root.scale.y = (height < 0 ? -1 : 1) * this.target.scale.y; 210 | this.root.scale.z = (depth < 0 ? -1 : 1) * this.target.scale.z; 211 | } 212 | } 213 | this.root.updateMatrixWorld(false); 214 | worldPosition.setFromMatrixPosition(this.root.matrixWorld); 215 | 216 | // NOTE: Workaround for negative scales messing with extracted rotation — elisee 217 | const scaleX = this.root.scale.x / Math.abs(this.root.scale.x); 218 | const scaleY = this.root.scale.y / Math.abs(this.root.scale.y); 219 | const scaleZ = this.root.scale.z / Math.abs(this.root.scale.z); 220 | const negativeScaleFixMatrix = new THREE.Matrix4().makeScale(scaleX, scaleY, scaleZ); 221 | worldRotation.setFromRotationMatrix(tempMatrix.extractRotation(this.root.matrixWorld).multiply(negativeScaleFixMatrix)); 222 | 223 | this.camera.threeCamera.updateMatrixWorld(false); 224 | camPosition.setFromMatrixPosition(this.camera.threeCamera.matrixWorld); 225 | camRotation.setFromRotationMatrix(tempMatrix.extractRotation(this.camera.threeCamera.matrixWorld)); 226 | 227 | const scale = worldPosition.distanceTo(camPosition) / 8 * this.size; 228 | this.position.copy(worldPosition); 229 | this.scale.set(scale, scale, scale); 230 | 231 | eye.copy(camPosition).sub(worldPosition).normalize(); 232 | 233 | if (this.space === "local" || this.mode === "scale" || this.mode === "resize") this.gizmo[this.mode].update(worldRotation, eye); 234 | else if (this.space === "world") this.gizmo[this.mode].update(new THREE.Euler(), eye); 235 | 236 | if (!this.disabled) this.gizmo[this.mode].highlight(this.axis); 237 | 238 | this.updateMatrixWorld(true); 239 | } 240 | 241 | private onPointerDown = (event: MouseEvent|TouchEvent) => { 242 | if (this.target == null || this.dragging || ((event as MouseEvent).button != null && (event as MouseEvent).button !== 0) || event.altKey) return; 243 | 244 | const pointer: MouseEvent = (event as any).changedTouches ? (event as any).changedTouches[ 0 ] : event; 245 | 246 | if (pointer.button === 0 || pointer.button == null) { 247 | const intersect = this.intersectObjects(pointer, this.gizmo[this.mode].pickersRoot.children); 248 | 249 | if (intersect != null) { 250 | event.preventDefault(); 251 | event.stopPropagation(); 252 | 253 | this.dispatchEvent(this.mouseDownEvent); 254 | this.axis = intersect.object.name; 255 | this.update(); 256 | 257 | eye.copy(camPosition).sub(worldPosition).normalize(); 258 | 259 | this.gizmo[this.mode].setActivePlane(this.axis, eye); 260 | 261 | const planeIntersect = this.intersectObjects(pointer, [ this.gizmo[this.mode].activePlane ]); 262 | 263 | if (planeIntersect != null) { 264 | oldPosition.copy(this.root.position); 265 | oldScale.copy(this.root.scale); 266 | 267 | oldRotationMatrix.extractRotation(this.root.matrix); 268 | worldRotationMatrix.extractRotation(this.root.matrixWorld); 269 | 270 | parentRotationMatrix.extractRotation(this.root.parent.matrixWorld); 271 | parentScale.setFromMatrixScale(tempMatrix.getInverse(this.root.parent.matrixWorld)); 272 | 273 | offset.copy(planeIntersect.point); 274 | } 275 | } 276 | } 277 | 278 | this.dragging = true; 279 | } 280 | 281 | private onPointerHover = (event: MouseEvent|TouchEvent) => { 282 | if (this.target == null || this.dragging || ((event as MouseEvent).button != null && (event as MouseEvent).button !== 0)) return; 283 | 284 | const pointer: MouseEvent = (event as any).changedTouches ? (event as any).changedTouches[ 0 ] : event; 285 | 286 | let newAxis: string; 287 | const intersect = this.intersectObjects(pointer, this.gizmo[this.mode].pickersRoot.children); 288 | 289 | if (intersect != null) { 290 | newAxis = intersect.object.name; 291 | event.preventDefault(); 292 | } 293 | 294 | if (this.axis !== newAxis) { 295 | this.axis = newAxis; 296 | this.update(); 297 | this.dispatchEvent(this.changeEvent); 298 | } 299 | } 300 | 301 | private onPointerMove = (event: MouseEvent|TouchEvent) => { 302 | if (this.target == null || this.axis == null || !this.dragging || ((event as MouseEvent).button != null && (event as MouseEvent).button !== 0)) return; 303 | 304 | const pointer: MouseEvent = (event as any).changedTouches ? (event as any).changedTouches[ 0 ] : event; 305 | 306 | const planeIntersect = this.intersectObjects(pointer, [ this.gizmo[this.mode].activePlane ]); 307 | if (planeIntersect == null) return; 308 | 309 | event.preventDefault(); 310 | event.stopPropagation(); 311 | 312 | point.copy(planeIntersect.point); 313 | 314 | switch (this.mode) { 315 | case "translate": { 316 | point.sub(offset); 317 | point.multiply(parentScale); 318 | 319 | if (this.space === "local") { 320 | point.applyMatrix4(tempMatrix.getInverse(worldRotationMatrix)); 321 | 322 | if (this.axis.search("X") === -1) point.x = 0; 323 | if (this.axis.search("Y") === -1) point.y = 0; 324 | if (this.axis.search("Z") === -1) point.z = 0; 325 | 326 | point.applyMatrix4(oldRotationMatrix); 327 | 328 | this.root.position.copy(oldPosition); 329 | this.root.position.add(point); 330 | } 331 | 332 | if (this.space === "world" || this.axis.search("XYZ") !== -1) { 333 | if (this.axis.search("X") === -1) point.x = 0; 334 | if (this.axis.search("Y") === -1) point.y = 0; 335 | if (this.axis.search("Z") === -1) point.z = 0; 336 | 337 | point.applyMatrix4(tempMatrix.getInverse(parentRotationMatrix)); 338 | 339 | this.root.position.copy(oldPosition); 340 | this.root.position.add(point); 341 | } 342 | 343 | if (this.translationSnap !== null) { 344 | if (this.space === "local") this.root.position.sub(worldPosition).applyMatrix4(tempMatrix.getInverse(worldRotationMatrix)); 345 | 346 | if (this.axis.search("X") !== -1) this.root.position.x = Math.round(this.root.position.x / this.translationSnap) * this.translationSnap; 347 | if (this.axis.search("Y") !== -1) this.root.position.y = Math.round(this.root.position.y / this.translationSnap) * this.translationSnap; 348 | if (this.axis.search("Z") !== -1) this.root.position.z = Math.round(this.root.position.z / this.translationSnap) * this.translationSnap; 349 | 350 | if (this.space === "local") this.root.position.applyMatrix4(worldRotationMatrix).add(worldPosition); 351 | } 352 | } break; 353 | 354 | case "scale": { 355 | point.sub(offset); 356 | point.multiply(parentScale); 357 | 358 | if (this.axis === "XYZ") { 359 | const scale = 1 + ( (point.y) / Math.max(oldScale.x, oldScale.y, oldScale.z)); 360 | 361 | this.root.scale.x = oldScale.x * scale; 362 | this.root.scale.y = oldScale.y * scale; 363 | this.root.scale.z = oldScale.z * scale; 364 | } else { 365 | point.applyMatrix4(tempMatrix.getInverse(worldRotationMatrix)); 366 | 367 | if (this.axis === "X") this.root.scale.x = oldScale.x * (1 + point.x / oldScale.x); 368 | if (this.axis === "Y") this.root.scale.y = oldScale.y * (1 + point.y / oldScale.y); 369 | if (this.axis === "Z") this.root.scale.z = oldScale.z * (1 + point.z / oldScale.z); 370 | } 371 | } break; 372 | 373 | case "resize": { 374 | point.sub(offset); 375 | point.multiply(parentScale); 376 | 377 | const multiplier = 16; 378 | 379 | if (this.axis === "XYZ") { 380 | const scale = 1 + ( (point.y) / Math.max(oldScale.x, oldScale.y, oldScale.z) * multiplier); 381 | 382 | this.root.scale.x = oldScale.x * scale; 383 | this.root.scale.y = oldScale.y * scale; 384 | this.root.scale.z = oldScale.z * scale; 385 | } else { 386 | point.applyMatrix4(tempMatrix.getInverse(worldRotationMatrix)); 387 | 388 | if (this.axis === "X") this.root.scale.x = oldScale.x * (1 + point.x / oldScale.x * multiplier); 389 | if (this.axis === "Y") this.root.scale.y = oldScale.y * (1 + point.y / oldScale.y * multiplier); 390 | if (this.axis === "Z") this.root.scale.z = oldScale.z * (1 + point.z / oldScale.z * multiplier); 391 | } 392 | 393 | this.root.scale.x = Math.round(Math.max(1, this.root.scale.x)); 394 | this.root.scale.y = Math.round(Math.max(1, this.root.scale.y)); 395 | this.root.scale.z = Math.round(Math.max(1, this.root.scale.z)); 396 | } break; 397 | 398 | case "rotate": { 399 | point.sub(worldPosition); 400 | point.multiply(parentScale); 401 | tempVector.copy(offset).sub(worldPosition); 402 | tempVector.multiply(parentScale); 403 | 404 | if (this.axis === "E") { 405 | point.applyMatrix4(tempMatrix.getInverse(lookAtMatrix)); 406 | tempVector.applyMatrix4(tempMatrix.getInverse(lookAtMatrix)); 407 | 408 | rotation.set(Math.atan2(point.z, point.y), Math.atan2(point.x, point.z), Math.atan2(point.y, point.x)); 409 | offsetRotation.set(Math.atan2(tempVector.z, tempVector.y), Math.atan2(tempVector.x, tempVector.z), Math.atan2(tempVector.y, tempVector.x)); 410 | 411 | tempQuaternion.setFromRotationMatrix(tempMatrix.getInverse(parentRotationMatrix)); 412 | 413 | quaternionE.setFromAxisAngle(eye, rotation.z - offsetRotation.z); 414 | quaternionXYZ.setFromRotationMatrix(worldRotationMatrix); 415 | 416 | tempQuaternion.multiplyQuaternions(tempQuaternion, quaternionE); 417 | tempQuaternion.multiplyQuaternions(tempQuaternion, quaternionXYZ); 418 | 419 | this.root.quaternion.copy(tempQuaternion); 420 | 421 | } else if (this.axis === "XYZE") { 422 | quaternionE.setFromEuler(point.clone().cross(tempVector).normalize() as any); // rotation axis 423 | 424 | tempQuaternion.setFromRotationMatrix(tempMatrix.getInverse(parentRotationMatrix)); 425 | quaternionX.setFromAxisAngle(quaternionE as any, - point.clone().angleTo(tempVector)); 426 | quaternionXYZ.setFromRotationMatrix(worldRotationMatrix); 427 | 428 | tempQuaternion.multiplyQuaternions(tempQuaternion, quaternionX); 429 | tempQuaternion.multiplyQuaternions(tempQuaternion, quaternionXYZ); 430 | 431 | this.root.quaternion.copy(tempQuaternion); 432 | 433 | } else if (this.space === "local") { 434 | point.applyMatrix4(tempMatrix.getInverse(worldRotationMatrix)); 435 | 436 | tempVector.applyMatrix4(tempMatrix.getInverse(worldRotationMatrix)); 437 | 438 | rotation.set(Math.atan2(point.z, point.y), Math.atan2(point.x, point.z), Math.atan2(point.y, point.x)); 439 | offsetRotation.set(Math.atan2(tempVector.z, tempVector.y), Math.atan2(tempVector.x, tempVector.z), Math.atan2(tempVector.y, tempVector.x)); 440 | 441 | quaternionXYZ.setFromRotationMatrix(oldRotationMatrix); 442 | 443 | if (this.rotationSnap !== null) { 444 | quaternionX.setFromAxisAngle(unitX, Math.round( (rotation.x - offsetRotation.x) / this.rotationSnap) * this.rotationSnap); 445 | quaternionY.setFromAxisAngle(unitY, Math.round( (rotation.y - offsetRotation.y) / this.rotationSnap) * this.rotationSnap); 446 | quaternionZ.setFromAxisAngle(unitZ, Math.round( (rotation.z - offsetRotation.z) / this.rotationSnap) * this.rotationSnap); 447 | } else { 448 | quaternionX.setFromAxisAngle(unitX, rotation.x - offsetRotation.x); 449 | quaternionY.setFromAxisAngle(unitY, rotation.y - offsetRotation.y); 450 | quaternionZ.setFromAxisAngle(unitZ, rotation.z - offsetRotation.z); 451 | } 452 | 453 | if (this.axis === "X") quaternionXYZ.multiplyQuaternions(quaternionXYZ, quaternionX); 454 | if (this.axis === "Y") quaternionXYZ.multiplyQuaternions(quaternionXYZ, quaternionY); 455 | if (this.axis === "Z") quaternionXYZ.multiplyQuaternions(quaternionXYZ, quaternionZ); 456 | 457 | this.root.quaternion.copy(quaternionXYZ); 458 | 459 | } else if (this.space === "world") { 460 | rotation.set(Math.atan2(point.z, point.y), Math.atan2(point.x, point.z), Math.atan2(point.y, point.x)); 461 | offsetRotation.set(Math.atan2(tempVector.z, tempVector.y), Math.atan2(tempVector.x, tempVector.z), Math.atan2(tempVector.y, tempVector.x)); 462 | 463 | tempQuaternion.setFromRotationMatrix(tempMatrix.getInverse(parentRotationMatrix)); 464 | 465 | if (this.rotationSnap !== null) { 466 | quaternionX.setFromAxisAngle(unitX, Math.round( (rotation.x - offsetRotation.x) / this.rotationSnap) * this.rotationSnap); 467 | quaternionY.setFromAxisAngle(unitY, Math.round( (rotation.y - offsetRotation.y) / this.rotationSnap) * this.rotationSnap); 468 | quaternionZ.setFromAxisAngle(unitZ, Math.round( (rotation.z - offsetRotation.z) / this.rotationSnap) * this.rotationSnap); 469 | } else { 470 | quaternionX.setFromAxisAngle(unitX, rotation.x - offsetRotation.x); 471 | quaternionY.setFromAxisAngle(unitY, rotation.y - offsetRotation.y); 472 | quaternionZ.setFromAxisAngle(unitZ, rotation.z - offsetRotation.z); 473 | } 474 | 475 | quaternionXYZ.setFromRotationMatrix(worldRotationMatrix); 476 | 477 | if (this.axis === "X") tempQuaternion.multiplyQuaternions(tempQuaternion, quaternionX); 478 | if (this.axis === "Y") tempQuaternion.multiplyQuaternions(tempQuaternion, quaternionY); 479 | if (this.axis === "Z") tempQuaternion.multiplyQuaternions(tempQuaternion, quaternionZ); 480 | 481 | tempQuaternion.multiplyQuaternions(tempQuaternion, quaternionXYZ); 482 | 483 | this.root.quaternion.copy(tempQuaternion); 484 | } 485 | } break; 486 | } 487 | 488 | this.update(false); 489 | this.dispatchEvent(this.changeEvent); 490 | this.dispatchEvent(this.objectChangeEvent); 491 | } 492 | 493 | private onPointerUp = (event: MouseEvent) => { 494 | if (event.button != null && event.button !== 0 || event.altKey) return; 495 | 496 | if (this.dragging && (this.axis !== null)) { 497 | this.mouseUpEvent.mode = this.mode; 498 | this.dispatchEvent(this.mouseUpEvent); 499 | } 500 | 501 | this.dragging = false; 502 | this.onPointerHover(event); 503 | } 504 | 505 | private intersectObjects(pointer: MouseEvent, objects: THREE.Object3D[]) { 506 | const rect = this.domElement.getBoundingClientRect(); 507 | const viewport = this.camera.getViewport(); 508 | 509 | pointerVector.x = ((pointer.clientX - rect.left) / rect.width * 2 - 1) / viewport.width; 510 | pointerVector.y = -((pointer.clientY - rect.top) / rect.height * 2 - 1) / viewport.height; 511 | ray.setFromCamera(pointerVector, this.camera.threeCamera); 512 | 513 | const intersections = ray.intersectObjects(objects, true); 514 | return intersections[0]; 515 | } 516 | } 517 | -------------------------------------------------------------------------------- /three/helpers/TransformGizmos.ts: -------------------------------------------------------------------------------- 1 | const lineRadius = 0.015; 2 | 3 | type ColorName = "white"|"red"|"green"|"blue"|"yellow"|"cyan"|"magenta"; 4 | const colors: { [colorName: string]: { enabled: number; disabled: number } } = { 5 | white: { enabled: 0xffffff, disabled: 0xffffff }, 6 | red: { enabled: 0xe5432e, disabled: 0x646464 }, 7 | green: { enabled: 0x5bd72f, disabled: 0xb0b0b0 }, 8 | blue: { enabled: 0x3961d4, disabled: 0x606060 }, 9 | yellow: { enabled: 0xffff00, disabled: 0xececec }, 10 | cyan: { enabled: 0x00ffff, disabled: 0xc8c8c8 }, 11 | magenta: { enabled: 0xff00ff, disabled: 0x484848 } 12 | }; 13 | 14 | export class GizmoMaterial extends THREE.MeshBasicMaterial { 15 | private enabledColor: THREE.Color; 16 | private disabledColor: THREE.Color; 17 | private oldOpacity: number; 18 | 19 | constructor(parameters?: THREE.MeshBasicMaterialParameters) { 20 | super(parameters); 21 | 22 | this.transparent = true; 23 | 24 | this.setValues(parameters); 25 | this.enabledColor = this.color.clone(); 26 | this.disabledColor = this.color.clone(); 27 | this.oldOpacity = this.opacity; 28 | } 29 | 30 | setColor(colorName: ColorName) { 31 | this.color.setHex(colors[colorName].enabled); 32 | this.enabledColor.setHex(colors[colorName].enabled); 33 | this.disabledColor.setHex(colors[colorName].disabled); 34 | } 35 | 36 | highlight(highlighted: boolean) { 37 | if (highlighted) { 38 | this.color.setRGB(1, 1, 0); 39 | this.opacity = 1; 40 | } else { 41 | this.color.copy(this.enabledColor); 42 | this.opacity = this.oldOpacity; 43 | } 44 | } 45 | 46 | setDisabled(disabled: boolean) { 47 | this.color.copy(disabled ? this.disabledColor : this.enabledColor); 48 | } 49 | } 50 | 51 | const pickerMaterial = new GizmoMaterial({ visible: false, transparent: false, side: THREE.DoubleSide }); 52 | 53 | export abstract class TransformGizmo extends THREE.Object3D { 54 | protected handlesRoot: THREE.Object3D; 55 | pickersRoot: THREE.Object3D; 56 | private planesRoot: THREE.Object3D; 57 | 58 | protected planes: { [name: string]: THREE.Mesh } = {}; 59 | activePlane: THREE.Mesh; 60 | 61 | constructor() { 62 | super(); 63 | 64 | this.handlesRoot = new THREE.Object3D(); 65 | this.pickersRoot = new THREE.Object3D(); 66 | this.planesRoot = new THREE.Object3D(); 67 | 68 | this.add(this.handlesRoot); 69 | this.add(this.pickersRoot); 70 | this.add(this.planesRoot); 71 | 72 | // Planes 73 | const planeGeometry = new THREE.PlaneBufferGeometry(50, 50, 2, 2); 74 | const planeMaterial = new THREE.MeshBasicMaterial({ visible: false, side: THREE.DoubleSide }); 75 | 76 | const planes: { [planeName: string]: THREE.Mesh; } = { 77 | "XY": new THREE.Mesh(planeGeometry, planeMaterial), 78 | "YZ": new THREE.Mesh(planeGeometry, planeMaterial), 79 | "XZ": new THREE.Mesh(planeGeometry, planeMaterial), 80 | "XYZE": new THREE.Mesh(planeGeometry, planeMaterial) 81 | }; 82 | 83 | this.activePlane = planes["XYZE"]; 84 | 85 | planes["YZ"].rotation.set(0, Math.PI / 2, 0); 86 | planes["XZ"].rotation.set(- Math.PI / 2, 0, 0); 87 | 88 | for (const planeName in planes) { 89 | planes[planeName].name = planeName; 90 | this.planesRoot.add(planes[planeName]); 91 | this.planes[planeName] = planes[planeName]; 92 | } 93 | 94 | // Handles and Pickers 95 | this.initGizmos(); 96 | 97 | // Reset Transformations 98 | this.traverse((child) => { 99 | child.layers.set(1); 100 | 101 | if (child instanceof THREE.Mesh) { 102 | child.updateMatrix(); 103 | 104 | const tempGeometry = child.geometry.clone(); 105 | tempGeometry.applyMatrix(child.matrix); 106 | child.geometry = tempGeometry; 107 | 108 | child.position.set(0, 0, 0); 109 | child.rotation.set(0, 0, 0); 110 | child.scale.set(1, 1, 1); 111 | } 112 | }); 113 | } 114 | 115 | highlight(axis: string) { 116 | this.traverse((child: any) => { 117 | if (child.material != null && child.material.highlight != null) { 118 | child.material.highlight(child.name === axis); 119 | } 120 | }); 121 | } 122 | 123 | setDisabled(disabled: boolean) { 124 | this.traverse((child: any) => { 125 | if (child.material != null && child.material.setDisabled != null) { 126 | child.material.setDisabled(disabled); 127 | } 128 | }); 129 | } 130 | 131 | setupGizmo(name: string, object: THREE.Mesh, parent: THREE.Object3D, position?: [number, number, number], rotation?: [number, number, number], colorName?: ColorName) { 132 | object.name = name; 133 | 134 | if (position != null) object.position.set(position[0], position[1], position[2]); 135 | if (rotation != null) object.rotation.set(rotation[0], rotation[1], rotation[2]); 136 | if (colorName != null) (object.material as GizmoMaterial).setColor(colorName); 137 | 138 | parent.add(object); 139 | } 140 | 141 | update(rotation: THREE.Euler, eye: THREE.Vector3) { 142 | const vec1 = new THREE.Vector3(0, 0, 0); 143 | const vec2 = new THREE.Vector3(0, 1, 0); 144 | const lookAtMatrix = new THREE.Matrix4(); 145 | 146 | this.traverse(function(child) { 147 | if (child.name.search("E") !== - 1) { 148 | child.quaternion.setFromRotationMatrix(lookAtMatrix.lookAt(eye, vec1, vec2)); 149 | } else if (child.name.search("X") !== - 1 || child.name.search("Y") !== - 1 || child.name.search("Z") !== - 1) { 150 | child.quaternion.setFromEuler(rotation); 151 | } 152 | }); 153 | } 154 | 155 | abstract initGizmos(): void; 156 | abstract setActivePlane(axis: string, eye: THREE.Vector3): void; 157 | } 158 | 159 | export class TransformGizmoTranslate extends TransformGizmo { 160 | initGizmos() { 161 | // Handles 162 | const geometry = new THREE.CylinderGeometry(0, 0.06, 0.2, 12, 1, false); 163 | const mesh = new THREE.Mesh(geometry); 164 | mesh.position.y = 0.5; 165 | mesh.updateMatrix(); 166 | 167 | const arrowGeometry = new THREE.Geometry(); 168 | arrowGeometry.merge(geometry, mesh.matrix); 169 | 170 | const lineGeometry = new THREE.CylinderGeometry(lineRadius, lineRadius, 1); 171 | 172 | this.setupGizmo("X", new THREE.Mesh(arrowGeometry, new GizmoMaterial()), this.handlesRoot, [ 0.5, 0, 0 ], [ 0, 0, - Math.PI / 2 ], "red"); 173 | this.setupGizmo("X", new THREE.Mesh(lineGeometry, new GizmoMaterial()), this.handlesRoot, [ 0.5, 0, 0 ], [ 0, 0, - Math.PI / 2 ], "red"); 174 | 175 | this.setupGizmo("Y", new THREE.Mesh(arrowGeometry, new GizmoMaterial()), this.handlesRoot, [ 0, 0.5, 0 ], null, "green"); 176 | this.setupGizmo("Y", new THREE.Mesh(lineGeometry, new GizmoMaterial()), this.handlesRoot, [ 0, 0.5, 0 ], null, "green"); 177 | 178 | this.setupGizmo("Z", new THREE.Mesh(arrowGeometry, new GizmoMaterial()), this.handlesRoot, [ 0, 0, 0.5 ], [ Math.PI / 2, 0, 0 ], "blue"); 179 | this.setupGizmo("Z", new THREE.Mesh(lineGeometry, new GizmoMaterial()), this.handlesRoot, [ 0, 0, 0.5 ], [ Math.PI / 2, 0, 0 ], "blue"); 180 | 181 | const handlePlaneGeometry = new THREE.PlaneBufferGeometry(0.29, 0.29); 182 | this.setupGizmo("XY", new THREE.Mesh(handlePlaneGeometry, new GizmoMaterial({ opacity: 0.5, side: THREE.DoubleSide })), this.handlesRoot, [ 0.15, 0.15, 0 ], null, "yellow"); 183 | this.setupGizmo("YZ", new THREE.Mesh(handlePlaneGeometry, new GizmoMaterial({ opacity: 0.5, side: THREE.DoubleSide })), this.handlesRoot, [ 0, 0.15, 0.15 ], [ 0, Math.PI / 2, 0 ], "cyan"); 184 | this.setupGizmo("XZ", new THREE.Mesh(handlePlaneGeometry, new GizmoMaterial({ opacity: 0.5, side: THREE.DoubleSide })), this.handlesRoot, [ 0.15, 0, 0.15 ], [ - Math.PI / 2, 0, 0 ], "magenta"); 185 | 186 | this.setupGizmo("XYZ", new THREE.Mesh(new THREE.OctahedronGeometry(0.1, 0), new GizmoMaterial({ opacity: 0.8 })), this.handlesRoot, [ 0, 0, 0 ], [ 0, 0, 0 ], "white"); 187 | 188 | // Pickers 189 | this.setupGizmo("X", new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0, 1, 4, 1, false), pickerMaterial), this.pickersRoot, [ 0.6, 0, 0 ], [ 0, 0, - Math.PI / 2 ]); 190 | this.setupGizmo("Y", new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0, 1, 4, 1, false), pickerMaterial), this.pickersRoot, [ 0, 0.6, 0 ]); 191 | this.setupGizmo("Z", new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0, 1, 4, 1, false), pickerMaterial), this.pickersRoot, [ 0, 0, 0.6 ], [ Math.PI / 2, 0, 0 ]); 192 | 193 | this.setupGizmo("XY", new THREE.Mesh(new THREE.PlaneBufferGeometry(0.4, 0.4), pickerMaterial), this.pickersRoot, [ 0.2, 0.2, 0 ]); 194 | this.setupGizmo("YZ", new THREE.Mesh(new THREE.PlaneBufferGeometry(0.4, 0.4), pickerMaterial), this.pickersRoot, [ 0, 0.2, 0.2 ], [ 0, Math.PI / 2, 0 ]); 195 | this.setupGizmo("XZ", new THREE.Mesh(new THREE.PlaneBufferGeometry(0.4, 0.4), pickerMaterial), this.pickersRoot, [ 0.2, 0, 0.2 ], [ - Math.PI / 2, 0, 0 ]); 196 | 197 | this.setupGizmo("XYZ", new THREE.Mesh(new THREE.OctahedronGeometry(0.2, 0), pickerMaterial), this.pickersRoot); 198 | } 199 | 200 | setActivePlane(axis: string, eye: THREE.Vector3) { 201 | const tempMatrix = new THREE.Matrix4(); 202 | eye.applyMatrix4(tempMatrix.getInverse(tempMatrix.extractRotation(this.planes["XY"].matrixWorld)) ); 203 | 204 | switch (axis) { 205 | case "X": 206 | if (Math.abs(eye.y) > Math.abs(eye.z)) this.activePlane = this.planes["XZ"]; 207 | else this.activePlane = this.planes["XY"]; 208 | break; 209 | case "Y": 210 | if (Math.abs(eye.x) > Math.abs(eye.z)) this.activePlane = this.planes["YZ"]; 211 | else this.activePlane = this.planes["XY"]; 212 | break; 213 | case "Z": 214 | if (Math.abs(eye.x) > Math.abs(eye.y)) this.activePlane = this.planes["YZ"]; 215 | else this.activePlane = this.planes["XZ"]; 216 | break; 217 | case "XYZ": 218 | this.activePlane = this.planes["XYZE"]; 219 | break; 220 | case "XY": 221 | case "YZ": 222 | case "XZ": 223 | this.activePlane = this.planes[axis]; 224 | break; 225 | } 226 | } 227 | } 228 | 229 | export class TransformGizmoRotate extends TransformGizmo { 230 | initGizmos() { 231 | const radius = 0.7; 232 | const globalRadius = radius * 1.2; 233 | 234 | // Handles 235 | const ringGeometry = new THREE.TorusGeometry(radius, lineRadius, 4, 32); 236 | this.setupGizmo("X", new THREE.Mesh(ringGeometry, new GizmoMaterial({ side: THREE.DoubleSide })), this.handlesRoot, null, [ 0, -Math.PI / 2, -Math.PI / 2 ], "red"); 237 | this.setupGizmo("Y", new THREE.Mesh(ringGeometry, new GizmoMaterial({ side: THREE.DoubleSide })), this.handlesRoot, null, [ Math.PI / 2, 0, 0 ], "green"); 238 | this.setupGizmo("Z", new THREE.Mesh(ringGeometry, new GizmoMaterial({ side: THREE.DoubleSide })), this.handlesRoot, null, [ 0, 0, -Math.PI / 2 ], "blue"); 239 | 240 | const globalRingGeometry = new THREE.RingGeometry(globalRadius - lineRadius, globalRadius + lineRadius, 32, 8); 241 | this.setupGizmo("E", new THREE.Mesh(globalRingGeometry, new GizmoMaterial({ opacity: 0.8, side: THREE.DoubleSide })), this.handlesRoot, null, null, "white"); 242 | 243 | // Pickers 244 | const pickerThickness = 0.08; 245 | 246 | const torusGeometry = new THREE.TorusGeometry(radius, lineRadius * 2, 4, 16); 247 | this.setupGizmo("X", new THREE.Mesh(torusGeometry, pickerMaterial), this.pickersRoot, null, [ 0, - Math.PI / 2, - Math.PI / 2 ]); 248 | this.setupGizmo("Y", new THREE.Mesh(torusGeometry, pickerMaterial), this.pickersRoot, null, [ Math.PI / 2, 0, 0 ]); 249 | this.setupGizmo("Z", new THREE.Mesh(torusGeometry, pickerMaterial), this.pickersRoot, null, [ 0, 0, - Math.PI / 2 ]); 250 | 251 | const globalTorusGeometry = new THREE.RingGeometry(globalRadius - pickerThickness, globalRadius + pickerThickness, 16, 8); 252 | this.setupGizmo("E", new THREE.Mesh(globalTorusGeometry, pickerMaterial), this.pickersRoot); 253 | } 254 | 255 | setActivePlane(axis: string) { 256 | if (axis === "X") this.activePlane = this.planes["YZ"]; 257 | else if (axis === "Y") this.activePlane = this.planes["XZ"]; 258 | else if (axis === "Z") this.activePlane = this.planes["XY"]; 259 | else if (axis === "E") this.activePlane = this.planes["XYZE"]; 260 | } 261 | } 262 | 263 | export class TransformGizmoScale extends TransformGizmo { 264 | initGizmos() { 265 | // Handles 266 | const geometry = new THREE.BoxGeometry(0.125, 0.125, 0.125); 267 | const mesh = new THREE.Mesh(geometry); 268 | mesh.position.y = 0.5; 269 | mesh.updateMatrix(); 270 | 271 | const arrowGeometry = new THREE.Geometry(); 272 | arrowGeometry.merge(geometry, mesh.matrix); 273 | 274 | const lineGeometry = new THREE.CylinderGeometry(lineRadius, lineRadius, 1); 275 | 276 | this.setupGizmo("X", new THREE.Mesh(arrowGeometry, new GizmoMaterial()), this.handlesRoot, [ 0.5, 0, 0 ], [ 0, 0, - Math.PI / 2 ], "red"); 277 | this.setupGizmo("X", new THREE.Mesh(lineGeometry, new GizmoMaterial()), this.handlesRoot, [ 0.5, 0, 0 ], [ 0, 0, - Math.PI / 2 ], "red"); 278 | 279 | this.setupGizmo("Y", new THREE.Mesh(arrowGeometry, new GizmoMaterial()), this.handlesRoot, [ 0, 0.5, 0 ], null, "green"); 280 | this.setupGizmo("Y", new THREE.Mesh(lineGeometry, new GizmoMaterial()), this.handlesRoot, [ 0, 0.5, 0 ], null, "green"); 281 | 282 | this.setupGizmo("Z", new THREE.Mesh(arrowGeometry, new GizmoMaterial()), this.handlesRoot, [ 0, 0, 0.5 ], [ Math.PI / 2, 0, 0 ], "blue"); 283 | this.setupGizmo("Z", new THREE.Mesh(lineGeometry, new GizmoMaterial()), this.handlesRoot, [ 0, 0, 0.5 ], [ Math.PI / 2, 0, 0 ], "blue"); 284 | 285 | this.setupGizmo("XYZ", new THREE.Mesh(new THREE.OctahedronGeometry(0.1, 0), new GizmoMaterial({ opacity: 0.8 })), this.handlesRoot, [ 0, 0, 0 ], [ 0, 0, 0 ], "white"); 286 | 287 | // Pickers 288 | this.setupGizmo("X", new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0, 1, 4, 1, false), pickerMaterial), this.pickersRoot, [ 0.6, 0, 0 ], [ 0, 0, - Math.PI / 2 ]); 289 | this.setupGizmo("Y", new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0, 1, 4, 1, false), pickerMaterial), this.pickersRoot, [ 0, 0.6, 0 ]); 290 | this.setupGizmo("Z", new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0, 1, 4, 1, false), pickerMaterial), this.pickersRoot, [ 0, 0, 0.6 ], [ Math.PI / 2, 0, 0 ]); 291 | 292 | this.setupGizmo("XYZ", new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.4, 0.4), pickerMaterial), this.pickersRoot); 293 | } 294 | 295 | setActivePlane(axis: string, eye: THREE.Vector3) { 296 | const tempMatrix = new THREE.Matrix4(); 297 | eye.applyMatrix4(tempMatrix.getInverse(tempMatrix.extractRotation(this.planes["XY"].matrixWorld))); 298 | 299 | if (axis === "X") { 300 | if (Math.abs(eye.y) > Math.abs(eye.z)) this.activePlane = this.planes["XZ"]; 301 | else this.activePlane = this.planes["XY"]; 302 | 303 | } else if (axis === "Y") { 304 | if (Math.abs(eye.x) > Math.abs(eye.z)) this.activePlane = this.planes["YZ"]; 305 | else this.activePlane = this.planes["XY"]; 306 | 307 | } else if (axis === "Z") { 308 | if (Math.abs(eye.x) > Math.abs(eye.y)) this.activePlane = this.planes["YZ"]; 309 | else this.activePlane = this.planes["XZ"]; 310 | 311 | } else if (axis === "XYZ") this.activePlane = this.planes["XYZE"]; 312 | } 313 | } 314 | 315 | export class TransformGizmoResize extends TransformGizmo { 316 | initGizmos() { 317 | // Handles 318 | const geometry = new THREE.BoxGeometry(0.2, 0.03, 0.2); 319 | const mesh = new THREE.Mesh(geometry); 320 | mesh.position.y = 0.5; 321 | mesh.updateMatrix(); 322 | 323 | const arrowGeometry = new THREE.Geometry(); 324 | arrowGeometry.merge(geometry, mesh.matrix); 325 | 326 | const lineGeometry = new THREE.CylinderGeometry(lineRadius, lineRadius, 1); 327 | 328 | this.setupGizmo("X", new THREE.Mesh(arrowGeometry, new GizmoMaterial()), this.handlesRoot, [ 0.5, 0, 0 ], [ 0, 0, - Math.PI / 2 ], "red"); 329 | this.setupGizmo("X", new THREE.Mesh(lineGeometry, new GizmoMaterial()), this.handlesRoot, [ 0.5, 0, 0 ], [ 0, 0, - Math.PI / 2 ], "red"); 330 | 331 | this.setupGizmo("Y", new THREE.Mesh(arrowGeometry, new GizmoMaterial()), this.handlesRoot, [ 0, 0.5, 0 ], null, "green"); 332 | this.setupGizmo("Y", new THREE.Mesh(lineGeometry, new GizmoMaterial()), this.handlesRoot, [ 0, 0.5, 0 ], null, "green"); 333 | 334 | this.setupGizmo("Z", new THREE.Mesh(arrowGeometry, new GizmoMaterial()), this.handlesRoot, [ 0, 0, 0.5 ], [ Math.PI / 2, 0, 0 ], "blue"); 335 | this.setupGizmo("Z", new THREE.Mesh(lineGeometry, new GizmoMaterial()), this.handlesRoot, [ 0, 0, 0.5 ], [ Math.PI / 2, 0, 0 ], "blue"); 336 | 337 | this.setupGizmo("XYZ", new THREE.Mesh(new THREE.OctahedronGeometry(0.1, 0), new GizmoMaterial({ opacity: 0.8 })), this.handlesRoot, [ 0, 0, 0 ], [ 0, 0, 0 ], "white"); 338 | 339 | // Pickers 340 | this.setupGizmo("X", new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0, 1, 4, 1, false), pickerMaterial), this.pickersRoot, [ 0.6, 0, 0 ], [ 0, 0, - Math.PI / 2 ]); 341 | this.setupGizmo("Y", new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0, 1, 4, 1, false), pickerMaterial), this.pickersRoot, [ 0, 0.6, 0 ]); 342 | this.setupGizmo("Z", new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0, 1, 4, 1, false), pickerMaterial), this.pickersRoot, [ 0, 0, 0.6 ], [ Math.PI / 2, 0, 0 ]); 343 | 344 | this.setupGizmo("XYZ", new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.4, 0.4), pickerMaterial), this.pickersRoot); 345 | } 346 | 347 | setActivePlane(axis: string, eye: THREE.Vector3) { 348 | const tempMatrix = new THREE.Matrix4(); 349 | eye.applyMatrix4(tempMatrix.getInverse(tempMatrix.extractRotation(this.planes["XY"].matrixWorld))); 350 | 351 | if (axis === "X") { 352 | if (Math.abs(eye.y) > Math.abs(eye.z)) this.activePlane = this.planes["XZ"]; 353 | else this.activePlane = this.planes["XY"]; 354 | 355 | } else if (axis === "Y") { 356 | if (Math.abs(eye.x) > Math.abs(eye.z)) this.activePlane = this.planes["YZ"]; 357 | else this.activePlane = this.planes["XY"]; 358 | 359 | } else if (axis === "Z") { 360 | if (Math.abs(eye.x) > Math.abs(eye.y)) this.activePlane = this.planes["YZ"]; 361 | else this.activePlane = this.planes["XZ"]; 362 | 363 | } else if (axis === "XYZ") this.activePlane = this.planes["XYZE"]; 364 | } 365 | } 366 | -------------------------------------------------------------------------------- /three/helpers/TransformMarker.ts: -------------------------------------------------------------------------------- 1 | export default class TransformMarker { 2 | private line: THREE.LineSegments; 3 | 4 | constructor(root: THREE.Object3D) { 5 | const geometry = new THREE.Geometry(); 6 | geometry.vertices.push( 7 | new THREE.Vector3( -0.25, 0, 0 ), new THREE.Vector3( 0.25, 0, 0 ), 8 | new THREE.Vector3( 0, -0.25, 0 ), new THREE.Vector3( 0, 0.25, 0 ), 9 | new THREE.Vector3( 0, 0, -0.25 ), new THREE.Vector3( 0, 0, 0.25 ) 10 | ); 11 | 12 | this.line = new THREE.LineSegments(geometry, new THREE.LineBasicMaterial({ color: 0xffffff, opacity: 0.25, transparent: true })); 13 | this.line.layers.set(1); 14 | root.add(this.line); 15 | this.line.updateMatrixWorld(false); 16 | } 17 | 18 | move(target: THREE.Object3D) { 19 | this.line.visible = true; 20 | this.line.position.copy(target.getWorldPosition()); 21 | this.line.quaternion.copy(target.getWorldQuaternion()); 22 | this.line.updateMatrixWorld(false); 23 | return this; 24 | } 25 | 26 | hide() { 27 | this.line.visible = false; 28 | return this; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /three/helpers/index.ts: -------------------------------------------------------------------------------- 1 | import GridHelper from "./GridHelper"; 2 | import SelectionBoxRenderer from "./SelectionBoxRenderer"; 3 | import TransformControls from "./TransformControls"; 4 | import TransformMarker from "./TransformMarker"; 5 | import { GizmoMaterial } from "./TransformGizmos"; 6 | 7 | (global as any).SupTHREE.GridHelper = GridHelper; 8 | (global as any).SupTHREE.SelectionBoxRenderer = SelectionBoxRenderer; 9 | (global as any).SupTHREE.TransformControls = TransformControls; 10 | (global as any).SupTHREE.TransformMarker = TransformMarker; 11 | (global as any).SupTHREE.GizmoMaterial = GizmoMaterial; 12 | -------------------------------------------------------------------------------- /three/index.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /three/main.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare namespace SupTHREE { 4 | export function createWebGLRenderer(params?: THREE.WebGLRendererParameters): THREE.WebGLRenderer; 5 | 6 | // `maxLateTicks` limits how many late ticks to try and catch up. 7 | // This helps avoid falling into the "black pit of despair" or "doom spiral" 8 | // where every tick takes longer than the previous one. 9 | // See http://blogs.msdn.com/b/shawnhar/archive/2011/03/25/technical-term-that-should-exist-quot-black-pit-of-despair-quot.aspx 10 | interface TickerOptions { timeStep: number; maxLateTicks: number; } 11 | 12 | export class Ticker { 13 | constructor(tickCallback: () => boolean, options?: SupTHREE.TickerOptions); 14 | 15 | /** 16 | * @returns Number of ticks processed 17 | */ 18 | tick(accumulatedTime: number): number; 19 | 20 | reset(): void; 21 | } 22 | 23 | export class Camera { 24 | threeCamera: THREE.OrthographicCamera|THREE.PerspectiveCamera; 25 | 26 | constructor(root: THREE.Object3D, canvas: HTMLCanvasElement) 27 | computeAspectRatio(): this; 28 | setOrthographicMode(isOrthographic: boolean): this; 29 | setFOV(fov: number): this; 30 | setOrthographicScale(orthographicScale: number): this; 31 | getOrthographicScale(): number; 32 | setViewport(x: number, y: number, width: number, height: number): this; 33 | getViewport(): { x: number; y: number; width: number; height: number; }; 34 | setDepth(depth: number): this; 35 | setNearClippingPlane(nearClippingPlane: number): this; 36 | setFarClippingPlane(farClippingPlane: number): this; 37 | render(renderer: THREE.WebGLRenderer, scene: THREE.Scene, channels: number[]): void; 38 | } 39 | 40 | interface Camera2DControlsOptions { 41 | zoomMin?: number; 42 | zoomMax?: number; 43 | zoomSpeed?: number; 44 | zoomCallback?: Function; 45 | moveCallback?: Function; 46 | } 47 | 48 | export class Camera2DControls { 49 | constructor(camera: Camera, canvas: HTMLCanvasElement, options?: Camera2DControlsOptions); 50 | setMultiplier(newMultiplier: number): this; 51 | } 52 | 53 | export class Camera3DControls { 54 | constructor(root: THREE.Object3D, camera: Camera, canvas: HTMLCanvasElement); 55 | setEnabled(enabled: boolean): this; 56 | resetOrbitPivot(position: THREE.Vector3, radius?: number): this; 57 | getOrbitPivot(): { position: THREE.Vector3, radius: number }; 58 | setPosition(position: THREE.Vector3): this; 59 | getPosition(): THREE.Vector3; 60 | setOrientation(orientation: { theta: number; phi: number; gamma: number; }): this; 61 | getOrientation(): { theta: number; phi: number; gamma: number; }; 62 | hasJustPanned(): boolean; 63 | setMoveSpeed(moveSpeed: number): this; 64 | update(): void; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /three/main/Camera.ts: -------------------------------------------------------------------------------- 1 | export default class Camera{ 2 | fov = 45; 3 | orthographicScale = 10; 4 | 5 | threeCamera: THREE.OrthographicCamera|THREE.PerspectiveCamera; 6 | viewport = { x: 0, y: 0, width: 1, height: 1 }; 7 | 8 | layers: number[] = []; 9 | depth = 0; 10 | nearClippingPlane = 0.1; 11 | farClippingPlane = 1000; 12 | 13 | cachedRatio: number; 14 | isOrthographic: boolean; 15 | projectionNeedsUpdate: boolean; 16 | 17 | constructor(root: THREE.Object3D, private canvas: HTMLCanvasElement) { 18 | this.setOrthographicMode(false); 19 | this.computeAspectRatio(); 20 | } 21 | 22 | computeAspectRatio() { 23 | this.cachedRatio = (this.canvas.clientWidth * this.viewport.width) / (this.canvas.clientHeight * this.viewport.height); 24 | this.projectionNeedsUpdate = true; 25 | return this; 26 | } 27 | 28 | setOrthographicMode(isOrthographic: boolean) { 29 | this.isOrthographic = isOrthographic; 30 | 31 | if (this.isOrthographic) { 32 | this.threeCamera = new THREE.OrthographicCamera(-this.orthographicScale * this.cachedRatio / 2, 33 | this.orthographicScale * this.cachedRatio / 2, 34 | this.orthographicScale / 2, -this.orthographicScale / 2, 35 | this.nearClippingPlane, this.farClippingPlane); 36 | } 37 | else this.threeCamera = new THREE.PerspectiveCamera(this.fov, this.cachedRatio, this.nearClippingPlane, this.farClippingPlane); 38 | 39 | this.projectionNeedsUpdate = true; 40 | return this; 41 | } 42 | 43 | setFOV(fov: number) { 44 | this.fov = fov; 45 | if (!this.isOrthographic) this.projectionNeedsUpdate = true; 46 | return this; 47 | } 48 | 49 | setOrthographicScale(orthographicScale: number) { 50 | this.orthographicScale = orthographicScale; 51 | if (this.isOrthographic) { 52 | // NOTE: Apply immediately because it's used for ray calculation 53 | const orthographicCamera = this.threeCamera as THREE.OrthographicCamera; 54 | orthographicCamera.left = -this.orthographicScale * this.cachedRatio / 2; 55 | orthographicCamera.right = this.orthographicScale * this.cachedRatio / 2; 56 | orthographicCamera.top = this.orthographicScale / 2; 57 | orthographicCamera.bottom = -this.orthographicScale / 2; 58 | this.threeCamera.updateProjectionMatrix(); 59 | } 60 | return this; 61 | } 62 | 63 | getOrthographicScale() { return this.orthographicScale; } 64 | 65 | setViewport(x: number, y: number, width: number, height: number) { 66 | this.viewport.x = x; 67 | this.viewport.y = y; 68 | this.viewport.width = width; 69 | this.viewport.height = height; 70 | this.projectionNeedsUpdate = true; 71 | this.computeAspectRatio(); 72 | return this; 73 | } 74 | 75 | getViewport() { return { x: this.viewport.x, y: this.viewport.y, width: this.viewport.width, height: this.viewport.height }; } 76 | 77 | setDepth(depth: number) { 78 | this.depth = depth; 79 | return this; 80 | } 81 | 82 | setNearClippingPlane(nearClippingPlane: number) { 83 | this.nearClippingPlane = nearClippingPlane; 84 | this.threeCamera.near = this.nearClippingPlane; 85 | this.projectionNeedsUpdate = true; 86 | return this; 87 | } 88 | 89 | setFarClippingPlane(farClippingPlane: number) { 90 | this.farClippingPlane = farClippingPlane; 91 | this.threeCamera.far = this.farClippingPlane; 92 | this.projectionNeedsUpdate = true; 93 | return this; 94 | } 95 | 96 | render(renderer: THREE.WebGLRenderer, scene: THREE.Scene, channels: number[]) { 97 | if (this.projectionNeedsUpdate) { 98 | this.projectionNeedsUpdate = false; 99 | 100 | if (this.isOrthographic) { 101 | const orthographicCamera = this.threeCamera; 102 | orthographicCamera.left = -this.orthographicScale * this.cachedRatio / 2; 103 | orthographicCamera.right = this.orthographicScale * this.cachedRatio / 2; 104 | orthographicCamera.top = this.orthographicScale / 2; 105 | orthographicCamera.bottom = -this.orthographicScale / 2; 106 | } 107 | else { 108 | const perspectiveCamera = this.threeCamera; 109 | perspectiveCamera.fov = this.fov; 110 | perspectiveCamera.aspect = this.cachedRatio; 111 | } 112 | this.threeCamera.updateProjectionMatrix(); 113 | } 114 | 115 | renderer.setViewport( 116 | this.viewport.x * this.canvas.width , (1 - this.viewport.y - this.viewport.height) * this.canvas.height, 117 | this.viewport.width * this.canvas.width, this.viewport.height * this.canvas.height 118 | ); 119 | 120 | for (const channel of channels) { 121 | renderer.clearDepth(); 122 | this.threeCamera.layers.set(channel); 123 | renderer.render(scene, this.threeCamera); 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /three/main/Camera2DControls.ts: -------------------------------------------------------------------------------- 1 | import Camera from "./Camera"; 2 | 3 | export default class Camera2DControls { 4 | private mousePosition = new THREE.Vector3(0, 0, 0); 5 | private options: SupTHREE.Camera2DControlsOptions; 6 | private multiplier = 1; 7 | private isMoving = false; 8 | 9 | constructor(private camera: Camera, private canvas: HTMLCanvasElement, options?: SupTHREE.Camera2DControlsOptions) { 10 | this.options = options != null ? options : {}; 11 | if (this.options.zoomSpeed == null) this.options.zoomSpeed = 1.5; 12 | if (this.options.zoomMin == null) this.options.zoomMin = 0.1; 13 | if (this.options.zoomMax == null) this.options.zoomMax = 10000; 14 | 15 | canvas.addEventListener("mousedown", this.onMouseDown); 16 | canvas.addEventListener("mousemove", this.onMouseMove); 17 | canvas.addEventListener("wheel", this.onWheel); 18 | canvas.addEventListener("keypress", this.onKeyPress); 19 | document.addEventListener("mouseup", this.onMouseUp); 20 | canvas.addEventListener("mouseout", this.onMouseUp); 21 | canvas.addEventListener("contextmenu", (event) => { event.preventDefault(); }); 22 | } 23 | 24 | setMultiplier(newMultiplier: number) { 25 | this.multiplier = newMultiplier; 26 | const newOrthographicScale = this.camera.orthographicScale * this.multiplier; 27 | this.changeOrthographicScale(newOrthographicScale); 28 | } 29 | 30 | private onMouseDown = (event: MouseEvent) => { 31 | if (event.button === 1 || (event.button === 0 && event.altKey)) this.isMoving = true; 32 | } 33 | 34 | private onMouseUp = (event: MouseEvent) => { 35 | if (event.button === 0 || event.button === 1) this.isMoving = false; 36 | } 37 | 38 | private onMouseMove = (event: MouseEvent) => { 39 | const rect = this.canvas.getBoundingClientRect(); 40 | this.mousePosition.x = (event.clientX - rect.left) / this.canvas.clientWidth * 2 - 1; 41 | this.mousePosition.y = -((event.clientY - rect.top) / this.canvas.clientHeight * 2 - 1); 42 | 43 | if (this.isMoving) { 44 | const cameraZ = this.camera.threeCamera.position.z; 45 | this.camera.threeCamera.position 46 | .set(-event.movementX / this.canvas.clientWidth * 2, event.movementY / this.canvas.clientHeight * 2, 0) 47 | .unproject(this.camera.threeCamera) 48 | .z = cameraZ; 49 | this.camera.threeCamera.updateMatrixWorld(false); 50 | if (this.options.moveCallback != null) this.options.moveCallback(); 51 | } 52 | } 53 | 54 | private onWheel = (event: WheelEvent) => { 55 | if (event.ctrlKey) return; 56 | 57 | let newOrthographicScale: number; 58 | if (event.deltaY > 0) newOrthographicScale = Math.min(this.options.zoomMax, this.camera.orthographicScale * this.multiplier * this.options.zoomSpeed); 59 | else if (event.deltaY < 0) newOrthographicScale = Math.max(this.options.zoomMin, this.camera.orthographicScale * this.multiplier / this.options.zoomSpeed); 60 | else return; 61 | 62 | this.changeOrthographicScale(newOrthographicScale, this.mousePosition); 63 | } 64 | 65 | private onKeyPress = (event: KeyboardEvent) => { 66 | if (SupClient.Dialogs.BaseDialog.activeDialog != null) return; 67 | 68 | if (event.keyCode === 43 /* Ctrl+Numpad+ */) { 69 | const newOrthographicScale = Math.max(this.options.zoomMin, this.camera.orthographicScale * this.multiplier / this.options.zoomSpeed); 70 | this.changeOrthographicScale(newOrthographicScale); 71 | } 72 | if (event.keyCode === 45 /* Ctrl+Numpad- */) { 73 | const newOrthographicScale = Math.min(this.options.zoomMax, this.camera.orthographicScale * this.multiplier * this.options.zoomSpeed); 74 | this.changeOrthographicScale(newOrthographicScale); 75 | } 76 | } 77 | 78 | private changeOrthographicScale(newOrthographicScale: number, mousePosition = { x: 0, y: 0 }) { 79 | const startPosition = new THREE.Vector3(mousePosition.x, mousePosition.y, 0).unproject(this.camera.threeCamera); 80 | this.camera.setOrthographicScale(newOrthographicScale / this.multiplier); 81 | const endPosition = new THREE.Vector3(mousePosition.x, mousePosition.y, 0).unproject(this.camera.threeCamera); 82 | 83 | this.camera.threeCamera.position.x += startPosition.x - endPosition.x; 84 | this.camera.threeCamera.position.y += startPosition.y - endPosition.y; 85 | this.camera.threeCamera.updateMatrixWorld(false); 86 | if (this.options.zoomCallback != null) this.options.zoomCallback(); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /three/main/Camera3DControls.ts: -------------------------------------------------------------------------------- 1 | import Camera from "./Camera"; 2 | 3 | const tmpVector3 = new THREE.Vector3(); 4 | const tmpQuaternion = new THREE.Quaternion(); 5 | const tmpEuler = new THREE.Euler(); 6 | const upVector = new THREE.Vector3(0, 1, 0); 7 | 8 | const epsilon = 0.001; 9 | const lerpFactor = 0.3; 10 | const minOrbitRadius = 0.001; 11 | const maxOrbitRadius = 500; 12 | const initialOrbitRadius = 10; 13 | 14 | const panningSpeed = 0.005; 15 | const orbitingSpeed = 0.008; 16 | const orbitingThetaSpeed = Math.PI / 56; 17 | const rotateGammaSpeed = 0.02; 18 | const zoomingSpeed = 1.2; 19 | 20 | export default class Camera3DControls { 21 | private enabled = true; 22 | private moveSpeed = 0.3; 23 | 24 | private wantToPan = false; 25 | private hasMovedWhilePanning = false; 26 | 27 | private isOrbiting = false; 28 | private isOrbitingThetaLeft = false; 29 | private isOrbitingThetaRight = false; 30 | private orbitPivot: THREE.Vector3; 31 | private targetOrbitPivot: THREE.Vector3; 32 | 33 | private orbitRadius = initialOrbitRadius; 34 | private targetOrbitRadius = initialOrbitRadius; 35 | 36 | // Horizontal angle 37 | private theta: number; 38 | private targetTheta: number; 39 | // Vertical angle 40 | private phi: number; 41 | private targetPhi: number; 42 | // Forward angle 43 | private gamma: number; 44 | private targetGamma: number; 45 | 46 | private moveVector = new THREE.Vector3(); 47 | private pivotMarker: THREE.LineSegments; 48 | private pivotMarkerOpacity = 0; 49 | 50 | constructor(private root: THREE.Object3D, private camera: Camera, private canvas: HTMLCanvasElement) { 51 | this.orbitPivot = new THREE.Vector3(0, 0, -this.orbitRadius).applyQuaternion(this.camera.threeCamera.quaternion).add(this.camera.threeCamera.position); 52 | this.targetOrbitPivot = this.orbitPivot.clone(); 53 | 54 | tmpQuaternion.setFromUnitVectors(this.camera.threeCamera.up, upVector); 55 | tmpVector3.copy(this.camera.threeCamera.position).sub(this.orbitPivot).applyQuaternion(tmpQuaternion); 56 | 57 | this.theta = Math.atan2(tmpVector3.x, tmpVector3.z); 58 | this.targetTheta = this.theta; 59 | this.phi = Math.atan2(Math.sqrt(tmpVector3.x * tmpVector3.x + tmpVector3.z * tmpVector3.z), tmpVector3.y); 60 | this.targetPhi = this.phi; 61 | this.gamma = this.targetGamma = 0; 62 | 63 | const pivotGeometry = new THREE.Geometry(); 64 | pivotGeometry.vertices.push( 65 | new THREE.Vector3( -0.5, 0, 0 ), new THREE.Vector3( 0.5, 0, 0 ), 66 | new THREE.Vector3( 0, -0.5, 0 ), new THREE.Vector3( 0, 0.5, 0 ), 67 | new THREE.Vector3( 0, 0, -0.5 ), new THREE.Vector3( 0, 0, 0.5 ) 68 | ); 69 | 70 | this.pivotMarker = new THREE.LineSegments(pivotGeometry, new THREE.LineBasicMaterial({ color: 0xffffff, opacity: this.pivotMarkerOpacity, transparent: true })); 71 | this.pivotMarker.layers.set(1); 72 | root.add(this.pivotMarker); 73 | 74 | canvas.addEventListener("mousedown", this.onMouseDown); 75 | canvas.addEventListener("mousemove", this.onMouseMove); 76 | canvas.addEventListener("wheel", this.onWheel); 77 | canvas.addEventListener("keydown", this.onKeyDown); 78 | document.addEventListener("keyup", this.onKeyUp); 79 | document.addEventListener("mouseup", this.onMouseUp); 80 | canvas.addEventListener("mouseout", this.onMouseUp); 81 | canvas.addEventListener("contextmenu", (event) => { event.preventDefault(); }); 82 | window.addEventListener("blur", this.onBlur); 83 | } 84 | 85 | private onMouseDown = (event: MouseEvent) => { 86 | if (!this.enabled) return; 87 | if (this.wantToPan || this.isOrbiting) return; 88 | 89 | if (event.button === 2) { 90 | this.wantToPan = true; 91 | this.hasMovedWhilePanning = false; 92 | 93 | } else if (event.button === 1 || (event.button === 0 && event.altKey)) { 94 | this.isOrbiting = true; 95 | if ((this.canvas as any).requestPointerLock) (this.canvas as any).requestPointerLock(); 96 | else if ((this.canvas as any).webkitRequestPointerLock) (this.canvas as any).webkitRequestPointerLock(); 97 | else if ((this.canvas as any).mozRequestPointerLock) (this.canvas as any).mozRequestPointerLock(); 98 | 99 | this.targetOrbitPivot = new THREE.Vector3(0, 0, -this.targetOrbitRadius).applyQuaternion(this.camera.threeCamera.quaternion).add(this.camera.threeCamera.position); 100 | 101 | tmpQuaternion.setFromUnitVectors(this.camera.threeCamera.up, upVector); 102 | tmpVector3.copy(this.camera.threeCamera.position).sub(this.targetOrbitPivot).applyQuaternion(tmpQuaternion); 103 | 104 | this.theta = Math.atan2(tmpVector3.x, tmpVector3.z); 105 | this.targetTheta = this.theta; 106 | this.phi = Math.atan2(Math.sqrt(tmpVector3.x * tmpVector3.x + tmpVector3.z * tmpVector3.z), tmpVector3.y); 107 | this.targetPhi = this.phi; 108 | } 109 | } 110 | 111 | private onMouseMove = (event: MouseEvent) => { 112 | if (!this.enabled) return; 113 | 114 | if (this.wantToPan) { 115 | this.hasMovedWhilePanning = true; 116 | 117 | const panningMultiplier = panningSpeed * (1 + Math.sqrt(this.targetOrbitRadius)); 118 | tmpVector3.set(-event.movementX * panningMultiplier, event.movementY * panningMultiplier, 0).applyQuaternion(this.camera.threeCamera.quaternion); 119 | this.targetOrbitPivot.add(tmpVector3); 120 | this.camera.threeCamera.position.add(tmpVector3); 121 | this.camera.threeCamera.updateMatrixWorld(false); 122 | 123 | } else if (this.isOrbiting) { 124 | this.targetTheta -= event.movementX * orbitingSpeed; 125 | this.targetPhi -= event.movementY * orbitingSpeed; 126 | 127 | this.targetPhi = Math.max(0.001, Math.min(Math.PI - 0.001, this.targetPhi)); 128 | } 129 | } 130 | 131 | private onWheel = (event: WheelEvent) => { 132 | if (!this.enabled) return; 133 | 134 | if (event.deltaY > 0) this.targetOrbitRadius *= zoomingSpeed; 135 | else if (event.deltaY < 0) this.targetOrbitRadius /= zoomingSpeed; 136 | else return; 137 | 138 | this.targetOrbitRadius = Math.min(Math.max(this.targetOrbitRadius, minOrbitRadius), maxOrbitRadius); 139 | } 140 | 141 | private onKeyDown = (event: KeyboardEvent) => { 142 | if (!this.enabled) return; 143 | if (event.ctrlKey || event.altKey || event.metaKey) return; 144 | 145 | if (event.keyCode === 87 /* W */ || event.keyCode === 90 /* Z */) { 146 | this.moveVector.z = -1; 147 | } else if (event.keyCode === 83 /* S */) { 148 | this.moveVector.z = 1; 149 | } else if (event.keyCode === 81 /* W */ || event.keyCode === 65 /* A */) { 150 | this.moveVector.x = -1; 151 | } else if (event.keyCode === 68 /* D */) { 152 | this.moveVector.x = 1; 153 | } else if (event.keyCode === 32 /* SPACE */) { 154 | this.moveVector.y = 1; 155 | } else if (event.keyCode === 16 /* SHIFT */) { 156 | this.moveVector.y = -1; 157 | } else if (event.keyCode === 74 /* J */) { 158 | this.targetGamma = Math.min(this.targetGamma + rotateGammaSpeed, Math.PI / 2); 159 | } else if (event.keyCode === 75 /* K */) { 160 | this.targetGamma = Math.max(this.targetGamma - rotateGammaSpeed, -Math.PI / 2); 161 | } else if (event.keyCode === 33 /* Page Up */) { 162 | this.isOrbitingThetaLeft = true; 163 | } else if (event.keyCode === 34 /* Page Down */) { 164 | this.isOrbitingThetaRight = true; 165 | } 166 | } 167 | 168 | private onKeyUp = (event: KeyboardEvent) => { 169 | if (event.keyCode === 87 /* W */ || event.keyCode === 90 /* Z */) { 170 | this.moveVector.z = 0; 171 | } else if (event.keyCode === 83 /* S */) { 172 | this.moveVector.z = 0; 173 | } else if (event.keyCode === 81 /* W */ || event.keyCode === 65 /* A */) { 174 | this.moveVector.x = 0; 175 | } else if (event.keyCode === 68 /* D */) { 176 | this.moveVector.x = 0; 177 | } else if (event.keyCode === 32 /* SPACE */) { 178 | this.moveVector.y = 0; 179 | } else if (event.keyCode === 16 /* SHIFT */) { 180 | this.moveVector.y = 0; 181 | } else if (event.keyCode === 33 /* Page Up */) { 182 | this.isOrbitingThetaLeft = false; 183 | } else if (event.keyCode === 34 /* Page Down */) { 184 | this.isOrbitingThetaRight = false; 185 | } 186 | } 187 | 188 | private onMouseUp = (event: MouseEvent) => { 189 | if (event.button === 2) { 190 | this.wantToPan = false; 191 | this.hasMovedWhilePanning = false; 192 | 193 | } else if (event.button === 1 || event.button === 0) { 194 | this.isOrbiting = false; 195 | if ((document as any).exitPointerLock) (document as any).exitPointerLock(); 196 | else if ((document as any).webkitExitPointerLock) (document as any).webkitExitPointerLock(); 197 | else if ((document as any).mozExitPointerLock) (document as any).mozExitPointerLock(); 198 | } 199 | } 200 | 201 | private onBlur = () => { 202 | this.moveVector.set(0, 0, 0); 203 | this.wantToPan = false; 204 | 205 | if (this.isOrbiting) { 206 | this.isOrbiting = false; 207 | if ((document as any).exitPointerLock) (document as any).exitPointerLock(); 208 | else if ((document as any).webkitExitPointerLock) (document as any).webkitExitPointerLock(); 209 | else if ((document as any).mozExitPointerLock) (document as any).mozExitPointerLock(); 210 | } 211 | 212 | this.isOrbitingThetaLeft = false; 213 | this.isOrbitingThetaRight = false; 214 | } 215 | 216 | setEnabled(enabled: boolean) { 217 | this.enabled = enabled; 218 | 219 | if (!this.enabled) this.onBlur(); 220 | 221 | return this; 222 | } 223 | 224 | resetOrbitPivot(position: THREE.Vector3, radius?: number) { 225 | if (!this.enabled) return this; 226 | 227 | this.targetOrbitPivot.copy(position); 228 | if (radius != null) this.targetOrbitRadius = Math.min(Math.max(radius, minOrbitRadius), maxOrbitRadius); 229 | else this.targetOrbitRadius = Math.max(this.targetOrbitRadius, initialOrbitRadius); 230 | return this; 231 | } 232 | 233 | getOrbitPivot() { 234 | return { position: this.targetOrbitPivot.clone(), radius: this.targetOrbitRadius }; 235 | } 236 | 237 | setMoveSpeed(moveSpeed: number) { 238 | this.moveSpeed = moveSpeed; 239 | return this; 240 | } 241 | 242 | setPosition(position: THREE.Vector3) { 243 | if (!this.enabled) return this; 244 | 245 | tmpVector3.x = this.orbitRadius * Math.sin(this.targetPhi) * Math.sin(this.targetTheta); 246 | tmpVector3.y = this.orbitRadius * Math.cos(this.targetPhi); 247 | tmpVector3.z = this.orbitRadius * Math.sin(this.targetPhi) * Math.cos(this.targetTheta); 248 | tmpVector3.applyQuaternion(tmpQuaternion.clone().inverse()); 249 | tmpVector3.sub(position).negate(); 250 | 251 | this.targetOrbitPivot.copy(tmpVector3); 252 | return this; 253 | } 254 | getPosition() { return this.camera.threeCamera.position; } 255 | 256 | setOrientation(orientation: { theta: number; phi: number; gamma: number; }) { 257 | if (!this.enabled) return this; 258 | 259 | this.targetTheta = orientation.theta; 260 | this.targetPhi = orientation.phi; 261 | this.targetGamma = orientation.gamma; 262 | return this; 263 | } 264 | getOrientation() { return { theta: this.theta, phi: this.phi, gamma: this.gamma }; } 265 | 266 | hasJustPanned() { return this.wantToPan && this.hasMovedWhilePanning; } 267 | 268 | update() { 269 | if (this.moveVector.length() !== 0) { 270 | const rotatedMoveVector = this.moveVector.clone(); 271 | rotatedMoveVector.applyQuaternion(this.camera.threeCamera.quaternion).normalize().multiplyScalar(this.moveSpeed); 272 | this.camera.threeCamera.position.add(rotatedMoveVector); 273 | this.targetOrbitPivot.add(rotatedMoveVector); 274 | } 275 | 276 | this.orbitPivot.lerp(this.targetOrbitPivot, lerpFactor); 277 | this.orbitRadius += (this.targetOrbitRadius - this.orbitRadius) * lerpFactor; 278 | 279 | if (this.isOrbitingThetaLeft) this.targetTheta += orbitingThetaSpeed; 280 | if (this.isOrbitingThetaRight) this.targetTheta -= orbitingThetaSpeed; 281 | 282 | if (Math.abs(this.targetTheta - this.theta) > epsilon) this.theta += (this.targetTheta - this.theta) * lerpFactor; 283 | else this.theta = this.targetTheta; 284 | 285 | if (Math.abs(this.targetPhi - this.phi) > epsilon) this.phi += (this.targetPhi - this.phi) * lerpFactor; 286 | else this.phi = this.targetPhi; 287 | 288 | if (Math.abs(this.targetGamma - this.gamma) > epsilon) this.gamma += (this.targetGamma - this.gamma) * lerpFactor; 289 | else this.gamma = this.targetGamma; 290 | 291 | tmpVector3.x = this.orbitRadius * Math.sin(this.phi) * Math.sin(this.theta); 292 | tmpVector3.y = this.orbitRadius * Math.cos(this.phi); 293 | tmpVector3.z = this.orbitRadius * Math.sin(this.phi) * Math.cos(this.theta); 294 | tmpVector3.applyQuaternion(tmpQuaternion.clone().inverse()); 295 | 296 | this.camera.threeCamera.position.copy(this.orbitPivot).add(tmpVector3); 297 | this.camera.threeCamera.lookAt(this.orbitPivot); 298 | tmpEuler.setFromQuaternion(this.camera.threeCamera.quaternion); 299 | tmpEuler.z = this.gamma; 300 | this.camera.threeCamera.setRotationFromEuler(tmpEuler); 301 | this.camera.threeCamera.updateMatrixWorld(false); 302 | 303 | // Update marker 304 | if (this.orbitPivot.distanceTo(this.targetOrbitPivot) > 0.1 || 305 | Math.abs(this.orbitRadius - this.targetOrbitRadius) > 0.1 || this.isOrbiting || this.wantToPan) { 306 | this.pivotMarker.material.opacity = 0.5; 307 | } else { 308 | this.pivotMarker.material.opacity *= 1 - lerpFactor; 309 | } 310 | 311 | this.pivotMarker.position.copy(this.orbitPivot); 312 | this.pivotMarker.updateMatrixWorld(false); 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /three/main/main.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from "three"; 2 | (window as any).THREE = THREE; 3 | THREE.Euler.DefaultOrder = "YXZ"; 4 | 5 | import Camera from "./Camera"; 6 | import Camera2DControls from "./Camera2DControls"; 7 | import Camera3DControls from "./Camera3DControls"; 8 | 9 | export { Camera, Camera2DControls, Camera3DControls }; 10 | 11 | export function createWebGLRenderer(params?: THREE.WebGLRendererParameters) { 12 | if (params == null) params = {}; 13 | if (params.precision == null) params.precision = "mediump"; 14 | if (params.alpha == null) params.alpha = false; 15 | if (params.antialias == null) params.antialias = false; 16 | // NOTE: We ask for a stencil buffer by default because of a Firefox bug: 17 | // Without it, Firefox will often return a 16-bit depth buffer 18 | // (rather than a more useful 24-bit depth buffer). 19 | // See https://bugzilla.mozilla.org/show_bug.cgi?id=1202387 20 | if (params.stencil == null) params.stencil = true; 21 | 22 | const renderer = new THREE.WebGLRenderer(params); 23 | 24 | return renderer; 25 | } 26 | 27 | export class Ticker { 28 | private previousTimestamp = 0; 29 | private accumulatedTime = 0; 30 | 31 | private maxAccumulatedTime: number; 32 | private timeStep: number; 33 | 34 | constructor(private tickCallback: () => boolean, options?: SupTHREE.TickerOptions) { 35 | if (options == null) options = { timeStep: 1000 / 60, maxLateTicks: 5 }; 36 | this.timeStep = options.timeStep; 37 | this.maxAccumulatedTime = options.maxLateTicks * options.timeStep; 38 | } 39 | 40 | tick(timestamp: number) { 41 | this.accumulatedTime += timestamp - this.previousTimestamp; 42 | this.previousTimestamp = timestamp; 43 | 44 | let ticks = 0; 45 | 46 | if (this.accumulatedTime > this.maxAccumulatedTime) this.accumulatedTime = this.maxAccumulatedTime; 47 | 48 | while (this.accumulatedTime >= this.timeStep) { 49 | if (this.tickCallback != null) { 50 | const keepGoing = this.tickCallback(); 51 | if (!keepGoing) break; 52 | } 53 | 54 | this.accumulatedTime -= this.timeStep; 55 | ticks++; 56 | } 57 | 58 | return ticks; 59 | } 60 | 61 | reset() { 62 | this.previousTimestamp = 0; 63 | this.accumulatedTime = 0; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /three/mainGulpfile.js: -------------------------------------------------------------------------------- 1 | const gulp = require("gulp"); 2 | const tasks = []; 3 | 4 | // Browserify 5 | const browserify = require("browserify"); 6 | const source = require("vinyl-source-stream"); 7 | 8 | function makeBrowserify(src, dest, output) { 9 | gulp.task(`${output}-browserify`, () => { 10 | return browserify(src, { standalone: "SupTHREE" }) 11 | .transform("brfs").bundle() 12 | .pipe(source(`${output}.js`)) 13 | .pipe(gulp.dest(dest)); 14 | }); 15 | tasks.push(`${output}-browserify`); 16 | } 17 | 18 | makeBrowserify("./main/main.js", "./public/", "main"); 19 | 20 | 21 | // All 22 | gulp.task("default", gulp.parallel(tasks)); 23 | -------------------------------------------------------------------------------- /three/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "superpowers-common-three-plugin", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@types/three": { 8 | "version": "0.0.24", 9 | "resolved": "https://registry.npmjs.org/@types/three/-/three-0.0.24.tgz", 10 | "integrity": "sha1-PtF9zL+dODtu8ngD42npEn4zlVk=", 11 | "dev": true 12 | }, 13 | "three": { 14 | "version": "0.88.0", 15 | "resolved": "https://registry.npmjs.org/three/-/three-0.88.0.tgz", 16 | "integrity": "sha1-QlbC/Djk+yOg0j66K2zOTfjkZtU=" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /three/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "superpowers-common-three-plugin", 3 | "description": "Three.js for Superpowers, the HTML5 app for real-time collaborative projects", 4 | "version": "1.0.0", 5 | "license": "ISC", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/superpowers/superpowers-common-plugins.git" 9 | }, 10 | "scripts": { 11 | "build": "gulp --gulpfile=../../../../../scripts/pluginGulpfile.js --cwd=. --silent && gulp --gulpfile=mainGulpfile.js" 12 | }, 13 | "dependencies": { 14 | "three": "^0.88.0" 15 | }, 16 | "devDependencies": { 17 | "@types/three": "0.0.24" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /three/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "noImplicitAny": true, 6 | "rootDir": "./", 7 | "typeRoots": [ "../../../../../node_modules/@types" ] 8 | }, 9 | "exclude": [ 10 | "node_modules", 11 | "typings" 12 | ] 13 | } 14 | --------------------------------------------------------------------------------