diff --git a/.gitignore b/.gitignore index 20ed833..251ccee 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ /node_modules /.rpt2_cache /dist/app.js +/dist/*.map +/dist/app.js.LICENSE.txt diff --git a/dist/index.html b/dist/index.html index e364eba..9971913 100644 --- a/dist/index.html +++ b/dist/index.html @@ -1,5 +1,6 @@ + Chip Annotation Viewer @@ -9,43 +10,47 @@ -
-
-
-
-
- - - - -
-
- -
+
+
- -
- -

- -
- - -
- -
-
-
-
+
+
+
+ + +
+ +
+
+
+
+ +
+
+
-
-
- + diff --git a/dist/res/github.png b/dist/res/github.png index 2752756..d44393a 100644 Binary files a/dist/res/github.png and b/dist/res/github.png differ diff --git a/dist/res/refresh.png b/dist/res/refresh.png index 710f506..2a757a1 100644 Binary files a/dist/res/refresh.png and b/dist/res/refresh.png differ diff --git a/dist/style.css b/dist/style.css index 0d3c6a3..e111f9c 100644 --- a/dist/style.css +++ b/dist/style.css @@ -3,15 +3,49 @@ padding: 0; } -body, html { - background: black; +:root { + --primary-color: #3B82F6; + --secondary-color: #60A5FA; + --accent-color: #22D3EE; + --background-dark: #0F172A; + --surface-color: #1E293B; + --text-primary: #F1F5F9; + --text-secondary: #94A3B8; + --border-color: #334155; + --success-color: #10B981; + --warning-color: #F59E0B; +} + +body, +html { + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); height: 100%; overflow: hidden; + font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; + color: var(--text-primary); } select { min-width: 150px; - height: 20px; + height: 24px; + padding: 0; + background: var(--surface-color); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 6px; + font-size: 14px; + cursor: pointer; + transition: all 0.2s ease; +} + +select:hover { + border-color: var(--primary-color); +} + +select:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); } .refreshButton { @@ -19,137 +53,607 @@ select { width: 20px; height: 20px; border: 0; + margin-left: 5px; + background: transparent url('res/refresh.png'); background-size: cover; - background-image: url('res/refresh.png'); + transition: transform 0.3s ease; +} + +.refreshButton:hover { + transform: rotate(180deg); } #selectPanel { position: absolute; - top: 5px; - left: 5px; + top: 15px; + left: 15px; z-index: 999; + background: rgba(30, 41, 59, 0.6); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + padding: 10px; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.1); } #cameraPanel { position: absolute; - bottom: 5px; - left: 5px; + bottom: 15px; + left: 15px; z-index: 999; + background: rgba(30, 41, 59, 0.6); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + padding: 10px; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + gap: 8px; + user-select: none; + -webkit-user-select: none; } .zoomButton { - width: 36px; - height: 36px; + width: 40px; + height: 40px; + border-radius: 8px; + border: none; + background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); + color: white; + font-size: 18px; + font-weight: bold; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2); +} + +.zoomButton:hover { + transform: translateY(-2px); + box-shadow: 0 6px 12px rgba(59, 130, 246, 0.4); +} + +.zoomButton:active { + transform: translateY(0); } .zoomText { - color: rgba(255, 255, 255, 0.8); + color: var(--text-secondary); font-size: 14px; + font-weight: 500; + display: flex; + align-items: center; } -#imageSource { - display: inline-block; - color: rgba(255, 255, 255, 0.8); +.imageSource { + margin-left: 4px; + color: var(--text-secondary); font-size: 12px; } -#panel { - display: none; - float: left; - width: 210px; - height: 100%; - background: white; - flex-flow: column; +.imageSourceLabel { + color: var(--text-secondary); + font-size: 12px; } -#panelScroll { +#container { + position: relative; + width: 100%; height: 100%; +} + +#propertyPanel { + position: absolute; + top: 15px; + right: 15px; + z-index: 998; + background: rgba(30, 41, 59, 0.6); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + padding: 15px; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.1); + min-width: 220px; + max-height: calc(100vh - 100px); overflow-y: auto; - flex: 1 1 auto; } -#container { - position: relative; +.ui-panel.dragging { + pointer-events: none; +} + +.ui-panel { + user-select: none; + -webkit-user-select: none; +} + +#propertyPanel::-webkit-scrollbar { + width: 6px; +} + +#propertyPanel::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.05); + border-radius: 3px; +} + +#propertyPanel::-webkit-scrollbar-thumb { + background: var(--primary-color); + border-radius: 3px; +} + +#creationButtons { display: flex; - height: 100%; + gap: 4px; + flex-wrap: wrap; + margin: 10px 0; +} + +#hint { + position: absolute; + bottom: 70px; + left: 50%; + transform: translateX(-50%); + z-index: 997; + color: var(--text-secondary); + font-size: 13px; + padding: 8px 16px; + background: rgba(30, 41, 59, 0.6); + backdrop-filter: blur(10px); + border-radius: 8px; + max-width: 80%; + transition: opacity 0.3s ease; +} + +#hint.hidden { + opacity: 0; + pointer-events: none; +} + +.hintToggleButton { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 14px; + font-weight: 600; + transition: all 0.3s ease; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + position: relative; + overflow: hidden; + margin: 4px 4px; +} + +.hintToggleButton:hover { + transform: translateY(-1px); + box-shadow: 0 0 20px rgba(102, 126, 234, 0.4); +} + +.hintToggleButton:active { + transform: translateY(0); +} + +.hintToggleButton svg { + flex-shrink: 0; +} + +.hintToggleButton.hintHidden { + background: rgba(100, 116, 139, 0.6); +} + +.hintToggleButton.hintHidden:hover { + background: rgba(100, 116, 139, 0.8); + box-shadow: 0 0 15px rgba(100, 116, 139, 0.3); +} + +.hintToggleText { + font-size: 14px; } .configButton { - /*margin:20px 5px;*/ + padding: 4px 8px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 14px; + font-weight: 600; + transition: all 0.3s ease; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + position: relative; + overflow: hidden; + margin: 4px 4px; +} + +.configButton:hover { + transform: translateY(-1px); + box-shadow: 0 0 20px rgba(102, 126, 234, 0.4); +} + +.configButton:active { + transform: translateY(0); +} + +.configButton::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background: rgba(255, 255, 255, 0.3); + transform: translate(-50%, -50%); + transition: width 0.6s ease, height 0.6s ease; +} + +.configButton:active::after { + width: 300px; + height: 300px; +} + +.iconButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + border-radius: 8px; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + margin: 4px; +} + +.iconButton:hover { + transform: translateY(-1px); + box-shadow: 0 0 20px rgba(102, 126, 234, 0.4); +} + +.iconButton:active { + transform: translateY(0); +} + +.iconButton svg { + width: 20px; + height: 20px; +} + +.checkboxRow { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin: 8px 0; +} + +.checkboxLabel { + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; + font-size: 14px; + color: var(--text-secondary); + user-select: none; +} + +.checkboxLabel input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--primary-color); + cursor: pointer; } .configColorAlphaContainer { - margin: 5px 10px; + margin: 8px 0; + padding: 12px; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; +} + +.sizeInput { + display: flex; + align-items: center; + gap: 8px; + margin: 6px 0; +} + +.sizeInput label { + font-size: 13px; + color: var(--text-secondary); + white-space: nowrap; } -.colorAlphaContainerSplit { - width: 0px; - display: inline-block; +.sizeInput input[type="number"] { + width: 60px; + padding: 4px 8px; + background: var(--surface-color); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 13px; + text-align: center; +} + +.sizeInput input[type="number"]:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); } .configColorButton { - width: 24px; - height: 24px; - border-radius: 12px; - border: 2px black solid; - margin: 2px 2px; + width: 14px; + height: 28px; + border: 1px solid transparent; + margin: 4px 0 4px 4px; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; +} + +.configColorButton:hover { + transform: scale(1.1); + border-color: var(--primary-color); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); } .configAlphaButton { - width: 12px; - height: 24px; - border: 2px black solid; - margin: 2px 0 2px 2px; + width: 14px; + height: 28px; + border: 1px solid transparent; + margin: 4px 0 4px 4px; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; +} + +.configAlphaButton:hover { + transform: scale(1.1); + border-color: var(--primary-color); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); } .configCheckbox { - /*margin:20px 5px;*/ + display: flex; + align-items: center; + gap: 8px; + margin: 8px 0; + cursor: pointer; +} + +.configCheckbox input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--primary-color); + cursor: pointer; } .configText { - /*margin:20px 5px;*/ + margin: 4px; + font-size: 14px; + color: var(--text-secondary); +} + +.titleInput { + display: flex; + align-items: center; + gap: 4px; +} + +.titleInput label { + font-size: 14px; + color: var(--text-primary); + font-weight: 500; + white-space: nowrap; +} + +.titleInput input[type="text"] { + flex: 1; + padding: 6px 10px; + background: var(--surface-color); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 6px; + font-size: 14px; + transition: all 0.2s ease; + box-sizing: border-box; +} + +.titleInput input[type="text"]:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); +} + +input[type="text"], +input[type="number"] { + width: 100%; + padding: 10px 12px; + background: var(--surface-color); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 6px; + font-size: 14px; + transition: all 0.2s ease; + box-sizing: border-box; +} + +input[type="text"]:focus, +input[type="number"]:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); } #footer { - position: relative; + position: absolute; + bottom: 15px; + right: 15px; + z-index: 997; font-size: 14px; line-height: 24px; vertical-align: middle; display: flex; flex-flow: row; - justify-content: space-around; + /* gap: 8px; */ flex-wrap: wrap; - margin-bottom: 3px; + background: rgba(30, 41, 59, 0.6); + backdrop-filter: blur(10px); + border-radius: 24px; + border: 1px solid rgba(255, 255, 255, 0.1); } #footer a { cursor: pointer; display: flex; flex-flow: row; -} -#footer a { + align-items: center; + color: var(--text-secondary); + padding: 8px 8px; + border-radius: 20px; + transition: all 0.2s ease; text-decoration: none; } -#footer a :hover { - text-decoration: underline; + +#footer a:hover { + background: var(--primary-color); + color: white; + text-decoration: none !important; } #footer span { - margin-left: 2px; + margin-left: 6px; cursor: pointer; line-height: 24px; vertical-align: middle; } .footerIcon { - width: 24px; - height: 24px; + width: 20px; + height: 20px; + opacity: 0.8; +} + +.uploadHelper { + display: inline-flex; + align-items: center; + padding: 6px 12px; + background: transparent; + border: 1px solid var(--border-color); + border-radius: 20px; + color: var(--text-secondary); + text-decoration: none; + font-size: 13px; + transition: all 0.2s ease; +} + +.uploadHelper:hover { + border-color: var(--primary-color); + color: var(--primary-color); + background: rgba(59, 130, 246, 0.1); } #toast { position: absolute; - bottom: 0; - right: 0; + bottom: 20px; + right: 20px; font-size: 16px; color: white; + background: linear-gradient(135deg, var(--success-color), #059669); + padding: 12px 24px; + border-radius: 8px; + box-shadow: 0 10px 30px rgba(16, 185, 129, 0.3); + animation: slideIn 0.3s ease; + z-index: 1000; + display: none; +} + +#toast.visible { + display: block; +} + +#toast.hiding { + animation: slideOut 0.3s ease forwards; +} + +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes slideOut { + from { + transform: translateX(0); + opacity: 1; + } + + to { + transform: translateX(100%); + opacity: 0; + } +} + +.label { + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 6px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 1px; +} + +.separator { + height: 1px; + background: linear-gradient(90deg, transparent, var(--border-color), transparent); + margin: 16px 0; +} + +@media (max-width: 768px) { + .configButton { + padding: 8px 12px; + font-size: 12px; + } + + #selectPanel, + #propertyPanel, + #cameraPanel { + padding: 8px; + border-radius: 8px; + } + + #propertyPanel { + min-width: 180px; + max-width: 200px; + } + + .zoomButton { + width: 36px; + height: 36px; + } + + #footer a span { + display: none; + } + + #hint { + font-size: 12px; + padding: 6px 12px; + } } \ No newline at end of file diff --git a/ts/App.ts b/ts/App.ts index 887d4cc..66c6a12 100644 --- a/ts/App.ts +++ b/ts/App.ts @@ -7,7 +7,6 @@ import "./elements/SelectElement"; import "./elements/TitleElement"; import "./editable/DrawablePolylineEditElement"; import "./editable/DrawableTextEditElement"; -import "./editable/DrawableMultipleEditElement"; import { Selection, SelectType } from "./layers/Selection"; import { DrawablePolyline, DrawablePolylinePack } from "./editable/DrawablePolyline"; import { DrawableText, DrawableTextPack } from "./editable/DrawableText"; @@ -24,12 +23,6 @@ let url_string = window.location.href; let url = new URL(url_string); let isReadOnly = !!url.searchParams.get("readonly"); -if (Ui.isMobile() || isReadOnly) { - document.getElementById("panel").style.display = "none"; -} else { - document.getElementById("panel").style.display = "flex"; -} - let canvas = new Canvas(document.getElementById("container"), 'canvas2d'); canvas.init(); @@ -79,6 +72,30 @@ class App { `, document.getElementById("selectPanel")); this.refresh(); + let hintElement = document.getElementById("hint"); + let hintToggle = document.getElementById("hintToggle") as HTMLButtonElement; + let hintIconEye = document.getElementById("hintIconEye") as HTMLElement; + let hintIconEyeOff = document.getElementById("hintIconEyeOff") as HTMLElement; + if (hintToggle) { + hintElement.classList.add("hidden"); + hintToggle.classList.add("hintHidden"); + if (hintIconEye) hintIconEye.style.display = "none"; + if (hintIconEyeOff) hintIconEyeOff.style.display = "block"; + hintToggle.onclick = () => { + hintElement.classList.toggle("hidden"); + hintToggle.classList.toggle("hintHidden"); + if (hintIconEye && hintIconEyeOff) { + if (hintElement.classList.contains("hidden")) { + hintIconEye.style.display = "none"; + hintIconEyeOff.style.display = "block"; + } else { + hintIconEye.style.display = "block"; + hintIconEyeOff.style.display = "none"; + } + } + }; + } + Selection.register(SelectType.POLYLINE, (item: DrawablePolyline) => { render(item.ui.render(canvas, this.map), document.getElementById("panelSelected")); canvas.enterEditors(EditorName.CAMERA_CONTROL, EditorName.SELECT, EditorName.POLYLINE_EDIT); @@ -112,7 +129,22 @@ class App { }); Selection.register(SelectType.MULTIPLE, (item: Drawable[]) => { - render(MultipleEdit.renderUi(canvas, item), document.getElementById("panelSelected")); + const polylines: DrawablePolyline[] = []; + const texts: DrawableText[] = []; + for (const d of item) { + if (d instanceof DrawablePolyline) { + polylines.push(d); + } else if (d instanceof DrawableText) { + texts.push(d); + } + } + const polylinePanel = polylines.length > 0 + ? html`` + : html``; + const textPanel = texts.length > 0 + ? html`` + : html``; + render(html`${polylinePanel}${textPanel}`, document.getElementById("panelSelected")); canvas.enterEditors(EditorName.CAMERA_CONTROL, EditorName.SELECT, EditorName.MULTIPLE_EDIT); }, () => { render(html``, document.getElementById("panelSelected")); @@ -167,11 +199,18 @@ function showToast(content: string) { let element = document.getElementById("toast"); if (element) { - element.style.display = "block"; + element.classList.remove("hiding"); + element.classList.add("visible"); element.innerText = content; + element.style.display = "block"; toastTimeout = setTimeout(() => { - element.style.display = "none"; - element.innerText = ""; + element.classList.remove("visible"); + element.classList.add("hiding"); + setTimeout(() => { + element.classList.remove("hiding"); + element.innerText = ""; + element.style.display = "none"; + }, 300); }, 2000); } } diff --git a/ts/Canvas.ts b/ts/Canvas.ts index 6f407a2..c2044b8 100644 --- a/ts/Canvas.ts +++ b/ts/Canvas.ts @@ -1,16 +1,16 @@ -import {Layer} from "./layers/Layer"; -import {Map} from "./data/Map"; -import {Renderer} from "./Renderer"; -import {Camera} from "./Camera"; -import {Data} from "./data/Data"; -import {Ui} from "./util/Ui"; +import { Layer } from "./layers/Layer"; +import { Map } from "./data/Map"; +import { Renderer } from "./Renderer"; +import { Camera } from "./Camera"; +import { Data } from "./data/Data"; +import { Ui } from "./util/Ui"; import "hammerjs"; -import {html, render} from "lit-html"; +import { html, render } from "lit-html"; import "./elements/ZoomElement" -import {LayerName} from "./layers/Layers"; -import {Editor, UsageType} from "./editors/Editor"; -import {EditorName} from "./editors/Editors"; -import {Env} from "./Env"; +import { LayerName } from "./layers/Layers"; +import { Editor, UsageType } from "./editors/Editor"; +import { EditorName } from "./editors/Editors"; +import { Env } from "./Env"; export class Canvas { private readonly domElement: HTMLElement; @@ -21,6 +21,7 @@ export class Canvas { private width: number; private height: number; + private _isDragging: boolean = false; public constructor(domElement: HTMLElement, id: string) { this.domElement = domElement; @@ -50,6 +51,7 @@ export class Canvas { } public init(): void { + let self = this; let convertMouseEvent = (event: MouseEvent) => { return { button: event.button, @@ -118,13 +120,39 @@ export class Canvas { event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); + document.querySelectorAll(".ui-panel").forEach(p => p.classList.add("dragging")); + self._isDragging = true; let length = this.currentEditors.length; for (let i = length - 1; i >= 0; i--) { let editor = this.currentEditors[i]; if (editor.mouseListener && editor.mouseListener.onmousedown(e)) break; } + + let onDocMouseMove = (docEvent: MouseEvent) => { + let docE = convertMouseEvent(docEvent); + let len = self.currentEditors.length; + for (let i = len - 1; i >= 0; i--) { + let editor = self.currentEditors[i]; + if (editor.mouseListener && editor.mouseListener.onmousemove(docE)) break; + } + }; + let onDocMouseUp = (docEvent: MouseEvent) => { + document.removeEventListener('mousemove', onDocMouseMove); + document.removeEventListener('mouseup', onDocMouseUp); + document.querySelectorAll(".ui-panel").forEach(p => p.classList.remove("dragging")); + self._isDragging = false; + let docE = convertMouseEvent(docEvent); + let len = self.currentEditors.length; + for (let i = len - 1; i >= 0; i--) { + let editor = self.currentEditors[i]; + if (editor.mouseListener && editor.mouseListener.onmouseup(docE)) break; + } + }; + document.addEventListener('mousemove', onDocMouseMove); + document.addEventListener('mouseup', onDocMouseUp); }; this.canvasElement.onmouseup = event => { + if (self._isDragging) return; let e = convertMouseEvent(event); event.preventDefault(); event.stopPropagation(); @@ -136,6 +164,7 @@ export class Canvas { } }; this.canvasElement.onmousemove = event => { + if (self._isDragging) return; let e = convertMouseEvent(event); event.preventDefault(); event.stopPropagation(); @@ -197,7 +226,7 @@ export class Canvas { if (Ui.isMobile()) { let hammer = new Hammer(this.canvasElement); - hammer.get('pan').set({direction: Hammer.DIRECTION_ALL}); + hammer.get('pan').set({ direction: Hammer.DIRECTION_ALL }); hammer.on("pan", (event: HammerInput) => { event.deltaX *= window.devicePixelRatio; event.deltaY *= window.devicePixelRatio; diff --git a/ts/editable/DrawableMultipleEditElement.ts b/ts/editable/DrawableMultipleEditElement.ts index 282ab0e..23edf2e 100644 --- a/ts/editable/DrawableMultipleEditElement.ts +++ b/ts/editable/DrawableMultipleEditElement.ts @@ -1,11 +1,12 @@ -import {customElement, html, LitElement, property} from "lit-element"; -import {Canvas} from "../Canvas"; -import {AlphaEntry, ColorEntry} from "../util/Color"; +import { customElement, html, LitElement, property } from "lit-element"; +import { Canvas } from "../Canvas"; +import { AlphaEntry, ColorEntry } from "../util/Color"; import "../elements/ColorAlphaElement" -import {Selection} from "../layers/Selection"; -import {Drawable} from "../drawable/Drawable"; -import {EditableColor, EditableDeleteClone, EditableMove, editableMultiple} from "./Editable"; -import {TemplateResult} from "lit-html"; +import { Selection } from "../layers/Selection"; +import { Drawable } from "../drawable/Drawable"; +import { EditableColor, EditableDeleteClone, EditableMove, editableMultiple } from "./Editable"; +import { TemplateResult } from "lit-html"; +import { rotateCCWIcon, rotateCWIcon, flipXIcon, flipYIcon } from "../util/Icons"; @customElement('multipleedit-element') export class MultipleEdit extends LitElement { @@ -55,24 +56,24 @@ export class MultipleEdit extends LitElement { let editable = editableMultiple(drawables); return html` -
-
+
+
- - - - + + + + -
color
+
Color
{ - editable.setColorAlpha(color, undefined); - this.canvas.requestRender(); - }} + editable.setColorAlpha(color, undefined); + this.canvas.requestRender(); + }} .setAlpha=${(alpha: AlphaEntry) => { - editable.setColorAlpha(undefined, alpha); - this.canvas.requestRender(); - }} + editable.setColorAlpha(undefined, alpha); + this.canvas.requestRender(); + }} > `; } diff --git a/ts/editable/DrawablePolyline.ts b/ts/editable/DrawablePolyline.ts index 7ee3e26..c24dc76 100644 --- a/ts/editable/DrawablePolyline.ts +++ b/ts/editable/DrawablePolyline.ts @@ -1,17 +1,17 @@ -import {Drawable} from "../drawable/Drawable"; -import {Canvas} from "../Canvas"; -import {Renderer} from "../Renderer"; -import {Camera} from "../Camera"; -import {Size} from "../util/Size"; -import {AlphaEntry, ColorEntry, combineColorAlpha} from "../util/Color"; -import {AABB} from "../util/AABB"; -import {html, TemplateResult} from "lit-html"; -import {Map} from "../data/Map"; -import {Primitive, PrimitivePack} from "./Primitive"; -import {EditableColor, EditableDeleteClone, EditableMove, EditablePick} from "./Editable"; -import {LayerPolylineView} from "../layers/LayerPolylineView"; -import {LayerName} from "../layers/Layers"; -import {Selection, SelectType} from "../layers/Selection"; +import { Drawable } from "../drawable/Drawable"; +import { Canvas } from "../Canvas"; +import { Renderer } from "../Renderer"; +import { Camera } from "../Camera"; +import { Size } from "../util/Size"; +import { AlphaEntry, ColorEntry, combineColorAlpha } from "../util/Color"; +import { AABB } from "../util/AABB"; +import { html, TemplateResult } from "lit-html"; +import { Map } from "../data/Map"; +import { Primitive, PrimitivePack } from "./Primitive"; +import { EditableColor, EditableDeleteClone, EditableMove, EditablePick } from "./Editable"; +import { LayerPolylineView } from "../layers/LayerPolylineView"; +import { LayerName } from "../layers/Layers"; +import { Selection, SelectType } from "../layers/Selection"; export class Point { public constructor(x: number, y: number) { @@ -37,8 +37,8 @@ class PointSegmentResult { export class DrawablePolylinePack implements PrimitivePack { public constructor(points: Point[], closed: boolean, lineWidth: Size, - fill: boolean, fillColorName: string, fillAlphaName: string, - stroke: boolean, strokeColorName: string, strokeAlphaName: string) { + fill: boolean, fillColorName: string, fillAlphaName: string, + stroke: boolean, strokeColorName: string, strokeAlphaName: string) { this.points = points; this.closed = closed; this.lineWidth = lineWidth; @@ -224,7 +224,7 @@ export class DrawablePolylineEditor { } } public rotateCW(centerX?: number, centerY?: number) { - if(centerX === undefined || centerY === undefined) { + if (centerX === undefined || centerY === undefined) { let center = this.polyline.calculator.aabbCenter(); centerX = center.x; centerY = center.y; @@ -237,7 +237,7 @@ export class DrawablePolylineEditor { } } public rotateCCW(centerX?: number, centerY?: number) { - if(centerX === undefined || centerY === undefined) { + if (centerX === undefined || centerY === undefined) { let center = this.polyline.calculator.aabbCenter(); centerX = center.x; centerY = center.y; @@ -339,6 +339,96 @@ export class DrawablePolylineCalculator { } return (newX === xy.x && newY === xy.y) ? undefined : new Point(newX, newY); } + + public intersectsAABB(minX: number, minY: number, maxX: number, maxY: number): boolean { + if (this.points.length === 0) return false; + + const aabbMinX = Math.min(minX, maxX); + const aabbMaxX = Math.max(minX, maxX); + const aabbMinY = Math.min(minY, maxY); + const aabbMaxY = Math.max(minY, maxY); + + for (const point of this.points) { + if (point.x >= aabbMinX && point.x <= aabbMaxX && point.y >= aabbMinY && point.y <= aabbMaxY) { + return true; + } + } + + if (this.polyline.style.closed && this.points.length >= 3) { + const corners = [ + new Point(aabbMinX, aabbMinY), + new Point(aabbMaxX, aabbMinY), + new Point(aabbMaxX, aabbMaxY), + new Point(aabbMinX, aabbMaxY) + ]; + for (const corner of corners) { + if (this.pointInPolygon(corner)) { + return true; + } + } + } + + const polylineEdges = this.points.length; + const closedOffset = this.polyline.style.closed ? 1 : 0; + + for (let i = 0; i < polylineEdges - 1 + closedOffset; i++) { + const p1 = this.points[i]; + const p2 = this.points[(i + 1) % this.points.length]; + + if (this.lineIntersectsAABB(p1.x, p1.y, p2.x, p2.y, aabbMinX, aabbMinY, aabbMaxX, aabbMaxY)) { + return true; + } + } + + return false; + } + + private lineIntersectsAABB(x1: number, y1: number, x2: number, y2: number, minX: number, minY: number, maxX: number, maxY: number): boolean { + if (x1 >= minX && x1 <= maxX && y1 >= minY && y1 <= maxY) return true; + if (x2 >= minX && x2 <= maxX && y2 >= minY && y2 <= maxY) return true; + + const edges = [ + [minX, minY, maxX, minY], + [maxX, minY, maxX, maxY], + [maxX, maxY, minX, maxY], + [minX, maxY, minX, minY] + ]; + + for (const edge of edges) { + if (this.lineSegmentsIntersect(x1, y1, x2, y2, edge[0], edge[1], edge[2], edge[3])) { + return true; + } + } + + return false; + } + + private lineSegmentsIntersect(x1: number, y1: number, x2: number, y2: number, x3: number, y3: number, x4: number, y4: number): boolean { + const denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1); + if (Math.abs(denom) < 1e-10) return false; + + const ua = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / denom; + const ub = ((x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3)) / denom; + + return ua >= 0 && ua <= 1 && ub >= 0 && ub <= 1; + } + + private pointInPolygon(point: Point): boolean { + if (this.points.length < 3) return false; + + let inside = false; + const n = this.points.length; + for (let i = 0, j = n - 1; i < n; j = i++) { + const xi = this.points[i].x, yi = this.points[i].y; + const xj = this.points[j].x, yj = this.points[j].y; + + if (((yi > point.y) !== (yj > point.y)) && + (point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi)) { + inside = !inside; + } + } + return inside; + } } export class DrawablePolylineStyle { @@ -461,7 +551,7 @@ export class DrawablePolylineEditUi { } render(canvas: Canvas, map: Map): TemplateResult { - return html``; + return html``; } } @@ -545,7 +635,7 @@ export class DrawablePolyline implements EditablePick, EditableDeleteClone, Edit pack.points = points; return pack; } - public cloneOnCanvas(canvas:Canvas, offsetX: number, offsetY: number): Drawable { + public cloneOnCanvas(canvas: Canvas, offsetX: number, offsetY: number): Drawable { if (!this.check()) return undefined; let layerView = canvas.findLayer(LayerName.POLYLINE_VIEW); let newPolyline = new DrawablePolyline(this.clone(offsetX, offsetY)); diff --git a/ts/editable/DrawablePolylineEditElement.ts b/ts/editable/DrawablePolylineEditElement.ts index 69b7060..0d028e9 100644 --- a/ts/editable/DrawablePolylineEditElement.ts +++ b/ts/editable/DrawablePolylineEditElement.ts @@ -1,29 +1,57 @@ -import {customElement, html, LitElement, property} from "lit-element"; -import {DrawablePolyline} from "./DrawablePolyline"; -import {Canvas} from "../Canvas"; -import {Map} from "../data/Map"; -import {AlphaEntry, ColorEntry} from "../util/Color"; +import { customElement, html, LitElement, property } from "lit-element"; +import { DrawablePolyline } from "./DrawablePolyline"; +import { Canvas } from "../Canvas"; +import { Map } from "../data/Map"; +import { AlphaEntry, ColorEntry } from "../util/Color"; import "../elements/ColorAlphaElement" -import {Selection, SelectType} from "../layers/Selection"; +import "../elements/TriStateCheckboxElement" +import "../elements/NumberInputElement" +import { Selection, SelectType } from "../layers/Selection"; +import { rotateCCWIcon, rotateCWIcon, flipXIcon, flipYIcon } from "../util/Icons"; +import { TriState, getTriState, getUnifiedValue } from "../util/MultiSelect"; @customElement('polylineedit-element') export class PolylineEdit extends LitElement { @property() - polyline: DrawablePolyline; + polylines: DrawablePolyline[] = []; @property() canvas: Canvas; @property() map: Map; + private get firstPolyline(): DrawablePolyline | undefined { + return this.polylines.length > 0 ? this.polylines[0] : undefined; + } + deletePolyline() { - this.polyline.deleteOnCanvas(this.canvas); + for (const polyline of this.polylines) { + polyline.deleteOnCanvas(this.canvas); + } + if (this.polylines.length > 1) { + Selection.deselectAny(); + } else { + Selection.deselect(SelectType.POLYLINE); + } } copyPolyline() { let offset = this.canvas.getCamera().screenSizeToCanvas(20); - this.polyline.cloneOnCanvas(this.canvas, offset, offset); - Selection.select(SelectType.POLYLINE, this.polyline); + const newPolylines: DrawablePolyline[] = []; + for (const polyline of this.polylines) { + const cloned = polyline.cloneOnCanvas(this.canvas, offset, offset) as DrawablePolyline; + if (cloned) newPolylines.push(cloned); + } + if (newPolylines.length > 0) { + if (this.polylines.length > 1) { + const selected = Selection.getSelected(); + if (Array.isArray(selected.item)) { + (selected.item as DrawablePolyline[]).splice(0, selected.item.length, ...newPolylines); + } + } else { + Selection.select(SelectType.POLYLINE, newPolylines[0]); + } + } } @property() @@ -31,102 +59,184 @@ export class PolylineEdit extends LitElement { calcArea() { let width = this.map.widthMillimeter; let height = this.map.heightMillimeter; - let unit = this.polyline.style.fill ? "mm^2" : "mm"; + let unit = "mm"; if (!(this.map.widthMillimeter > 0 && this.map.heightMillimeter > 0)) { width = this.map.width; height = this.map.height; unit = "pixels" } - if (this.polyline.style.fill) { - let area = this.polyline.calculator.area(); - let areaMM2 = area / this.map.width / this.map.height * width * height; - areaMM2 = Math.round(areaMM2 * 100) / 100; - this.area = areaMM2 + unit; - } else { - let length = this.polyline.calculator.length(); - let lengthMM = length * Math.sqrt(width * height / this.map.width / this.map.height); - lengthMM = Math.round(lengthMM * 100) / 100; - this.area = lengthMM + unit; + let totalValue = 0; + for (const polyline of this.polylines) { + if (polyline.style.fill) { + let area = polyline.calculator.area(); + let areaMM2 = area / this.map.width / this.map.height * width * height; + totalValue += areaMM2; + unit = "mm²"; + } else { + let length = polyline.calculator.length(); + let lengthMM = length * Math.sqrt(width * height / this.map.width / this.map.height); + totalValue += lengthMM; + } } + totalValue = Math.round(totalValue * 100) / 100; + this.area = totalValue + unit; } rotateCCW() { - this.polyline.editor.rotateCCW(); + for (const polyline of this.polylines) { + polyline.editor.rotateCCW(); + } this.canvas.requestRender(); } rotateCW() { - this.polyline.editor.rotateCW(); + for (const polyline of this.polylines) { + polyline.editor.rotateCW(); + } this.canvas.requestRender(); } flipX() { - this.polyline.editor.flipX(); + for (const polyline of this.polylines) { + polyline.editor.flipX(); + } this.canvas.requestRender(); } flipY() { - this.polyline.editor.flipY(); + for (const polyline of this.polylines) { + polyline.editor.flipY(); + } this.canvas.requestRender(); } - private onStyleCheck = (ev: Event, options: { fill?: boolean, stroke?: boolean, closed?: boolean }) => { - if (options.fill !== undefined) this.polyline.style.fill = options.fill; - if (options.stroke !== undefined) this.polyline.style.stroke = options.stroke; - if (options.closed !== undefined) this.polyline.style.closed = options.closed; + private getFillState(): TriState { + return getTriState(this.polylines, p => p.style.fill); + } + private getStrokeState(): TriState { + return getTriState(this.polylines, p => p.style.stroke); + } + private getClosedState(): TriState { + return getTriState(this.polylines, p => p.style.closed); + } + private getStrokeColor(): ColorEntry | undefined { + return getUnifiedValue(this.polylines, p => p.style.strokeColor); + } + private getStrokeAlpha(): AlphaEntry | undefined { + return getUnifiedValue(this.polylines, p => p.style.strokeAlpha); + } + private getFillColor(): ColorEntry | undefined { + return getUnifiedValue(this.polylines, p => p.style.fillColor); + } + private getFillAlpha(): AlphaEntry | undefined { + return getUnifiedValue(this.polylines, p => p.style.fillAlpha); + } + private getOnScreen(): number | undefined { + return getUnifiedValue(this.polylines, p => p.style.onScreen); + } + private getOnCanvas(): number | undefined { + return getUnifiedValue(this.polylines, p => p.style.onCanvas); + } + + private onStyleCheck = (options: { fill?: boolean, stroke?: boolean, closed?: boolean }) => { + for (const polyline of this.polylines) { + if (options.fill !== undefined) polyline.style.fill = options.fill; + if (options.stroke !== undefined) polyline.style.stroke = options.stroke; + if (options.closed !== undefined) polyline.style.closed = options.closed; + } this.canvas.requestRender(); this.performUpdate(); }; - private onSizeInput = (ev: Event, options: { screen?: string, canvas?: string }) => { - if (options.screen !== undefined) this.polyline.style.onScreen = parseInt(options.screen); - if (options.canvas !== undefined) this.polyline.style.onCanvas = parseInt(options.canvas); + private onSizeInput = (options: { screen?: string, canvas?: string }) => { + for (const polyline of this.polylines) { + if (options.screen !== undefined) polyline.style.onScreen = parseInt(options.screen); + if (options.canvas !== undefined) polyline.style.onCanvas = parseInt(options.canvas); + } this.canvas.requestRender(); this.performUpdate(); }; render() { return html` -
-
+
+
- + ${this.area}
- - - - - -
+ + + + - this.onStyleCheck(ev, {fill: (ev.target).checked})} .checked="${this.polyline.style.fill}" >fill
- this.onStyleCheck(ev, {stroke: (ev.target).checked})} .checked="${this.polyline.style.stroke}" >stroke
- this.onStyleCheck(ev, {closed: (ev.target).checked})} .checked="${this.polyline.style.closed}" >closed
+
+ + + +
-
strokeColor
+
Stroke Color
{ - this.polyline.style.setStrokeColor(color, undefined); - this.canvas.requestRender(); - }} + for (const polyline of this.polylines) { + polyline.style.setStrokeColor(color, undefined); + } + this.canvas.requestRender(); + }} .setAlpha=${(alpha: AlphaEntry) => { - this.polyline.style.setStrokeColor(undefined, alpha); - this.canvas.requestRender(); - }} + for (const polyline of this.polylines) { + polyline.style.setStrokeColor(undefined, alpha); + } + this.canvas.requestRender(); + }} > -
fillColor
+
Fill Color
{ - this.polyline.style.setFillColor(color, undefined); - this.canvas.requestRender(); - }} + for (const polyline of this.polylines) { + polyline.style.setFillColor(color, undefined); + } + this.canvas.requestRender(); + }} .setAlpha=${(alpha: AlphaEntry) => { - this.polyline.style.setFillColor(undefined, alpha); - this.canvas.requestRender(); - }} + for (const polyline of this.polylines) { + polyline.style.setFillColor(undefined, alpha); + } + this.canvas.requestRender(); + }} > - this.onSizeInput(ev, {screen: (ev.target).value})}>pixel onScreen
- this.onSizeInput(ev, {canvas: (ev.target).value})}>pixel onCanvas
+
+ + +
+
+ + +
`; } diff --git a/ts/editable/DrawableText.ts b/ts/editable/DrawableText.ts index f49d6ba..68cfbb4 100644 --- a/ts/editable/DrawableText.ts +++ b/ts/editable/DrawableText.ts @@ -1,16 +1,16 @@ -import {Drawable} from "../drawable/Drawable"; -import {Canvas} from "../Canvas"; -import {Renderer} from "../Renderer"; -import {Camera} from "../Camera"; -import {Size} from "../util/Size"; -import {AlphaEntry, ColorEntry, combineColorAlpha} from "../util/Color"; -import {AABB} from "../util/AABB"; -import {html, TemplateResult} from "lit-html"; -import {Primitive, PrimitivePack} from "./Primitive"; -import {EditableColor, EditableDeleteClone, EditableMove, EditablePick} from "./Editable"; -import {LayerTextView} from "../layers/LayerTextView"; -import {LayerName} from "../layers/Layers"; -import {Selection, SelectType} from "../layers/Selection"; +import { Drawable } from "../drawable/Drawable"; +import { Canvas } from "../Canvas"; +import { Renderer } from "../Renderer"; +import { Camera } from "../Camera"; +import { Size } from "../util/Size"; +import { AlphaEntry, ColorEntry, combineColorAlpha } from "../util/Color"; +import { AABB } from "../util/AABB"; +import { html, TemplateResult } from "lit-html"; +import { Primitive, PrimitivePack } from "./Primitive"; +import { EditableColor, EditableDeleteClone, EditableMove, EditablePick } from "./Editable"; +import { LayerTextView } from "../layers/LayerTextView"; +import { LayerName } from "../layers/Layers"; +import { Selection, SelectType } from "../layers/Selection"; export class DrawableTextPack implements PrimitivePack { public constructor(text: string, colorName: string, alphaName: string, fontSize: Size, x: number, y: number) { @@ -225,7 +225,7 @@ export class DrawableText implements EditablePick, EditableDeleteClone, Editable if (!this.sizeValid || this.canvasZoom != camera.getZoom()) { this.sizeValid = true; - let {width, totalHeight, fontSize} = renderer.measureText(camera, this._text, this.fontSize); + let { width, totalHeight, fontSize } = renderer.measureText(camera, this._text, this.fontSize); let ratio = camera.screenSizeToCanvas(1); this.canvasZoom = camera.getZoom(); this.canvasWidth = width * ratio / 2; @@ -253,6 +253,6 @@ export class DrawableText implements EditablePick, EditableDeleteClone, Editable } public renderUi(canvas: Canvas): TemplateResult { - return html``; + return html``; } } diff --git a/ts/editable/DrawableTextEditElement.ts b/ts/editable/DrawableTextEditElement.ts index e421ada..9f5272b 100644 --- a/ts/editable/DrawableTextEditElement.ts +++ b/ts/editable/DrawableTextEditElement.ts @@ -1,64 +1,137 @@ -import {customElement, html, LitElement, property} from "lit-element"; -import {DrawableText} from "./DrawableText"; -import {Canvas} from "../Canvas"; -import {AlphaEntry, ColorEntry} from "../util/Color"; +import { customElement, html, LitElement, property } from "lit-element"; +import { DrawableText } from "./DrawableText"; +import { Canvas } from "../Canvas"; +import { AlphaEntry, ColorEntry } from "../util/Color"; import "../elements/ColorAlphaElement" -import {Selection, SelectType} from "../layers/Selection"; +import "../elements/NumberInputElement" +import "../elements/TextInputElement" +import { Selection, SelectType } from "../layers/Selection"; +import { getUnifiedValue } from "../util/MultiSelect"; @customElement('textedit-element') export class TextEdit extends LitElement { @property() - text: DrawableText; + texts: DrawableText[] = []; @property() canvas: Canvas; + private get firstText(): DrawableText | undefined { + return this.texts.length > 0 ? this.texts[0] : undefined; + } + deleteText() { - this.text.deleteOnCanvas(this.canvas); + for (const text of this.texts) { + text.deleteOnCanvas(this.canvas); + } + if (this.texts.length > 1) { + Selection.deselectAny(); + } else { + Selection.deselect(SelectType.TEXT); + } } copyText() { let offset = this.canvas.getCamera().screenSizeToCanvas(20); - this.text.cloneOnCanvas(this.canvas, offset, offset); - Selection.select(SelectType.TEXT, this.text); + const newTexts: DrawableText[] = []; + for (const text of this.texts) { + const cloned = text.cloneOnCanvas(this.canvas, offset, offset) as DrawableText; + if (cloned) newTexts.push(cloned); + } + if (newTexts.length > 0) { + if (this.texts.length > 1) { + const selected = Selection.getSelected(); + if (Array.isArray(selected.item)) { + (selected.item as DrawableText[]).splice(0, selected.item.length, ...newTexts); + } + } else { + Selection.select(SelectType.TEXT, newTexts[0]); + } + } + } + + private getText(): string | undefined { + return getUnifiedValue(this.texts, t => t.text); + } + private getColor(): ColorEntry | undefined { + return getUnifiedValue(this.texts, t => t.color); + } + private getAlpha(): AlphaEntry | undefined { + return getUnifiedValue(this.texts, t => t.alpha); + } + private getOnScreen(): number | undefined { + return getUnifiedValue(this.texts, t => t.onScreen); + } + private getOnCanvas(): number | undefined { + return getUnifiedValue(this.texts, t => t.onCanvas); } private editText = (content: string) => { - if (!content.length) content = "text"; - this.text.text = content; + for (const text of this.texts) { + if (content.length) { + text.text = content; + } + } this.canvas.requestRender(); this.performUpdate(); }; - private onSizeInput = (ev: Event, options: { screen?: string, canvas?: string }) => { - if (options.screen !== undefined) this.text.onScreen = parseInt(options.screen); - if (options.canvas !== undefined) this.text.onCanvas = parseInt(options.canvas); + private onSizeInput = (options: { screen?: string, canvas?: string }) => { + for (const text of this.texts) { + if (options.screen !== undefined) text.onScreen = parseInt(options.screen); + if (options.canvas !== undefined) text.onCanvas = parseInt(options.canvas); + } this.canvas.requestRender(); this.performUpdate(); }; render() { return html` -
-
+
+
- text
- this.editText((ev.target).value)}>
+ Text
+ +
-
color
+
Text Color
{ - this.text.setColorAlpha(color, undefined); - this.canvas.requestRender(); - }} + for (const text of this.texts) { + text.setColorAlpha(color, undefined); + } + this.canvas.requestRender(); + }} .setAlpha=${(alpha: AlphaEntry) => { - this.text.setColorAlpha(undefined, alpha); - this.canvas.requestRender(); - }} + for (const text of this.texts) { + text.setColorAlpha(undefined, alpha); + } + this.canvas.requestRender(); + }} > - this.onSizeInput(ev, {screen: (ev.target).value})}>pixel onScreen
- this.onSizeInput(ev, {canvas: (ev.target).value})}>pixel onCanvas
+
+ + +
+
+ + +
`; } diff --git a/ts/editors/EditorSelect.ts b/ts/editors/EditorSelect.ts index c742137..a77270a 100644 --- a/ts/editors/EditorSelect.ts +++ b/ts/editors/EditorSelect.ts @@ -1,27 +1,34 @@ -import {Editor, Usage} from "./Editor"; -import {MouseIn, MouseListener} from "../MouseListener"; -import {EditorName} from "./Editors"; -import {Canvas} from "../Canvas"; -import {DrawablePolyline} from "../editable/DrawablePolyline"; -import {Selection, SelectType} from "../layers/Selection"; -import {Env} from "../Env"; -import {Renderer} from "../Renderer"; -import {DrawableText} from "../editable/DrawableText"; -import {Drawable} from "../drawable/Drawable"; -import {EditablePick} from "../editable/Editable"; -import {Camera} from "../Camera"; +import { Editor, Usage } from "./Editor"; +import { MouseIn, MouseListener } from "../MouseListener"; +import { EditorName } from "./Editors"; +import { Canvas } from "../Canvas"; +import { DrawablePolyline } from "../editable/DrawablePolyline"; +import { Selection, SelectType } from "../layers/Selection"; +import { Env } from "../Env"; +import { Renderer } from "../Renderer"; +import { DrawableText } from "../editable/DrawableText"; +import { Drawable } from "../drawable/Drawable"; +import { EditablePick } from "../editable/Editable"; +import { Camera } from "../Camera"; export class EditorSelect extends Editor { + private dragging = false; + private dragStartX = 0; + private dragStartY = 0; + private dragCurrentX = 0; + private dragCurrentY = 0; + private previewSelection: (Drawable & EditablePick)[] = []; + constructor(canvas: Canvas) { super(EditorName.SELECT, canvas); } usages(): Usage[] { return [ - Editor.usage("left click polygon/text to select"), + Editor.usage("left click to select"), Editor.usage("hold ctrl and left click to select another"), - Editor.usage("hold ctrl and drag to select along"), + Editor.usage("left click and drag to select multiple"), ]; } @@ -29,102 +36,165 @@ export class EditorSelect extends Editor { let self = this; this._mouseListener = new class extends MouseListener { - private moved = false; onmousedown(event: MouseIn): boolean { - this.moved = false; + if (event.button == 0) { + self.dragging = false; + let canvasXY = self.camera.screenXyToCanvas(event.offsetX, event.offsetY); + self.dragStartX = canvasXY.x; + self.dragStartY = canvasXY.y; + self.dragCurrentX = canvasXY.x; + self.dragCurrentY = canvasXY.y; + return true; + } return false; } onmouseup(event: MouseIn): boolean { - if (event.button == 0 && !this.moved) { + if (event.button == 0) { let canvasXY = self.camera.screenXyToCanvas(event.offsetX, event.offsetY); let x = canvasXY.x, y = canvasXY.y; - let {item, type} = self.pickAny(x, y, env); - if (item) { - if (!event.ctrlKey) { - Selection.select(type, item); - return true; + if (self.dragging) { + let selected = self.previewSelection; + + if (selected.length > 0) { + if (!event.ctrlKey) { + if (selected.length === 1) { + Selection.select(selected[0].pickType, selected[0]); + } else { + Selection.select(SelectType.MULTIPLE, selected); + } + } else { + let current = Selection.getSelected(); + let currentType = current.type; + if (!currentType) { + if (selected.length === 1) { + Selection.select(selected[0].pickType, selected[0]); + } else { + Selection.select(SelectType.MULTIPLE, selected); + } + } else if (currentType === SelectType.MULTIPLE) { + let array = <(Drawable & EditablePick)[]>current.item; + for (let item of selected) { + if (array.indexOf(item) < 0) { + array.push(item); + } + } + Selection.select(SelectType.MULTIPLE, array); + } else { + let newArray: (Drawable & EditablePick)[] = [current.item]; + for (let item of selected) { + if (newArray.indexOf(item) < 0) { + newArray.push(item); + } + } + Selection.select(SelectType.MULTIPLE, newArray); + } + } } else { - let current = Selection.getSelected(); - let currentType = current.type; + if (!event.ctrlKey) { + Selection.deselectAny(); + } + } - if (!currentType) { //nothing selected + self.dragging = false; + self.previewSelection = []; + self.canvas.requestRender(); + return true; + } else { + let { item, type } = self.pickAny(x, y, env); + if (item) { + if (!event.ctrlKey) { Selection.select(type, item); return true; + } else { + let current = Selection.getSelected(); + let currentType = current.type; - } else if (currentType === SelectType.MULTIPLE) { //multiple selected - let array = <(Drawable & EditablePick)[]>current.item; - let index = array.indexOf(item); - if (index >= 0) { //deselecting existing - array.splice(index, 1); //remove this - if (array.length === 0) { //deselected everything - Selection.deselectAny(); - return true; - } else if (array.length === 1) { //only one left - Selection.select(array[0].pickType, array[0]); - return true; - } else { //update current array + if (!currentType) { + Selection.select(type, item); + return true; + + } else if (currentType === SelectType.MULTIPLE) { + let array = <(Drawable & EditablePick)[]>current.item; + let index = array.indexOf(item); + if (index >= 0) { + array.splice(index, 1); + if (array.length === 0) { + Selection.deselectAny(); + return true; + } else if (array.length === 1) { + Selection.select(array[0].pickType, array[0]); + return true; + } else { + Selection.select(SelectType.MULTIPLE, array); + return true; + } + } else { + array.push(item); Selection.select(SelectType.MULTIPLE, array); return true; } - } else { //select new - array.push(item); - Selection.select(SelectType.MULTIPLE, array); - return true; - } - } else { - if (current.item !== item) { //selected a second - Selection.select(SelectType.MULTIPLE, [current.item, item]); - return true; - } else { //deselected that - Selection.deselectAny(); - return true; + } else { + if (current.item !== item) { + Selection.select(SelectType.MULTIPLE, [current.item, item]); + return true; + } else { + Selection.deselectAny(); + return true; + } } } } - } - Selection.deselectAny(); - return false; - } else { - return false; + Selection.deselectAny(); + return false; + } } + return false; } onmousemove(event: MouseIn): boolean { if (event.buttons & 1) { - this.moved = true; - - if (event.ctrlKey) { - let canvasXY = self.camera.screenXyToCanvas(event.offsetX, event.offsetY); - let x = canvasXY.x, y = canvasXY.y; - - let current = Selection.getSelected(); - let currentType = current.type; - - let exclude: (Drawable & EditablePick)[] = []; - switch (currentType) { - case SelectType.POLYLINE: - case SelectType.TEXT: - exclude = [<(Drawable & EditablePick)>current.item]; - break; - case SelectType.MULTIPLE: - exclude = <(Drawable & EditablePick)[]>current.item; - break; - } + let canvasXY = self.camera.screenXyToCanvas(event.offsetX, event.offsetY); + let x = canvasXY.x, y = canvasXY.y; - let picked = self.pickAll(x, y, env, undefined, exclude); + if (Math.abs(x - self.dragStartX) > 3 || Math.abs(y - self.dragStartY) > 3) { + self.dragging = true; + } - if (picked.length) { - if(exclude.length === 0 && picked.length === 1) { - let item = picked[0]; - Selection.select(item.pickType, item); - } else { - exclude.push(...picked); //try to change the original array for MULTIPLE, so EditorMultiple will continue to work. - Selection.select(SelectType.MULTIPLE, exclude); + if (self.dragging) { + self.dragCurrentX = x; + self.dragCurrentY = y; + + let startX = self.dragStartX; + let startY = self.dragStartY; + let endX = self.dragCurrentX; + let endY = self.dragCurrentY; + + let minX = Math.min(startX, endX); + let maxX = Math.max(startX, endX); + let minY = Math.min(startY, endY); + let maxY = Math.max(startY, endY); + + self.previewSelection = []; + + for (let text of env.texts) { + let aabb = self.getAABB(text); + if (aabb) { + let partiallyContained = !(aabb.x2 < minX || aabb.x1 > maxX || aabb.y2 < minY || aabb.y1 > maxY); + if (partiallyContained) { + self.previewSelection.push(text); + } } } + for (let polyline of env.polylines) { + if (polyline.calculator.intersectsAABB(minX, minY, maxX, maxY)) { + self.previewSelection.push(polyline); + } + } + + self.canvas.requestRender(); return true; } } @@ -134,8 +204,8 @@ export class EditorSelect extends Editor { if (event.button == 0) { let canvasXY = self.camera.screenXyToCanvas(event.offsetX, event.offsetY); let x = canvasXY.x, y = canvasXY.y; - let {item, type} = self.pickAny(x, y, env); - if (type === SelectType.TEXT) { //double click a text with link to open link + let { item, type } = self.pickAny(x, y, env); + if (type === SelectType.TEXT) { let text = item; if (text.link) { window.open(text.link, "_blank"); @@ -226,23 +296,42 @@ export class EditorSelect extends Editor { //text first let text = EditorSelect.pickText(x, y, env.camera, texts); if (text) { - return {item: text, type: SelectType.TEXT}; + return { item: text, type: SelectType.TEXT }; } //polyline next let polyline = EditorSelect.pickPolyline(x, y, env.camera, polylines); if (polyline) { - return {item: polyline, type: SelectType.POLYLINE}; + return { item: polyline, type: SelectType.POLYLINE }; } - return {item: undefined, type: undefined}; + return { item: undefined, type: undefined }; }; + public getAABB(item: Drawable & EditablePick): { x1: number, y1: number, x2: number, y2: number } { + switch (item.pickType) { + case SelectType.TEXT: + let text = item; + return text.validateCanvasAABB(this.camera, null); + case SelectType.POLYLINE: + let polyline = item; + let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity; + polyline.editor.forEachPoint((x: number, y: number) => { + if (x < x1) x1 = x; + if (y < y1) y1 = y; + if (x > x2) x2 = x; + if (y > y2) y2 = y; + }); + return { x1, y1, x2, y2 }; + } + return null; + } + //render render(env: Env): void { - let {item: item, type: type} = Selection.getSelected(); + let { item: item, type: type } = Selection.getSelected(); switch (type) { case SelectType.POLYLINE: case SelectType.POLYLINE_CREATE: @@ -264,6 +353,30 @@ export class EditorSelect extends Editor { } break; } + + if (this.dragging) { + let p1 = this.camera.canvasToScreen(this.dragStartX, this.dragStartY); + let p2 = this.camera.canvasToScreen(this.dragCurrentX, this.dragCurrentY); + let x1 = Math.min(p1.x, p2.x); + let y1 = Math.min(p1.y, p2.y); + let x2 = Math.max(p1.x, p2.x); + let y2 = Math.max(p1.y, p2.y); + env.renderer.setColor("rgba(100, 149, 237, 0.2)"); + env.renderer.drawRect(x1, y1, x2, y2, true, false); + env.renderer.setColor("rgba(100, 149, 237, 1)"); + env.renderer.drawRect(x1, y1, x2, y2, false, true); + + for (let drawable of this.previewSelection) { + switch (drawable.pickType) { + case SelectType.POLYLINE: + this.drawPreviewPolyline(drawable, env.renderer); + break; + case SelectType.TEXT: + this.drawPreviewText(drawable, env.renderer); + break; + } + } + } } private drawSelectedPolyline(polyline: DrawablePolyline, renderer: Renderer) { @@ -290,4 +403,28 @@ export class EditorSelect extends Editor { ); } + private drawPreviewPolyline(polyline: DrawablePolyline, renderer: Renderer) { + let drawPointCircle = (x: number, y: number, renderer: Renderer) => { + let position = this.camera.canvasToScreen(x, y); + renderer.setColor("rgba(255,255,255,1)"); + renderer.drawCircle(position.x, position.y, 5, false, true, 1); + renderer.setColor("rgba(0,0,0,0.5)"); + renderer.drawCircle(position.x, position.y, 4, true, false); + }; + polyline.editor.forEachPoint((x: number, y: number) => { + drawPointCircle(x, y, renderer); + }); + } + + private drawPreviewText(text: DrawableText, renderer: Renderer) { + renderer.setColor(text.colorString); + let aabb = text.validateCanvasAABB(this.camera, renderer); + let p1 = this.camera.canvasToScreen(aabb.x1, aabb.y1); + let p2 = this.camera.canvasToScreen(aabb.x2, aabb.y2); + renderer.drawRect( + p1.x - 5, p1.y - 5, p2.x + 5, p2.y + 5, + false, true, 2 + ); + } + } diff --git a/ts/elements/ColorAlphaElement.ts b/ts/elements/ColorAlphaElement.ts index 2baaa88..048bc13 100644 --- a/ts/elements/ColorAlphaElement.ts +++ b/ts/elements/ColorAlphaElement.ts @@ -1,22 +1,60 @@ -import {customElement, html, LitElement, property} from "lit-element"; -import {AlphaEntry, ColorEntry} from "../util/Color"; +import { customElement, html, LitElement, property } from "lit-element"; +import { AlphaEntry, ColorEntry } from "../util/Color"; @customElement('coloralpha-element') export class ColorAlphaElement extends LitElement { @property() private setColor: (color: ColorEntry) => void; + @property() private setAlpha: (alpha: AlphaEntry) => void; + @property() + private currentColor: ColorEntry | undefined = undefined; + @property() + private currentAlpha: AlphaEntry | undefined = undefined; + + private isSelected(color: ColorEntry): boolean { + return this.currentColor !== undefined && this.currentColor.name === color.name; + } + + private isAlphaSelected(alpha: AlphaEntry): boolean { + return this.currentAlpha !== undefined && this.currentAlpha.value === alpha.value; + } + + private getColorButtonClass(color: ColorEntry): string { + const selected = this.isSelected(color) ? 'selected' : ''; + const empty = this.currentColor === undefined ? 'empty' : ''; + return `configColorButton ${selected} ${empty}`.trim(); + } + + private getAlphaButtonClass(alpha: AlphaEntry): string { + const selected = this.isAlphaSelected(alpha) ? 'selected' : ''; + const empty = this.currentAlpha === undefined ? 'empty' : ''; + return `configAlphaButton ${selected} ${empty}`.trim(); + } render() { return html` +
- ${ColorEntry.list.map(color => html``)} - + ${ColorEntry.list.map(color => html``)} +
${AlphaEntry.list.map(alpha => { - let color = 255 * (1 - alpha.value); - return html`` - })} + let color = 255 * (1 - alpha.value); + return html`` + })}
`; } @@ -25,4 +63,4 @@ export class ColorAlphaElement extends LitElement { return this; } -} \ No newline at end of file +} diff --git a/ts/elements/NumberInputElement.ts b/ts/elements/NumberInputElement.ts new file mode 100644 index 0000000..0a46b90 --- /dev/null +++ b/ts/elements/NumberInputElement.ts @@ -0,0 +1,97 @@ +import { css, customElement, html, LitElement, property } from 'lit-element'; + +@customElement('number-input') +export class NumberInputElement extends LitElement { + + @property() + value: number | null | undefined = null; + + @property() + placeholder: string = '-'; + + @property() + min: number | undefined; + + @property() + max: number | undefined; + + @property() + onChange: (value: number) => void; + + private isEmpty(): boolean { + return this.value === null || this.value === undefined; + } + + private handleInput(e: Event) { + const input = e.target as HTMLInputElement; + const val = input.value.trim(); + + if (val === '') { + this.value = this.min ?? 0; + } else { + const num = parseFloat(val); + if (!isNaN(num)) { + this.value = num; + } + } + + if (this.onChange) { + this.onChange(this.value!); + } + } + + static override styles = css` + :host { + display: inline-flex; + align-items: center; + vertical-align: middle; + } + + input { + width: 60px; + padding: 2px 4px; + border: 1px solid #666; + border-radius: 3px; + background: #fff; + color: #333; + font-size: 12px; + text-align: center; + -moz-appearance: textfield; + } + + // input::-webkit-outer-spin-button, + // input::-webkit-inner-spin-button { + // -webkit-appearance: none; + // margin: 0; + // } + + input:focus { + outline: none; + border-color: #4a90d9; + } + + input.empty { + color: #999; + } + + input::placeholder { + color: #999; + } + `; + + render() { + const displayValue = this.isEmpty() ? '-' : String(this.value); + + return html` + + `; + } +} diff --git a/ts/elements/SelectElement.ts b/ts/elements/SelectElement.ts index 64d9ff8..ae57fe5 100644 --- a/ts/elements/SelectElement.ts +++ b/ts/elements/SelectElement.ts @@ -1,8 +1,8 @@ -import {customElement, html, LitElement, property, TemplateResult} from "lit-element"; -import {Chip, Map} from "../data/Map"; -import {NetUtil} from "../util/NetUtil"; -import {Annotation, Data} from "../data/Data"; -import {Github} from "../util/GithubUtil"; +import { customElement, html, LitElement, property, TemplateResult } from "lit-element"; +import { Chip, Map } from "../data/Map"; +import { NetUtil } from "../util/NetUtil"; +import { Annotation, Data } from "../data/Data"; +import { Github } from "../util/GithubUtil"; @customElement('select-element') export class SelectElement extends LitElement { @@ -42,7 +42,7 @@ export class SelectElement extends LitElement { @property() annotation_current: Annotation; - private static dummyAnnotation: Annotation = {id: 0, user: "", content: {title: "", polylines: [], texts: []}}; + private static dummyAnnotation: Annotation = { id: 0, user: "", content: { title: "", polylines: [], texts: [] } }; //↑↑↑↑↑ annotation selection box ↑↑↑↑↑ @@ -55,7 +55,7 @@ export class SelectElement extends LitElement { protected firstUpdated(): void { let url_string = window.location.href; let url = new URL(url_string); - this.chip_name_toload = (url.searchParams.get("map") && decodeURIComponent(url.searchParams.get("map")) )|| "Fiji"; + this.chip_name_toload = (url.searchParams.get("map") && decodeURIComponent(url.searchParams.get("map"))) || "Fiji"; this.annotation_id_toload = parseInt(url.searchParams.get("commentId") || "0"); this.refreshChipList(); @@ -97,7 +97,7 @@ export class SelectElement extends LitElement { } private refreshChipList() { SelectElement.fetchChipList().then(chips => { - let {html, array, current} = SelectElement.showChipList(chips, this.chip_name_toload); + let { html, array, current } = SelectElement.showChipList(chips, this.chip_name_toload); this.chip_current = current; this.chiplist_html = html; this.chiplist_array = array; @@ -114,7 +114,7 @@ export class SelectElement extends LitElement { this.annotationlist_html = []; this.annotationlist_array = []; SelectElement.fetchAnnotationList(this.map_current).then(annotations => { - let {html, array, current} = SelectElement.showAnnotationList(annotations, this.annotation_id_toload); + let { html, array, current } = SelectElement.showAnnotationList(annotations, this.annotation_id_toload); this.annotation_current = current; this.annotationlist_html = html; this.annotationlist_array = array; @@ -187,7 +187,7 @@ export class SelectElement extends LitElement { last_Family = curr_Family; } - return {html: selections, array: selection_chip, current: current}; + return { html: selections, array: selection_chip, current: current }; } private static fetchMap(chip: Chip): Promise { @@ -211,7 +211,7 @@ export class SelectElement extends LitElement { if (data.title == null || data.title == "") { data.title = "untitled"; } - result.push({id: comment.id, user: comment.user.login, content: data}); + result.push({ id: comment.id, user: comment.user.login, content: data }); } } catch (_e) { // if anything goes wrong, ignore it, it's not a valid annotation @@ -249,11 +249,11 @@ export class SelectElement extends LitElement { } } - return {html: options, array: array, current: current} + return { html: options, array: array, current: current } } render() { - let source = this.map_current ? html`${this.map_current.source}` : html``; + let source = this.map_current ? html`` : html``; return html`
diff --git a/ts/elements/TextInputElement.ts b/ts/elements/TextInputElement.ts new file mode 100644 index 0000000..4df669c --- /dev/null +++ b/ts/elements/TextInputElement.ts @@ -0,0 +1,73 @@ +import { css, customElement, html, LitElement, property } from 'lit-element'; + +@customElement('text-input') +export class TextInputElement extends LitElement { + + @property() + value: string | null | undefined = null; + + @property() + placeholder: string = '-'; + + @property() + onChange: (value: string) => void; + + private isEmpty(): boolean { + return this.value === null || this.value === undefined; + } + + private handleInput(e: Event) { + const input = e.target as HTMLInputElement; + const val = input.value; + this.value = val; + + if (this.onChange) { + this.onChange(val); + } + } + + static override styles = css` + :host { + display: inline-flex; + align-items: center; + vertical-align: middle; + } + + input { + width: 120px; + padding: 2px 4px; + border: 1px solid #666; + border-radius: 3px; + background: #fff; + color: #333; + font-size: 12px; + } + + input:focus { + outline: none; + border-color: #4a90d9; + } + + input.empty { + color: #999; + } + + input::placeholder { + color: #999; + } + `; + + render() { + const displayValue = this.isEmpty() ? '' : String(this.value); + + return html` + + `; + } +} diff --git a/ts/elements/TitleElement.ts b/ts/elements/TitleElement.ts index 89432ce..ef2a225 100644 --- a/ts/elements/TitleElement.ts +++ b/ts/elements/TitleElement.ts @@ -1,8 +1,8 @@ -import {customElement, html, LitElement, property} from "lit-element"; -import {Annotation} from "../data/Data"; -import {Map} from "../data/Map"; -import {Canvas} from "../Canvas"; -import {Github} from "../util/GithubUtil"; +import { customElement, html, LitElement, property } from "lit-element"; +import { Annotation } from "../data/Data"; +import { Map } from "../data/Map"; +import { Canvas } from "../Canvas"; +import { Github } from "../util/GithubUtil"; @customElement('title-element') export class TitleElement extends LitElement { @@ -20,7 +20,7 @@ export class TitleElement extends LitElement { let data = this.canvas.save(); data.title = (document.getElementById("inputTitle") as HTMLInputElement).value; if (data.title == null || data.title == "") { - data.title = "untitled"; + data.title = "Untitled"; } let dataString = JSON.stringify(data); @@ -71,11 +71,12 @@ export class TitleElement extends LitElement { } return html` - - -
- - <-how? +
+ + +
+ + ? `; } diff --git a/ts/elements/TriStateCheckboxElement.ts b/ts/elements/TriStateCheckboxElement.ts new file mode 100644 index 0000000..5702d9e --- /dev/null +++ b/ts/elements/TriStateCheckboxElement.ts @@ -0,0 +1,95 @@ +import { css, customElement, html, LitElement, property } from 'lit-element'; + +@customElement('tristate-checkbox') +export class TriStateCheckboxElement extends LitElement { + + @property() + state: 'none' | 'some' | 'all' = 'none'; + + @property() + label: string = ''; + + @property() + onChange: (state: 'none' | 'all') => void; + + static override styles = css` + :host { + display: inline; + cursor: pointer; + user-select: none; + // line-height: 24px; + // height: 24px; + vertical-align: middle; + } + + .tsc-box { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid #666; + border-radius: 3px; + background: #fff; + transition: all 0.2s; + vertical-align: middle; + text-align: center; + } + + :host(:hover) .tsc-box { + border-color: #333; + } + + .tsc-box svg { + width: 14px; + height: 14px; + vertical-align: top; + } + + .tsc-box.tsc-checked { + background: #4a90d9; + border-color: #4a90d9; + } + + .tsc-label { + display: inline; + white-space: nowrap; + vertical-align: middle; + } + `; + + private toggle() { + if (this.state === 'all') { + this.state = 'none'; + } else { + this.state = 'all'; + } + if (this.onChange) this.onChange(this.state); + } + + private getSvg() { + if (this.state === 'none') { + return html``; + } else if (this.state === 'some') { + return html` + + + + `; + } else { + return html` + + + + `; + } + } + + render() { + return html` + this.toggle()}> + ${this.getSvg()} + + ${this.label ? html` this.toggle()}>${this.label}` : ''} + `; + } + +} diff --git a/ts/util/Icons.ts b/ts/util/Icons.ts new file mode 100644 index 0000000..d17ed8a --- /dev/null +++ b/ts/util/Icons.ts @@ -0,0 +1,37 @@ +import { html } from "lit-html"; + +export const rotateCCWIcon = html` + + + + +`; + +export const rotateCWIcon = html` + + + + +`; + +export const flipXIcon = html` + + + + + + + + +`; + +export const flipYIcon = html` + + + + + + + + +`; diff --git a/ts/util/MultiSelect.ts b/ts/util/MultiSelect.ts new file mode 100644 index 0000000..c9de32c --- /dev/null +++ b/ts/util/MultiSelect.ts @@ -0,0 +1,25 @@ +export type TriState = 'none' | 'some' | 'all'; +export type UnifiedValue = T | undefined; + +export function getUnifiedValue(arr: T[], getter: (item: T) => V): UnifiedValue { + if (arr.length === 0) return undefined; + const first = getter(arr[0]); + for (const item of arr) { + if (getter(item) !== first) return undefined; + } + return getter(arr[0]); +} + +export function getTriState(arr: T[], getter: (item: T) => boolean): TriState { + if (arr.length === 0) return 'none'; + const first = getter(arr[0]); + for (const item of arr) { + if (getter(item) !== first) return 'some'; + } + return first ? 'all' : 'none'; +} + +export function boolFromTriState(state: TriState): boolean | undefined { + if (state === 'some') return undefined; + return state === 'all'; +}