diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml new file mode 100644 index 000000000..e487b6f97 --- /dev/null +++ b/.github/workflows/maven.yml @@ -0,0 +1,24 @@ +# This workflow will build a Java project with Maven +# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven + +name: Java CI with Maven + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up JDK 17 + uses: actions/setup-java@v1 + with: + java-version: 17 + - name: Build with Maven + run: mvn -B package --file pom.xml diff --git a/.gitignore b/.gitignore index afda8a438..7c0e47ceb 100644 --- a/.gitignore +++ b/.gitignore @@ -99,9 +99,10 @@ Temporary Items ### Teamapps custom ignore -server-data/ +server-data*/ tmp/ */LICENSE.txt +/.vscode ### JavaScript stuff @@ -109,9 +110,11 @@ yarn-error.log teamapps-client/node/ teamapps-client/node_modules/ teamapps-client/npm-debug.log -teamapps-client/ts/**/*.js +#teamapps-client/ts/**/*.js +teamapps-client/ts/**/*.map teamapps-client/ts/generated/ teamapps-client/dist/ +teamapps-client/stats.json ### MicrosoftOffice template *.tmp @@ -122,3 +125,6 @@ teamapps-client/dist/ # Excel Backup File *.xlk +# All ai agent stuff +AGENTS.md +agent/ \ No newline at end of file diff --git a/.mvn/extensions-x.xml b/.mvn/extensions-x.xml new file mode 100644 index 000000000..62fd02dfb --- /dev/null +++ b/.mvn/extensions-x.xml @@ -0,0 +1,7 @@ + + + com.gradle + gradle-enterprise-maven-extension + 1.1.2 + + \ No newline at end of file diff --git a/README.md b/README.md index ff401c7a0..8b40d01d2 100644 --- a/README.md +++ b/README.md @@ -32,48 +32,98 @@ To start a ready to run server with TeamApps included use: ## Quick start -In this example we create a responsive application with a single perspective, a few empty panels and a toolbar. -Please use the `teamapps-server-jetty-embedded` dependency to run this example. +### Hello World + +This will start a server on port 8080, so you should see the result under http://localhost:8080 ```java -public static void main(String[] args) throws Exception { - WebController controller = SimpleWebController.createDefaultController(context -> { - //create a responsive application that will run on desktops as well as on smart phones - ResponsiveApplication application = ResponsiveApplication.createApplication(); - - //create perspective with default layout - Perspective perspective = Perspective.createPerspective(); - application.addPerspective(perspective); - - //create an empty left panel - perspective.addView(View.createView(StandardLayout.LEFT, MaterialIcon.MESSAGE, "Left panel", null)); - - //create a tabbed center panel - perspective.addView(View.createView(StandardLayout.CENTER, MaterialIcon.SEARCH, "Center panel", null)); - perspective.addView(View.createView(StandardLayout.CENTER, MaterialIcon.PEOPLE, "Center panel 2", null)); +import org.teamapps.icon.material.MaterialIcon; +import org.teamapps.server.jetty.embedded.TeamAppsJettyEmbeddedServer; +import org.teamapps.ux.component.field.Button; +import org.teamapps.ux.component.rootpanel.RootPanel; + +public class HelloWorld { + + public static void main(String[] args) throws Exception { + new TeamAppsJettyEmbeddedServer(sessionContext -> { + RootPanel rootPanel = sessionContext.addRootPanel(); + Button button = Button.create(MaterialIcon.INFO, "Click me!"); + button.onClicked.addListener(() -> { + sessionContext.showNotification(MaterialIcon.CHAT, "Hello World!", "Congrats for your first TeamApps program!"); + }); + rootPanel.setContent(button); + }, 8080).start(); + } - //create a right panel - perspective.addView(View.createView(StandardLayout.RIGHT, MaterialIcon.FOLDER, "Left panel", null)); - - //create a right bottom panel - perspective.addView(View.createView(StandardLayout.RIGHT_BOTTOM, MaterialIcon.VIEW_CAROUSEL, "Left bottom panel", null)); - - //create toolbar buttons - ToolbarButtonGroup buttonGroup = new ToolbarButtonGroup(); - buttonGroup.addButton(ToolbarButton.create(MaterialIcon.SAVE, "Save", "Save changes")).onClick.addListener(toolbarButtonClickEvent -> { - CurrentSessionContext.get().showNotification(MaterialIcon.MESSAGE, "Save was clicked!"); - }); - buttonGroup.addButton(ToolbarButton.create(MaterialIcon.DELETE, "Delete", "Delete some items")); - - //display these buttons only when this perspective is visible - perspective.addWorkspaceButtonGroup(buttonGroup); +} +``` +### Application Layout - application.showPerspective(perspective); - return application.createUi(); - }, Color.MATERIAL_GREY_300, true); +In this example we create a responsive application with a single perspective, a few empty panels and a toolbar. +Add the `teamapps-server-jetty-embedded` dependency to run this example. - new TeamAppsJettyEmbeddedServer(controller, Files.createTempDir()).start(); +```java +import org.teamapps.icon.material.MaterialIcon; +import org.teamapps.server.jetty.embedded.TeamAppsJettyEmbeddedServer; +import org.teamapps.ux.application.ResponsiveApplication; +import org.teamapps.ux.application.layout.StandardLayout; +import org.teamapps.ux.application.perspective.Perspective; +import org.teamapps.ux.application.view.View; +import org.teamapps.ux.component.rootpanel.RootPanel; +import org.teamapps.ux.component.toolbar.ToolbarButton; +import org.teamapps.ux.component.toolbar.ToolbarButtonGroup; +import org.teamapps.ux.session.CurrentSessionContext; +import org.teamapps.webcontroller.WebController; + +public class TeamAppsDemo { + + public static void main(String[] args) throws Exception { + WebController controller = sessionContext -> { + RootPanel rootPanel = new RootPanel(); + sessionContext.addRootPanel(null, rootPanel); + + //create a responsive application that will run on desktops as well as on smart phones + ResponsiveApplication application = ResponsiveApplication.createApplication(); + + //create perspective with default layout + Perspective perspective = Perspective.createPerspective(); + application.addPerspective(perspective); + + //create an empty left panel + perspective.addView(View.createView(StandardLayout.LEFT, MaterialIcon.MESSAGE, "Left panel", null)); + + //create a tabbed center panel + perspective.addView(View.createView(StandardLayout.CENTER, MaterialIcon.SEARCH, "Center panel", null)); + perspective.addView(View.createView(StandardLayout.CENTER, MaterialIcon.PEOPLE, "Center panel 2", null)); + + //create a right panel + perspective.addView(View.createView(StandardLayout.RIGHT, MaterialIcon.FOLDER, "Left panel", null)); + + //create a right bottom panel + perspective.addView(View.createView(StandardLayout.RIGHT_BOTTOM, MaterialIcon.VIEW_CAROUSEL, "Left bottom panel", null)); + + //create toolbar buttons + ToolbarButtonGroup buttonGroup = new ToolbarButtonGroup(); + buttonGroup.addButton(ToolbarButton.create(MaterialIcon.SAVE, "Save", "Save changes")).onClick.addListener(toolbarButtonClickEvent -> { + sessionContext.showNotification(MaterialIcon.MESSAGE, "Save was clicked!"); + }); + buttonGroup.addButton(ToolbarButton.create(MaterialIcon.DELETE, "Delete", "Delete some items")); + + //display these buttons only when this perspective is visible + perspective.addWorkspaceButtonGroup(buttonGroup); + + application.showPerspective(perspective); + rootPanel.setContent(application.getUi()); + + // set Background Image + String defaultBackground = "/resources/backgrounds/default-bl.jpg"; + sessionContext.registerBackgroundImage("default", defaultBackground, defaultBackground); + sessionContext.setBackgroundImage("default", 0); + }; + + new TeamAppsJettyEmbeddedServer(controller, 8080).start(); + } } ``` The result should look something like this: @@ -82,3 +132,13 @@ The result should look something like this: ## License The TeamApps Framework is released under version 2.0 of the [Apache License](https://www.apache.org/licenses/LICENSE-2.0). + +## Supported By + +JProfiler + + + +YourKit + + diff --git a/docs/bjesuiter/PdfViewerComponent_docs.md b/docs/bjesuiter/PdfViewerComponent_docs.md new file mode 100644 index 000000000..6566c9fac --- /dev/null +++ b/docs/bjesuiter/PdfViewerComponent_docs.md @@ -0,0 +1,278 @@ +# PdfViewerComponent Implementation + +## Parts + +DTO: `/teamapps-ui-api/src/main/dto/UiPdfViewer.dto` + +Java Impl: `teamapps-ux/src/main/java/org/teamapps/ux/component/pdfviewer` + +Typescript Impl: `teamapps-client/ts/modules` + +Testfile (for anybody working in this repo): +`teamapps-server-jetty-embedded/src/test/java/org/teamapps/server/jetty/embedded/TeamAppsJettyEmbeddedServerTest.java` + +## Current Test Harness + +Do not copy a static code block from this document. Use the current source of truth: +`teamapps-server-jetty-embedded/src/test/java/org/teamapps/server/jetty/embedded/TeamAppsJettyEmbeddedServerTest.java`. + +Current setup uses `UiPdfViwerTestHarness` mounted in the root panel. + +## Run the Test environment + +Preparation: +- Regenerate DTO artifacts first: + - `mvn clean install -pl teamapps-ui-api -am` +- Build/install the frontend module: + - `cd teamapps-client && mvn clean install` + +1. Run the Testfile (`teamapps-server-jetty-embedded/src/test/java/org/teamapps/server/jetty/embedded/TeamAppsJettyEmbeddedServerTest.java`) via IntelliJ (Green Arrow in UI) +2. Start the dev environment script (frontend dev server): `docs/bjesuiter/start_pdf_dev_env.sh` +3. Goto http://localhost:9000 to see/debug the component + +## Instructions for agents + +- Use http://localhost:9000/ to access the dev server. +- Restore a "blank slate" for testing by reloading the page. +- The buttons in class `toolbar-button-group` are controlled by the server, written in `teamapps-server-jetty-embedded/src/test/java/org/teamapps/server/jetty/embedded/TeamAppsJettyEmbeddedServerTest.java`. +- The buttons in class `dev-toolbar` are inside the component in `teamapps-client/ts/modules/UiPdfViewer.ts`. +- Do not control the Jetty server. Leave it to the human and IntelliJ IDEA. +- If you change something in the PDF test harness, notify the human to restart Jetty. +- Do not touch untracked files. Never commit them without approval. Only commit already tracked files without approval. +- When committing, use prefix: UiPdfViewer: + +### Workflow: Adding a new event + +When adding a new `UiPdfViewer` event, follow this exact order: + +1. Update the `.dto` file. +2. Run `mvn clean install` in the repo root. +3. Update the corresponding `.java` file. +4. Update the corresponding `.ts` file. +5. Restart the dev server via `docs/bjesuiter/start_pdf_dev_env.sh`. + +### Start/Restart Jetty Test Server (Human-only CLI reference) + +Jetty is intended to be started and restarted by the human via IntelliJ run config: +- **Main class:** `org.teamapps.server.jetty.embedded.TeamAppsJettyEmbeddedServerTest` +- **Module classpath:** `teamapps-server-jetty-embedded` +- **JDK:** 21 +- **Working dir:** repo root + +If you change the PDF test harness, tell the human to restart Jetty in IntelliJ. + +### Start Frontend Dev Server (CLI, bgproc) + +Script details: +- **Wrapper script path:** `docs/bjesuiter/start_pdf_dev_env.sh` +- **Underlying dev script path:** `teamapps-client/start-dev-server.sh` +- **Note:** Frontend dev server runs on port `9000` and connects to app server port `8082`. + +**Start (background, via script):** +```bash +docs/bjesuiter/start_pdf_dev_env.sh +``` + +**Status:** +```bash +bgproc status -n teamapps-frontend +``` + +**Logs:** +```bash +bgproc logs -n teamapps-frontend +``` + +**Stop:** +```bash +bgproc stop -n teamapps-frontend +``` + +## Post-Merge Regeneration Flow (Complete) + +Use this exact flow after merging `master` into a feature branch, especially when merge conflicts touched `teamapps-client/package.json`, `teamapps-client/yarn.lock`, or anything under `teamapps-ui-api/src/main/dto`. + +1. Confirm merge conflicts are resolved and staged. +2. Regenerate DTO outputs first (this is critical): +```bash +mvn clean install -pl teamapps-ui-api -am +``` +This builds required upstream modules (`teamapps-ui-dsl`) and regenerates: +- Java DTOs in `teamapps-ui-api/target/generated-sources/dto` +- TypeScript DTO configs in `teamapps-client/ts/generated` (gitignored, local build artifacts) + +3. Rebuild the client module: +```bash +cd teamapps-client +yarn install +mvn clean install +``` + +4. Verify merge/build state: +- No `UU` files in `git status --short` +- No `.git/MERGE_HEAD` file (merge concluded) +- `teamapps-client` Maven build ends with `BUILD SUCCESS` + +### Troubleshooting + +- If you see errors like: + - `TS2307: Cannot find module '../generated/...` + - `Property '...' does not exist on type 'UiMap2Config'` or `UiTreeConfig` + this means `teamapps-client/ts/generated` is stale. Re-run step 2. + +- `mvn clean install` inside `teamapps-client` runs `yarn sync-pom-version` and may update `teamapps-client/package.json` version. If this change is not intended for the commit, restore it: +```bash +git restore teamapps-client/package.json +``` + +## Continuous Virtual Mode (Evergreen) + +### Implementation anchors + +- DTO enum: `teamapps-ui-api/src/main/dto/UiPdfViewer.dto` (`UiPdfViewMode.CONTINUOUS_VIRTUAL`). +- Main component: `teamapps-client/ts/modules/UiPdfViewer.ts`. +- Virtual renderer: `teamapps-client/ts/modules/pdf-viewer/ContinuousVirtualRenderer.ts`. +- Dependency: `@tanstack/virtual-core`. + +### Runtime behavior + +- `CONTINUOUS_VIRTUAL` renders only visible/nearby pages instead of rendering every page canvas at once. +- Scroll container is `this.$canvasContainer`; virtual content is rendered into an inner positioned container (`$virtualInner`) with total height `virtualizer.getTotalSize()`. +- Virtual items are positioned via `translateY(virtualItem.start)`. +- `pageSpacing` is applied in continuous layouts and in virtual gap calculation. +- Overscan is currently fixed in code (`virtualOverscan = 2`), not DTO-configurable. + +### Sizing and rendering model + +- Initial page height estimate is derived from page 1 at current zoom. +- After page render, measured heights are cached (`virtualMeasuredPageHeights`) to improve placement for mixed-size documents. +- HiDPI rendering is handled by scaling canvas dimensions with `window.devicePixelRatio`. +- In-flight page render tasks are tracked per page and canceled when stale. + +### Concurrency and stale-work guards + +- `renderRequestId` is used to ignore stale async render results after mode/zoom/view changes. +- `documentLoadRequestId` is used to ensure only the latest `setUrl()` request can mutate state and emit load events. +- Virtual lifecycle is reset on relevant transitions (`teardown` / `markForRecreate`). + +### showPage behavior in virtual mode + +- In `CONTINUOUS_VIRTUAL`, `showPage(page)` scrolls to the target page index through the virtual renderer. +- If virtual mode is not initialized yet, `renderPdfDocument()` runs first. +- If called before a document is loaded, page requests are buffered in `pendingShowPageNumber`. + +## Specification + +### Public Interface (Java API) + +#### Constructors +| Constructor | Description | +|-------------|-------------| +| `PdfViewer()` | Creates an empty PDF viewer | +| `PdfViewer(String url)` | Creates a PDF viewer with the given PDF URL | + +#### Properties (Getters/Setters) + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `url` | `String` | `null` | URL of the PDF document to display | +| `showDevTools` | `boolean` | `false` | Shows a development toolbar with page navigation and zoom controls | +| `viewMode` | `UiPdfViewMode` | `null` | How pages are displayed (SINGLE_PAGE, CONTINUOUS, CONTINUOUS_VIRTUAL) | +| `zoomMode` | `UiPdfZoomMode` | `TO_WIDTH` | How zoom is calculated (TO_HEIGHT, TO_WIDTH, MANUAL) | +| `zoomFactor` | `float` | `1.0` | Manual zoom factor (only works when zoomMode is MANUAL) | +| `padding` | `int` | `0` | Padding around the PDF canvas in pixels | +| `pageSpacing` | `int` | `5` | Spacing between pages in continuous modes | +| `backgroundColor` | `String` | `null` | CSS color for the canvas container background | +| `borderColor` | `String` | `null` | CSS color for the canvas container border | +| `pageBorder` | `UiBorder` | `null` | Border configuration for PDF pages | + +#### Events + +| Event | Payload | Description | +|-------|---------|-------------| +| `onPdfInitialized` | `PdfInitializedEvent` (contains `numberOfPages`) | Fired when PDF document is loaded and first page is rendered | +| `onZoomFactorAutoChanged` | `ZoomFactorAutoChangedEvent` (contains `zoomFactor`) | Fired when zoom factor is auto-calculated by TO_WIDTH or TO_HEIGHT modes | +| `onPdfLoadFailed` | `PdfLoadFailedEvent` (contains `url`, `errorMessage`) | Fired when loading a PDF URL fails in the client | + +### Runtime Behavior Notes + +- `setUrl(String url)` is **not** a one-time initializer. It may be called multiple times during a component lifetime. +- Each `setUrl(...)` call replaces the currently displayed document and starts a new async load/render cycle. +- `onPdfInitialized` is fired after each successful `setUrl(...)` load/render cycle, with the page count of the newly loaded document. + +#### Enums + +**UiPdfViewMode:** +| Value | Description | +|-------|-------------| +| `SINGLE_PAGE` | Display one page at a time | +| `CONTINUOUS` | Display all pages in a scrollable view | +| `CONTINUOUS_VIRTUAL` | Virtualized continuous rendering mode for large documents | + +**UiPdfZoomMode:** +| Value | Description | +|-------|-------------| +| `TO_HEIGHT` | Auto-scale PDF page to fit container height | +| `TO_WIDTH` | Auto-scale PDF page to fit container width | +| `MANUAL` | Use the `zoomFactor` property for scaling | + +### Implementation Status + +#### ✅ Fully Implemented Features + +| Feature | DTO | Java | TypeScript | Notes | +|---------|-----|------|------------|-------| +| `setUrl` | ✅ | ✅ | ✅ | Load PDF from URL | +| `setShowDevTools` | ✅ | ✅ | ✅ | Toggle dev toolbar | +| `setViewMode` | ✅ | ✅ | ✅ | Supports SINGLE_PAGE, CONTINUOUS, CONTINUOUS_VIRTUAL | +| `setZoomFactor` | ✅ | ✅ | ✅ | Manual zoom control | +| `setZoomMode` | ✅ | ✅ | ✅ | Auto-zoom modes | +| `setPadding` | ✅ | ✅ | ✅ | Canvas container padding | +| `setPageSpacing` | ✅ | ✅ | ✅ | For CONTINUOUS mode | +| `setBackgroundColor` | ✅ | ✅ | ✅ | Container background | +| `setBorderColor` | ✅ | ✅ | ✅ | Container border | +| `setPageBorder` | ✅ | ✅ | ✅ | Page border config | +| `pdfInitialized` event | ✅ | ✅ | ✅ | PDF load notification | +| `zoomFactorAutoChanged` event | ✅ | ✅ | ✅ | Auto-zoom notification | + +#### ⚠️ Partially Implemented / Missing Features + +| Feature | DTO | Java | TypeScript | Issue | +|---------|-----|------|------------|-------| +| `pageShadow` | ❌ | ✅ | ❌ | Property exists in Java but not in DTO or TypeScript | + +### Usage Example + +```java +// Create PDF viewer +String testPdfLink = sessionContext.createResourceLink( + new ClassPathResource("test.pdf", "application/pdf") +); +PdfViewer pdfViewer = new PdfViewer(); + +// Configure appearance +pdfViewer.setPadding(10); +pdfViewer.setShowDevTools(true); +pdfViewer.setZoomMode(UiPdfZoomMode.TO_HEIGHT); +pdfViewer.setBackgroundColor("oklch(0.74 0.1 218.65)"); +pdfViewer.setBorderColor("#ff0000"); + +// Load PDF (can be done later, e.g., on button click) +pdfViewer.setUrl(testPdfLink); + +// Listen for events +pdfViewer.onPdfInitialized.addListener((initEvent) -> { + System.out.println("PDF loaded, pages: " + initEvent.getNumberOfPages()); +}); + +pdfViewer.onZoomFactorAutoChanged.addListener((zoomEvent) -> { + System.out.println("Zoom factor changed: " + zoomEvent.getZoomFactor()); +}); + +// Add to panel +panel.setContent(pdfViewer); +``` + +## Todos + +- **Add `pageShadow` to DTO** - property exists in Java but not exposed diff --git a/docs/bjesuiter/PlaygroundComponent_docs.md b/docs/bjesuiter/PlaygroundComponent_docs.md new file mode 100644 index 000000000..d85ab99b8 --- /dev/null +++ b/docs/bjesuiter/PlaygroundComponent_docs.md @@ -0,0 +1,20 @@ +# UiPlayground Component + +--- + +# Repo Log + +## 2025-05-21 Ui Playground Component anlegen + +1. Write `UiPlayground.dto` in `teamapps-ui-api` project + 1. Run `clean + install` for `teamapps-ui-api` project + +2. Create new java class `teamapps-ux/src/main/java/org/teamapps/ux/component/playground/Playground.java` + +3. Create new ts class `teamapps-client/ts/modules/UiPlayground.ts` + +4. Open `teamapps-client/ts/modules/index.ts` + 1. Add `export {UiPlayground} from "./UiPlayground";` + +=> Start Jetty & webpack dev server +=> current state: error! :( \ No newline at end of file diff --git a/docs/bjesuiter/continuous-virtual/tanstack-virtual.docs.md b/docs/bjesuiter/continuous-virtual/tanstack-virtual.docs.md new file mode 100644 index 000000000..e0a211156 --- /dev/null +++ b/docs/bjesuiter/continuous-virtual/tanstack-virtual.docs.md @@ -0,0 +1,507 @@ +--- +title: Virtualizer +--- + +The `Virtualizer` class is the core of TanStack Virtual. Virtualizer instances are usually created for you by your framework adapter, but you do receive the virtualizer directly. + +```tsx +export class Virtualizer { + constructor(options: VirtualizerOptions) +} +``` + +## Required Options + +### `count` + +```tsx +count: number +``` + +The total number of items to virtualize. + +### `getScrollElement` + +```tsx +getScrollElement: () => TScrollElement +``` + +A function that returns the scrollable element for the virtualizer. It may return null if the element is not available yet. + +### `estimateSize` + +```tsx +estimateSize: (index: number) => number +``` + +> 🧠 If you are dynamically measuring your elements, it's recommended to estimate the largest possible size (width/height, within comfort) of your items. This will ensure features like smooth-scrolling will have a better chance at working correctly. + +This function is passed the index of each item and should return the actual size (or estimated size if you will be dynamically measuring items with `virtualItem.measureElement`) for each item. This measurement should return either the width or height depending on the orientation of your virtualizer. + +## Optional Options + +### `enabled` + +```tsx +enabled?: boolean +``` + +Set to `false` to disable scrollElement observers and reset the virtualizer's state + +### `debug` + +```tsx +debug?: boolean +``` + +Set to `true` to enable debug logs + +### `initialRect` + +```tsx +initialRect?: Rect +``` + +The initial `Rect` of the scrollElement. This is mostly useful if you need to run the virtualizer in an SSR environment, otherwise the initialRect will be calculated on mount by the `observeElementRect` implementation. + +### `onChange` + +```tsx +onChange?: (instance: Virtualizer, sync: boolean) => void +``` + +A callback function that fires when the virtualizer's internal state changes. It's passed the virtualizer instance and the sync parameter. + +The sync parameter indicates whether scrolling is currently in progress. It is `true` when scrolling is ongoing, and `false` when scrolling has stopped or other actions (such as resizing) are being performed. + +### `overscan` + +```tsx +overscan?: number +``` + +The number of items to render above and below the visible area. Increasing this number will increase the amount of time it takes to render the virtualizer, but might decrease the likelihood of seeing slow-rendering blank items at the top and bottom of the virtualizer when scrolling. The default value is `1`. + +### `horizontal` + +```tsx +horizontal?: boolean +``` + +Set this to `true` if your virtualizer is oriented horizontally. + +### `paddingStart` + +```tsx +paddingStart?: number +``` + +The padding to apply to the start of the virtualizer in pixels. + +### `paddingEnd` + +```tsx +paddingEnd?: number +``` + +The padding to apply to the end of the virtualizer in pixels. + +### `scrollPaddingStart` + +```tsx +scrollPaddingStart?: number +``` + +The padding to apply to the start of the virtualizer in pixels when scrolling to an element. + +### `scrollPaddingEnd` + +```tsx +scrollPaddingEnd?: number +``` + +The padding to apply to the end of the virtualizer in pixels when scrolling to an element. + +### `initialOffset` + +```tsx +initialOffset?: number | (() => number) +``` + +The position where the list is scrolled to on render. This is useful if you are rendering the virtualizer in a SSR environment or are conditionally rendering the virtualizer. + +### `getItemKey` + +```tsx +getItemKey?: (index: number) => Key +``` + +This function is passed the index of each item and should return a unique key for that item. The default functionality of this function is to return the index of the item, but you should override this when possible to return a unique identifier for each item across the entire set. + +**Note:** The virtualizer automatically invalidates its measurement cache when measurement-affecting options change, ensuring `getTotalSize()` and other measurements return fresh values. While the virtualizer intelligently tracks which options actually affect measurements, it's still better to memoize `getItemKey` (e.g., using `useCallback` in React) to avoid unnecessary recalculations. + +### `rangeExtractor` + +```tsx +rangeExtractor?: (range: Range) => number[] +``` + +This function receives visible range indexes and should return array of indexes to render. This is useful if you need to add or remove items from the virtualizer manually regardless of the visible range, eg. rendering sticky items, headers, footers, etc. The default range extractor implementation will return the visible range indexes and is exported as `defaultRangeExtractor`. + +### `scrollToFn` + +```tsx +scrollToFn?: ( + offset: number, + options: { adjustments?: number; behavior?: 'auto' | 'smooth' }, + instance: Virtualizer, +) => void +``` + +An optional function that (if provided) should implement the scrolling behavior for your scrollElement. It will be called with the following arguments: + +- An `offset` (in pixels) to scroll towards. +- An object indicating whether there was a difference between the estimated size and actual size (`adjustments`) and/or whether scrolling was called with a smooth animation (`behaviour`). +- The virtualizer instance itself. + +Note that built-in scroll implementations are exported as `elementScroll` and `windowScroll`, which are automatically configured by the framework adapter functions like `useVirtualizer` or `useWindowVirtualizer`. + +> ⚠️ Attempting to use smoothScroll with dynamically measured elements will not work. + +### `observeElementRect` + +```tsx +observeElementRect: ( + instance: Virtualizer, + cb: (rect: Rect) => void, +) => void | (() => void) +``` + +An optional function that if provided is called when the scrollElement changes and should implement the initial measurement and continuous monitoring of the scrollElement's `Rect` (an object with `width` and `height`). It's called with the instance (which also gives you access to the scrollElement via `instance.scrollElement`. Built-in implementations are exported as `observeElementRect` and `observeWindowRect` which are automatically configured for you by your framework adapter's exported functions like `useVirtualizer` or `useWindowVirtualizer`. + +### `observeElementOffset` + +```tsx +observeElementOffset: ( + instance: Virtualizer, + cb: (offset: number) => void, + ) => void | (() => void) +``` + +An optional function that if provided is called when the scrollElement changes and should implement the initial measurement and continuous monitoring of the scrollElement's scroll offset (a number). It's called with the instance (which also gives you access to the scrollElement via `instance.scrollElement`. Built-in implementations are exported as `observeElementOffset` and `observeWindowOffset` which are automatically configured for you by your framework adapter's exported functions like `useVirtualizer` or `useWindowVirtualizer`. + +### `measureElement` + +```tsx +measureElement?: ( + element: TItemElement, + entry: ResizeObserverEntry | undefined, + instance: Virtualizer, +) => number +``` + +This optional function is called when the virtualizer needs to dynamically measure the size (width or height) of an item. + +> 🧠 You can use `instance.options.horizontal` to determine if the width or height of the item should be measured. + +### `scrollMargin` + +```tsx +scrollMargin?: number +``` + +With this option, you can specify where the scroll offset should originate. Typically, this value represents the space between the beginning of the scrolling element and the start of the list. This is especially useful in common scenarios such as when you have a header preceding a window virtualizer or when multiple virtualizers are utilized within a single scrolling element. If you are using absolute positioning of elements, you should take into account the `scrollMargin` in your CSS transform: +```tsx +transform: `translateY(${ + virtualRow.start - rowVirtualizer.options.scrollMargin +}px)` +``` +To dynamically measure value for `scrollMargin` you can use `getBoundingClientRect()` or ResizeObserver. This is helpful in scenarios when items above your virtual list might change their height. + +### `gap` + +```tsx +gap?: number +``` + +This option allows you to set the spacing between items in the virtualized list. It's particularly useful for maintaining a consistent visual separation between items without having to manually adjust each item's margin or padding. The value is specified in pixels. + +### `lanes` + +```tsx +lanes: number +``` + +The number of lanes the list is divided into (aka columns for vertical lists and rows for horizontal lists). + +### `isScrollingResetDelay` + +```tsx +isScrollingResetDelay: number +``` + +This option allows you to specify the duration to wait after the last scroll event before resetting the isScrolling instance property. The default value is 150 milliseconds. + +The implementation of this option is driven by the need for a reliable mechanism to handle scrolling behavior across different browsers. Until all browsers uniformly support the scrollEnd event. + +### `useScrollendEvent` + +```tsx +useScrollendEvent: boolean +``` + +Determines whether to use the native scrollend event to detect when scrolling has stopped. If set to false, a debounced fallback is used to reset the isScrolling instance property after isScrollingResetDelay milliseconds. The default value is `false`. + +The implementation of this option is driven by the need for a reliable mechanism to handle scrolling behavior across different browsers. Until all browsers uniformly support the scrollEnd event. + +### `isRtl` + +```tsx +isRtl: boolean +``` + +Whether to invert horizontal scrolling to support right-to-left language locales. + +### `useAnimationFrameWithResizeObserver` + +```tsx +useAnimationFrameWithResizeObserver: boolean +``` + +**Default:** `false` + +When enabled, defers ResizeObserver measurement processing to the next animation frame using `requestAnimationFrame`. + +**Important:** This option typically **should not be enabled** in most cases. ResizeObserver callbacks already execute at an optimal time in the browser's rendering pipeline (after layout, before paint), and the measurements provided in the callback are pre-computed by the browser without causing additional reflows. + +**Potential use cases:** +- If you're performing heavy DOM mutations in response to size changes and want to batch them with the next render cycle +- As a workaround for the "ResizeObserver loop completed with undelivered notifications" error (though this usually indicates a deeper issue that should be fixed) + +**Tradeoffs:** +- **Adds ~16ms delay:** Measurements are deferred to the next frame, which can cause visual artifacts, stale measurements, or slower time-to-interactive +- **No batching benefit:** ResizeObserver already batches multiple element resizes into a single callback +- **Defeats optimization:** The browser has already computed the measurements synchronously; deferring them provides no performance benefit for reading values + +Only enable this option if you have a specific reason and have measured that it improves your use case. + +## Virtualizer Instance + +The following properties and methods are available on the virtualizer instance: + +### `options` + +```tsx +options: readonly Required> +``` + +The current options for the virtualizer. This property is updated via your framework adapter and is read-only. + +### `scrollElement` + +```tsx +scrollElement: readonly TScrollElement | null +``` + +The current scrollElement for the virtualizer. This property is updated via your framework adapter and is read-only. + +### `getVirtualItems` + +```tsx +type getVirtualItems = () => VirtualItem[] +``` + +Returns the virtual items for the current state of the virtualizer. + +### `getVirtualIndexes` + +```tsx +type getVirtualIndexes = () => number[] +``` + +Returns the virtual row indexes for the current state of the virtualizer. + +### `scrollToOffset` + +```tsx +scrollToOffset: ( + toOffset: number, + options?: { + align?: 'start' | 'center' | 'end' | 'auto', + behavior?: 'auto' | 'smooth' + } +) => void +``` + +Scrolls the virtualizer to the pixel offset provided. You can optionally pass an alignment mode to anchor the scroll to a specific part of the scrollElement. + +### `scrollToIndex` + +```tsx +scrollToIndex: ( + index: number, + options?: { + align?: 'start' | 'center' | 'end' | 'auto', + behavior?: 'auto' | 'smooth' + } +) => void +``` + +Scrolls the virtualizer to the items of the index provided. You can optionally pass an alignment mode to anchor the scroll to a specific part of the scrollElement. + +### `getTotalSize` + +```tsx +getTotalSize: () => number +``` + +Returns the total size in pixels for the virtualized items. This measurement will incrementally change if you choose to dynamically measure your elements as they are rendered. + +### `measure` + +```tsx +measure: () => void +``` + +Resets any prev item measurements. + +### `measureElement` + +```tsx +measureElement: (el: TItemElement | null) => void +``` + +Measures the element using your configured `measureElement` virtualizer option. You are responsible for calling this in your virtualizer markup when the component is rendered (eg. using something like React's ref callback prop) also adding `data-index` + +```tsx +
...
+``` + +By default the `measureElement` virtualizer option is configured to measure elements with `getBoundingClientRect()`. + +### `resizeItem` + +```tsx +resizeItem: (index: number, size: number) => void +``` + +Change the virtualized item's size manually. Use this function to manually set the size calculated for this index. Useful in occations when using some custom morphing transition and you know the morphed item's size beforehand. + +You can also use this method with a throttled ResizeObserver instead of `Virtualizer.measureElement` to reduce re-rendering. + +> ⚠️ Please be aware that manually changing the size of an item when using `Virtualizer.measureElement` to monitor that item, will result in unpredictable behaviour as the `Virtualizer.measureElement` is also changing the size. However you can use one of resizeItem or measureElement in the same virtualizer instance but on different item indexes. + +### `scrollRect` + +```tsx +scrollRect: Rect +``` + +Current `Rect` of the scroll element. + +### `shouldAdjustScrollPositionOnItemSizeChange` + +```tsx +shouldAdjustScrollPositionOnItemSizeChange: undefined | ((item: VirtualItem, delta: number, instance: Virtualizer) => boolean) +``` + +The shouldAdjustScrollPositionOnItemSizeChange method enables fine-grained control over the adjustment of scroll position when the size of dynamically rendered items differs from the estimated size. When jumping in the middle of the list and scrolling backward new elements may have a different size than the initially estimated size. This discrepancy can cause subsequent items to shift, potentially disrupting the user's scrolling experience, particularly when navigating backward through the list. + +### `isScrolling` + +```tsx +isScrolling: boolean +``` + +Boolean flag indicating if list is currently being scrolled. + +### `scrollDirection` + +```tsx +scrollDirection: 'forward' | 'backward' | null +``` + +This option indicates the direction of scrolling, with possible values being 'forward' for scrolling downwards and 'backward' for scrolling upwards. The value is set to null when there is no active scrolling. + +### `scrollOffset` + +```tsx +scrollOffset: number +``` + +This option represents the current scroll position along the scrolling axis. It is measured in pixels from the starting point of the scrollable area. + + +----- + + +--- +title: VirtualItem +--- + +The `VirtualItem` object represents a single item returned by the virtualizer. It contains information you need to render the item in the coordinate space within your virtualizer's scrollElement and other helpful properties/functions. + +```tsx +export interface VirtualItem { + key: string | number | bigint + index: number + start: number + end: number + size: number +} +``` + +The following properties and methods are available on each VirtualItem object: + +### `key` + +```tsx +key: string | number | bigint +``` + +The unique key for the item. By default this is the item index, but should be configured via the `getItemKey` Virtualizer option. + +### `index` + +```tsx +index: number +``` + +The index of the item. + +### `start` + +```tsx +start: number +``` + +The starting pixel offset for the item. This is usually mapped to a css property or transform like `top/left` or `translateX/translateY`. + +### `end` + +```tsx +end: number +``` + +The ending pixel offset for the item. This value is not necessary for most layouts, but can be helpful so we've provided it anyway. + +### `size` + +```tsx +size: number +``` + +The size of the item. This is usually mapped to a css property like `width/height`. Before an item is measured with the `VirtualItem.measureElement` method, this will be the estimated size returned from your `estimateSize` virtualizer option. After an item is measured (if you choose to measure it at all), this value will be the number returned by your `measureElement` virtualizer option (which by default is configured to measure elements with `getBoundingClientRect()`). + +### `lane` + +```tsx +lane: number +``` + +The lane index of the item. In regular lists it will always be set to `0` but becomes useful for masonry layouts (see variable examples for more details). diff --git a/docs/bjesuiter/start_pdf_dev_env.sh b/docs/bjesuiter/start_pdf_dev_env.sh new file mode 100755 index 000000000..39ae21815 --- /dev/null +++ b/docs/bjesuiter/start_pdf_dev_env.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +PORT=9000 +XDG_DATA_HOME=/tmp/bgproc-data +export XDG_DATA_HOME +mkdir -p "${XDG_DATA_HOME}/bgproc/logs" + +bgproc stop -n teamapps-frontend >/dev/null 2>&1 || true + +if lsof -iTCP:${PORT} -sTCP:LISTEN >/dev/null 2>&1; then + echo "Port ${PORT} is already in use. Stopping existing listener." + lsof -tiTCP:${PORT} -sTCP:LISTEN | xargs -r kill +fi + +bgproc start -f -n teamapps-frontend -- /bin/zsh -lc "cd /Users/bjesuiter/Develop/teamapps-org/teamapps/teamapps-client && ./start-dev-server.sh 8082 ${PORT}" + +for i in {1..60}; do + if lsof -iTCP:${PORT} -sTCP:LISTEN >/dev/null 2>&1; then + echo "Dev server is listening on port ${PORT}." + exit 0 + fi + sleep 1 +done + +echo "Timed out waiting for dev server to listen on port ${PORT}." +exit 1 diff --git a/pom.xml b/pom.xml index 20517397c..dc83e58b3 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.teamapps teamapps pom - 0.9.8-SNAPSHOT + 0.9.208-SNAPSHOT TeamApps A Java web application framework @@ -37,27 +37,22 @@ teamapps-material-icon-provider teamapps-ui-dsl teamapps-ui-api - teamapps-client teamapps-ux - teamapps-ux-servlet + teamapps-client teamapps-server-jetty-embedded teamapps-server-undertow-embedded - teamapps-server-tomcat-embedded - 10 - 10 + 17 + 17 UTF-8 - 3.0.0 - 1.7.25 - 2.9.8 - 23.6-jre - 1.8.3 - 4.5.2 - 2.6 - 4.12 + 2.0.16 + 2.18.2 + 33.4.0-jre + 2.18.0 + 4.13.2 1.9.9 @@ -67,6 +62,31 @@ slf4j-api ${slf4j-version} + + + junit + junit + ${junit.version} + test + + + org.assertj + assertj-core + 3.26.3 + test + + + org.awaitility + awaitility + 4.2.2 + test + + + org.mockito + mockito-core + 5.13.0 + test + @@ -77,6 +97,11 @@ teamapps-common ${project.version} + + org.teamapps + teamapps-commons + 0.1.2 + org.teamapps @@ -102,12 +127,6 @@ ${project.version} - - org.teamapps - teamapps-ux-servlet - ${project.version} - - org.teamapps teamapps-ux @@ -142,59 +161,16 @@ ${guava-version} - - org.bouncycastle - bcprov-jdk16 - 1.46 - - - - com.thoughtworks.xstream - xstream - 1.4.8 - - - - junit - junit - ${junit.version} - test - - org.apache.commons commons-lang3 - 3.4 + 3.17.0 org.apache.commons commons-collections4 - 4.1 - - - - net.coobird - thumbnailator - 0.4.8 - - - - com.googlecode.json-simple - json-simple - 1.1.1 - - - - org.antlr - ST4 - 4.0.8 - - - - com.google.code.gson - gson - 2.3.1 + 4.4 @@ -203,67 +179,12 @@ ${apache-commons-io} - - org.jsoup - jsoup - 1.8.3 - - - - org.apache.httpcomponents - httpclient - ${appache-httpcomponents} - - - - org.apache.httpcomponents - httpmime - ${appache-httpcomponents} - - - - org.apache.httpcomponents - fluent-hc - ${appache-httpcomponents} - - - - org.assertj - assertj-core - 3.9.0 - test - - - - net.lingala.zip4j - zip4j - 1.3.2 - - org.slf4j slf4j-simple ${slf4j-version} - - io.github.classgraph - classgraph - 4.1.7 - - - - org.mindrot - jbcrypt - 0.4 - - - - uk.com.robust-it - cloning - ${cloning.version} - - com.fasterxml.jackson.core jackson-core @@ -288,60 +209,38 @@ org.jetbrains annotations - 13.0 - - - - javax - javaee-web-api - 8.0 - - - - org.mockito - mockito-core - 2.3.5 - test + 24.1.0 - ossrh - https://oss.sonatype.org/content/repositories/snapshots + https://s01.oss.sonatype.org/content/repositories/snapshots ossrh - https://oss.sonatype.org/service/local/staging/deploy/maven2/ + https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/ - - - - org.teamapps - teamapps-universal-db-maven-plugin - 1.0 - - - org.apache.maven.plugins maven-compiler-plugin - 3.8.0 + 3.13.0 true true + -Xlint:-options org.apache.maven.plugins maven-release-plugin - 2.5.3 + 3.1.1 forked-path @@ -349,34 +248,20 @@ true teamapps-@{version} - javadoc-jar,sources-jar,license-handling + javadoc-jar,sources-jar,license-handling,release + true - org.sonatype.plugins - nexus-staging-maven-plugin - 1.6.8 + org.sonatype.central + central-publishing-maven-plugin + 0.8.0 true - ossrh - https://oss.sonatype.org/ - false + TeamApps ${project.version} + ossrh - - org.apache.maven.plugins - maven-gpg-plugin - 1.6 - - - sign-artifacts - verify - - sign - - - - @@ -388,7 +273,7 @@ org.apache.maven.plugins maven-javadoc-plugin - 3.0.1 + 3.8.0 generate-javadoc-jar @@ -408,7 +293,7 @@ org.apache.maven.plugins maven-source-plugin - 3.0.1 + 3.3.1 generate-sources-jar @@ -491,6 +376,27 @@ + + release + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.2.8 + + + sign-artifacts + verify + + sign + + + + + + + diff --git a/release.sh b/release.sh new file mode 100755 index 000000000..488d8d2f2 --- /dev/null +++ b/release.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +export JDK_JAVA_OPTIONS='--add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.lang.reflect=ALL-UNNAMED --add-opens java.base/java.text=ALL-UNNAMED --add-opens java.desktop/java.awt.font=ALL-UNNAMED' +mvn -e -P javadoc-jar,sources-jar,license-handling,release release:prepare release:perform + +echo "---------" +echo "HEADS UP!" +echo "The artifact has been uploaded for staging but is not yet published. This is now a manual step!" +echo "Please visit https://central.sonatype.com/publishing/deployments to publish the artifact." +echo "---------" \ No newline at end of file diff --git a/teamapps-client/build/config.js b/teamapps-client/build/config.js deleted file mode 100644 index cdb2fbd14..000000000 --- a/teamapps-client/build/config.js +++ /dev/null @@ -1,63 +0,0 @@ -'use strict' -// Template version: 1.3.1 -// see http://vuejs-templates.github.io/webpack for documentation. - -const path = require('path') - -module.exports = { - dev: { - - // Paths - assetsSubDirectory: '.', - assetsPublicPath: '/', - appServerUrl: process.env.appServerUrl || "http://localhost:9000", - - // Various Dev Server settings - host: 'localhost', // can be overwritten by process.env.HOST - port: 8080, // can be overwritten by process.env.PORT, if port is in use, a free one will be determined - autoOpenBrowser: false, - errorOverlay: true, - notifyOnErrors: true, - poll: false, // https://webpack.js.org/configuration/dev-server/#devserver-watchoptions- - - /** - * Source Maps - */ - - // https://webpack.js.org/configuration/devtool/#development - devtool: 'cheap-module-source-map', // TODO change back to cheap-module-eval-source-map!!! https://github.com/webpack-contrib/mini-css-extract-plugin/issues/29 - - // If you have problems debugging vue-files in devtools, - // set this to false - it *may* help - // https://vue-loader.vuejs.org/en/options.html#cachebusting - cacheBusting: true, - - cssSourceMap: true - }, - - build: { - generatedIndexHtml: path.resolve(__dirname, '../dist/index.html'), - - // Paths - assetsSubDirectory: '.', - assetsPublicPath: '/', - appServerUrl: null, - - /** - * Source Maps - */ - - productionSourceMap: true, - // https://webpack.js.org/configuration/devtool/#production - devtool: '#source-map', - - productionGzip: true, - productionGzipExtensions: ['js', 'css'], - - // Run the build command with an extra argument to - // View the bundle analyzer report after build finishes: - // `npm run build --report` - // Set to `true` or `false` to always turn it on or off - bundleAnalyzerReport: process.env.npm_config_report - } -} diff --git a/teamapps-client/build/webpack.base.conf.js b/teamapps-client/build/webpack.base.conf.js deleted file mode 100644 index 383c6766b..000000000 --- a/teamapps-client/build/webpack.base.conf.js +++ /dev/null @@ -1,169 +0,0 @@ -'use strict'; -const path = require('path'); -const webpack = require('webpack'); -const CopyWebpackPlugin = require('copy-webpack-plugin'); -const MiniCssExtractPlugin = require("mini-css-extract-plugin"); - -function resolve(dir) { - return path.join(__dirname, '..', dir) -} - -module.exports = function (stageConfig) { - return { - context: path.resolve(__dirname, '../'), - entry: { - main: resolve('/ts/modules/index.ts') - }, - output: { - library: "teamapps", - libraryTarget: "umd", - umdNamedDefine: true - }, - // Enable sourcemaps for debugging webpack's output. - devtool: "source-map", - - resolve: { - extensions: ['.ts', '.tsx', '.vue', '.json', '.less', '.js', '.css'], - alias: { - "@less": resolve('less/'), - "./images/sort-asc.gif": resolve('node_modules/slickgrid/images/sort-asc.gif'), - "./images/sort-desc.gif": resolve('node_modules/slickgrid/images/sort-desc.gif'), - }, - modules: [resolve("node_modules")] - }, - module: { - rules: [ - { - test: /\.tsx?$/, - loader: "awesome-typescript-loader", - options: { - configFileName: './ts/modules/tsconfig.json' - } - }, - { - test: /\.less$/, - use: [ - MiniCssExtractPlugin.loader, - { - loader: "css-loader", - options: { - sourceMap: true - } - }, { - loader: 'postcss-loader', - options: { - sourceMap: true, - plugins: () => [require('autoprefixer')({browsers: ['> 1%']})], - } - }, { - loader: "less-loader", - options: { - sourceMap: true - } - } - ] - }, - { - test: /\.css$/, - use: [ - MiniCssExtractPlugin.loader, - { - loader: "css-loader", - options: { - sourceMap: true - } - }, { - loader: 'postcss-loader', - options: { - sourceMap: true, - plugins: () => [require('autoprefixer')({browsers: ['> 1%']})], - } - } - ] - }, - { - test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, - loader: 'url-loader', - options: { - limit: 10000, - name: path.posix.join(stageConfig.assetsSubDirectory, 'img/[name].[hash:7].[ext]') - } - }, - { - test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/, - loader: 'url-loader', - options: { - limit: 10000, - name: path.posix.join(stageConfig.assetsSubDirectory, 'media/[name].[hash:7].[ext]') - } - }, - { - test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, - loader: 'url-loader', - options: { - limit: 10000, - name: path.posix.join(stageConfig.assetsSubDirectory, 'fonts/[name].[hash:7].[ext]') - } - }, - { - test: /\.js$/, - use: ["source-map-loader"], - enforce: "pre" - } - ] - }, - - node: { - // prevent webpack from injecting useless setImmediate polyfill because Vue - // source contains it (although only uses it if it's native). - setImmediate: false, - // prevent webpack from injecting mocks to Node native modules - // that does not make sense for the client - dgram: 'empty', - fs: 'empty', - net: 'empty', - tls: 'empty', - child_process: 'empty' - }, - - plugins: [ - // new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/), - new MiniCssExtractPlugin({ - filename: "css/teamapps.[hash].css" - }), - // copy custom static assets - new CopyWebpackPlugin([ - { - from: resolve('favicon'), - to: path.posix.join(stageConfig.assetsSubDirectory, 'favicon'), - ignore: ['**/.*'] - }, - { - from: resolve('resources'), - to: path.posix.join(stageConfig.assetsSubDirectory, 'resources'), - ignore: ['**/.*'] - }, - { - from: resolve('node_modules/moment/locale'), - to: path.posix.join(stageConfig.assetsSubDirectory, 'runtime-resources/moment-locales') - }, - { - from: resolve('node_modules/fullcalendar/dist/locale'), - to: path.posix.join(stageConfig.assetsSubDirectory, '/runtime-resources/fullcalendar-locales') - }, - { - from: resolve('node_modules/mediaelement/build'), - to: path.posix.join(stageConfig.assetsSubDirectory, '/runtime-resources/mediaelement') - }, - { - from: resolve('node_modules/tinymce'), - to: path.posix.join(stageConfig.assetsSubDirectory, "/runtime-resources/tinymce") - }, - { - from: resolve('node_modules/tinymce-i18n/langs'), - to: path.posix.join(stageConfig.assetsSubDirectory, "/runtime-resources/tinymce/langs") - } - ]) - ] - } -}; diff --git a/teamapps-client/index.html b/teamapps-client/index.html index 9b95730f4..dd4c6935a 100644 --- a/teamapps-client/index.html +++ b/teamapps-client/index.html @@ -3,7 +3,7 @@ - + diff --git a/teamapps-client/jest.config.js b/teamapps-client/jest.config.js new file mode 100644 index 000000000..91a2d2c0d --- /dev/null +++ b/teamapps-client/jest.config.js @@ -0,0 +1,4 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', +}; \ No newline at end of file diff --git a/teamapps-client/js-build/config.js b/teamapps-client/js-build/config.js new file mode 100644 index 000000000..dc4d1b6e4 --- /dev/null +++ b/teamapps-client/js-build/config.js @@ -0,0 +1,34 @@ +'use strict' +// Template version: 1.3.1 +// see http://vuejs-templates.github.io/webpack for documentation. + +const path = require('path') + + +let appServerUrlEnv = process.env.appServerUrl; +let appServerUrl = appServerUrlEnv == null ? "http://localhost:8080" : /^\d+$/.test(appServerUrlEnv) ? "http://localhost:" + appServerUrlEnv : appServerUrlEnv; +console.log("appServerUrl: " + appServerUrl) +module.exports = { + dev: { + // Paths + assetsSubDirectory: '.', + assetsPublicPath: '/', + appServerUrl: appServerUrl, + + // Dev Server settings + host: 'localhost', // can be overwritten by process.env.HOST + port: 8080, // can be overwritten by process.env.PORT, if port is in use, a free one will be determined + autoOpenBrowser: false, + errorOverlay: true, + notifyOnErrors: false, + poll: false, // https://webpack.js.org/configuration/dev-server/#devserver-watchoptions- + }, + + build: { + // Paths + assetsSubDirectory: '.', + assetsPublicPath: '/', + + bundleAnalyzerReport: process.env.npm_config_report + } +} diff --git a/teamapps-client/build/logo.png b/teamapps-client/js-build/logo.png similarity index 100% rename from teamapps-client/build/logo.png rename to teamapps-client/js-build/logo.png diff --git a/teamapps-client/js-build/webpack.base.conf.js b/teamapps-client/js-build/webpack.base.conf.js new file mode 100644 index 000000000..7b4ce67ee --- /dev/null +++ b/teamapps-client/js-build/webpack.base.conf.js @@ -0,0 +1,202 @@ +'use strict'; +const path = require('path'); +const webpack = require('webpack'); +const CopyWebpackPlugin = require('copy-webpack-plugin'); +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +// const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; + +function resolve(dir) { + return path.join(__dirname, '..', dir) +} + +module.exports = function (stageConfig) { + return { + context: path.resolve(__dirname, '../'), + entry: { + teamapps: resolve('/ts/modules/index.ts'), + UiRichTextEditor: resolve('/ts/modules/formfield/UiRichTextEditor.ts') + }, + output: { + library: "[name]", + libraryTarget: "umd", + umdNamedDefine: true + }, + // Enable sourcemaps for debugging webpack's output. + devtool: "source-map", + + resolve: { + extensions: ['.ts', '.tsx', '.vue', '.json', '.less', '.js', 'mjs', '.css'], + alias: { + "@less": resolve('less/'), + "./images/sort-asc.gif": resolve('node_modules/slickgrid/images/sort-asc.gif'), + "./images/sort-desc.gif": resolve('node_modules/slickgrid/images/sort-desc.gif'), + "react": "preact/compat", + "react-dom": "preact/compat", + }, + modules: [resolve("node_modules")] + }, + module: { + rules: [ + { + test: /\.tsx?$/, + loader: require.resolve('ts-loader') + }, + { + test: /\.less$/, + use: [ + MiniCssExtractPlugin.loader, + { + loader: "css-loader", + options: { + sourceMap: true + } + }, { + loader: 'postcss-loader', + options: { + sourceMap: true, + plugins: () => [require('autoprefixer')], + } + }, { + loader: "less-loader", + options: { + sourceMap: true + } + } + ] + }, + { + test: /\.css$/, + use: [ + MiniCssExtractPlugin.loader, + { + loader: "css-loader", + options: { + sourceMap: true + } + }, { + loader: 'postcss-loader', + options: { + sourceMap: true, + plugins: () => [require('autoprefixer')], + } + } + ] + }, + { + test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, + loader: 'url-loader', + options: { + limit: 10000, + name: path.posix.join(stageConfig.assetsSubDirectory, 'img/[name].[hash:7].[ext]') + } + }, + { + test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/, + loader: 'url-loader', + options: { + limit: 10000, + name: path.posix.join(stageConfig.assetsSubDirectory, 'media/[name].[hash:7].[ext]') + } + }, + { + test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, + loader: 'url-loader', + options: { + limit: 10000, + name: path.posix.join(stageConfig.assetsSubDirectory, 'fonts/[name].[hash:7].[ext]') + } + }, + { + test: /\.js$/, + include: /node_modules\/@tanstack\/virtual-core/, + use: { + loader: 'esbuild-loader', + options: { + target: 'es2015' + } + } + }, + { + test: /\.js$/, + use: ["source-map-loader"], + enforce: "pre", + exclude: /node_modules/ + }, + + { + test: /\.mjs$/, // Add rule for .mjs files + use: { + loader: 'esbuild-loader', + options: { + target: 'es2015' + } + } + } + ] + }, + optimization: { + splitChunks: { + chunks: 'all' + }, + noEmitOnErrors: true + + }, + node: { + // prevent webpack from injecting useless setImmediate polyfill because Vue + // source contains it (although only uses it if it's native). + setImmediate: false, + // prevent webpack from injecting mocks to Node native modules + // that does not make sense for the client + dgram: 'empty', + fs: 'empty', + net: 'empty', + tls: 'empty', + child_process: 'empty' + }, + + plugins: [ + // new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/), + new MiniCssExtractPlugin({ + filename: "css/teamapps.[hash].css" + }), + // copy custom static assets + new CopyWebpackPlugin([ + { + from: resolve('favicon'), + to: path.posix.join(stageConfig.assetsSubDirectory, 'favicon'), + ignore: ['**/.*'] + }, + { + from: resolve('resources'), + to: path.posix.join(stageConfig.assetsSubDirectory, 'resources'), + ignore: ['**/.*'] + }, + { + from: resolve('node_modules/moment/locale'), + to: path.posix.join(stageConfig.assetsSubDirectory, 'runtime-resources/moment-locales') + }, + { + from: resolve('node_modules/@fullcalendar/core/locales'), + to: path.posix.join(stageConfig.assetsSubDirectory, '/runtime-resources/fullcalendar-locales') + }, + { + from: resolve('node_modules/mediaelement/build'), + to: path.posix.join(stageConfig.assetsSubDirectory, '/runtime-resources/mediaelement') + }, + { + from: resolve('node_modules/tinymce'), + to: path.posix.join(stageConfig.assetsSubDirectory, "/runtime-resources/tinymce") + }, + { + from: resolve('node_modules/tinymce-i18n/langs'), + to: path.posix.join(stageConfig.assetsSubDirectory, "/runtime-resources/tinymce/langs") + }, + { + from: resolve('node_modules/pdfjs-dist/build/pdf.worker.mjs'), + to: path.posix.join(stageConfig.assetsSubDirectory, "/worker-libs") + } + ]), + ] + } +}; diff --git a/teamapps-client/build/webpack.dev.conf.js b/teamapps-client/js-build/webpack.dev.conf.js similarity index 63% rename from teamapps-client/build/webpack.dev.conf.js rename to teamapps-client/js-build/webpack.dev.conf.js index f40401b71..bf18dae8a 100755 --- a/teamapps-client/build/webpack.dev.conf.js +++ b/teamapps-client/js-build/webpack.dev.conf.js @@ -12,36 +12,33 @@ const HOST = process.env.HOST; const PORT = process.env.PORT && Number(process.env.PORT); const devWebpackConfig = merge(baseWebpackConfig, { - // cheap-module-eval-source-map is faster for development - devtool: config.devtool, + devtool: "cheap-module-source-map", output: { filename: path.posix.join(config.assetsSubDirectory, 'js/[name].js'), publicPath: config.assetsPublicPath }, - // these devServer options should be customized in /config/index.js devServer: { - clientLogLevel: 'warning', - hot: true, + clientLogLevel: "warning", contentBase: false, // since we use CopyWebpackPlugin. compress: true, host: HOST || config.host, port: PORT || config.port, - open: config.autoOpenBrowser, - overlay: config.errorOverlay - ? {warnings: false, errors: true} - : false, + open: false, + overlay: {warnings: false, errors: true}, publicPath: "/", proxy: [{ context: [ '**', '!/', '!/index.html', + '!/test-harness.html', "!/css/*", "!/favicon/*", "!/fonts/*", "!/img/*", "!/js/*", "!/resources/*", + "!/static/*", "!/runtime-resources/*", "!/tsd/*" ], @@ -49,47 +46,29 @@ const devWebpackConfig = merge(baseWebpackConfig, { }], quiet: true, // necessary for FriendlyErrorsPlugin watchOptions: { - poll: config.poll, + poll: false } }, plugins: [ new webpack.DefinePlugin({ - 'process.env': {NODE_ENV: "'development'"} + 'process.env': {NODE_ENV: "'development'"}, + '__TEAMAPPS_VERSION__': `"DEV"` }), - new webpack.HotModuleReplacementPlugin(), - new webpack.NamedModulesPlugin(), // HMR shows correct file names in console on update. - new webpack.NoEmitOnErrorsPlugin(), - // https://github.com/ampedandwired/html-webpack-plugin new HtmlWebpackPlugin({ filename: 'index.html', template: 'index.html', inject: true, appServerUrl: config.appServerUrl + }), + new HtmlWebpackPlugin({ + filename: 'test-harness.html', + template: 'test-harness.html', + inject: true, + appServerUrl: config.appServerUrl }) ] }); -const createNotifierCallback = () => { - const notifier = require('node-notifier'); - - return (severity, errors) => { - if (severity !== 'error') { - return; - } - - const error = errors[0]; - const filename = error.file && error.file.split('!').pop(); - - notifier.notify({ - title: require('../package.json').name, - message: severity + ': ' + error.name, - subtitle: filename || '', - icon: path.join(__dirname, 'logo.png'), - timeout: 3 - }) - } -}; - module.exports = new Promise((resolve, reject) => { portfinder.basePort = process.env.PORT || config.port; portfinder.getPort((err, port) => { @@ -105,10 +84,7 @@ module.exports = new Promise((resolve, reject) => { devWebpackConfig.plugins.push(new FriendlyErrorsPlugin({ compilationSuccessInfo: { messages: [`Your dev server is running here: http://${devWebpackConfig.devServer.host}:${port} ( proxy for ${config.appServerUrl} )`], - }, - onErrors: config.notifyOnErrors - ? createNotifierCallback() - : undefined + } })); resolve(devWebpackConfig) diff --git a/teamapps-client/build/webpack.prod.conf.js b/teamapps-client/js-build/webpack.prod.conf.js similarity index 59% rename from teamapps-client/build/webpack.prod.conf.js rename to teamapps-client/js-build/webpack.prod.conf.js index a32eb7205..c685f0f73 100644 --- a/teamapps-client/build/webpack.prod.conf.js +++ b/teamapps-client/js-build/webpack.prod.conf.js @@ -1,39 +1,32 @@ 'use strict'; +const pkgJson = require('../package.json') const path = require('path'); const webpack = require('webpack'); const config = require('./config').build; const baseWebpackConfig = require('./webpack.base.conf')(config); const merge = require('webpack-merge'); const HtmlWebpackPlugin = require('html-webpack-plugin'); -const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); -const CleanWebpackPlugin = require('clean-webpack-plugin'); +const {CleanWebpackPlugin} = require('clean-webpack-plugin'); +const TerserPlugin = require('terser-webpack-plugin'); const webpackConfig = merge(baseWebpackConfig, { - devtool: config.productionSourceMap ? config.devtool : false, + devtool: "source-map", output: { path: path.resolve(__dirname, '../dist'), filename: path.posix.join(config.assetsSubDirectory, 'js/[name].[chunkhash].js'), chunkFilename: path.posix.join(config.assetsSubDirectory, 'js/[id].[chunkhash].js'), - publicPath: config.assetsPublicPath - }, + publicPath: config.assetsPublicPath + }, plugins: [ - new CleanWebpackPlugin(['dist'], { - root: path.resolve(__dirname, '..'), - verbose: true, - dry: false + new webpack.DefinePlugin({ + '__TEAMAPPS_VERSION__': `"${pkgJson.version}"` }), - new UglifyJsPlugin({ - uglifyOptions: { - compress: { - warnings: false - }, - ascii_only: true // https://www.tinymce.com/docs/advanced/usage-with-module-loaders/#minificationwithuglifyjs2 - }, - sourceMap: config.productionSourceMap, - parallel: true + new CleanWebpackPlugin({ + verbose: true, + dry: true }), new HtmlWebpackPlugin({ - filename: config.generatedIndexHtml, + filename: path.resolve(__dirname, '../dist/index.html'), template: 'index.html', inject: true, minify: { @@ -44,26 +37,30 @@ const webpackConfig = merge(baseWebpackConfig, { chunksSortMode: 'dependency', // necessary to consistently work with multiple chunks via CommonsChunkPlugin webSocketUrl: config.webSocketUrl }) - ] + ], + optimization: { + minimizer: [new TerserPlugin({ + sourceMap: true, + parallel: true, + terserOptions: { + ascii_only: true // https://www.tinymce.com/docs/advanced/usage-with-module-loaders/#minificationwithuglifyjs2 + } + })], + } }); -if (config.productionGzip) { - const CompressionWebpackPlugin = require('compression-webpack-plugin'); +const CompressionWebpackPlugin = require('compression-webpack-plugin'); - webpackConfig.plugins.push( - new CompressionWebpackPlugin({ - asset: '[path].gz[query]', - algorithm: 'gzip', - test: new RegExp( - '\\.(' + - config.productionGzipExtensions.join('|') + - ')$' - ), - threshold: 10240, - minRatio: 0.8 - }) - ) -} +webpackConfig.plugins.push( + new CompressionWebpackPlugin({ + filename: '[path].gz[query]', + algorithm: 'gzip', + test: /\.(js|css|html|svg)$/, + threshold: 10240, + minRatio: 0.8, + deleteOriginalAssets: false + }) +); if (config.bundleAnalyzerReport) { const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; diff --git a/teamapps-client/less/bootstrap-imports.less b/teamapps-client/less/bootstrap-imports.less new file mode 100644 index 000000000..fb3f1c30f --- /dev/null +++ b/teamapps-client/less/bootstrap-imports.less @@ -0,0 +1,39 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +// Core variables and mixins +@import "bootstrap/mixins.less"; + +// Reset and dependencies +@import "bootstrap/normalize.less"; +//@import "bootstrap/print.less"; +@import "bootstrap/glyphicons.less"; + +// Core CSS +@import "bootstrap/scaffolding.less"; +@import "bootstrap/type.less"; +@import "bootstrap/code.less"; +@import "bootstrap/forms.less"; +@import "bootstrap/buttons.less"; + +// Components +@import "bootstrap/progress-bars.less"; + +// Utility classes +@import "bootstrap/utilities.less"; diff --git a/teamapps-client/less/bootstrap/buttons.less b/teamapps-client/less/bootstrap/buttons.less new file mode 100644 index 000000000..a7b1fe722 --- /dev/null +++ b/teamapps-client/less/bootstrap/buttons.less @@ -0,0 +1,77 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +// stylelint-disable selector-no-qualifying-type + +// +// Buttons +// -------------------------------------------------- + + +// Base styles +// -------------------------------------------------- + +.btn { + display: inline-block; + margin-bottom: 0; // For input.btn + font-weight: var(--ta-btn-font-weight); + text-align: center; + white-space: nowrap; + vertical-align: middle; + touch-action: manipulation; + cursor: pointer; + background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214 + padding: var(--ta-padding-base-vertical) var(--ta-padding-base-horizontal); + font-size: var(--ta-font-size); + line-height: var(--ta-line-height); + border-radius: var(--ta-border-radius); + user-select: none; + + background-color: var(--ta-btn-default-bg); + border: 1px solid var(--ta-input-border-color); + + &:hover, + &:focus, + &.focus { + background-color: var(--ta-bg-color); + text-decoration: none; + } + + &:active, + &.active { + background-image: none; + outline: 0; + box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); + } + + &.disabled, + &[disabled], + fieldset[disabled] & { + cursor: var(--ta-cursor-disabled); + opacity: .65; + box-shadow: none; + } + + a& { + &.disabled, + fieldset[disabled] & { + pointer-events: none; // Future-proof disabling of clicks on `` elements + } + } +} diff --git a/teamapps-client/less/bootstrap/code.less b/teamapps-client/less/bootstrap/code.less new file mode 100644 index 000000000..3f43ad9df --- /dev/null +++ b/teamapps-client/less/bootstrap/code.less @@ -0,0 +1,82 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +// +// Code (inline and block) +// -------------------------------------------------- + + +// Inline and block code styles +code, +kbd, +pre, +samp { + font-family: var(--ta-font-family-monospace); +} + +// Inline code +code { + padding: 2px 4px; + font-size: 90%; + color: #c7254e; + background-color: #f9f2f4; + border-radius: var(--ta-border-radius); +} + +// User input typically entered via keyboard +kbd { + padding: 2px 4px; + font-size: 90%; + color: var(--ta-text-color-inverted); + background-color: var(--ta-text-color); + border-radius: var(--ta-border-radius-small); + box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .25); + + kbd { + padding: 0; + font-size: 100%; + font-weight: var(--ta-font-weight-bold); + box-shadow: none; + } +} + +// Blocks of code +pre { + display: block; + padding: var(--ta-half-line-height-computed); + margin: 0 0 var(--ta-half-line-height-computed); + font-size: 0.93em; + line-height: var(--ta-line-height); + color: var(--ta-pre-color); + word-break: break-all; + word-wrap: break-word; + background-color: var(--ta-bg-color-disabled); + border: 1px solid var(--ta-border-color-disabled); + border-radius: var(--ta-border-radius); + + // Account for some code outputs that place code tags in pre tags + code { + padding: 0; + font-size: inherit; + color: inherit; + white-space: pre-wrap; + background-color: transparent; + border-radius: 0; + } +} diff --git a/teamapps-client/less/bootstrap/forms.less b/teamapps-client/less/bootstrap/forms.less new file mode 100644 index 000000000..b45c4a13a --- /dev/null +++ b/teamapps-client/less/bootstrap/forms.less @@ -0,0 +1,63 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ + +.form-control { + display: block; + width: 100%; + height: var(--ta-input-height); // Make inputs at least the height of their button counterpart (base line-height + padding + border) + padding: var(--ta-padding-base-vertical) var(--ta-padding-base-horizontal); + font-size: var(--ta-font-size); + line-height: var(--ta-line-height); + color: var(--ta-input-color); + background-color: var(--ta-input-bg); + background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214 + border: 1px solid var(--ta-input-border-color); + border-radius: var(--ta-input-border-radius); // Note: This has no effect on s in CSS. + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); + transition: border-color ease-in-out var(--ta-color-transition-time), box-shadow ease-in-out var(--ta-color-transition-time); + + // Unstyle the caret on `` background color -@input-bg: #fff; -//** `` background color -@input-bg-disabled: @gray-lighter; - -//** Text color for ``s -@input-color: @gray; -//** `` border color -@input-border: #ccc; - -// TODO: Rename `@input-border-radius` to `@input-border-radius-base` in v4 -//** Default `.form-control` border radius -// This has no effect on ``s in CSS. -@input-border-radius: @border-radius-base; -//** Large `.form-control` border radius -@input-border-radius-large: @border-radius-large; -//** Small `.form-control` border radius -@input-border-radius-small: @border-radius-small; - -//** Border color for inputs on focus -@input-border-focus: #66afe9; - -//** Placeholder text color -@input-color-placeholder: #999; - -//** Default `.form-control` height -@input-height-base: (@line-height-computed + (@padding-base-vertical * 2) + 2); -//** Large `.form-control` height -@input-height-large: (ceil(@font-size-large * @line-height-large) + (@padding-large-vertical * 2) + 2); -//** Small `.form-control` height -@input-height-small: (floor(@font-size-small * @line-height-small) + (@padding-small-vertical * 2) + 2); - -//** `.form-group` margin -@form-group-margin-bottom: 15px; - -@legend-color: @gray-dark; -@legend-border-color: #e5e5e5; - -//** Background color for textual input addons -@input-group-addon-bg: @gray-lighter; -//** Border color for textual input addons -@input-group-addon-border-color: @input-border; - -//** Disabled cursor for form controls and buttons. -@cursor-disabled: not-allowed; - - -//== Dropdowns -// -//## Dropdown menu container and contents. - -//** Background for the dropdown menu. -@dropdown-bg: #fff; -//** Dropdown menu `border-color`. -@dropdown-border: rgba(0,0,0,.15); -//** Dropdown menu `border-color` **for IE8**. -@dropdown-fallback-border: #ccc; -//** Divider color for between dropdown items. -@dropdown-divider-bg: #e5e5e5; - -//** Dropdown link text color. -@dropdown-link-color: @gray-dark; -//** Hover color for dropdown links. -@dropdown-link-hover-color: darken(@gray-dark, 5%); -//** Hover background for dropdown links. -@dropdown-link-hover-bg: #f5f5f5; - -//** Active dropdown menu item text color. -@dropdown-link-active-color: @component-active-color; -//** Active dropdown menu item background color. -@dropdown-link-active-bg: @component-active-bg; - -//** Disabled dropdown menu item background color. -@dropdown-link-disabled-color: @gray-light; - -//** Text color for headers within dropdown menus. -@dropdown-header-color: @gray-light; - -//** Deprecated `@dropdown-caret-color` as of v3.1.0 -@dropdown-caret-color: #000; - - -//-- Z-index master list -// -// Warning: Avoid customizing these values. They're used for a bird's eye view -// of components dependent on the z-axis and are designed to all work together. -// -// Note: These variables are not generated into the Customizer. - -@zindex-navbar: 1000; -@zindex-dropdown: 1000; -@zindex-popover: 1060; -@zindex-tooltip: 1070; -@zindex-navbar-fixed: 1030; -@zindex-modal-background: 1040; -@zindex-modal: 1050; - - -//== Media queries breakpoints -// -//## Define the breakpoints at which your layout will change, adapting to different screen sizes. - -// Extra small screen / phone -//** Deprecated `@screen-xs` as of v3.0.1 -@screen-xs: 480px; -//** Deprecated `@screen-xs-min` as of v3.2.0 -@screen-xs-min: @screen-xs; -//** Deprecated `@screen-phone` as of v3.0.1 -@screen-phone: @screen-xs-min; - -// Small screen / tablet -//** Deprecated `@screen-sm` as of v3.0.1 -@screen-sm: 768px; -@screen-sm-min: @screen-sm; -//** Deprecated `@screen-tablet` as of v3.0.1 -@screen-tablet: @screen-sm-min; - -// Medium screen / desktop -//** Deprecated `@screen-md` as of v3.0.1 -@screen-md: 992px; -@screen-md-min: @screen-md; -//** Deprecated `@screen-desktop` as of v3.0.1 -@screen-desktop: @screen-md-min; - -// Large screen / wide desktop -//** Deprecated `@screen-lg` as of v3.0.1 -@screen-lg: 1200px; -@screen-lg-min: @screen-lg; -//** Deprecated `@screen-lg-desktop` as of v3.0.1 -@screen-lg-desktop: @screen-lg-min; - -// So media queries don't overlap when required, provide a maximum -@screen-xs-max: (@screen-sm-min - 1); -@screen-sm-max: (@screen-md-min - 1); -@screen-md-max: (@screen-lg-min - 1); - - -//== Grid system -// -//## Define your custom responsive grid. - -//** Number of columns in the grid. -@grid-columns: 12; -//** Padding between columns. Gets divided in half for the left and right. -@grid-gutter-width: 6px; -// Navbar collapse -//** Point at which the navbar becomes uncollapsed. -@grid-float-breakpoint: @screen-sm-min; -//** Point at which the navbar begins collapsing. -@grid-float-breakpoint-max: (@grid-float-breakpoint - 1); - - -//== Container sizes -// -//## Define the maximum width of `.container` for different screen sizes. - -// Small screen / tablet -@container-tablet: (720px + @grid-gutter-width); -//** For `@screen-sm-min` and up. -@container-sm: @container-tablet; - -// Medium screen / desktop -@container-desktop: (940px + @grid-gutter-width); -//** For `@screen-md-min` and up. -@container-md: @container-desktop; - -// Large screen / wide desktop -@container-large-desktop: (1140px + @grid-gutter-width); -//** For `@screen-lg-min` and up. -@container-lg: @container-large-desktop; - - -//== Navbar -// -//## - -// Basics of a navbar -@navbar-height: 34px; -@navbar-margin-bottom: @line-height-computed; -@navbar-border-radius: @border-radius-base; -@navbar-padding-horizontal: floor((@grid-gutter-width / 2)); -@navbar-padding-vertical: ((@navbar-height - @line-height-computed) / 2); -@navbar-collapse-max-height: 340px; - -@navbar-default-color: #777; -@navbar-default-bg: rgba(255, 255, 255, 0.72); -@navbar-default-border: transparent; - -// Navbar links -@navbar-default-link-color: #444; -@navbar-default-link-hover-color: #000; -@navbar-default-link-hover-bg: rgba(0, 0, 0, .05); -@navbar-default-link-active-color: #000; -@navbar-default-link-active-bg: rgba(0, 0, 0, .05); -@navbar-default-link-disabled-color: #ccc; -@navbar-default-link-disabled-bg: transparent; - -// Navbar brand label -@navbar-default-brand-color: @navbar-default-link-color; -@navbar-default-brand-hover-color: darken(@navbar-default-brand-color, 10%); -@navbar-default-brand-hover-bg: transparent; - -// Navbar toggle -@navbar-default-toggle-hover-bg: transparent; -@navbar-default-toggle-icon-bar-bg: #000; -@navbar-default-toggle-border-color: transparent; - - -// Inverted navbar -// Reset inverted navbar basics -@navbar-inverse-color: lighten(@gray-light, 15%); -@navbar-inverse-bg: #222; -@navbar-inverse-border: darken(@navbar-inverse-bg, 10%); - -// Inverted navbar links -@navbar-inverse-link-color: lighten(@gray-light, 15%); -@navbar-inverse-link-hover-color: #fff; -@navbar-inverse-link-hover-bg: transparent; -@navbar-inverse-link-active-color: @navbar-inverse-link-hover-color; -@navbar-inverse-link-active-bg: darken(@navbar-inverse-bg, 10%); -@navbar-inverse-link-disabled-color: #444; -@navbar-inverse-link-disabled-bg: transparent; - -// Inverted navbar brand label -@navbar-inverse-brand-color: @navbar-inverse-link-color; -@navbar-inverse-brand-hover-color: #fff; -@navbar-inverse-brand-hover-bg: transparent; - -// Inverted navbar toggle -@navbar-inverse-toggle-hover-bg: #333; -@navbar-inverse-toggle-icon-bar-bg: #fff; -@navbar-inverse-toggle-border-color: #333; - - -//== Navs -// -//## - -//=== Shared nav styles -@nav-link-padding: 10px 15px; -@nav-link-hover-bg: @gray-lighter; - -@nav-disabled-link-color: @gray-light; -@nav-disabled-link-hover-color: @gray-light; - -//== Tabs -@nav-tabs-border-color: #ddd; - -@nav-tabs-link-hover-border-color: @gray-lighter; - -@nav-tabs-active-link-hover-bg: @body-bg; -@nav-tabs-active-link-hover-color: @gray; -@nav-tabs-active-link-hover-border-color: #ddd; - -@nav-tabs-justified-link-border-color: #ddd; -@nav-tabs-justified-active-link-border-color: @body-bg; - -//== Pills -@nav-pills-border-radius: @border-radius-base; -@nav-pills-active-link-hover-bg: @component-active-bg; -@nav-pills-active-link-hover-color: @component-active-color; - - -//== Pagination -// -//## - -@pagination-color: @link-color; -@pagination-bg: #fff; -@pagination-border: #ddd; - -@pagination-hover-color: @link-hover-color; -@pagination-hover-bg: @gray-lighter; -@pagination-hover-border: #ddd; - -@pagination-active-color: #fff; -@pagination-active-bg: @brand-primary; -@pagination-active-border: @brand-primary; - -@pagination-disabled-color: @gray-light; -@pagination-disabled-bg: #fff; -@pagination-disabled-border: #ddd; - - -//== Pager -// -//## - -@pager-bg: @pagination-bg; -@pager-border: @pagination-border; -@pager-border-radius: 15px; - -@pager-hover-bg: @pagination-hover-bg; - -@pager-active-bg: @pagination-active-bg; -@pager-active-color: @pagination-active-color; - -@pager-disabled-color: @pagination-disabled-color; - - -//== Jumbotron -// -//## - -@jumbotron-padding: 30px; -@jumbotron-color: inherit; -@jumbotron-bg: @gray-lighter; -@jumbotron-heading-color: inherit; -@jumbotron-font-size: ceil((@font-size-base * 1.5)); -@jumbotron-heading-font-size: ceil((@font-size-base * 4.5)); - - -//== Form states and alerts -// -//## Define colors for form feedback states and, by default, alerts. - -@state-success-text: #3c763d; -@state-success-bg: #dff0d8; -@state-success-border: darken(spin(@state-success-bg, -10), 5%); - -@state-info-text: #31708f; -@state-info-bg: #d9edf7; -@state-info-border: darken(spin(@state-info-bg, -10), 7%); - -@state-warning-text: #8a6d3b; -@state-warning-bg: #fcf8e3; -@state-warning-border: darken(spin(@state-warning-bg, -10), 20%); - -@state-danger-text: #a94442; -@state-danger-bg: #f2dede; -@state-danger-border: darken(spin(@state-danger-bg, -10), 5%); - - -//== Tooltips -// -//## - -//** Tooltip max width -@tooltip-max-width: 200px; -//** Tooltip text color -@tooltip-color: #fff; -//** Tooltip background color -@tooltip-bg: #000; -@tooltip-opacity: .9; - -//** Tooltip arrow width -@tooltip-arrow-width: 5px; -//** Tooltip arrow color -@tooltip-arrow-color: @tooltip-bg; - - -//== Popovers -// -//## - -//** Popover body background color -@popover-bg: #fff; -//** Popover maximum width -@popover-max-width: 276px; -//** Popover border color -@popover-border-color: rgba(0,0,0,.2); -//** Popover fallback border color -@popover-fallback-border-color: #ccc; - -//** Popover title background color -@popover-title-bg: darken(@popover-bg, 3%); - -//** Popover arrow width -@popover-arrow-width: 10px; -//** Popover arrow color -@popover-arrow-color: @popover-bg; - -//** Popover outer arrow width -@popover-arrow-outer-width: (@popover-arrow-width + 1); -//** Popover outer arrow color -@popover-arrow-outer-color: fadein(@popover-border-color, 5%); -//** Popover outer arrow fallback color -@popover-arrow-outer-fallback-color: darken(@popover-fallback-border-color, 20%); - - -//== Labels -// -//## - -//** Default label background color -@label-default-bg: @gray-light; -//** Primary label background color -@label-primary-bg: @brand-primary; -//** Success label background color -@label-success-bg: @brand-success; -//** Info label background color -@label-info-bg: @brand-info; -//** Warning label background color -@label-warning-bg: @brand-warning; -//** Danger label background color -@label-danger-bg: @brand-danger; - -//** Default label text color -@label-color: #fff; -//** Default text color of a linked label -@label-link-hover-color: #fff; - - -//== Modals -// -//## - -//** Padding applied to the modal body -@modal-inner-padding: 15px; - -//** Padding applied to the modal title -@modal-title-padding: 15px; -//** Modal title line-height -@modal-title-line-height: @line-height-base; - -//** Background color of modal content area -@modal-content-bg: #fff; -//** Modal content border color -@modal-content-border-color: rgba(0,0,0,.2); -//** Modal content border color **for IE8** -@modal-content-fallback-border-color: #999; - -//** Modal backdrop background color -@modal-backdrop-bg: #000; -//** Modal backdrop opacity -@modal-backdrop-opacity: .5; -//** Modal header border color -@modal-header-border-color: #e5e5e5; -//** Modal footer border color -@modal-footer-border-color: @modal-header-border-color; - -@modal-lg: 900px; -@modal-md: 600px; -@modal-sm: 300px; - - -//== Alerts -// -//## Define alert colors, border radius, and padding. - -@alert-padding: 15px; -@alert-border-radius: @border-radius-base; -@alert-link-font-weight: bold; - -@alert-success-bg: @state-success-bg; -@alert-success-text: @state-success-text; -@alert-success-border: @state-success-border; - -@alert-info-bg: @state-info-bg; -@alert-info-text: @state-info-text; -@alert-info-border: @state-info-border; - -@alert-warning-bg: @state-warning-bg; -@alert-warning-text: @state-warning-text; -@alert-warning-border: @state-warning-border; - -@alert-danger-bg: @state-danger-bg; -@alert-danger-text: @state-danger-text; -@alert-danger-border: @state-danger-border; - - -//== Progress bars -// -//## - -//** Background color of the whole progress component -@progress-bg: #f5f5f5; -//** Progress bar text color -@progress-bar-color: #fff; -//** Variable for setting rounded corners on progress bar. -@progress-border-radius: @border-radius-base; - -//** Default progress bar color -@progress-bar-bg: @brand-primary; -//** Success progress bar color -@progress-bar-success-bg: @brand-success; -//** Warning progress bar color -@progress-bar-warning-bg: @brand-warning; -//** Danger progress bar color -@progress-bar-danger-bg: @brand-danger; -//** Info progress bar color -@progress-bar-info-bg: @brand-info; - - -//== List group -// -//## - -//** Background color on `.list-group-item` -@list-group-bg: #fff; -//** `.list-group-item` border color -@list-group-border: #ddd; -//** List group border radius -@list-group-border-radius: @border-radius-base; - -//** Background color of single list items on hover -@list-group-hover-bg: #f5f5f5; -//** Text color of active list items -@list-group-active-color: @component-active-color; -//** Background color of active list items -@list-group-active-bg: @component-active-bg; -//** Border color of active list elements -@list-group-active-border: @list-group-active-bg; -//** Text color for content within active list items -@list-group-active-text-color: lighten(@list-group-active-bg, 40%); - -//** Text color of disabled list items -@list-group-disabled-color: @gray-light; -//** Background color of disabled list items -@list-group-disabled-bg: @gray-lighter; -//** Text color for content within disabled list items -@list-group-disabled-text-color: @list-group-disabled-color; - -@list-group-link-color: #555; -@list-group-link-hover-color: @list-group-link-color; -@list-group-link-heading-color: #333; - - -//== Panels -// -//## - -@panel-bg: #fff; -@panel-body-padding: 8px; -@panel-heading-padding: 4px 8px; -@panel-footer-padding: @panel-heading-padding; -@panel-border-radius: @border-radius-base; - -//** Border color for elements within panels -@panel-inner-border: #ddd; -@panel-footer-bg: #f5f5f5; - -@panel-default-text: @gray-dark; -@panel-default-border: #000; -@panel-default-heading-bg: #f5f5f5; - -@panel-primary-text: #fff; -@panel-primary-border: @brand-primary; -@panel-primary-heading-bg: @brand-primary; - -@panel-success-text: @state-success-text; -@panel-success-border: @state-success-border; -@panel-success-heading-bg: @state-success-bg; - -@panel-info-text: @state-info-text; -@panel-info-border: @state-info-border; -@panel-info-heading-bg: @state-info-bg; - -@panel-warning-text: @state-warning-text; -@panel-warning-border: @state-warning-border; -@panel-warning-heading-bg: @state-warning-bg; - -@panel-danger-text: @state-danger-text; -@panel-danger-border: @state-danger-border; -@panel-danger-heading-bg: @state-danger-bg; - - -//== Thumbnails -// -//## - -//** Padding around the thumbnail image -@thumbnail-padding: 4px; -//** Thumbnail background color -@thumbnail-bg: @body-bg; -//** Thumbnail border color -@thumbnail-border: #ddd; -//** Thumbnail border radius -@thumbnail-border-radius: @border-radius-base; - -//** Custom text color for thumbnail captions -@thumbnail-caption-color: @text-color; -//** Padding around the thumbnail caption -@thumbnail-caption-padding: 9px; - - -//== Wells -// -//## - -@well-bg: #f5f5f5; -@well-border: darken(@well-bg, 7%); - - -//== Badges -// -//## - -@badge-color: #fff; -//** Linked badge text color on hover -@badge-link-hover-color: #fff; -@badge-bg: @gray-light; - -//** Badge text color in active nav link -@badge-active-color: @link-color; -//** Badge background color in active nav link -@badge-active-bg: #fff; - -@badge-font-weight: bold; -@badge-line-height: 1; -@badge-border-radius: 10px; - - -//== Breadcrumbs -// -//## - -@breadcrumb-padding-vertical: 8px; -@breadcrumb-padding-horizontal: 15px; -//** Breadcrumb background color -@breadcrumb-bg: #f5f5f5; -//** Breadcrumb text color -@breadcrumb-color: #ccc; -//** Text color of current page in the breadcrumb -@breadcrumb-active-color: @gray-light; -//** Textual separator for between breadcrumb elements -@breadcrumb-separator: "/"; - - -//== Carousel -// -//## - -@carousel-text-shadow: 0 1px 2px rgba(0,0,0,.6); - -@carousel-control-color: #fff; -@carousel-control-width: 15%; -@carousel-control-opacity: .5; -@carousel-control-font-size: 20px; - -@carousel-indicator-active-bg: #fff; -@carousel-indicator-border-color: #fff; - -@carousel-caption-color: #fff; - - -//== Close -// -//## - -@close-font-weight: bold; -@close-color: #000; -@close-text-shadow: 0 1px 0 #fff; - - -//== Code -// -//## - -@code-color: #c7254e; -@code-bg: #f9f2f4; - -@kbd-color: #fff; -@kbd-bg: #333; - -@pre-bg: #f5f5f5; -@pre-color: @gray-dark; -@pre-border-color: #e0e0e0; -@pre-scrollable-max-height: 340px; - - -//== Type -// -//## - -//** Horizontal offset for forms and lists. -@component-offset-horizontal: 180px; -//** Text muted color -@text-muted: @gray-light; -//** Abbreviations and acronyms border color -@abbr-border-color: @gray-light; -//** Headings small color -@headings-small-color: @gray-light; -//** Blockquote small color -@blockquote-small-color: @gray-light; -//** Blockquote font size -@blockquote-font-size: @font-size-base; -//** Blockquote border color -@blockquote-border-color: @gray-lighter; -//** Page header border color -@page-header-border-color: @gray-lighter; -//** Width of horizontal description list titles -@dl-horizontal-offset: @component-offset-horizontal; -//** Point at which .dl-horizontal becomes horizontal -@dl-horizontal-breakpoint: @grid-float-breakpoint; -//** Horizontal line color. -@hr-border: @gray-lighter; diff --git a/teamapps-client/less/third-party-fixes/quill-snow-fixes.less b/teamapps-client/less/third-party-fixes/quill-snow-fixes.less deleted file mode 100644 index 411e054c8..000000000 --- a/teamapps-client/less/third-party-fixes/quill-snow-fixes.less +++ /dev/null @@ -1,83 +0,0 @@ -/*- - * ========================LICENSE_START================================= - * TeamApps - * --- - * Copyright (C) 2014 - 2019 TeamApps.org - * --- - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * =========================LICENSE_END================================== - */ -/* -================= header fix ================== - - Fix header styles from quill.core.css. - - See bootstrap's type.less. - */ - -// Headings -// ------------------------- - -.ql-snow, .ql-editor { - h1, h2, h3, h4, h5, h6, - .h1, .h2, .h3, .h4, .h5, .h6 { - font-family: "Work Sans","Helvetica Neue","Helvetica","Roboto","Arial",sans-serif,sans-serif; - font-weight: 200; - color: #0172b9; - //font-family: @headings-font-family; - //font-weight: @headings-font-weight; - line-height: @headings-line-height; - //color: @headings-color; - - small, - .small { - font-weight: normal; - line-height: 1; - color: @headings-small-color; - } - } - - h1, .h1 { - - } - - h1, .h1, - h2, .h2, - h3, .h3, - h4, .h4, - h5, .h5, - h6, .h6 { - margin-top: (@line-height-computed / 2); - margin-bottom: (@line-height-computed / 2); - - small, - .small { - font-size: 75%; - } - } - - h1, .h1 { font-size: @font-size-h1; } - h2, .h2 { font-size: @font-size-h2; } - h3, .h3 { font-size: @font-size-h3; } - h4, .h4 { font-size: @font-size-h4; } - h5, .h5 { font-size: @font-size-h5; } - h6, .h6 { font-size: @font-size-h6; } - - h4, h5 { - font-weight: 600; - } -} - -/* -================= end of header fix ================== - */ diff --git a/teamapps-client/less/third-party-fixes/trivial-components-fixes.less b/teamapps-client/less/third-party-fixes/trivial-components-fixes.less index 10fe243a1..8dfd9e21f 100644 --- a/teamapps-client/less/third-party-fixes/trivial-components-fixes.less +++ b/teamapps-client/less/third-party-fixes/trivial-components-fixes.less @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,7 +19,7 @@ */ @tr-tree-indent: 15px; @tr-editor-font-size: 100%; -@tr-input-min-height: @input-height-base; +@tr-input-min-height: var(--ta-input-height); .tr-tree-expander { margin: 0 1px 0 3px !important; @@ -30,8 +30,8 @@ } .tr-input-wrapper { - border-radius: @border-radius-base; - min-height: @input-height-base; + border-radius: var(--ta-border-radius); + min-height: var(--ta-input-height); } .tr-combobox-selected-entry-wrapper { @@ -44,11 +44,6 @@ } } -.tr-dropdown { - max-height: 200px; - z-index: 10000000; -} - .tr-datetimefield .tr-editor-wrapper .tr-date-editor, .tr-datetimefield .tr-editor-wrapper .tr-time-editor { padding-left: 3px; @@ -59,15 +54,7 @@ .tr-input-wrapper { box-shadow: none; - transition: border-color @default-color-transition-time, box-shadow @default-color-transition-time; -} - -.tr-remove-button { - opacity: .6; -} - -.tr-editor { - border-radius: 4px; + transition: border-color var(--ta-color-transition-time), box-shadow var(--ta-color-transition-time); } .tr-tree-entry-children-wrapper:empty { diff --git a/teamapps-client/less/trivial-components/calendarbox.less b/teamapps-client/less/trivial-components/calendarbox.less new file mode 100644 index 000000000..46a467272 --- /dev/null +++ b/teamapps-client/less/trivial-components/calendarbox.less @@ -0,0 +1,262 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +/* + * + * Copyright 2016 Yann Massard (https://github.com/yamass) and other contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +.tr-calendarbox { + font-family: @tr-editor-font-family; + font-size: @tr-editor-font-size; + cursor: default; + user-select: none; + + display: flex; + align-items: center; + justify-content: space-around; + + .tr-calendar-display { + width: 176px; + + .year, .month { + display: flex; + border-radius: 2px; + + .name { + flex: 1 1 100px; + text-align: center; + padding: 2px 0; + font-weight: bold; + .fix-firefox-baseline; + } + + &.keyboard-nav { + .blinking-box-shadow; + } + } + + .month-table { + margin-top: 5px; + th, td { + width: 28px; + border: none; + border-spacing: 0; + border-radius: 2px; + text-align: right; + padding: 1px 3px; + .fix-firefox-baseline; + position: relative; + } + + td:hover { + background-color: @tr-highlight-color; + } + + .current-month { + color: black; + } + + .other-month { + color: lightgrey; + } + + .today:before { + position: absolute; + top: 9px; + left: 2px; + display: block; + width: 4px; + height: 4px; + border-radius: 2px; + float: left; + background-color: #ff641c; + content: ''; + } + + .selected { + background-color: @tr-highlight-color; + } + + .keyboard-nav { + .blinking-box-shadow; + } + + } + } + + .tr-clock-display { + .clock { + stroke: black; + stroke-linecap: round; + + .clockcircle { + stroke-width: 2px; + fill: #fff; + } + + .ticks { + stroke-width: 1px; + } + + .hourhand { + stroke-width: 3px; + } + + .minutehand { + stroke-width: 2px; + } + + .hourhand.keyboard-nav, .minutehand.keyboard-nav { + .blinking-stroke; + } + + .numbers { + font-family: sans-serif; + font-size: 10pt; + text-anchor: middle; + stroke: none; + fill: black; + } + + .numbers { + font-family: sans-serif; + font-size: 10pt; + text-anchor: middle; + stroke: none; + fill: black; + } + + .am-pm-box { + font-weight: 100; + font-size: 11px; + stroke-width: 0.3; + fill: transparent; + .am-pm-text { + fill: #444; + stroke: #444; + stroke-width: .3px; + } + } + } + + .digital-time-display { + display: flex; + justify-content: center; + align-items: center; + font-size: 130%; + + .hour-wrapper, .minute-wrapper { + display: flex; + flex-direction: column; + align-items: stretch; + padding: 2px; + border-radius: 2px; + &.keyboard-nav { + .blinking-box-shadow; + } + } + } + } + + .back-button, .forward-button, .up-button, .down-button { + flex: 0 0 16px; + background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhLS0gQ3JlYXRlZCB3aXRoIElua3NjYXBlIChodHRwOi8vd3d3Lmlua3NjYXBlLm9yZy8pIC0tPgoKPHN2ZwogICB2aWV3Qm94PSIwIDAgMTAwIDEwMCIKICAgdmVyc2lvbj0iMS4xIgogICBpZD0ic3ZnNTE2IgogICB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDxkZWZzCiAgICAgaWQ9ImRlZnM1MTMiIC8+CiAgPGcKICAgICBpZD0ibGF5ZXIxIj4KICAgIDxwYXRoCiAgICAgICBzdHlsZT0iZmlsbDojMDAwMDAwO3N0cm9rZS13aWR0aDowLjI2NDU4MyIKICAgICAgIGQ9Ik0gNDAsMjAgNzAsNTAgNDAsODAgMzAsNzUgNTUsNTAgMzAsMjUiCiAgICAgICBpZD0icGF0aDIxODciIC8+CiAgPC9nPgo8L3N2Zz4K); + background-position: center; + background-repeat: no-repeat; + transition: transform 0.1s; + cursor: pointer; + } + .back-button { + transform: scale(-1, 1); + } + .down-button { + flex: 0 0 10px; + transform: rotate(90deg); + } + .up-button { + flex: 0 0 10px; + transform: rotate(-90deg) scale(1, -1); + } + +} + +.blinking-box-shadow { + animation: blinking-box-shadow 0.5s ease-in-out infinite alternate; + background-color: transparent; +} + +@keyframes blinking-box-shadow { + 0% { + box-shadow: inset 0 0 0 1px lighten(@tr-highlight-color-base, 40%, relative); + } + 30% { + box-shadow: inset 0 0 0 1px lighten(@tr-highlight-color-base, 40%, relative); + } + 70% { + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .5); + } + 100% { + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .5); + } +} + +.blinking-stroke { + animation: blinking-stroke 0.5s ease-out infinite alternate; +} + +@keyframes blinking-stroke { + 0% { + stroke: lighten(@tr-highlight-color-base, 40%, relative); + color: lighten(@tr-highlight-color-base, 40%, relative); + } + 40% { + stroke: lighten(@tr-highlight-color-base, 40%, relative); + color: lighten(@tr-highlight-color-base, 40%, relative); + } + 80% { + stroke: black; + color: black; + } + 100% { + stroke: black; + color: black; + } +} + +.fix-firefox-baseline { + &:after { + // this is a hack for fixing firefox baseline. See http://blogs.adobe.com/webplatform/2014/08/13/one-weird-trick-to-baseline-align-text/ + content: ''; + display: inline-block; + height: 13px; + } +} diff --git a/teamapps-client/less/trivial-components/calendarcombobox.less b/teamapps-client/less/trivial-components/calendarcombobox.less new file mode 100644 index 000000000..bdfb1a698 --- /dev/null +++ b/teamapps-client/less/trivial-components/calendarcombobox.less @@ -0,0 +1,93 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +/* + * + * Copyright 2016 Yann Massard (https://github.com/yamass) and other contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +.tr-calendarcombobox { + min-height: @tr-input-min-height; + + .tr-formatted-field { + flex: 1 1 0px; + padding: 3px 5px; + background-color: #f5f5f5; + font-family: @tr-editor-font-family; + font-size: @tr-editor-font-size; + border-radius: @tr-border-radius; + + display: flex; + justify-content: flex-end; + + .tr-formatted-input-section { + border: 1px solid lighten(@tr-border-color, 12%); + padding: 2px 2px; + outline: none; + background-color: white; + &:focus { + border-color: @tr-input-border-focus; + } + &.right { + text-align: right; + } + &.left { + text-align: left; + } + &.center { + text-align: center; + } + + &:empty:not(:focus):before{ + content:attr(data-placeholder); + color: @tr-disabled-text-color; + } + } + + .separator { + padding: 2px 2px; + margin: 0 -1px; + } + + .generate-char-widths(10); + .generate-char-widths(@n, @i: 1) when (@i =< @n) { + .tr-formatted-input-section.tr-@{i}-chars { + min-width: ~"calc(" @i * 1ch ~" + 6px)"; + } + .generate-char-widths(@n, (@i + 1)); + } + } +} + +.tr-calendarcombobox-dropdown { + padding: 5px 0; +} diff --git a/teamapps-client/less/trivial-components/combobox.less b/teamapps-client/less/trivial-components/combobox.less new file mode 100644 index 000000000..86ec2476e --- /dev/null +++ b/teamapps-client/less/trivial-components/combobox.less @@ -0,0 +1,111 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +/* + * + * Copyright 2016 Yann Massard (https://github.com/yamass) and other contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +.tr-combobox { + + .tr-combobox-main-area { + flex: 1 1 auto; + display: flex; + align-items: stretch; + position: relative; + overflow: hidden; + + .tr-combobox-selected-entry-wrapper { + overflow: hidden; + flex-grow: 1; + display: flex; + align-items: center; // if the template doesn't take the whole space... + + .tr-combobox-entry { + flex: 1 1 auto; + + * { + cursor: text; + } + } + } + + .tr-combobox-editor { + border: none; + outline: none; + background-color: transparent; + z-index: 1; + font-family: @tr-editor-font-family; + font-size: @tr-editor-font-size; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + + .placeholder-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + .tr-tool-buttons { + display: flex; + align-items: center; + + > .UiToolButton .img { + width: 16px; + height: 16px; + } + + > * { + cursor: pointer; + } + } + + &.editor-hidden .tr-combobox-editor { + opacity: 0; + pointer-events: none; + } + + &:not(.editor-hidden) .tr-combobox-selected-entry-wrapper { + visibility: hidden; + pointer-events: none; + } + + &:not(.open) .tr-dropdown { + display: none; + } +} + diff --git a/teamapps-client/less/trivial-components/common.less b/teamapps-client/less/trivial-components/common.less new file mode 100644 index 000000000..7b5d19ca6 --- /dev/null +++ b/teamapps-client/less/trivial-components/common.less @@ -0,0 +1,240 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +/* + * + * Copyright 2016 Yann Massard (https://github.com/yamass) and other contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +.tr-original-input { + tab-index: -1; + display: none !important; +} + +.tr-default-spinner, .tr-default-no-data-display { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + + display: flex; + + align-items: center; + justify-content: center; + + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-size: 14px; + color: #999999; + background-color: rgba(255, 255, 255, 0.7); + + .spinner { + animation: tr-spin 1s infinite linear; + width: 10px; + height: 10px; + background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAN0lEQVQYV2NkQAXGDAwMPlChLQwMDGdh0oxoCuvR+I0UKyTaapBNMOvh1oIE0d1ItEKiraajrwH17w4LpvE5NgAAAABJRU5ErkJggg==); + margin-right: 5px; + + @keyframes tr-spin { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(359deg); + transform: rotate(359deg); + } + } + } + +} + +.tr-component-focus() { + @color-rgba: rgba(red(@tr-input-border-focus), green(@tr-input-border-focus), blue(@tr-input-border-focus), .6); + &.focus, &:focus { + border-color: @tr-input-border-focus; + outline: 0; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 8px ~"@{color-rgba}"; + } +} + +.tr-highlighted-text { + background-color: rgba(244, 195, 125, 0.7); + border-radius: 3px; + box-shadow: 1px 1px 3px 0 rgba(0, 0, 0, 0.75); +} + +.tr-selected-entry { + background-color: var(--ta-selection-color); +} + +.tr-dropdown { + position: fixed; + background-color: white; + max-height: 300px; + overflow-x: hidden; + overflow-y: auto; + border: @tr-border; + border-radius: 0 0 @tr-border-radius @tr-border-radius; + z-index: var(--ta-zindex-dropdown); + + &:not(.broader-than-combobox) { + border-top: none; + } + + &.flipped { + border-radius: @tr-border-radius @tr-border-radius 0 0; + border-top: @tr-border; + border-bottom: none; + } + + > * { + // needs to be set on the content (e.g. listbox) so the dropdown component has the min size! Otherwise, the listbox will shrink down to zero, even if an empty template is to be displayed. + min-height: 20px; + } +} + +.tr-input-wrapper { + position: relative; + display: flex; + background-color: white; + border: @tr-border; + border-radius: @tr-border-radius; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); + transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s; + .tr-component-focus; + &.open { + border-radius: @tr-border-radius @tr-border-radius 0 0; + } + &.open.dropdown-flipped { + border-radius: 0 0 @tr-border-radius @tr-border-radius; + } + &, * { + box-sizing: border-box; + } + + .tr-remove-button { + align-self: center; + margin: 3px; + } + + .tr-trigger { + order: 9999999; + flex: 0 0 20px; + width: 20px; + border-left: @tr-border; + display: flex; + align-items: center; + justify-content: center; + background-color: @tr-button-bg; + + .tr-trigger-icon { + display: inline-block; + width: 0; + height: 0; + vertical-align: middle; + border-top: @tr-caret-width solid var(--ta-text-color); + border-right: @tr-caret-width solid transparent; + border-left: @tr-caret-width solid transparent; + } + + &:hover, .open & { + background-color: @tr-button-bg-active; + } + } + + &.disabled { + background-color: @tr-button-bg; + + .tr-remove-button { + display: none !important; + } + + .tr-trigger:hover { + background-color: inherit; + } + + .tr-trigger-icon { + border-top-color: white; + } + + .tr-editor { + display: none; + } + } + + &.readonly { + border-color: transparent; + box-shadow: none; + + .tr-remove-button { + display: none !important; + } + + .tr-trigger { + display: none; + } + + .tr-editor { + display: none; + } + } + + input::-ms-clear { // hide ie's reset button + display: none; + } +} + +.tr-remove-button { + flex: 0 0 16px; + width: 16px; + height: 16px; + text-align: center; + background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8' standalone='no'%3F%3E%3Csvg viewBox='0 0 120 120' version='1.1' id='svg5' xmlns='http://www.w3.org/2000/svg' xmlns:svg='http://www.w3.org/2000/svg'%3E%3Cdefs id='defs2'/%3E%3Cg id='layer1'%3E%3Cpath id='shape' style='fill:%23000000;stroke-width:0.216778' d='M 85,21.270365 60.091545,45.858249 35.503661,20.949794 21.270363,34.99991 45.858247,59.908365 20.949794,84.496247 34.99991,98.729545 59.908363,74.141663 84.496245,99.050116 98.729543,85 74.141661,60.091547 99.050116,35.503663 Z'/%3E%3C/g%3E%3C/svg%3E"); + background-position: center; + background-repeat: no-repeat; + opacity: .6; + + .tr-input-wrapper.disabled &, + .tr-input-wrapper.readonly & { + display: none; + } + + &:hover { + opacity: 1; + } +} + +input.tr-editor { + border-radius: 4px; + padding: var(--ta-padding-base-vertical) var(--ta-padding-base-horizontal); +} + diff --git a/teamapps-client/less/trivial-components/datetimefield.less b/teamapps-client/less/trivial-components/datetimefield.less new file mode 100644 index 000000000..25aa15f47 --- /dev/null +++ b/teamapps-client/less/trivial-components/datetimefield.less @@ -0,0 +1,71 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +/* + * + * Copyright 2016 Yann Massard (https://github.com/yamass) and other contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +.tr-datetimefield { + .tr-editor-wrapper { + padding: 2px 0; + flex: 1 1 0px; + overflow: hidden; + display: flex; + align-items: center; + + .tr-date-icon-wrapper, + .tr-time-icon-wrapper { + flex: 0 0 auto; + display: flex; + align-items: center; + } + .tr-time-icon-wrapper { + margin-left: 4px; + } + + .tr-date-editor, + .tr-time-editor { + outline: none; + font-family: @tr-editor-font-family; + font-size: @tr-editor-font-size; + padding-left: 1px; + } + .tr-date-editor { + flex: 0 0 auto; + min-width: 9ch; + } + .tr-time-editor { + flex: 1 0 auto; + min-width: 4.5ch; + } + } +} diff --git a/teamapps-client/less/trivial-components/icon-template-date.less b/teamapps-client/less/trivial-components/icon-template-date.less new file mode 100644 index 000000000..d1be2f9f9 --- /dev/null +++ b/teamapps-client/less/trivial-components/icon-template-date.less @@ -0,0 +1,76 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +/* + * + * Copyright 2016 Yann Massard (https://github.com/yamass) and other contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +.calendar-icon { + align-self: center; + + .calendar-symbol-page-background { + fill: #ffffff; + } + + .calendar-symbol-color { + fill: #d35f5f; + } + + .calendar-symbol-page { + shape-rendering: crispEdges; + fill: none; + stroke: #0000003b; + stroke-width: 20; + stroke-linejoin: miter; + stroke-miterlimit: 4; + } + + .calendar-symbol-ring-gradient-stop1 { stop-color: #555; } + .calendar-symbol-ring-gradient-stop2 { stop-color: #aaa; } + .calendar-symbol-ring-gradient-stop3 { stop-color: black; } + + .calendar-symbol-ring { + shape-rendering: geometricPrecision; + fill: url(#Gradient1); + stroke: none; + } + + .calendar-symbol-date { + fill: #333; + //stroke: #000000; + //stroke-width: 10px; + font-size: 250px; + cursor: default; + } + +} + diff --git a/teamapps-client/less/trivial-components/icon-template-time.less b/teamapps-client/less/trivial-components/icon-template-time.less new file mode 100644 index 000000000..339d6d3e0 --- /dev/null +++ b/teamapps-client/less/trivial-components/icon-template-time.less @@ -0,0 +1,77 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +/* + * + * Copyright 2016 Yann Massard (https://github.com/yamass) and other contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +// TODO wrap these styles!!! + +.clock-icon { + align-self: center; + + .clockcircle { + stroke: #555; + stroke-width: 5px; + fill: #fff; + } + + .hourhand { + stroke: #333; + stroke-width: 8.7px; + stroke-linecap: round; + } + + .minutehand { + stroke: #333; + stroke-width: 6.2px; + stroke-linecap: round; + } + + &.night-true .clockcircle { + stroke-width: 5px; + stroke: #333; + fill: rgba(77, 130, 184, 0.76); + } + + &.night-true .hourhand { + stroke-width: 8px; + stroke: #fff; + fill: #333; + } + + &.night-true .minutehand { + stroke-width: 6px; + stroke: #fff; + fill: #333; + } +} diff --git a/teamapps-client/less/trivial-components/tagcombobox.less b/teamapps-client/less/trivial-components/tagcombobox.less new file mode 100644 index 000000000..d1922d039 --- /dev/null +++ b/teamapps-client/less/trivial-components/tagcombobox.less @@ -0,0 +1,134 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +/* + * + * Copyright 2016 Yann Massard (https://github.com/yamass) and other contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +@tr-default-tag-border-color: #BAD2EA; + +.tr-tagcombobox { + display: flex; + + .tr-tagcombobox-main-area { + flex: 1 1 auto; + display: flex; + align-items: center; + position: relative; + + .tr-tagcombobox-tagarea { + flex: 1 1 auto; + display: flex; + align-items: center; + flex-wrap: wrap; + padding: 1px; + overflow: hidden; + + .tr-tagcombobox.editable & { + cursor: text; + } + } + + .placeholder-text { + position: absolute; + pointer-events: none; + top: 0; + left: 0; + width: 100%; + height: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + .tr-tagcombobox-tag { + flex: 0 0 auto; + cursor: default; + + &.marked-for-removal > * { + box-shadow: 0 0 1px 1px var(--ta-input-border-focus); + > * { + filter: opacity(.8) grayscale(1); + } + } + } + + .tr-tagcombobox-editor { + flex: 0 0 auto; + max-width: 100%; + outline: none; + font-family: @tr-editor-font-family; + font-size: @tr-editor-font-size; + min-height: 1em; + + &:focus, + &:not(:empty){ + min-width: 2px; + margin: 2px 3px; + } + + &:first-child:last-child:not(:focus):empty:before { + content: attr(placeholder); + color: @tr-disabled-text-color; + } + } + + &:not(.open) .tr-dropdown { + display: none; + } +} + +.tr-tagcombobox-default-wrapper-template { + border: 1px solid @tr-default-tag-border-color; + border-radius: 3px; + box-shadow: 1px 2px 1px -1px rgba(0, 0, 0, 0.13); + background-color: white; + margin: 2px; + overflow: hidden; + + display: flex; + align-items: center; + + .tr-tagcombobox-tag-content { + flex: 1 1 auto; + padding: 0 1px; + } + + .tr-remove-button { + border: 1px solid @tr-default-tag-border-color; + border-radius: 3px; + margin: 2px; + align-self: flex-start; + } +} diff --git a/teamapps-client/less/trivial-components/template-currency-2-lines.less b/teamapps-client/less/trivial-components/template-currency-2-lines.less new file mode 100644 index 000000000..fc92a268a --- /dev/null +++ b/teamapps-client/less/trivial-components/template-currency-2-lines.less @@ -0,0 +1,63 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +/* + * + * Copyright 2016 Yann Massard (https://github.com/yamass) and other contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +.tr-template-currency-2-lines { + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + padding: 1px 3px; + font-size: 14px; + display: flex; + flex-direction: column; + justify-content: center; + .main-line { + line-height: 14px; + margin: 1px 0; + white-space: nowrap; + display: flex; + justify-content: space-between; + + .currency-code { + font-weight: 500; + } + } + .additional-info { + font-size: 90%; + color: #555; + font-weight: 200; + white-space: nowrap; + display: flex; + justify-content: space-between; + } +} diff --git a/teamapps-client/less/trivial-components/template-currency-single-line-long.less b/teamapps-client/less/trivial-components/template-currency-single-line-long.less new file mode 100644 index 000000000..1f7fa977e --- /dev/null +++ b/teamapps-client/less/trivial-components/template-currency-single-line-long.less @@ -0,0 +1,69 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +/* + * + * Copyright 2016 Yann Massard (https://github.com/yamass) and other contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +.tr-template-currency-single-line-long { + padding: 5px 4px 5px 8px; + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-weight: 400; + font-size: 14px; + line-height: 15px; + white-space: nowrap; + text-align: right; + + .content-wrapper { + display: flex; + justify-content: space-between; + .symbol-and-code { + .currency-code { + display: inline-block; + } + .currency-symbol { + } + .currency-code + .currency-symbol { + color: #555; + min-width: 2.7em; + &:before { + content: '('; + } + &:after { + content: ')'; + } + } + } + .currency-name { + } + } +} diff --git a/teamapps-client/less/trivial-components/template-currency-single-line-short.less b/teamapps-client/less/trivial-components/template-currency-single-line-short.less new file mode 100644 index 000000000..67ad7f27f --- /dev/null +++ b/teamapps-client/less/trivial-components/template-currency-single-line-short.less @@ -0,0 +1,59 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +/* + * + * Copyright 2016 Yann Massard (https://github.com/yamass) and other contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +.tr-template-currency-single-line-short { + padding: 5px 1px 5px 6px; + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-weight: 400; + font-size: 14px; + line-height: 15px; + white-space: nowrap; + text-align: right; + + .currency-code { + display: inline-block; + } + .currency-symbol + .currency-code { + color: #555; + min-width: 2.7em; + &:before { + content: '('; + } + &:after { + content: ')'; + } + } +} diff --git a/teamapps-client/less/trivial-components/template-icon-single-line.less b/teamapps-client/less/trivial-components/template-icon-single-line.less new file mode 100644 index 000000000..8232ff85d --- /dev/null +++ b/teamapps-client/less/trivial-components/template-icon-single-line.less @@ -0,0 +1,68 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +/* + * + * Copyright 2016 Yann Massard (https://github.com/yamass) and other contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +.tr-template-icon-single-line { + display: flex; + align-items: stretch; + padding: 1px; + font-size: 14px; + line-height: 15px; + + &.empty { + color: gray; + } + + .img-wrapper { + flex: 0 0 24px; + width: 24px; + height: 24px; + background-position: center; + background-size: cover; + border-radius: 2px; + } + + .content-wrapper { + flex: 1 1 auto; + margin-left: 5px; + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-weight: 400; + line-height: 14px; + display: flex; + align-items: center; + white-space: nowrap; + } +} + diff --git a/teamapps-client/less/trivial-components/tree.less b/teamapps-client/less/trivial-components/tree.less new file mode 100644 index 000000000..52c66993c --- /dev/null +++ b/teamapps-client/less/trivial-components/tree.less @@ -0,0 +1,46 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +/* + * + * Copyright 2016 Yann Massard (https://github.com/yamass) and other contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +.tr-tree { + width: 100%; + + border: @tr-border; + border-radius: @tr-border-radius; + + .tr-component-focus(); + + overflow: auto; +} diff --git a/teamapps-client/less/trivial-components/treebox.less b/teamapps-client/less/trivial-components/treebox.less new file mode 100644 index 000000000..117ee265e --- /dev/null +++ b/teamapps-client/less/trivial-components/treebox.less @@ -0,0 +1,96 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +/* + * + * Copyright 2016 Yann Massard (https://github.com/yamass) and other contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +.tr-treebox { + + position: relative; + + .tr-tree-entryTree { + flex: 1 1 300px; + } + + .tr-tree-entry-outer-wrapper { + > .tr-tree-entry-and-expander-wrapper { + display: flex; + align-items: center; + user-select: none; + + .tr-indent-spacer { + width: @tr-tree-indent; + flex: 0 0 auto; + } + + .tr-tree-expander { + @tr-tree-expander-size: 15px; + flex: 0 0 @tr-tree-expander-size; + width: @tr-tree-expander-size; + height: @tr-tree-expander-size; + margin: 7px; + } + + .tr-tree-entry { + flex: 1 1 auto; + } + } + + &.has-children > .tr-tree-entry-and-expander-wrapper > .tr-tree-expander { + background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhLS0gQ3JlYXRlZCB3aXRoIElua3NjYXBlIChodHRwOi8vd3d3Lmlua3NjYXBlLm9yZy8pIC0tPgoKPHN2ZwogICB2aWV3Qm94PSIwIDAgMTAwIDEwMCIKICAgdmVyc2lvbj0iMS4xIgogICBpZD0ic3ZnNTE2IgogICB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDxkZWZzCiAgICAgaWQ9ImRlZnM1MTMiIC8+CiAgPGcKICAgICBpZD0ibGF5ZXIxIj4KICAgIDxwYXRoCiAgICAgICBzdHlsZT0iZmlsbDojMDAwMDAwO3N0cm9rZS13aWR0aDowLjI2NDU4MyIKICAgICAgIGQ9Ik0gNDAsMjAgNzAsNTAgNDAsODAgMzAsNzUgNTUsNTAgMzAsMjUiCiAgICAgICBpZD0icGF0aDIxODciIC8+CiAgPC9nPgo8L3N2Zz4K); + background-position: center; + background-repeat: no-repeat; + transition: transform 0.1s; + cursor: pointer; + } + + &.expanded > .tr-tree-entry-and-expander-wrapper > .tr-tree-expander { + transform: rotate(0.25turn); + } + + > .tr-tree-entry-children-wrapper { + padding-left: 0; + overflow-y: hidden; // especially when collapsed! + position: relative; // for lazy children spinner + } + + } + + &.hide-expanders .tr-tree-expander { + display: none; + } + + .tr-tree-entry-and-expander-wrapper:not(.tr-selected-entry):hover { + background-color: var(--ta-hover-color); + } +} diff --git a/teamapps-client/less/trivial-components/trivial-components-bootstrap.less b/teamapps-client/less/trivial-components/trivial-components-bootstrap.less new file mode 100644 index 000000000..c959a10cd --- /dev/null +++ b/teamapps-client/less/trivial-components/trivial-components-bootstrap.less @@ -0,0 +1,46 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +/* + Copyright 2016 Yann Massard (https://github.com/yamass) and other contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +.input-group .tr-input-wrapper { + + &:not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + &:not(:last-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + +} diff --git a/teamapps-client/less/trivial-components/trivial-components.less b/teamapps-client/less/trivial-components/trivial-components.less new file mode 100644 index 000000000..35b1463d9 --- /dev/null +++ b/teamapps-client/less/trivial-components/trivial-components.less @@ -0,0 +1,52 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +/* + * + * Copyright 2016 Yann Massard (https://github.com/yamass) and other contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +@import "variables"; +@import "common"; +@import "combobox"; +@import "tagcombobox"; +@import "treebox"; +@import "tree"; +@import "unitbox"; +@import "calendarbox"; +@import "calendarcombobox"; +@import "datetimefield"; +@import "icon-template-date"; +@import "icon-template-time"; +@import "template-icon-single-line"; +@import "template-currency-single-line-short"; +@import "template-currency-single-line-long"; +@import "template-currency-2-lines"; diff --git a/teamapps-client/less/trivial-components/unitbox.less b/teamapps-client/less/trivial-components/unitbox.less new file mode 100644 index 000000000..4060070f7 --- /dev/null +++ b/teamapps-client/less/trivial-components/unitbox.less @@ -0,0 +1,95 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +.tr-unitbox { + min-height: @tr-input-min-height; + align-items: stretch; + + .tr-unitbox-editor { + flex: 1 1 0px; + border: none; + padding: 0 6px 0 4px; + text-align: right; + outline: none; + background-color: transparent; + font-family: @tr-editor-font-family; + font-size: @tr-editor-font-size; + } + + .tr-unitbox-selected-entry-and-trigger-wrapper { + flex: 0 0 auto; + display: flex; + + background-color: @tr-button-bg; + cursor: default; + + &:hover, .tr-unitbox.open & { + background-color: @tr-button-bg-active; + } + + .tr-unitbox-selected-entry-wrapper { + flex: 0 0 auto; + display: flex; + align-items: center; + } + .tr-trigger { + border-left: none; + background-color: transparent; + } + } + + &.unit-display-left { + flex-direction: row-reverse; + + .tr-unitbox-selected-entry-and-trigger-wrapper { + border-right: @tr-border; + } + } + + &.unit-display-right .tr-unitbox-selected-entry-and-trigger-wrapper { + border-left: @tr-border; + } + + &.readonly, + &.disabled { + .tr-unitbox-editor { + display: block; + } + .tr-trigger:hover { + background-color: transparent; + } + .tr-unitbox-selected-entry-and-trigger-wrapper:hover { + background-color: inherit; + } + } + + &.readonly { + .tr-unitbox-editor { + padding: 0; + } + .tr-unitbox-selected-entry-and-trigger-wrapper { + border: none; + background-color: transparent; + } + } + + &:not(.open) .tr-dropdown { + display: none; + } +} diff --git a/teamapps-client/less/trivial-components/variables.less b/teamapps-client/less/trivial-components/variables.less new file mode 100644 index 000000000..42ee3d7c1 --- /dev/null +++ b/teamapps-client/less/trivial-components/variables.less @@ -0,0 +1,51 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +/* + * + * Copyright 2016 Yann Massard (https://github.com/yamass) and other contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +@tr-border-color: #ccc; +@tr-border: 1px solid @tr-border-color; +@tr-border-radius: 4px; +@tr-input-border-focus: #66afe9; +@tr-button-bg: rgba(0, 0, 0, 0.05); +@tr-button-bg-active: rgba(0, 0, 0, 0.1); +@tr-editor-font-family: sans-serif; +@tr-editor-font-size: 14px; +@tr-highlight-color-base: rgb(0, 120, 255); +@tr-highlight-color: lighten(@tr-highlight-color-base, 80%, relative); +@tr-select-color: lighten(@tr-highlight-color-base, 60%, relative); +@tr-input-min-height: 30px; +@tr-disabled-text-color: @tr-border-color; +@tr-caret-width: 4px; +@tr-tree-indent: 29px; diff --git a/teamapps-client/less/util/animations.less b/teamapps-client/less/util/animations.less new file mode 100644 index 000000000..2ca359214 --- /dev/null +++ b/teamapps-client/less/util/animations.less @@ -0,0 +1,35 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +.ta-blink { + animation: ta-blink 1s linear infinite; +} + +.ta-blink-subtle { + animation: ta-blink-subtle 7s linear infinite; +} + +@keyframes ta-blink { + 50% { opacity: .1; } +} + +@keyframes ta-blink-subtle { + 15% { opacity: .1; } + 30% { opacity: 1; } +} diff --git a/teamapps-client/less/util/drop-zone.less b/teamapps-client/less/util/drop-zone.less index 95823ebaf..0c8acd965 100644 --- a/teamapps-client/less/util/drop-zone.less +++ b/teamapps-client/less/util/drop-zone.less @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,8 +25,11 @@ content: "\e198"; position: absolute; pointer-events: none; - width: 100%; - height: 100%; + top: 3px; + left: 3px; + width: ~"calc(100% - 6px)"; + height: ~"calc(100% - 6px)"; + border-radius: 2px; display: flex; align-items: center; justify-content: center; @@ -34,7 +37,7 @@ color: rgba(0, 0, 0, .3); border: 3px dashed rgba(0, 0, 0, .3); z-index: 1; - background-color: rgba(255, 255, 255, .85); + background-color: var(--ta-bg-color-semi-transparent); opacity: 0; transition: opacity .2s, backdrop-filter .2s; } @@ -44,28 +47,26 @@ backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); } +} - .upload-button-wrapper { - display: inline-block; - position: relative; - overflow: hidden; - border: 1px solid @input-border; - border-radius: @input-border-radius; +.file-list { + position: relative; + min-height: 36px; - .file-input { - position: absolute; - width: 100%; - height: 100%; - top: 0; - left: 0; - bottom: 0; - right: 0; - margin: 0; - padding: 0; - font-size: 20px; - cursor: default; - opacity: 0; - filter: alpha(opacity=0); - } + &:empty:before { + .glyphicon(); + top: 0; + left: 0; + content: "\e198"; + position: absolute; + pointer-events: none; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 200%; + color: rgba(0, 0, 0, .3); + z-index: 1; } } diff --git a/teamapps-client/less/util/glyphicon-button.less b/teamapps-client/less/util/glyphicon-button.less index 22006d3d1..ce0febc1f 100644 --- a/teamapps-client/less/util/glyphicon-button.less +++ b/teamapps-client/less/util/glyphicon-button.less @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,18 +19,18 @@ */ .glyphicon-button { padding: 7px; - color: fadeout(@text-color, 30%); + color: var(--ta-text-color-faded); display: flex; align-items: center; justify-content: center; - transition: color .2s; + transition: color var(--ta-color-transition-time); &:hover { - color: @text-color; + color: var(--ta-text-color); } &.disabled { pointer-events: none; - color: @input-border; + color: var(--ta-input-border-color); } } diff --git a/teamapps-client/less/util/page-transitions.less b/teamapps-client/less/util/page-transitions.less new file mode 100755 index 000000000..5f6d3a7cc --- /dev/null +++ b/teamapps-client/less/util/page-transitions.less @@ -0,0 +1,1316 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +/* animation sets */ + +/* move from / to */ + +.pt-page-moveToLeft { + -webkit-animation: moveToLeft .6s ease both; + animation: moveToLeft .6s ease both; +} + +.pt-page-moveFromLeft { + -webkit-animation: moveFromLeft .6s ease both; + animation: moveFromLeft .6s ease both; +} + +.pt-page-moveToRight { + -webkit-animation: moveToRight .6s ease both; + animation: moveToRight .6s ease both; +} + +.pt-page-moveFromRight { + -webkit-animation: moveFromRight .6s ease both; + animation: moveFromRight .6s ease both; +} + +.pt-page-moveToTop { + -webkit-animation: moveToTop .6s ease both; + animation: moveToTop .6s ease both; +} + +.pt-page-moveFromTop { + -webkit-animation: moveFromTop .6s ease both; + animation: moveFromTop .6s ease both; +} + +.pt-page-moveToBottom { + -webkit-animation: moveToBottom .6s ease both; + animation: moveToBottom .6s ease both; +} + +.pt-page-moveFromBottom { + -webkit-animation: moveFromBottom .6s ease both; + animation: moveFromBottom .6s ease both; +} + +/* fade */ + +.pt-page-fade { + -webkit-animation: fade .7s ease both; + animation: fade .7s ease both; +} + +/* move from / to and fade */ + +.pt-page-moveToLeftFade { + -webkit-animation: moveToLeftFade .7s ease both; + animation: moveToLeftFade .7s ease both; +} + +.pt-page-moveFromLeftFade { + -webkit-animation: moveFromLeftFade .7s ease both; + animation: moveFromLeftFade .7s ease both; +} + +.pt-page-moveToRightFade { + -webkit-animation: moveToRightFade .7s ease both; + animation: moveToRightFade .7s ease both; +} + +.pt-page-moveFromRightFade { + -webkit-animation: moveFromRightFade .7s ease both; + animation: moveFromRightFade .7s ease both; +} + +.pt-page-moveToTopFade { + -webkit-animation: moveToTopFade .7s ease both; + animation: moveToTopFade .7s ease both; +} + +.pt-page-moveFromTopFade { + -webkit-animation: moveFromTopFade .7s ease both; + animation: moveFromTopFade .7s ease both; +} + +.pt-page-moveToBottomFade { + -webkit-animation: moveToBottomFade .7s ease both; + animation: moveToBottomFade .7s ease both; +} + +.pt-page-moveFromBottomFade { + -webkit-animation: moveFromBottomFade .7s ease both; + animation: moveFromBottomFade .7s ease both; +} + +/* move to with different easing */ + +.pt-page-moveToLeftEasing { + -webkit-animation: moveToLeft .7s ease-in-out both; + animation: moveToLeft .7s ease-in-out both; +} +.pt-page-moveToRightEasing { + -webkit-animation: moveToRight .7s ease-in-out both; + animation: moveToRight .7s ease-in-out both; +} +.pt-page-moveToTopEasing { + -webkit-animation: moveToTop .7s ease-in-out both; + animation: moveToTop .7s ease-in-out both; +} +.pt-page-moveToBottomEasing { + -webkit-animation: moveToBottom .7s ease-in-out both; + animation: moveToBottom .7s ease-in-out both; +} + +/********************************* keyframes **************************************/ + +/* move from / to */ + +@-webkit-keyframes moveToLeft { + from { } + to { -webkit-transform: translateX(-100%); } +} +@keyframes moveToLeft { + from { } + to { -webkit-transform: translateX(-100%); transform: translateX(-100%); } +} + +@-webkit-keyframes moveFromLeft { + from { -webkit-transform: translateX(-100%); } +} +@keyframes moveFromLeft { + from { -webkit-transform: translateX(-100%); transform: translateX(-100%); } +} + +@-webkit-keyframes moveToRight { + from { } + to { -webkit-transform: translateX(100%); } +} +@keyframes moveToRight { + from { } + to { -webkit-transform: translateX(100%); transform: translateX(100%); } +} + +@-webkit-keyframes moveFromRight { + from { -webkit-transform: translateX(100%); } +} +@keyframes moveFromRight { + from { -webkit-transform: translateX(100%); transform: translateX(100%); } +} + +@-webkit-keyframes moveToTop { + from { } + to { -webkit-transform: translateY(-100%); } +} +@keyframes moveToTop { + from { } + to { -webkit-transform: translateY(-100%); transform: translateY(-100%); } +} + +@-webkit-keyframes moveFromTop { + from { -webkit-transform: translateY(-100%); } +} +@keyframes moveFromTop { + from { -webkit-transform: translateY(-100%); transform: translateY(-100%); } +} + +@-webkit-keyframes moveToBottom { + from { } + to { -webkit-transform: translateY(100%); } +} +@keyframes moveToBottom { + from { } + to { -webkit-transform: translateY(100%); transform: translateY(100%); } +} + +@-webkit-keyframes moveFromBottom { + from { -webkit-transform: translateY(100%); } +} +@keyframes moveFromBottom { + from { -webkit-transform: translateY(100%); transform: translateY(100%); } +} + +/* fade */ + +@-webkit-keyframes fade { + from { } + to { opacity: 0.3; } +} +@keyframes fade { + from { } + to { opacity: 0.3; } +} + +/* move from / to and fade */ + +@-webkit-keyframes moveToLeftFade { + from { } + to { opacity: 0.3; -webkit-transform: translateX(-100%); } +} +@keyframes moveToLeftFade { + from { } + to { opacity: 0.3; -webkit-transform: translateX(-100%); transform: translateX(-100%); } +} + +@-webkit-keyframes moveFromLeftFade { + from { opacity: 0.3; -webkit-transform: translateX(-100%); } +} +@keyframes moveFromLeftFade { + from { opacity: 0.3; -webkit-transform: translateX(-100%); transform: translateX(-100%); } +} + +@-webkit-keyframes moveToRightFade { + from { } + to { opacity: 0.3; -webkit-transform: translateX(100%); } +} +@keyframes moveToRightFade { + from { } + to { opacity: 0.3; -webkit-transform: translateX(100%); transform: translateX(100%); } +} + +@-webkit-keyframes moveFromRightFade { + from { opacity: 0.3; -webkit-transform: translateX(100%); } +} +@keyframes moveFromRightFade { + from { opacity: 0.3; -webkit-transform: translateX(100%); transform: translateX(100%); } +} + +@-webkit-keyframes moveToTopFade { + from { } + to { opacity: 0.3; -webkit-transform: translateY(-100%); } +} +@keyframes moveToTopFade { + from { } + to { opacity: 0.3; -webkit-transform: translateY(-100%); transform: translateY(-100%); } +} + +@-webkit-keyframes moveFromTopFade { + from { opacity: 0.3; -webkit-transform: translateY(-100%); } +} +@keyframes moveFromTopFade { + from { opacity: 0.3; -webkit-transform: translateY(-100%); transform: translateY(-100%); } +} + +@-webkit-keyframes moveToBottomFade { + from { } + to { opacity: 0.3; -webkit-transform: translateY(100%); } +} +@keyframes moveToBottomFade { + from { } + to { opacity: 0.3; -webkit-transform: translateY(100%); transform: translateY(100%); } +} + +@-webkit-keyframes moveFromBottomFade { + from { opacity: 0.3; -webkit-transform: translateY(100%); } +} +@keyframes moveFromBottomFade { + from { opacity: 0.3; -webkit-transform: translateY(100%); transform: translateY(100%); } +} + +/* scale and fade */ + +.pt-page-scaleDown { + -webkit-animation: scaleDown .7s ease both; + animation: scaleDown .7s ease both; +} + +.pt-page-scaleUp { + -webkit-animation: scaleUp .7s ease both; + animation: scaleUp .7s ease both; +} + +.pt-page-scaleUpDown { + -webkit-animation: scaleUpDown .5s ease both; + animation: scaleUpDown .5s ease both; +} + +.pt-page-scaleDownUp { + -webkit-animation: scaleDownUp .5s ease both; + animation: scaleDownUp .5s ease both; +} + +.pt-page-scaleDownCenter { + -webkit-animation: scaleDownCenter .4s ease-in both; + animation: scaleDownCenter .4s ease-in both; +} + +.pt-page-scaleUpCenter { + -webkit-animation: scaleUpCenter .4s ease-out both; + animation: scaleUpCenter .4s ease-out both; +} + +/********************************* keyframes **************************************/ + +/* scale and fade */ + +@-webkit-keyframes scaleDown { + from { } + to { opacity: 0; -webkit-transform: scale(.8); } +} +@keyframes scaleDown { + from { } + to { opacity: 0; -webkit-transform: scale(.8); transform: scale(.8); } +} + +@-webkit-keyframes scaleUp { + from { opacity: 0; -webkit-transform: scale(.8); } +} +@keyframes scaleUp { + from { opacity: 0; -webkit-transform: scale(.8); transform: scale(.8); } +} + +@-webkit-keyframes scaleUpDown { + from { opacity: 0; -webkit-transform: scale(1.2); } +} +@keyframes scaleUpDown { + from { opacity: 0; -webkit-transform: scale(1.2); transform: scale(1.2); } +} + +@-webkit-keyframes scaleDownUp { + from { } + to { opacity: 0; -webkit-transform: scale(1.2); } +} +@keyframes scaleDownUp { + from { } + to { opacity: 0; -webkit-transform: scale(1.2); transform: scale(1.2); } +} + +@-webkit-keyframes scaleDownCenter { + from { } + to { opacity: 0; -webkit-transform: scale(.7); } +} +@keyframes scaleDownCenter { + from { } + to { opacity: 0; -webkit-transform: scale(.7); transform: scale(.7); } +} + +@-webkit-keyframes scaleUpCenter { + from { opacity: 0; -webkit-transform: scale(.7); } +} +@keyframes scaleUpCenter { + from { opacity: 0; -webkit-transform: scale(.7); transform: scale(.7); } +} + +/* rotate sides first and scale */ + +.pt-page-rotateRightSideFirst { + -webkit-transform-origin: 0% 50%; + transform-origin: 0% 50%; + -webkit-animation: rotateRightSideFirst .8s both ease-in; + animation: rotateRightSideFirst .8s both ease-in; +} +.pt-page-rotateLeftSideFirst { + -webkit-transform-origin: 100% 50%; + transform-origin: 100% 50%; + -webkit-animation: rotateLeftSideFirst .8s both ease-in; + animation: rotateLeftSideFirst .8s both ease-in; +} +.pt-page-rotateTopSideFirst { + -webkit-transform-origin: 50% 100%; + transform-origin: 50% 100%; + -webkit-animation: rotateTopSideFirst .8s both ease-in; + animation: rotateTopSideFirst .8s both ease-in; +} +.pt-page-rotateBottomSideFirst { + -webkit-transform-origin: 50% 0%; + transform-origin: 50% 0%; + -webkit-animation: rotateBottomSideFirst .8s both ease-in; + animation: rotateBottomSideFirst .8s both ease-in; +} + +/* flip */ + +.pt-page-flipOutRight { + -webkit-transform-origin: 50% 50%; + transform-origin: 50% 50%; + -webkit-animation: flipOutRight .5s both ease-in; + animation: flipOutRight .5s both ease-in; +} +.pt-page-flipInLeft { + -webkit-transform-origin: 50% 50%; + transform-origin: 50% 50%; + -webkit-animation: flipInLeft .5s both ease-out; + animation: flipInLeft .5s both ease-out; +} +.pt-page-flipOutLeft { + -webkit-transform-origin: 50% 50%; + transform-origin: 50% 50%; + -webkit-animation: flipOutLeft .5s both ease-in; + animation: flipOutLeft .5s both ease-in; +} +.pt-page-flipInRight { + -webkit-transform-origin: 50% 50%; + transform-origin: 50% 50%; + -webkit-animation: flipInRight .5s both ease-out; + animation: flipInRight .5s both ease-out; +} +.pt-page-flipOutTop { + -webkit-transform-origin: 50% 50%; + transform-origin: 50% 50%; + -webkit-animation: flipOutTop .5s both ease-in; + animation: flipOutTop .5s both ease-in; +} +.pt-page-flipInBottom { + -webkit-transform-origin: 50% 50%; + transform-origin: 50% 50%; + -webkit-animation: flipInBottom .5s both ease-out; + animation: flipInBottom .5s both ease-out; +} +.pt-page-flipOutBottom { + -webkit-transform-origin: 50% 50%; + transform-origin: 50% 50%; + -webkit-animation: flipOutBottom .5s both ease-in; + animation: flipOutBottom .5s both ease-in; +} +.pt-page-flipInTop { + -webkit-transform-origin: 50% 50%; + transform-origin: 50% 50%; + -webkit-animation: flipInTop .5s both ease-out; + animation: flipInTop .5s both ease-out; +} + +/* rotate fall */ + +.pt-page-rotateFall { + -webkit-transform-origin: 0% 0%; + transform-origin: 0% 0%; + -webkit-animation: rotateFall 1s both ease-in; + animation: rotateFall 1s both ease-in; +} + +/* rotate newspaper */ +.pt-page-rotateOutNewspaper { + -webkit-transform-origin: 50% 50%; + transform-origin: 50% 50%; + -webkit-animation: rotateOutNewspaper .5s both ease-in; + animation: rotateOutNewspaper .5s both ease-in; +} +.pt-page-rotateInNewspaper { + -webkit-transform-origin: 50% 50%; + transform-origin: 50% 50%; + -webkit-animation: rotateInNewspaper .5s both ease-out; + animation: rotateInNewspaper .5s both ease-out; +} + +/* push */ +.pt-page-rotatePushLeft { + -webkit-transform-origin: 0% 50%; + transform-origin: 0% 50%; + -webkit-animation: rotatePushLeft .8s both ease; + animation: rotatePushLeft .8s both ease; +} +.pt-page-rotatePushRight { + -webkit-transform-origin: 100% 50%; + transform-origin: 100% 50%; + -webkit-animation: rotatePushRight .8s both ease; + animation: rotatePushRight .8s both ease; +} +.pt-page-rotatePushTop { + -webkit-transform-origin: 50% 0%; + transform-origin: 50% 0%; + -webkit-animation: rotatePushTop .8s both ease; + animation: rotatePushTop .8s both ease; +} +.pt-page-rotatePushBottom { + -webkit-transform-origin: 50% 100%; + transform-origin: 50% 100%; + -webkit-animation: rotatePushBottom .8s both ease; + animation: rotatePushBottom .8s both ease; +} + +/* pull */ +.pt-page-rotatePullRight { + -webkit-transform-origin: 100% 50%; + transform-origin: 100% 50%; + -webkit-animation: rotatePullRight .5s both ease; + animation: rotatePullRight .5s both ease; +} +.pt-page-rotatePullLeft { + -webkit-transform-origin: 0% 50%; + transform-origin: 0% 50%; + -webkit-animation: rotatePullLeft .5s both ease; + animation: rotatePullLeft .5s both ease; +} +.pt-page-rotatePullTop { + -webkit-transform-origin: 50% 0%; + transform-origin: 50% 0%; + -webkit-animation: rotatePullTop .5s both ease; + animation: rotatePullTop .5s both ease; +} +.pt-page-rotatePullBottom { + -webkit-transform-origin: 50% 100%; + transform-origin: 50% 100%; + -webkit-animation: rotatePullBottom .5s both ease; + animation: rotatePullBottom .5s both ease; +} + +/* fold */ +.pt-page-rotateFoldRight { + -webkit-transform-origin: 0% 50%; + transform-origin: 0% 50%; + -webkit-animation: rotateFoldRight .7s both ease; + animation: rotateFoldRight .7s both ease; +} +.pt-page-rotateFoldLeft { + -webkit-transform-origin: 100% 50%; + transform-origin: 100% 50%; + -webkit-animation: rotateFoldLeft .7s both ease; + animation: rotateFoldLeft .7s both ease; +} +.pt-page-rotateFoldTop { + -webkit-transform-origin: 50% 100%; + transform-origin: 50% 100%; + -webkit-animation: rotateFoldTop .7s both ease; + animation: rotateFoldTop .7s both ease; +} +.pt-page-rotateFoldBottom { + -webkit-transform-origin: 50% 0%; + transform-origin: 50% 0%; + -webkit-animation: rotateFoldBottom .7s both ease; + animation: rotateFoldBottom .7s both ease; +} + +/* unfold */ +.pt-page-rotateUnfoldLeft { + -webkit-transform-origin: 100% 50%; + transform-origin: 100% 50%; + -webkit-animation: rotateUnfoldLeft .7s both ease; + animation: rotateUnfoldLeft .7s both ease; +} +.pt-page-rotateUnfoldRight { + -webkit-transform-origin: 0% 50%; + transform-origin: 0% 50%; + -webkit-animation: rotateUnfoldRight .7s both ease; + animation: rotateUnfoldRight .7s both ease; +} +.pt-page-rotateUnfoldTop { + -webkit-transform-origin: 50% 100%; + transform-origin: 50% 100%; + -webkit-animation: rotateUnfoldTop .7s both ease; + animation: rotateUnfoldTop .7s both ease; +} +.pt-page-rotateUnfoldBottom { + -webkit-transform-origin: 50% 0%; + transform-origin: 50% 0%; + -webkit-animation: rotateUnfoldBottom .7s both ease; + animation: rotateUnfoldBottom .7s both ease; +} + +/* room walls */ +.pt-page-rotateRoomLeftOut { + -webkit-transform-origin: 100% 50%; + transform-origin: 100% 50%; + -webkit-animation: rotateRoomLeftOut .8s both ease; + animation: rotateRoomLeftOut .8s both ease; +} +.pt-page-rotateRoomLeftIn { + -webkit-transform-origin: 0% 50%; + transform-origin: 0% 50%; + -webkit-animation: rotateRoomLeftIn .8s both ease; + animation: rotateRoomLeftIn .8s both ease; +} +.pt-page-rotateRoomRightOut { + -webkit-transform-origin: 0% 50%; + transform-origin: 0% 50%; + -webkit-animation: rotateRoomRightOut .8s both ease; + animation: rotateRoomRightOut .8s both ease; +} +.pt-page-rotateRoomRightIn { + -webkit-transform-origin: 100% 50%; + transform-origin: 100% 50%; + -webkit-animation: rotateRoomRightIn .8s both ease; + animation: rotateRoomRightIn .8s both ease; +} +.pt-page-rotateRoomTopOut { + -webkit-transform-origin: 50% 100%; + transform-origin: 50% 100%; + -webkit-animation: rotateRoomTopOut .8s both ease; + animation: rotateRoomTopOut .8s both ease; +} +.pt-page-rotateRoomTopIn { + -webkit-transform-origin: 50% 0%; + transform-origin: 50% 0%; + -webkit-animation: rotateRoomTopIn .8s both ease; + animation: rotateRoomTopIn .8s both ease; +} +.pt-page-rotateRoomBottomOut { + -webkit-transform-origin: 50% 0%; + transform-origin: 50% 0%; + -webkit-animation: rotateRoomBottomOut .8s both ease; + animation: rotateRoomBottomOut .8s both ease; +} +.pt-page-rotateRoomBottomIn { + -webkit-transform-origin: 50% 100%; + transform-origin: 50% 100%; + -webkit-animation: rotateRoomBottomIn .8s both ease; + animation: rotateRoomBottomIn .8s both ease; +} + +/* cube */ +.pt-page-rotateCubeLeftOut { + -webkit-transform-origin: 100% 50%; + transform-origin: 100% 50%; + -webkit-animation: rotateCubeLeftOut .6s both ease-in; + animation: rotateCubeLeftOut .6s both ease-in; +} +.pt-page-rotateCubeLeftIn { + -webkit-transform-origin: 0% 50%; + transform-origin: 0% 50%; + -webkit-animation: rotateCubeLeftIn .6s both ease-in; + animation: rotateCubeLeftIn .6s both ease-in; +} +.pt-page-rotateCubeRightOut { + -webkit-transform-origin: 0% 50%; + transform-origin: 0% 50%; + -webkit-animation: rotateCubeRightOut .6s both ease-in; + animation: rotateCubeRightOut .6s both ease-in; +} +.pt-page-rotateCubeRightIn { + -webkit-transform-origin: 100% 50%; + transform-origin: 100% 50%; + -webkit-animation: rotateCubeRightIn .6s both ease-in; + animation: rotateCubeRightIn .6s both ease-in; +} +.pt-page-rotateCubeTopOut { + -webkit-transform-origin: 50% 100%; + transform-origin: 50% 100%; + -webkit-animation: rotateCubeTopOut .6s both ease-in; + animation: rotateCubeTopOut .6s both ease-in; +} +.pt-page-rotateCubeTopIn { + -webkit-transform-origin: 50% 0%; + transform-origin: 50% 0%; + -webkit-animation: rotateCubeTopIn .6s both ease-in; + animation: rotateCubeTopIn .6s both ease-in; +} +.pt-page-rotateCubeBottomOut { + -webkit-transform-origin: 50% 0%; + transform-origin: 50% 0%; + -webkit-animation: rotateCubeBottomOut .6s both ease-in; + animation: rotateCubeBottomOut .6s both ease-in; +} +.pt-page-rotateCubeBottomIn { + -webkit-transform-origin: 50% 100%; + transform-origin: 50% 100%; + -webkit-animation: rotateCubeBottomIn .6s both ease-in; + animation: rotateCubeBottomIn .6s both ease-in; +} + +/* carousel */ +.pt-page-rotateCarouselLeftOut { + -webkit-transform-origin: 100% 50%; + transform-origin: 100% 50%; + -webkit-animation: rotateCarouselLeftOut .8s both ease; + animation: rotateCarouselLeftOut .8s both ease; +} +.pt-page-rotateCarouselLeftIn { + -webkit-transform-origin: 0% 50%; + transform-origin: 0% 50%; + -webkit-animation: rotateCarouselLeftIn .8s both ease; + animation: rotateCarouselLeftIn .8s both ease; +} +.pt-page-rotateCarouselRightOut { + -webkit-transform-origin: 0% 50%; + transform-origin: 0% 50%; + -webkit-animation: rotateCarouselRightOut .8s both ease; + animation: rotateCarouselRightOut .8s both ease; +} +.pt-page-rotateCarouselRightIn { + -webkit-transform-origin: 100% 50%; + transform-origin: 100% 50%; + -webkit-animation: rotateCarouselRightIn .8s both ease; + animation: rotateCarouselRightIn .8s both ease; +} +.pt-page-rotateCarouselTopOut { + -webkit-transform-origin: 50% 100%; + transform-origin: 50% 100%; + -webkit-animation: rotateCarouselTopOut .8s both ease; + animation: rotateCarouselTopOut .8s both ease; +} +.pt-page-rotateCarouselTopIn { + -webkit-transform-origin: 50% 0%; + transform-origin: 50% 0%; + -webkit-animation: rotateCarouselTopIn .8s both ease; + animation: rotateCarouselTopIn .8s both ease; +} +.pt-page-rotateCarouselBottomOut { + -webkit-transform-origin: 50% 0%; + transform-origin: 50% 0%; + -webkit-animation: rotateCarouselBottomOut .8s both ease; + animation: rotateCarouselBottomOut .8s both ease; +} +.pt-page-rotateCarouselBottomIn { + -webkit-transform-origin: 50% 100%; + transform-origin: 50% 100%; + -webkit-animation: rotateCarouselBottomIn .8s both ease; + animation: rotateCarouselBottomIn .8s both ease; +} + +/* sides */ +.pt-page-rotateSidesOut { + -webkit-transform-origin: -50% 50%; + transform-origin: -50% 50%; + -webkit-animation: rotateSidesOut .5s both ease-in; + animation: rotateSidesOut .5s both ease-in; +} +.pt-page-rotateSidesIn { + -webkit-transform-origin: 150% 50%; + transform-origin: 150% 50%; + -webkit-animation: rotateSidesIn .5s both ease-out; + animation: rotateSidesIn .5s both ease-out; +} + +/* slide */ +.pt-page-rotateSlideOut { + -webkit-animation: rotateSlideOut 1s both ease; + animation: rotateSlideOut 1s both ease; +} +.pt-page-rotateSlideIn { + -webkit-animation: rotateSlideIn 1s both ease; + animation: rotateSlideIn 1s both ease; +} + +/********************************* keyframes **************************************/ + +/* rotate sides first and scale */ + +@-webkit-keyframes rotateRightSideFirst { + 0% { } + 40% { -webkit-transform: rotateY(15deg); opacity: .8; -webkit-animation-timing-function: ease-out; } + 100% { -webkit-transform: scale(0.8) translateZ(-200px); opacity:0; } +} +@keyframes rotateRightSideFirst { + 0% { } + 40% { -webkit-transform: rotateY(15deg); transform: rotateY(15deg); opacity: .8; -webkit-animation-timing-function: ease-out; animation-timing-function: ease-out; } + 100% { -webkit-transform: scale(0.8) translateZ(-200px); transform: scale(0.8) translateZ(-200px); opacity:0; } +} + +@-webkit-keyframes rotateLeftSideFirst { + 0% { } + 40% { -webkit-transform: rotateY(-15deg); opacity: .8; -webkit-animation-timing-function: ease-out; } + 100% { -webkit-transform: scale(0.8) translateZ(-200px); opacity:0; } +} +@keyframes rotateLeftSideFirst { + 0% { } + 40% { -webkit-transform: rotateY(-15deg); transform: rotateY(-15deg); opacity: .8; -webkit-animation-timing-function: ease-out; animation-timing-function: ease-out; } + 100% { -webkit-transform: scale(0.8) translateZ(-200px); transform: scale(0.8) translateZ(-200px); opacity:0; } +} + +@-webkit-keyframes rotateTopSideFirst { + 0% { } + 40% { -webkit-transform: rotateX(15deg); opacity: .8; -webkit-animation-timing-function: ease-out; } + 100% { -webkit-transform: scale(0.8) translateZ(-200px); opacity:0; } +} +@keyframes rotateTopSideFirst { + 0% { } + 40% { -webkit-transform: rotateX(15deg); transform: rotateX(15deg); opacity: .8; -webkit-animation-timing-function: ease-out; animation-timing-function: ease-out; } + 100% { -webkit-transform: scale(0.8) translateZ(-200px); transform: scale(0.8) translateZ(-200px); opacity:0; } +} + +@-webkit-keyframes rotateBottomSideFirst { + 0% { } + 40% { -webkit-transform: rotateX(-15deg); opacity: .8; -webkit-animation-timing-function: ease-out; } + 100% { -webkit-transform: scale(0.8) translateZ(-200px); opacity:0; } +} +@keyframes rotateBottomSideFirst { + 0% { } + 40% { -webkit-transform: rotateX(-15deg); transform: rotateX(-15deg); opacity: .8; -webkit-animation-timing-function: ease-out; animation-timing-function: ease-out; } + 100% { -webkit-transform: scale(0.8) translateZ(-200px); transform: scale(0.8) translateZ(-200px); opacity:0; } +} + +/* flip */ + +@-webkit-keyframes flipOutRight { + from { } + to { -webkit-transform: translateZ(-1000px) rotateY(90deg); opacity: 0.2; } +} +@keyframes flipOutRight { + from { } + to { -webkit-transform: translateZ(-1000px) rotateY(90deg); transform: translateZ(-1000px) rotateY(90deg); opacity: 0.2; } +} + +@-webkit-keyframes flipInLeft { + from { -webkit-transform: translateZ(-1000px) rotateY(-90deg); opacity: 0.2; } +} +@keyframes flipInLeft { + from { -webkit-transform: translateZ(-1000px) rotateY(-90deg); transform: translateZ(-1000px) rotateY(-90deg); opacity: 0.2; } +} + +@-webkit-keyframes flipOutLeft { + from { } + to { -webkit-transform: translateZ(-1000px) rotateY(-90deg); opacity: 0.2; } +} +@keyframes flipOutLeft { + from { } + to { -webkit-transform: translateZ(-1000px) rotateY(-90deg); transform: translateZ(-1000px) rotateY(-90deg); opacity: 0.2; } +} + +@-webkit-keyframes flipInRight { + from { -webkit-transform: translateZ(-1000px) rotateY(90deg); opacity: 0.2; } +} +@keyframes flipInRight { + from { -webkit-transform: translateZ(-1000px) rotateY(90deg); transform: translateZ(-1000px) rotateY(90deg); opacity: 0.2; } +} + +@-webkit-keyframes flipOutTop { + from { } + to { -webkit-transform: translateZ(-1000px) rotateX(90deg); opacity: 0.2; } +} +@keyframes flipOutTop { + from { } + to { -webkit-transform: translateZ(-1000px) rotateX(90deg); transform: translateZ(-1000px) rotateX(90deg); opacity: 0.2; } +} + +@-webkit-keyframes flipInBottom { + from { -webkit-transform: translateZ(-1000px) rotateX(-90deg); opacity: 0.2; } +} +@keyframes flipInBottom { + from { -webkit-transform: translateZ(-1000px) rotateX(-90deg); transform: translateZ(-1000px) rotateX(-90deg); opacity: 0.2; } +} + +@-webkit-keyframes flipOutBottom { + from { } + to { -webkit-transform: translateZ(-1000px) rotateX(-90deg); opacity: 0.2; } +} +@keyframes flipOutBottom { + from { } + to { -webkit-transform: translateZ(-1000px) rotateX(-90deg); transform: translateZ(-1000px) rotateX(-90deg); opacity: 0.2; } +} + +@-webkit-keyframes flipInTop { + from { -webkit-transform: translateZ(-1000px) rotateX(90deg); opacity: 0.2; } +} +@keyframes flipInTop { + from { -webkit-transform: translateZ(-1000px) rotateX(90deg); transform: translateZ(-1000px) rotateX(90deg); opacity: 0.2; } +} + +/* fall */ + +@-webkit-keyframes rotateFall { + 0% { -webkit-transform: rotateZ(0deg); } + 20% { -webkit-transform: rotateZ(10deg); -webkit-animation-timing-function: ease-out; } + 40% { -webkit-transform: rotateZ(17deg); } + 60% { -webkit-transform: rotateZ(16deg); } + 100% { -webkit-transform: translateY(100%) rotateZ(17deg); } +} +@keyframes rotateFall { + 0% { -webkit-transform: rotateZ(0deg); transform: rotateZ(0deg); } + 20% { -webkit-transform: rotateZ(10deg); transform: rotateZ(10deg); -webkit-animation-timing-function: ease-out; animation-timing-function: ease-out; } + 40% { -webkit-transform: rotateZ(17deg); transform: rotateZ(17deg); } + 60% { -webkit-transform: rotateZ(16deg); transform: rotateZ(16deg); } + 100% { -webkit-transform: translateY(100%) rotateZ(17deg); transform: translateY(100%) rotateZ(17deg); } +} + +/* newspaper */ + +@-webkit-keyframes rotateOutNewspaper { + from { } + to { -webkit-transform: translateZ(-3000px) rotateZ(360deg); opacity: 0; } +} +@keyframes rotateOutNewspaper { + from { } + to { -webkit-transform: translateZ(-3000px) rotateZ(360deg); transform: translateZ(-3000px) rotateZ(360deg); opacity: 0; } +} + +@-webkit-keyframes rotateInNewspaper { + from { -webkit-transform: translateZ(-3000px) rotateZ(-360deg); opacity: 0; } +} +@keyframes rotateInNewspaper { + from { -webkit-transform: translateZ(-3000px) rotateZ(-360deg); transform: translateZ(-3000px) rotateZ(-360deg); opacity: 0; } +} + +/* push */ + +@-webkit-keyframes rotatePushLeft { + from { } + to { opacity: 0; -webkit-transform: rotateY(90deg); } +} +@keyframes rotatePushLeft { + from { } + to { opacity: 0; -webkit-transform: rotateY(90deg); transform: rotateY(90deg); } +} + +@-webkit-keyframes rotatePushRight { + from { } + to { opacity: 0; -webkit-transform: rotateY(-90deg); } +} +@keyframes rotatePushRight { + from { } + to { opacity: 0; -webkit-transform: rotateY(-90deg); transform: rotateY(-90deg); } +} + +@-webkit-keyframes rotatePushTop { + from { } + to { opacity: 0; -webkit-transform: rotateX(-90deg); } +} +@keyframes rotatePushTop { + from { } + to { opacity: 0; -webkit-transform: rotateX(-90deg); transform: rotateX(-90deg); } +} + +@-webkit-keyframes rotatePushBottom { + from { } + to { opacity: 0; -webkit-transform: rotateX(90deg); } +} +@keyframes rotatePushBottom { + from { } + to { opacity: 0; -webkit-transform: rotateX(90deg); transform: rotateX(90deg); } +} + +/* pull */ + +@-webkit-keyframes rotatePullRight { + from { opacity: 0; -webkit-transform: rotateY(-90deg); } +} +@keyframes rotatePullRight { + from { opacity: 0; -webkit-transform: rotateY(-90deg); transform: rotateY(-90deg); } +} + +@-webkit-keyframes rotatePullLeft { + from { opacity: 0; -webkit-transform: rotateY(90deg); } +} +@keyframes rotatePullLeft { + from { opacity: 0; -webkit-transform: rotateY(90deg); transform: rotateY(90deg); } +} + +@-webkit-keyframes rotatePullTop { + from { opacity: 0; -webkit-transform: rotateX(-90deg); } +} +@keyframes rotatePullTop { + from { opacity: 0; -webkit-transform: rotateX(-90deg); transform: rotateX(-90deg); } +} + +@-webkit-keyframes rotatePullBottom { + from { opacity: 0; -webkit-transform: rotateX(90deg); } +} +@keyframes rotatePullBottom { + from { opacity: 0; -webkit-transform: rotateX(90deg); transform: rotateX(90deg); } +} + +/* fold */ + +@-webkit-keyframes rotateFoldRight { + from { } + to { opacity: 0; -webkit-transform: translateX(100%) rotateY(90deg); } +} +@keyframes rotateFoldRight { + from { } + to { opacity: 0; -webkit-transform: translateX(100%) rotateY(90deg); transform: translateX(100%) rotateY(90deg); } +} + +@-webkit-keyframes rotateFoldLeft { + from { } + to { opacity: 0; -webkit-transform: translateX(-100%) rotateY(-90deg); } +} +@keyframes rotateFoldLeft { + from { } + to { opacity: 0; -webkit-transform: translateX(-100%) rotateY(-90deg); transform: translateX(-100%) rotateY(-90deg); } +} + +@-webkit-keyframes rotateFoldTop { + from { } + to { opacity: 0; -webkit-transform: translateY(-100%) rotateX(90deg); } +} +@keyframes rotateFoldTop { + from { } + to { opacity: 0; -webkit-transform: translateY(-100%) rotateX(90deg); transform: translateY(-100%) rotateX(90deg); } +} + +@-webkit-keyframes rotateFoldBottom { + from { } + to { opacity: 0; -webkit-transform: translateY(100%) rotateX(-90deg); } +} +@keyframes rotateFoldBottom { + from { } + to { opacity: 0; -webkit-transform: translateY(100%) rotateX(-90deg); transform: translateY(100%) rotateX(-90deg); } +} + +/* unfold */ + +@-webkit-keyframes rotateUnfoldLeft { + from { opacity: 0; -webkit-transform: translateX(-100%) rotateY(-90deg); } +} +@keyframes rotateUnfoldLeft { + from { opacity: 0; -webkit-transform: translateX(-100%) rotateY(-90deg); transform: translateX(-100%) rotateY(-90deg); } +} + +@-webkit-keyframes rotateUnfoldRight { + from { opacity: 0; -webkit-transform: translateX(100%) rotateY(90deg); } +} +@keyframes rotateUnfoldRight { + from { opacity: 0; -webkit-transform: translateX(100%) rotateY(90deg); transform: translateX(100%) rotateY(90deg); } +} + +@-webkit-keyframes rotateUnfoldTop { + from { opacity: 0; -webkit-transform: translateY(-100%) rotateX(90deg); } +} +@keyframes rotateUnfoldTop { + from { opacity: 0; -webkit-transform: translateY(-100%) rotateX(90deg); transform: translateY(-100%) rotateX(90deg); } +} + +@-webkit-keyframes rotateUnfoldBottom { + from { opacity: 0; -webkit-transform: translateY(100%) rotateX(-90deg); } +} +@keyframes rotateUnfoldBottom { + from { opacity: 0; -webkit-transform: translateY(100%) rotateX(-90deg); transform: translateY(100%) rotateX(-90deg); } +} + +/* room walls */ + +@-webkit-keyframes rotateRoomLeftOut { + from { } + to { opacity: .3; -webkit-transform: translateX(-100%) rotateY(90deg); } +} +@keyframes rotateRoomLeftOut { + from { } + to { opacity: .3; -webkit-transform: translateX(-100%) rotateY(90deg); transform: translateX(-100%) rotateY(90deg); } +} + +@-webkit-keyframes rotateRoomLeftIn { + from { opacity: .3; -webkit-transform: translateX(100%) rotateY(-90deg); } +} +@keyframes rotateRoomLeftIn { + from { opacity: .3; -webkit-transform: translateX(100%) rotateY(-90deg); transform: translateX(100%) rotateY(-90deg); } +} + +@-webkit-keyframes rotateRoomRightOut { + from { } + to { opacity: .3; -webkit-transform: translateX(100%) rotateY(-90deg); } +} +@keyframes rotateRoomRightOut { + from { } + to { opacity: .3; -webkit-transform: translateX(100%) rotateY(-90deg); transform: translateX(100%) rotateY(-90deg); } +} + +@-webkit-keyframes rotateRoomRightIn { + from { opacity: .3; -webkit-transform: translateX(-100%) rotateY(90deg); } +} +@keyframes rotateRoomRightIn { + from { opacity: .3; -webkit-transform: translateX(-100%) rotateY(90deg); transform: translateX(-100%) rotateY(90deg); } +} + +@-webkit-keyframes rotateRoomTopOut { + from { } + to { opacity: .3; -webkit-transform: translateY(-100%) rotateX(-90deg); } +} +@keyframes rotateRoomTopOut { + from { } + to { opacity: .3; -webkit-transform: translateY(-100%) rotateX(-90deg); transform: translateY(-100%) rotateX(-90deg); } +} + +@-webkit-keyframes rotateRoomTopIn { + from { opacity: .3; -webkit-transform: translateY(100%) rotateX(90deg); } +} +@keyframes rotateRoomTopIn { + from { opacity: .3; -webkit-transform: translateY(100%) rotateX(90deg); transform: translateY(100%) rotateX(90deg); } +} + +@-webkit-keyframes rotateRoomBottomOut { + from { } + to { opacity: .3; -webkit-transform: translateY(100%) rotateX(90deg); } +} +@keyframes rotateRoomBottomOut { + from { } + to { opacity: .3; -webkit-transform: translateY(100%) rotateX(90deg); transform: translateY(100%) rotateX(90deg); } +} + +@-webkit-keyframes rotateRoomBottomIn { + from { opacity: .3; -webkit-transform: translateY(-100%) rotateX(-90deg); } +} +@keyframes rotateRoomBottomIn { + from { opacity: .3; -webkit-transform: translateY(-100%) rotateX(-90deg); transform: translateY(-100%) rotateX(-90deg); } +} + +/* cube */ + +@-webkit-keyframes rotateCubeLeftOut { + 0% { } + 50% { -webkit-animation-timing-function: ease-out; -webkit-transform: translateX(-50%) translateZ(-200px) rotateY(-45deg); } + 100% { opacity: .3; -webkit-transform: translateX(-100%) rotateY(-90deg); } +} +@keyframes rotateCubeLeftOut { + 0% { } + 50% { -webkit-animation-timing-function: ease-out; animation-timing-function: ease-out; -webkit-transform: translateX(-50%) translateZ(-200px) rotateY(-45deg); transform: translateX(-50%) translateZ(-200px) rotateY(-45deg); } + 100% { opacity: .3; -webkit-transform: translateX(-100%) rotateY(-90deg); transform: translateX(-100%) rotateY(-90deg); } +} + +@-webkit-keyframes rotateCubeLeftIn { + 0% { opacity: .3; -webkit-transform: translateX(100%) rotateY(90deg); } + 50% { -webkit-animation-timing-function: ease-out; -webkit-transform: translateX(50%) translateZ(-200px) rotateY(45deg); } +} +@keyframes rotateCubeLeftIn { + 0% { opacity: .3; -webkit-transform: translateX(100%) rotateY(90deg); transform: translateX(100%) rotateY(90deg); } + 50% { -webkit-animation-timing-function: ease-out; animation-timing-function: ease-out; -webkit-transform: translateX(50%) translateZ(-200px) rotateY(45deg); transform: translateX(50%) translateZ(-200px) rotateY(45deg); } +} + +@-webkit-keyframes rotateCubeRightOut { + 0% { } + 50% { -webkit-animation-timing-function: ease-out; -webkit-transform: translateX(50%) translateZ(-200px) rotateY(45deg); } + 100% { opacity: .3; -webkit-transform: translateX(100%) rotateY(90deg); } +} +@keyframes rotateCubeRightOut { + 0% { } + 50% { -webkit-animation-timing-function: ease-out; animation-timing-function: ease-out; -webkit-transform: translateX(50%) translateZ(-200px) rotateY(45deg); transform: translateX(50%) translateZ(-200px) rotateY(45deg); } + 100% { opacity: .3; -webkit-transform: translateX(100%) rotateY(90deg); transform: translateX(100%) rotateY(90deg); } +} + +@-webkit-keyframes rotateCubeRightIn { + 0% { opacity: .3; -webkit-transform: translateX(-100%) rotateY(-90deg); } + 50% { -webkit-animation-timing-function: ease-out; -webkit-transform: translateX(-50%) translateZ(-200px) rotateY(-45deg); } +} +@keyframes rotateCubeRightIn { + 0% { opacity: .3; -webkit-transform: translateX(-100%) rotateY(-90deg); transform: translateX(-100%) rotateY(-90deg); } + 50% { -webkit-animation-timing-function: ease-out; animation-timing-function: ease-out; -webkit-transform: translateX(-50%) translateZ(-200px) rotateY(-45deg); transform: translateX(-50%) translateZ(-200px) rotateY(-45deg); } +} + +@-webkit-keyframes rotateCubeTopOut { + 0% { } + 50% { -webkit-animation-timing-function: ease-out; -webkit-transform: translateY(-50%) translateZ(-200px) rotateX(45deg); } + 100% { opacity: .3; -webkit-transform: translateY(-100%) rotateX(90deg); } +} +@keyframes rotateCubeTopOut { + 0% {} + 50% { -webkit-animation-timing-function: ease-out; animation-timing-function: ease-out; -webkit-transform: translateY(-50%) translateZ(-200px) rotateX(45deg); transform: translateY(-50%) translateZ(-200px) rotateX(45deg); } + 100% { opacity: .3; -webkit-transform: translateY(-100%) rotateX(90deg); transform: translateY(-100%) rotateX(90deg); } +} + +@-webkit-keyframes rotateCubeTopIn { + 0% { opacity: .3; -webkit-transform: translateY(100%) rotateX(-90deg); } + 50% { -webkit-animation-timing-function: ease-out; -webkit-transform: translateY(50%) translateZ(-200px) rotateX(-45deg); } +} +@keyframes rotateCubeTopIn { + 0% { opacity: .3; -webkit-transform: translateY(100%) rotateX(-90deg); transform: translateY(100%) rotateX(-90deg); } + 50% { -webkit-animation-timing-function: ease-out; animation-timing-function: ease-out; -webkit-transform: translateY(50%) translateZ(-200px) rotateX(-45deg); transform: translateY(50%) translateZ(-200px) rotateX(-45deg); } +} + +@-webkit-keyframes rotateCubeBottomOut { + 0% { } + 50% { -webkit-animation-timing-function: ease-out; -webkit-transform: translateY(50%) translateZ(-200px) rotateX(-45deg); } + 100% { opacity: .3; -webkit-transform: translateY(100%) rotateX(-90deg); } +} +@keyframes rotateCubeBottomOut { + 0% { } + 50% { -webkit-animation-timing-function: ease-out; animation-timing-function: ease-out; -webkit-transform: translateY(50%) translateZ(-200px) rotateX(-45deg); transform: translateY(50%) translateZ(-200px) rotateX(-45deg); } + 100% { opacity: .3; -webkit-transform: translateY(100%) rotateX(-90deg); transform: translateY(100%) rotateX(-90deg); } +} + +@-webkit-keyframes rotateCubeBottomIn { + 0% { opacity: .3; -webkit-transform: translateY(-100%) rotateX(90deg); } + 50% { -webkit-animation-timing-function: ease-out; -webkit-transform: translateY(-50%) translateZ(-200px) rotateX(45deg); } +} +@keyframes rotateCubeBottomIn { + 0% { opacity: .3; -webkit-transform: translateY(-100%) rotateX(90deg); transform: translateY(-100%) rotateX(90deg); } + 50% { -webkit-animation-timing-function: ease-out; animation-timing-function: ease-out; -webkit-transform: translateY(-50%) translateZ(-200px) rotateX(45deg); transform: translateY(-50%) translateZ(-200px) rotateX(45deg); } +} + +/* carousel */ + +@-webkit-keyframes rotateCarouselLeftOut { + from { } + to { opacity: .3; -webkit-transform: translateX(-150%) scale(.4) rotateY(-65deg); } +} +@keyframes rotateCarouselLeftOut { + from { } + to { opacity: .3; -webkit-transform: translateX(-150%) scale(.4) rotateY(-65deg); transform: translateX(-150%) scale(.4) rotateY(-65deg); } +} + +@-webkit-keyframes rotateCarouselLeftIn { + from { opacity: .3; -webkit-transform: translateX(200%) scale(.4) rotateY(65deg); } +} +@keyframes rotateCarouselLeftIn { + from { opacity: .3; -webkit-transform: translateX(200%) scale(.4) rotateY(65deg); transform: translateX(200%) scale(.4) rotateY(65deg); } +} + +@-webkit-keyframes rotateCarouselRightOut { + from { } + to { opacity: .3; -webkit-transform: translateX(200%) scale(.4) rotateY(65deg); } +} +@keyframes rotateCarouselRightOut { + from { } + to { opacity: .3; -webkit-transform: translateX(200%) scale(.4) rotateY(65deg); transform: translateX(200%) scale(.4) rotateY(65deg); } +} + +@-webkit-keyframes rotateCarouselRightIn { + from { opacity: .3; -webkit-transform: translateX(-200%) scale(.4) rotateY(-65deg); } +} +@keyframes rotateCarouselRightIn { + from { opacity: .3; -webkit-transform: translateX(-200%) scale(.4) rotateY(-65deg); transform: translateX(-200%) scale(.4) rotateY(-65deg); } +} + +@-webkit-keyframes rotateCarouselTopOut { + from { } + to { opacity: .3; -webkit-transform: translateY(-200%) scale(.4) rotateX(65deg); } +} +@keyframes rotateCarouselTopOut { + from { } + to { opacity: .3; -webkit-transform: translateY(-200%) scale(.4) rotateX(65deg); transform: translateY(-200%) scale(.4) rotateX(65deg); } +} + +@-webkit-keyframes rotateCarouselTopIn { + from { opacity: .3; -webkit-transform: translateY(200%) scale(.4) rotateX(-65deg); } +} +@keyframes rotateCarouselTopIn { + from { opacity: .3; -webkit-transform: translateY(200%) scale(.4) rotateX(-65deg); transform: translateY(200%) scale(.4) rotateX(-65deg); } +} + +@-webkit-keyframes rotateCarouselBottomOut { + from { } + to { opacity: .3; -webkit-transform: translateY(200%) scale(.4) rotateX(-65deg); } +} +@keyframes rotateCarouselBottomOut { + from { } + to { opacity: .3; -webkit-transform: translateY(200%) scale(.4) rotateX(-65deg); transform: translateY(200%) scale(.4) rotateX(-65deg); } +} + +@-webkit-keyframes rotateCarouselBottomIn { + from { opacity: .3; -webkit-transform: translateY(-200%) scale(.4) rotateX(65deg); } +} +@keyframes rotateCarouselBottomIn { + from { opacity: .3; -webkit-transform: translateY(-200%) scale(.4) rotateX(65deg); transform: translateY(-200%) scale(.4) rotateX(65deg); } +} + +/* sides */ + +@-webkit-keyframes rotateSidesOut { + from { } + to { opacity: 0; -webkit-transform: translateZ(-500px) rotateY(90deg); } +} +@keyframes rotateSidesOut { + from { } + to { opacity: 0; -webkit-transform: translateZ(-500px) rotateY(90deg); transform: translateZ(-500px) rotateY(90deg); } +} + +@-webkit-keyframes rotateSidesIn { + from { opacity: 0; -webkit-transform: translateZ(-500px) rotateY(-90deg); } +} +@keyframes rotateSidesIn { + from { opacity: 0; -webkit-transform: translateZ(-500px) rotateY(-90deg); transform: translateZ(-500px) rotateY(-90deg); } +} + +/* slide */ + +@-webkit-keyframes rotateSlideOut { + 0% { } + 25% { opacity: .5; -webkit-transform: translateZ(-500px); } + 75% { opacity: .5; -webkit-transform: translateZ(-500px) translateX(-200%); } + 100% { opacity: .5; -webkit-transform: translateZ(-500px) translateX(-200%); } +} +@keyframes rotateSlideOut { + 0% { } + 25% { opacity: .5; -webkit-transform: translateZ(-500px); transform: translateZ(-500px); } + 75% { opacity: .5; -webkit-transform: translateZ(-500px) translateX(-200%); transform: translateZ(-500px) translateX(-200%); } + 100% { opacity: .5; -webkit-transform: translateZ(-500px) translateX(-200%); transform: translateZ(-500px) translateX(-200%); } +} + +@-webkit-keyframes rotateSlideIn { + 0%, 25% { opacity: .5; -webkit-transform: translateZ(-500px) translateX(200%); } + 75% { opacity: .5; -webkit-transform: translateZ(-500px); } + 100% { opacity: 1; -webkit-transform: translateZ(0) translateX(0); } +} +@keyframes rotateSlideIn { + 0%, 25% { opacity: .5; -webkit-transform: translateZ(-500px) translateX(200%); transform: translateZ(-500px) translateX(200%); } + 75% { opacity: .5; -webkit-transform: translateZ(-500px); transform: translateZ(-500px); } + 100% { opacity: 1; -webkit-transform: translateZ(0) translateX(0); transform: translateZ(0) translateX(0); } +} + +/* animation delay classes */ + +.pt-page-delay100 { + -webkit-animation-delay: .1s; + animation-delay: .1s; +} +.pt-page-delay180 { + -webkit-animation-delay: .180s; + animation-delay: .180s; +} +.pt-page-delay200 { + -webkit-animation-delay: .2s; + animation-delay: .2s; +} +.pt-page-delay300 { + -webkit-animation-delay: .3s; + animation-delay: .3s; +} +.pt-page-delay400 { + -webkit-animation-delay: .4s; + animation-delay: .4s; +} +.pt-page-delay500 { + -webkit-animation-delay: .5s; + animation-delay: .5s; +} +.pt-page-delay700 { + -webkit-animation-delay: .7s; + animation-delay: .7s; +} +.pt-page-delay1000 { + -webkit-animation-delay: 1s; + animation-delay: 1s; +} diff --git a/teamapps-client/less/variables.less b/teamapps-client/less/variables.less index dcc136c44..a43965754 100644 --- a/teamapps-client/less/variables.less +++ b/teamapps-client/less/variables.less @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,17 +17,196 @@ * limitations under the License. * =========================LICENSE_END================================== */ -@default-color-transition-time: .3s; -@teamapps-hover-color: rgba(0, 0, 0, 0.05); -@teamapps-active-color: rgba(0, 0, 0, 0.1); +@icon-font-path: "../../node_modules/bootstrap/fonts/"; +@icon-font-name: "glyphicons-halflings-regular"; +@icon-font-svg-id: "glyphicons_halflingsregular"; -@input-border-hover: fadeout(@input-border, 30%, relative); +@ta-color-primary: rgb(51, 122, 183); +@ta-color-success: rgb(92, 184, 92); +@ta-color-info: rgb(91, 192, 222); +@ta-color-warning: rgb(255, 165, 0); +@ta-color-danger: rgb(217, 83, 79); -@flying-box-shadow: 0 3px 8px -1px rgba(0, 0, 0, .4); +@ta-text-color: #333; +@ta-text-color-inverted: white; -@default-input-width: 180px; +@ta-state-primary-text: @ta-text-color-inverted; +@ta-state-primary-text-emphasis: darken(@ta-text-color-inverted, 10%); +@ta-state-primary-bg: @ta-color-primary; +@ta-state-primary-border: darken(spin(@ta-text-color-inverted, -10), 5%); + +@ta-state-success-text: #3c763d; +@ta-state-success-text-emphasis: darken(#3c763d, 10%); +@ta-state-success-bg: #dff0d8; +@ta-state-success-border: darken(spin(@ta-state-success-bg, -10), 15%); + +@ta-state-info-text: #31708f; +@ta-state-info-text-emphasis: darken(#31708f, 10%); +@ta-state-info-bg: #d9edf7; +@ta-state-info-border: darken(spin(@ta-state-info-bg, -10), 15%); + +@ta-state-warning-text: #8a6d3b; +@ta-state-warning-text-emphasis: darken(#8a6d3b, 10%); +@ta-state-warning-bg: #fcf8e3; +@ta-state-warning-border: darken(spin(@ta-state-warning-bg, -10), 20%); + +@ta-state-danger-text: #a94442; +@ta-state-danger-text-emphasis: darken(#a94442, 10%); +@ta-state-danger-bg: #f2dede; +@ta-state-danger-border: darken(spin(@ta-state-danger-bg, -10), 15%); + +:root { + --ta-color-primary: @ta-color-primary; + --ta-color-success: @ta-color-success; + --ta-color-info: @ta-color-info; + --ta-color-warning: @ta-color-warning; + --ta-color-danger: @ta-color-danger; + + --ta-state-primary-text: @ta-state-primary-text; + --ta-state-primary-text-emphasis: @ta-state-primary-text-emphasis; + --ta-state-primary-bg: @ta-state-primary-bg; + --ta-state-primary-border: @ta-state-primary-border; + + --ta-state-success-text: @ta-state-success-text; + --ta-state-success-text-emphasis: @ta-state-success-text-emphasis; + --ta-state-success-bg: @ta-state-success-bg; + --ta-state-success-border: @ta-state-success-border; + + --ta-state-info-text: @ta-state-info-text; + --ta-state-info-text-emphasis: @ta-state-info-text-emphasis; + --ta-state-info-bg: @ta-state-info-bg; + --ta-state-info-border: @ta-state-info-border; + + --ta-state-warning-text: @ta-state-warning-text; + --ta-state-warning-text-emphasis: @ta-state-warning-text-emphasis; + --ta-state-warning-bg: @ta-state-warning-bg; + --ta-state-warning-border: @ta-state-warning-border; + + --ta-state-danger-text: @ta-state-danger-text; + --ta-state-danger-text-emphasis: @ta-state-danger-text-emphasis; + --ta-state-danger-bg: @ta-state-danger-bg; + --ta-state-danger-border: @ta-state-danger-border; + + --ta-text-color: @ta-text-color; + --ta-text-color-faded: fadeout(@ta-text-color, 30%); + --ta-text-color-inverted: @ta-text-color-inverted; + --ta-text-color-gray: #707070; + --ta-bg-color: #fff; + --ta-bg-color-semi-transparent: rgba(255, 255, 255, 0.8); + --ta-bg-color-semi-transparent-muted: rgba(220, 220, 220, 0.8); + --ta-bg-color-solid: #eee; + --ta-bg-color-disabled: #eee; + --ta-bg-dim-overlay: rgba(0, 0, 0, .35); + --ta-special-button-section-bg: rgba(173, 173, 173, .22); + --ta-special-button-section-border: rgba(0, 0, 0, 0.1); + --ta-panel-border-color: #ccc; + --ta-border-color-disabled: #ccc; + --ta-inner-border-color: rgba(0, 0, 0, 0.09); + --ta-inner-border-color-gradient-end: rgba(0, 0, 0, 0); + --ta-inner-border-color-light: rgba(255, 255, 255, .5); + --ta-inner-border-color-light-gradient-end: rgba(255, 255, 255, 0); + + --ta-hover-color: rgba(0, 0, 0, 0.05); + --ta-active-color: rgba(0, 0, 0, 0.1); + --ta-selection-color: #99c9ff; + + --ta-link-color: @ta-color-primary; + --ta-link-hover-color: darken(@ta-color-primary, 15%); + --ta-link-hover-decoration: underline; + + --ta-pre-color: #333; + + --ta-font-family-sans-serif: /*"Roboto",*/ "Helvetica Neue", Helvetica, Arial, sans-serif; + --ta-font-family-serif: Georgia, "Times New Roman", Times, serif; + --ta-font-family-monospace: Menlo, Monaco, Consolas, "Courier New", monospace; + --ta-font-family-base: var(--ta-font-family-sans-serif); + + --ta-font-size: 13px; + --ta-font-size-small: 0.85em; + + --ta-font-weight-normal: 400; + --ta-font-weight-bold: 700; + --ta-font-weight-light: 200; + + --ta-font-size-h1: 2.55em; + --ta-font-size-h2: 2.15em; + --ta-font-size-h3: 1.7em; + --ta-font-size-h4: 1.25em; + --ta-font-size-h5: 1em; + --ta-font-size-h6: 0.85em; + + --ta-headings-font-family: inherit; + --ta-headings-font-weight: 500; + --ta-headings-line-height: 1.1; + --ta-headings-color: inherit; + + --ta-line-height: 1.428571429; + --ta-line-height-computed: calc(var(--ta-line-height) * var(--ta-font-size)); + --ta-half-line-height-computed: calc(var(--ta-line-height-computed) / 2); + + --ta-padding-base-vertical: 4px; + --ta-padding-base-horizontal: 5px; + + --ta-border-radius: 3px; + --ta-border-radius-small: 3px; + + --ta-caret-size: 4px; + + //== Buttons + --ta-btn-font-weight: var(--ta-font-weight-normal); + --ta-btn-default-border: var(--ta-panel-border-color); + --ta-btn-default-bg: var(--ta-bg-color); + --ta-btn-border-radius-base: var(--ta-border-radius); + + + //== Forms + --ta-input-bg: var(--ta-bg-color); + --ta-input-bg-disabled: var(--ta-bg-color-disabled); + --ta-input-border-color: var(--ta-panel-border-color); + --ta-input-border-radius: var(--ta-border-radius); + --ta-input-border-focus: #66afe9; + --ta-input-height: calc(var(--ta-line-height-computed) + var(--ta-padding-base-vertical) * 2 + 2px); + --ta-input-default-width: 180px; + --ta-input-color: var(--ta-text-color); + --ta-input-color-placeholder: #999; + + + //== Misc. + --ta-cursor-disabled: not-allowed; + + --ta-panel-gap: 6px; + --ta-tab-button-height: 23px; + --ta-resize-bar-size: var(--ta-panel-gap); + --ta-resize-square-handle-size: 10px; + + --ta-box-shadown-color: rgba(0, 0, 0, .3); + --ta-box-shadow-base: 0 1px 4px var(--ta-box-shadown-color); + --ta-box-shadow-large: 0 3px 8px -1px var(--ta-box-shadown-color); + + --ta-color-transition-time: .3s; + + --ta-zindex-floating-component: 500; + --ta-zindex-window: 1000; + --ta-zindex-dropdown: 2000; + --ta-zindex-tooltip: 10000; + + --ta-tooltip-arrow-size: 5px; + + //== Component-Specific + --ta-calendar-grid-border-color: #ECECEC; + --ta-calendar-grid-non-business-bg-color: #d7d7d7; + + --ta-calendar-day-occupation-background-circle-color: #d7d7d755; + --ta-calendar-day-occupation-today-bg: #f8d9ac; + + // == button-style-item + --ta-3d-button-gradient: linear-gradient(to bottom, #ffffff 0%,#fbfbfc 62%,#f7f8f9 84%,#f5f6f7 94%,#edeff1 100%); + --ta-3d-button-gradient-lowered: linear-gradient(to bottom, #fafafa 0%, #fbfbfc 62%, #f7f8f9 84%, #f5f6f7 94%, #edeff1 100%); + --ta-glow-gradient: linear-gradient(to bottom, #feeeac 0%,#fee5a6 7%,#fee7a8 92%,#fef4b0 97%,#fef7b4 100%); + --ta-glow-gradient-lowered: linear-gradient(to bottom, #fef7b4 0%,#fef4b0 7%,#fee7a8 92%,#fee5a6 97%,#feeeac 100%); + --ta-glow-border-color: lighten(#C19344, 30%); + +} -@maximized-shadow-color: rgba(0, 0, 0, .4); -@direct-transparency-alpha: 0.82; // TODO search for 0.72 and replace diff --git a/teamapps-client/package.json b/teamapps-client/package.json index 6d8e36c34..049275a1a 100644 --- a/teamapps-client/package.json +++ b/teamapps-client/package.json @@ -1,6 +1,6 @@ { "name": "teamapps-client", - "version": "1.0.0", + "version": "0.9.208-SNAPSHOT", "description": "teamapps", "author": "TeamApps.org", "main": "dist/js/main.js", @@ -11,113 +11,157 @@ "test": "test" }, "scripts": { - "dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js --mode development --host 0.0.0.0", + "sync-pom-version": "sync-pom-version --use-yarn", + "dev": "webpack-dev-server --inline --progress --config js-build/webpack.dev.conf.js --mode development --host 0.0.0.0", + "dev2": "webpack-dev-server --inline --progress --config js-build/webpack.config.js --mode development --host 0.0.0.0", "worker": "webpack --config ts/worker/webpack.conf.js --mode production", "start": "yarn run dev", - "unit": "jest --config test/unit/jest.conf.js --coverage", + "unit": "jest", "test": "yarn run unit", - "build": "cross-env webpack -p --config build/webpack.prod.conf.js --mode production" + "build": "cross-env webpack -p --config js-build/webpack.prod.conf.js --mode production", + "stats": "cross-env webpack -p --config js-build/webpack.prod.conf.js --mode production --json > stats.json", + "report": "cross-env npm_config_report=true webpack -p --config js-build/webpack.prod.conf.js --mode production" }, "devDependencies": { - "autoprefixer": "latest", + "@types/canvas-gauges": "^2.1.1", + "@types/d3": "5.x", + "@types/jquery": "^3.3.38", + "@types/jqueryui": "1.x", + "@types/json-stable-stringify": "^1.0.32", + "@types/leaflet": "^1.4.6", + "@types/leaflet-draw": "^1.0.0", + "@types/leaflet.heat": "^0.2.0", + "@types/loglevel": "^1.6.3", + "@types/md5": "^2.1.32", + "@types/moment-timezone": "^0.5.4", + "@types/mustache": "^0.8.30", + "@types/node": "^12.6.6", + "@types/nouislider": "^9.0.4", + "@types/slick-carousel": "^1.6.32", + "@types/slickgrid": "^2.1.32", + "@types/wnumb": "^1.0.28", + "@types/youtube": "^0.0.38", + "autoprefixer": "^9.6.1", "awesome-typescript-loader": "^5.0.0", "bower": "1.x", - "clean-webpack-plugin": "^0.1.19", - "compression-webpack-plugin": "^1.1.8", - "copy-webpack-plugin": "^4.4.2", + "clean-webpack-plugin": "^3.0.0", + "compression-webpack-plugin": "^3.0.0", + "copy-webpack-plugin": "^5.0.3", "cross-env": "^5.1.4", - "css-loader": "^0.28.11", + "css-loader": "^3.0.0", + "esbuild": "^0.25.1", + "esbuild-loader": "^4.3.0", "extract-text-webpack-plugin": "^3.0.2", - "file-loader": "^1.1.11", + "file-loader": "^4.0.0", "friendly-errors-webpack-plugin": "^1.6.1", "html-webpack-plugin": "^3.2.0", - "jasmine-core": "^2.3.4", - "less": "3.9.0", - "less-loader": "^4.1.0", - "mini-css-extract-plugin": "^0.4.0", - "multipipe": "latest", - "node-notifier": "^5.2.1", - "optimize-css-assets-webpack-plugin": "^4.0.1", - "ora": "^2.0.0", - "postcss-loader": "^2.1.5", - "raw-loader": "^0.5.1", + "jasmine-core": "^3.4.0", + "jest": "^26.5.3", + "less": "^3.11.1", + "less-loader": "^6.1.0", + "mini-css-extract-plugin": "^0.8.0", + "node-notifier": "^8.0.1", + "optimize-css-assets-webpack-plugin": "^5.0.3", + "ora": "^3.4.0", + "postcss-loader": "^3.0.0", + "raw-loader": "^3.0.0", "source-map-loader": "^0.2.3", - "style-loader": "^0.20.2", - "tslint": "5.x", + "style-loader": "^0.23.1", + "sync-pom-version-to-package": "^1.6.1", + "terser-webpack-plugin": "^1.3.0", + "ts-loader": "^6.0.4", + "tslint": "^5.18.0", "tslint-loader": "^3.5.3", - "typescript": "2.9.2", - "uglifyjs-webpack-plugin": "^1.2.5", - "url-loader": "^1.0.1", - "webpack": "^4.22.0", - "webpack-cli": "^3.1.2", - "webpack-dev-server": "^3.1.9", + "typescript": "~4.5.0", + "url-loader": "^2.0.1", + "webpack": "^4.47.0", + "webpack-bundle-analyzer": "^4.3.0", + "webpack-cli": "^3.3.6", + "webpack-dev-server": "^3.7.2", "webpack-merge": "^4.1.4", "worker-loader": "2.0.0" }, "dependencies": { - "@types/bootstrap-notify": "^3.1.33", - "@types/canvas-gauges": "^2.1.1", - "@types/ckeditor": "0.x", - "@types/d3": "5.x", - "@types/jquery": "3.3.2", - "@types/jqueryui": "1.x", - "@types/json-stable-stringify": "^1.0.32", - "@types/leaflet": "^1.2.5", - "@types/leaflet.heat": "^0.2.0", - "@types/loglevel": "^1.5.3", - "@types/md5": "^2.1.32", - "@types/moment-timezone": "^0.5.4", - "@types/mustache": "^0.8.30", - "@types/nouislider": "^9.0.4", - "@types/pako": "^1.0.0", - "@types/quill": "^1.3.6", - "@types/slick-carousel": "^1.6.32", - "@types/slickgrid": "2.x", - "@types/tinymce": "^4.5.16", - "@types/wnumb": "^1.0.28", - "@types/youtube": "^0.0.32", - "animate.css": "^3.6.1", - "bootstrap": "3.x", - "bootstrap-notify": "^3.1.3", + "@fullcalendar/core": "4.4.2", + "@fullcalendar/daygrid": "4.4.2", + "@fullcalendar/interaction": "4.4.2", + "@fullcalendar/moment": "4.4.2", + "@fullcalendar/moment-timezone": "4.4.2", + "@fullcalendar/timegrid": "4.4.2", + "@popperjs/core": "^2.6.0", + "@tanstack/virtual-core": "^3.13.11", + "@turf/circle": "^6.5.0", + "@types/jest": "^26.0.22", + "@types/luxon": "^1.25.0", + "animate.css": "^4.1.0", + "autolinker": "^3.11.1", + "axios": "^0.27.2", + "bootstrap": "^3.4.1", "canvas-gauges": "^2.1.5", - "ckeditor": "4.x", - "d3": "5.x", + "color-rgba": "^2.1.1", + "d3": "^5.9.7", + "d3-flextree": "^2.1.1", + "d3-luxon": "^1.0.2", + "d3-voronoi": "^1.1.4", "d3v3": "npm:d3@3.x", "exports-loader": "^0.7.0", - "fullcalendar": "^3.9.0", - "jquery": "3.x", + "image-layout": "^0.4.1", + "jquery": "^3.5.1", "jquery-ui": "^1.12.1", "json-stable-stringify": "^1.0.1", + "jsqr-es6": "^1.2.0-3", "jstz": "^2.0.0", - "leaflet": "^1.3.1", + "leaflet": "^1.5.1", + "leaflet-draw": "^1.0.4", "leaflet.heat": "^0.2.0", "leaflet.markercluster": "^1.3.0", - "loglevel": "1.x", + "levenshtein": "^1.0.5", + "loglevel": "1.6.3", + "luxon": "^1.25.0", + "maplibre-gl": "^5.6.1", "md5": "^2.2.1", - "mediaelement": "4.2.9", + "mediaelement": "^4.2.16", + "mediasoup-client": "^3.6.28", "minimatch": "^3.0.4", "moment": "^2.21.0", - "moment-jdateformatparser": "^1.1.0", - "moment-timezone": "^0.5.14", - "mustache": "^2.3.0", - "nouislider": "^11.1.0", + "moment-timezone": "^0.5.26", + "mustache": "^3.0.1", + "nosleep.js": "^0.12.0", + "pdfjs-dist": "^5.0.375", "pickr-widget": "0.3.1", - "popper.js": "^1.14.4", - "quill": "1.x", - "shaka-player": "2.x", + "place-to": "^0.1.2", + "preact": "^10.5.7", + "react-jsx-parser": "^1.28.1", + "resize-observer-polyfill": "^1.5.1", + "seamless-scroll-polyfill": "^2.3.4", + "shaka-player": "^4.13.0", "slick-carousel": "^1.8.1", - "slickgrid": "https://github.com/yamass/SlickGrid.git#d8ed005670faae0635ec5abad2f5ed20bf454812", - "tiny-slider": "^2.8.8", - "tinymce": "^4.7.13", - "tinymce-i18n": "^18.5.8", - "tooltip.js": "^1.3.0", - "trivial-components": "0.3.23", + "slickgrid": "yamass/SlickGrid", + "socket.io-client": "^4.5.1", + "terra-draw": "^1.10.0", + "terra-draw-maplibre-gl-adapter": "^1.1.1", + "tinymce": "5.10.5", + "tinymce-i18n": "^18.12.25", + "ts-jest": "^26.5.4", + "tslib": "^2.3.1", + "typeface-roboto": "^0.0.54", + "ulp": "^1.0.1", + "voice-activity-detection": "^0.0.5", "webui-popover": "^1.2.18", "wnumb": "^1.1.0" }, + "comments": { + "axios": "mediasoup v3", + "mediasoup-client": "mediasoup v3", + "socket.io-client": "mediasoup v3" + }, "engines": { "node": ">= 7.0.0", "npm": ">= 5.0.0", "yarn": ">= 1.3.2" - } + }, + "browserslist": [ + "last 2 version", + "> 1%" + ] } diff --git a/teamapps-client/pom.xml b/teamapps-client/pom.xml index 59c818603..8a71eb8ff 100644 --- a/teamapps-client/pom.xml +++ b/teamapps-client/pom.xml @@ -3,7 +3,7 @@ teamapps org.teamapps - 0.9.8-SNAPSHOT + 0.9.208-SNAPSHOT 4.0.0 @@ -39,17 +39,13 @@ commons-io commons-io - - net.lingala.zip4j - zip4j - maven-clean-plugin - 2.6.1 + 3.4.0 @@ -61,10 +57,10 @@ com.github.eirslett frontend-maven-plugin - 1.6 + 1.15.1 - v10.12.0 - v1.5.1 + v22.11.0 + v1.22.22 @@ -79,6 +75,15 @@ yarn + + sync version to package.json + + yarn + + + sync-pom-version + + webpack @@ -88,11 +93,20 @@ build + + test + + yarn + + + test + + maven-assembly-plugin - ${maven-assembly-plugin-version} + 3.7.1 create-project-bundle @@ -106,14 +120,14 @@ false teamapps-client + false - org.apache.maven.plugins maven-antrun-plugin - 1.8 + 3.1.0 default-cli @@ -127,16 +141,33 @@ run + + copy-zip-to-resources + process-resources + + + + + + + + + + + run + + + + maven-jar-plugin + 3.4.2 + - target - - teamapps-client*.zip* - + src/main/resources diff --git a/teamapps-client/resources/backgrounds/dark-bl.jpg b/teamapps-client/resources/backgrounds/dark-bl.jpg new file mode 100644 index 000000000..daabbfec2 Binary files /dev/null and b/teamapps-client/resources/backgrounds/dark-bl.jpg differ diff --git a/teamapps-client/src/main/java/org/teamapps/client/ClientCodeExtractor.java b/teamapps-client/src/main/java/org/teamapps/client/ClientCodeExtractor.java index 996f3b476..8f751df6f 100644 --- a/teamapps-client/src/main/java/org/teamapps/client/ClientCodeExtractor.java +++ b/teamapps-client/src/main/java/org/teamapps/client/ClientCodeExtractor.java @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,17 +19,20 @@ */ package org.teamapps.client; -import net.lingala.zip4j.core.ZipFile; +import org.apache.commons.io.FileUtils; import org.apache.commons.io.IOUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; +import java.io.*; import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.attribute.FileTime; +import java.time.Instant; import java.util.Objects; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; public class ClientCodeExtractor { @@ -38,10 +41,11 @@ public class ClientCodeExtractor { private static final String TEAMAPPS_CLIENT_FILE_NAME = "teamapps-client.zip"; private static final String TEAMAPPS_CLIENT_CHECKSUM_FILE_NAME = "teamapps-client.zip.MD5"; + public static final String TEAMAPPS_CLIENT_CHECKSUM_RESOURCE_NAME = "/" + TEAMAPPS_CLIENT_CHECKSUM_FILE_NAME; public static void initializeWebserverDirectory(File webAppDirectory) throws IOException { File currentlyDeployedChecksumFile = new File(webAppDirectory, TEAMAPPS_CLIENT_CHECKSUM_FILE_NAME); - String currentlyDeployedArtifactChecksum = currentlyDeployedChecksumFile.exists() ? IOUtils.toString(currentlyDeployedChecksumFile.toURI()).trim() : null; + String currentlyDeployedArtifactChecksum = currentlyDeployedChecksumFile.exists() ? read(currentlyDeployedChecksumFile) : null; String[] currentlyDeployedFiles = webAppDirectory.list((dir, name) -> !name.startsWith(".")); if (currentlyDeployedArtifactChecksum == null && currentlyDeployedFiles != null && currentlyDeployedFiles.length > 0) { @@ -51,55 +55,101 @@ public static void initializeWebserverDirectory(File webAppDirectory) throws IOE } URL checksumResourceUrl = ClientCodeExtractor.class.getResource("/" + TEAMAPPS_CLIENT_CHECKSUM_FILE_NAME); - String artifactChecksum = checksumResourceUrl != null ? IOUtils.toString(checksumResourceUrl).trim() : null; + String artifactChecksum = checksumResourceUrl != null ? IOUtils.toString(checksumResourceUrl, StandardCharsets.UTF_8).trim() : null; LOGGER.info("Checksum of currently deployed artifact (" + webAppDirectory.getAbsolutePath() + "): " + currentlyDeployedArtifactChecksum); LOGGER.info("Checksum of executed artifact: " + artifactChecksum); if (!Objects.equals(currentlyDeployedArtifactChecksum, artifactChecksum)) { - overwriteWebappDirectory(webAppDirectory, currentlyDeployedChecksumFile, checksumResourceUrl); + overwriteWebappDirectory(webAppDirectory, currentlyDeployedChecksumFile); } else { LOGGER.info("Checksum has not changed. Nothing to do."); } } - private static void overwriteWebappDirectory(File webAppDirectory, File currentlyDeployedChecksumFile, URL checksumResourceUrl) throws IOException { + private static String read(File currentlyDeployedChecksumFile) throws IOException { + try (FileInputStream input = new FileInputStream(currentlyDeployedChecksumFile)) { + return IOUtils.toString(input, StandardCharsets.UTF_8).trim(); + } + } + + private static void overwriteWebappDirectory(File webAppDirectory, File currentlyDeployedChecksumFile) throws IOException { if (webAppDirectory.exists()) { - boolean deleted = webAppDirectory.delete(); - if (!deleted) { - LOGGER.error("Could not delete webapp directory!"); - } + FileUtils.deleteDirectory(webAppDirectory); } webAppDirectory.mkdirs(); File tempFile = File.createTempFile("teamapps-client", "zip"); try (InputStream in = ClientCodeExtractor.class.getResourceAsStream("/" + TEAMAPPS_CLIENT_FILE_NAME); - FileOutputStream out = new FileOutputStream(tempFile);) { + FileOutputStream out = new FileOutputStream(tempFile)) { LOGGER.info("Extracting " + TEAMAPPS_CLIENT_FILE_NAME + " from classpath to temp file: " + tempFile.getAbsolutePath()); IOUtils.copy(in, out); } LOGGER.info("Unzipping " + tempFile.getAbsolutePath() + " to " + webAppDirectory.getAbsolutePath()); unzipFile(tempFile, webAppDirectory); - try (InputStream in = checksumResourceUrl.openStream(); - FileOutputStream out = new FileOutputStream(currentlyDeployedChecksumFile)) { + String newChecksum = readResourceAsStringOrNull(TEAMAPPS_CLIENT_CHECKSUM_RESOURCE_NAME); + try (FileOutputStream out = new FileOutputStream(currentlyDeployedChecksumFile)) { LOGGER.info("Writing checksum to " + currentlyDeployedChecksumFile.getAbsolutePath()); - IOUtils.copy(in, out); + IOUtils.write(newChecksum, out, StandardCharsets.UTF_8); + } + } + + private static void unzipFile(File zipFile, File destDir) throws IOException { + Objects.requireNonNull(zipFile); + Objects.requireNonNull(destDir); + createDirIfNotExists(destDir); + + byte[] buffer = new byte[1024]; + try (ZipInputStream zis = new ZipInputStream(new FileInputStream(zipFile))) { + ZipEntry zipEntry = zis.getNextEntry(); + while (zipEntry != null) { + while (zipEntry != null) { + File destFile = new File(destDir, zipEntry.getName()); + String destDirPath = destDir.getCanonicalPath(); + String destFilePath = destFile.getCanonicalPath(); + if (!destFilePath.startsWith(destDirPath + File.separator)) { + // Prevent zip slip! See https://snyk.io/research/zip-slip-vulnerability + throw new IOException("Entry is outside of the target dir: " + zipEntry.getName()); + } + if (zipEntry.isDirectory()) { + createDirIfNotExists(destFile); + } else { + File parent = destFile.getParentFile(); + createDirIfNotExists(parent); + FileOutputStream fos = new FileOutputStream(destFile); + int len; + while ((len = zis.read(buffer)) > 0) { + fos.write(buffer, 0, len); + } + fos.close(); + } + zipEntry = zis.getNextEntry(); + } + } + zis.closeEntry(); + } + + Files.setLastModifiedTime(destDir.toPath().resolve("index.html"), FileTime.from(Instant.now())); + } + + private static void createDirIfNotExists(File dir) throws IOException { + if (!dir.isDirectory() && !dir.mkdirs()) { + throw new IOException("Failed to create directory " + dir); } } - private static boolean unzipFile(File file, File destinationDir) { - try { - if (file == null || !file.exists() || destinationDir == null || !destinationDir.isDirectory()) { - return false; + static String readResourceAsStringOrNull(String resourceName) { + ClassLoader classLoader = ClassLoader.getSystemClassLoader(); + try (InputStream is = classLoader.getResourceAsStream(resourceName)) { + if (is != null) { + return IOUtils.toString(is, StandardCharsets.UTF_8); + } else { + return null; } - ZipFile zipFile = new ZipFile(file); - zipFile.extractAll(destinationDir.getAbsolutePath()); - return true; - } catch (Exception e) { - e.printStackTrace(); + } catch (IOException e) { + return null; } - return false; } } diff --git a/teamapps-client/start-dev-server.sh b/teamapps-client/start-dev-server.sh index 2231d26a8..85720fef4 100755 --- a/teamapps-client/start-dev-server.sh +++ b/teamapps-client/start-dev-server.sh @@ -14,14 +14,6 @@ if [ ! -z "$1" ] export appServerUrl=$1 fi -if [ -z "$appServerUrl" ] - then - export appServerUrl="http://localhost:8080" - echo "appServerUrl is not set! Setting to default: " $appServerUrl - else - echo "appServerUrl set to " $appServerUrl -fi - export PORT=${2:-9000} echo "Dev server will be available on port " $PORT " or higher if already in use..."; diff --git a/teamapps-client/test-harness.html b/teamapps-client/test-harness.html new file mode 100644 index 000000000..56e534261 --- /dev/null +++ b/teamapps-client/test-harness.html @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/teamapps-client/ts/__tests__/DateSuggestionEngine.test.ts b/teamapps-client/ts/__tests__/DateSuggestionEngine.test.ts new file mode 100644 index 000000000..2728ee894 --- /dev/null +++ b/teamapps-client/ts/__tests__/DateSuggestionEngine.test.ts @@ -0,0 +1,259 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +/* + * + * Copyright 2016 Yann Massard (https://github.com/yamass) and other contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { + DateSuggestionEngine, + getYearMonthDayOrderFromDateFormat +} from "../modules/formfield/datetime/DateSuggestionEngine"; +import { LocalDateTime } from "../modules/datetime/LocalDateTime"; + + +describe('TeamApps.extractAllPossibleDateFragmentCombinations', function () { + it('returns suggestions for the next week if the input is empty', function () { + expect(new DateSuggestionEngine({preferredYearMonthDayOrder: "YMD"}) + .generateSuggestions("", LocalDateTime.fromISO("2015-01-27"))).toEqual([ + LocalDateTime.fromISO("2015-01-27"), + LocalDateTime.fromISO("2015-01-28"), + LocalDateTime.fromISO("2015-01-29"), + LocalDateTime.fromISO("2015-01-30"), + LocalDateTime.fromISO("2015-01-31"), + LocalDateTime.fromISO("2015-02-01"), + LocalDateTime.fromISO("2015-02-02") + ]); + }); + it('returns future day suggestions for a single digit input', function () { + expect(new DateSuggestionEngine({preferredYearMonthDayOrder: "YMD"}).generateSuggestions("2", LocalDateTime.fromISO("2015-01-01"))).toEqual([ + LocalDateTime.fromISO("2015-01-02") + ]); + }); + it('returns future day suggestions for a single digit input, jumping into the next month if necessary', function () { + expect(new DateSuggestionEngine({preferredYearMonthDayOrder: "YMD"}).generateSuggestions("2", LocalDateTime.fromISO("2015-01-05"))).toEqual([ + LocalDateTime.fromISO("2015-02-02") + ]); + }); + it('returns past day suggestions for a single digit input, if favorPastDates == true', function () { + expect(new DateSuggestionEngine({preferredYearMonthDayOrder: "YMD", favorPastDates: true}).generateSuggestions("3", LocalDateTime.fromISO("2015-01-02"))).toEqual([ + LocalDateTime.fromISO("2014-12-03") + ]); + expect(new DateSuggestionEngine({preferredYearMonthDayOrder: "YMD", favorPastDates: true}).generateSuggestions("4", LocalDateTime.fromISO("2015-01-05"))).toEqual([ + LocalDateTime.fromISO("2015-01-04") + ]); + }); + it('returns day (!) and day-month suggestions if the input has 2 digits and is interpretable as day', function () { + expect(new DateSuggestionEngine({preferredYearMonthDayOrder: "YMD"}).generateSuggestions("14", LocalDateTime.fromISO("2015-01-05"), { + shuffledFormatSuggestionsEnabled: true + })).toEqual([ + LocalDateTime.fromISO("2015-01-14"), + LocalDateTime.fromISO("2016-01-04"), + LocalDateTime.fromISO("2015-04-01") + ]); + }); + it('skips months until it finds a valid date in the future for day-only input', function () { + expect(new DateSuggestionEngine({preferredYearMonthDayOrder: "YMD"}).generateSuggestions("30", LocalDateTime.fromISO("2015-01-31"))).toEqual([ + LocalDateTime.fromISO("2015-03-30") + ]); + }); + it('skips months until it finds a valid date in the past for day-only input, if favorPastDates == true', function () { + expect(new DateSuggestionEngine({preferredYearMonthDayOrder: "YMD", favorPastDates: true}).generateSuggestions("30", LocalDateTime.fromISO("2015-03-29"))).toEqual([ + LocalDateTime.fromISO("2015-01-30") + ]); + }); + it('returns only day-month suggestions if the input has 2 digits and is NOT interpretable as day', function () { + expect(new DateSuggestionEngine({preferredYearMonthDayOrder: "YMD"}).generateSuggestions("94", LocalDateTime.fromISO("2015-01-05"), { + shuffledFormatSuggestionsEnabled: true + })).toEqual([ + LocalDateTime.fromISO("2015-09-04"), + LocalDateTime.fromISO("2015-04-09"), + ]); + expect(new DateSuggestionEngine({preferredYearMonthDayOrder: "YMD"}).generateSuggestions("94", LocalDateTime.fromISO("2015-10-01"), { + shuffledFormatSuggestionsEnabled: true + })).toEqual([ + LocalDateTime.fromISO("2016-09-04"), + LocalDateTime.fromISO("2016-04-09"), + ]); + }); + it('returns only day-month suggestions if the input has 2 digits and is NOT interpretable as day - in the past, if favorPastDates == true', function () { + expect(new DateSuggestionEngine({preferredYearMonthDayOrder: "YMD", favorPastDates: true}).generateSuggestions("94", LocalDateTime.fromISO("2015-01-05"), { + shuffledFormatSuggestionsEnabled: true + })).toEqual([ + LocalDateTime.fromISO("2014-09-04"), + LocalDateTime.fromISO("2014-04-09"), + ]); + expect(new DateSuggestionEngine({preferredYearMonthDayOrder: "YMD", favorPastDates: true}).generateSuggestions("94", LocalDateTime.fromISO("2015-10-01"), { + shuffledFormatSuggestionsEnabled: true + })).toEqual([ + LocalDateTime.fromISO("2015-09-04"), + LocalDateTime.fromISO("2015-04-09"), + ]); + }); + it('returns day-month and day-month-year suggestions for 3-digit inputs', function () { + expect(new DateSuggestionEngine({preferredYearMonthDayOrder: "YMD"}).generateSuggestions("123", LocalDateTime.fromISO("2015-01-05"), { + shuffledFormatSuggestionsEnabled: true + })).toEqual([ + LocalDateTime.fromISO("2015-01-23"), + LocalDateTime.fromISO("2015-12-03"), + LocalDateTime.fromISO("2001-02-03"), + LocalDateTime.fromISO("2015-03-12"), + LocalDateTime.fromISO("2001-03-02"), + LocalDateTime.fromISO("2002-01-03"), + LocalDateTime.fromISO("2002-03-01"), + LocalDateTime.fromISO("2003-01-02"), + LocalDateTime.fromISO("2003-02-01"), + ]); + expect(new DateSuggestionEngine({preferredYearMonthDayOrder: "YMD"}).generateSuggestions("193", LocalDateTime.fromISO("2015-01-05"), { + shuffledFormatSuggestionsEnabled: true + })).toEqual([ + LocalDateTime.fromISO("2001-09-03"), + LocalDateTime.fromISO("2015-03-19"), + LocalDateTime.fromISO("2001-03-09"), + LocalDateTime.fromISO("2003-01-09"), + LocalDateTime.fromISO("2003-09-01"), + LocalDateTime.fromISO("2009-01-03"), + LocalDateTime.fromISO("2009-03-01"), + ]); + expect(new DateSuggestionEngine({preferredYearMonthDayOrder: "YMD"}).generateSuggestions("789", LocalDateTime.fromISO("2015-01-05"), { + shuffledFormatSuggestionsEnabled: true + })).toEqual([ + LocalDateTime.fromISO("2007-08-09"), + LocalDateTime.fromISO("2007-09-08"), + LocalDateTime.fromISO("2008-07-09"), + LocalDateTime.fromISO("2008-09-07"), + LocalDateTime.fromISO("2009-07-08"), + LocalDateTime.fromISO("2009-08-07"), + ]); + }); + it('returns day-month and day-month-year suggestions for 4-digit inputs', function () { + expect(new DateSuggestionEngine({preferredYearMonthDayOrder: "YMD"}).generateSuggestions("1212", LocalDateTime.fromISO("2015-01-05"), { + shuffledFormatSuggestionsEnabled: true + })) + .toEqual([ + LocalDateTime.fromISO("2015-12-12"), + LocalDateTime.fromISO("2001-02-12"), + LocalDateTime.fromISO("2012-01-02"), + LocalDateTime.fromISO("2001-02-21"), + LocalDateTime.fromISO("2001-12-02"), + LocalDateTime.fromISO("2002-01-12"), + LocalDateTime.fromISO("2002-01-21"), + LocalDateTime.fromISO("2002-12-01"), + LocalDateTime.fromISO("2012-02-01"), + LocalDateTime.fromISO("2021-01-02"), + LocalDateTime.fromISO("2021-02-01") + ]); + }); + it('returns and day-month-year suggestions for 5-digit inputs', function () { + expect(new DateSuggestionEngine({preferredYearMonthDayOrder: "MDY"}).generateSuggestions("12123", LocalDateTime.fromISO("2015-01-05"), { + shuffledFormatSuggestionsEnabled: true + })) + .toEqual([ + LocalDateTime.fromISO("2003-12-12"), + LocalDateTime.fromISO("2023-01-21"), + LocalDateTime.fromISO("2023-12-01"), + LocalDateTime.fromISO("2001-12-23"), + LocalDateTime.fromISO("2012-01-23"), + LocalDateTime.fromISO("2012-03-12"), + LocalDateTime.fromISO("2012-12-03"), + LocalDateTime.fromISO("2021-01-23"), + LocalDateTime.fromISO("2023-01-12") + ]); + }); + it('returns and day-month-year suggestions for 6-digit inputs', function () { + expect(new DateSuggestionEngine({preferredYearMonthDayOrder: "YMD"}).generateSuggestions("121423", LocalDateTime.fromISO("2015-01-05"), { + shuffledFormatSuggestionsEnabled: true + })).toEqual([ + LocalDateTime.fromISO("2014-12-23"), + LocalDateTime.fromISO("2023-12-14") + ]); + expect(new DateSuggestionEngine({preferredYearMonthDayOrder: "YMD"}).generateSuggestions("129923", LocalDateTime.fromISO("2015-01-05"), { + shuffledFormatSuggestionsEnabled: true + })).toEqual([ + LocalDateTime.fromISO("1999-12-23") + ]); + }); + it('returns and day-month-year suggestions for 8-digit inputs', function () { + expect(new DateSuggestionEngine({preferredYearMonthDayOrder: "YMD"}).generateSuggestions("10210023", LocalDateTime.fromISO("2015-01-05"), { + shuffledFormatSuggestionsEnabled: true + })).toEqual([ + LocalDateTime.fromISO("2100-10-23") + ]); + }); + it('returns an empty array for 9-digit inputs', function () { + expect(new DateSuggestionEngine({preferredYearMonthDayOrder: "YMD"}).generateSuggestions("102111232", LocalDateTime.fromISO("2015-01-05"))).toEqual([]); + }); + it('suggests the right thing for 4417 ;-)', () => { + expect(new DateSuggestionEngine({preferredYearMonthDayOrder: "DMY"}).generateSuggestions("4417", LocalDateTime.fromISO("2017-05-12"), { + shuffledFormatSuggestionsEnabled: true + })) + .toEqual([ + LocalDateTime.fromISO("2017-04-04"), + LocalDateTime.fromISO("1941-04-07"), + LocalDateTime.fromISO("1941-07-04"), + LocalDateTime.fromISO("1944-01-07"), + LocalDateTime.fromISO("1944-07-01"), + LocalDateTime.fromISO("2004-04-17") + ]); + }); + it('suggests the right thing for 231221 ;-)', () => { + expect(new DateSuggestionEngine({preferredYearMonthDayOrder: "DMY"}).generateSuggestions("231221", LocalDateTime.fromISO("2017-05-12"), { + shuffledFormatSuggestionsEnabled: true + })) + .toEqual([ + LocalDateTime.fromISO("2021-12-23"), + LocalDateTime.fromISO("2023-12-21"), + ]); + }); + it('Does not make suggestions for 3 digit days (leading zero)', () => { + expect(new DateSuggestionEngine({preferredYearMonthDayOrder: "DMY"}).generateSuggestions("0210202", LocalDateTime.fromISO("2017-05-12"))) + .toEqual([]); + }); + it('does not allow zero-padded 4-digit fragments', function () { + expect(new DateSuggestionEngine({preferredYearMonthDayOrder: "YMD"}).generateSuggestions("00230130", LocalDateTime.fromISO("2015-01-05"), { + shuffledFormatSuggestionsEnabled: true + })).toEqual([ + // NOT LocalDateTime.fromISO("2023-01-30") + ]); + }); +}); + +describe('DateSuggestionEngine.dateFormatToYmdOrder', function () { + it('gets the YMD order right', function () { + expect(getYearMonthDayOrderFromDateFormat("YYYY-MM-DD")).toEqual("YMD"); + expect(getYearMonthDayOrderFromDateFormat("MM/DD/YYYY")).toEqual("MDY"); + expect(getYearMonthDayOrderFromDateFormat("M/D/Y")).toEqual("MDY"); + expect(getYearMonthDayOrderFromDateFormat("DD.MM.YYYY")).toEqual("DMY"); + expect(getYearMonthDayOrderFromDateFormat("DD.MM.DD.YYYY")).toEqual("DMY"); + }); +}); diff --git a/teamapps-client/ts/__tests__/IntervalManagerTest.ts b/teamapps-client/ts/__tests__/IntervalManagerTest.ts new file mode 100644 index 000000000..6f9e1f7f3 --- /dev/null +++ b/teamapps-client/ts/__tests__/IntervalManagerTest.ts @@ -0,0 +1,95 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +import {Interval, IntervalManager} from "../modules/util/IntervalManager"; + +describe('IntervalManager', function () { + + it('is initially empty', function () { + var intervalManager:IntervalManager = new IntervalManager(); + expect(intervalManager.getCoveredIntervals().length).toBe(0); + }); + + it('adds the first interval as is', function () { + var intervalManager:IntervalManager = new IntervalManager(); + var interval: Interval = [100, 200]; + + intervalManager.addInterval(interval); + + expect(intervalManager.getCoveredIntervals()).toEqual([interval]); + }); + + it('adds uncovered intervals as is', function () { + var intervalManager:IntervalManager = new IntervalManager(); + var interval1 :Interval = [100, 200]; + intervalManager.addInterval(interval1); + + var interval2 :Interval = [300, 400]; + intervalManager.addInterval(interval2); + + expect(intervalManager.getCoveredIntervals()).toEqual([interval1, interval2]); + }); + + it('merges adjacent intervals (exactly adjacent)', function () { + var intervalManager:IntervalManager = new IntervalManager(); + intervalManager.addInterval([100, 200]); + + var interval2 :Interval = [200, 400]; + intervalManager.addInterval(interval2); + expect(intervalManager.getCoveredIntervals()).toEqual([[100, 400]]); + + var interval3 :Interval = [50, 100]; + intervalManager.addInterval(interval3); + expect(intervalManager.getCoveredIntervals()).toEqual([[50, 400]]); + }); + + it('returns the missing intervals when adding a super interval', function () { + var intervalManager:IntervalManager = new IntervalManager(); + intervalManager.addInterval([100, 200]); + intervalManager.addInterval([300, 400]); + + var newInterval :Interval = [50, 450]; + intervalManager.addInterval(newInterval); + + expect(intervalManager.getCoveredIntervals()).toEqual([[50, 450]]); + }); + + it('merges intervals if added interval overlaps them', function () { + var intervalManager:IntervalManager = new IntervalManager(); + intervalManager.addInterval([100, 200]); + intervalManager.addInterval([300, 400]); + + var newInterval :Interval = [150, 350]; + intervalManager.addInterval(newInterval); + + expect(intervalManager.getCoveredIntervals().length).toBe(1); + expect(intervalManager.getCoveredIntervals()).toEqual([[100, 400]]); + }); + + it('returns an empty array if the interval is already covered', function () { + var intervalManager:IntervalManager = new IntervalManager(); + intervalManager.addInterval([100, 200]); + intervalManager.addInterval([100, 200]); + intervalManager.addInterval([101, 199]); + + expect(intervalManager.getCoveredIntervals().length).toBe(1); + expect(intervalManager.getCoveredIntervals()).toEqual([[100, 200]]); + }); +}); + diff --git a/teamapps-client/ts/custom-declarations/color-rgba.d.ts b/teamapps-client/ts/custom-declarations/color-rgba.d.ts new file mode 100644 index 000000000..071c057d1 --- /dev/null +++ b/teamapps-client/ts/custom-declarations/color-rgba.d.ts @@ -0,0 +1,22 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +declare module "color-rgba" { + export default function rgba(s:string): [number, number, number, number]; +} diff --git a/teamapps-client/ts/custom-declarations/d3-flextree.d.ts b/teamapps-client/ts/custom-declarations/d3-flextree.d.ts new file mode 100644 index 000000000..7e52e1aef --- /dev/null +++ b/teamapps-client/ts/custom-declarations/d3-flextree.d.ts @@ -0,0 +1,81 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +declare module "d3-flextree" { + + import {HierarchyNode, HierarchyPointNode} from "d3-hierarchy"; + + export interface FlexTreeLayout { + /** + * Lays out the specified root hierarchy. + * You may want to call `root.sort` before passing the hierarchy to the tree layout. + * + * @param root The specified root hierarchy. + */ + (root: HierarchyNode): HierarchyPointNode; + + /** + * If children is specified, sets the specified children accessor function. + * If children is not specified, returns the current children accessor function, + * which by default assumes that the input data is an object with a children property, + * whose value is either an array or null if there are no children: data => data.children + * + * Note that unlike the other accessors, this takes a data node as an argument. + * This is used only in the creation of a hierarchy, prior to computing the layout, by the layout.hierarchy method. + */ + children(): (d: Datum) => Datum[]; + + children(children: (d: Datum) => Datum[]): this; + + + /** + * If nodeSize is specified as a two-element array [xSize, ySize], then this sets that as the fixed size for every node in the tree. + * If nodeSize is a function, then that function is passed the hierarchy node as an argument, and should return a two-element array. + * If nodeSize is not specified, this returns the current setting. + * + * The default nodeSize assumes that a node's size is available as a property on the data item: + * node => node.data.size + */ + nodeSize(nodeSize: [number, number]): this; + + nodeSize(nodeSize: (node: HierarchyNode) => [number, number]): this; + + nodeSize(): [number, number] | ((d: Datum) => [number, number]); + + /** + * If a spacing argument is given as a constant number, then the layout will insert the given fixed spacing between every adjacent node. + * If it is given as a function, then that function will be passed two nodes, and should return the minimum allowable spacing between those nodes. + * If spacing is not specified, this returns the current spacing, which defaults to 0. + * + * To increase the spacing for nodes as the distance of their relationship increases, you could use, for example: + * layout.spacing((nodeA, nodeB) => nodeA.path(nodeB).length); + */ + spacing(spacing: number): this; + + spacing(spacing: (node1: HierarchyNode, node2: HierarchyNode) => number): this; + + spacing(): number | ((node1: HierarchyNode, node2: HierarchyNode) => number); + } + + /** + * Creates a new tree layout with default settings. + */ + export function flextree(): FlexTreeLayout; +} + diff --git a/teamapps-icon/src/main/java/org/teamapps/icons/systemprovider/IconUtils.java b/teamapps-client/ts/custom-declarations/image-layout.d.ts similarity index 66% rename from teamapps-icon/src/main/java/org/teamapps/icons/systemprovider/IconUtils.java rename to teamapps-client/ts/custom-declarations/image-layout.d.ts index a90501fff..07c6426d0 100644 --- a/teamapps-icon/src/main/java/org/teamapps/icons/systemprovider/IconUtils.java +++ b/teamapps-client/ts/custom-declarations/image-layout.d.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,17 +17,21 @@ * limitations under the License. * =========================LICENSE_END================================== */ -package org.teamapps.icons.systemprovider; -import java.io.ByteArrayInputStream; -import java.io.InputStream; - -public class IconUtils { - - public static InputStream getInputStream(byte[] bytes) { - if (bytes == null || bytes.length == 0) { - return null; - } - return new ByteArrayInputStream(bytes); +declare module "image-layout" { + export function fixed_partition(images: {width: number, height: number}[], options: { + spacing: number, + containerWidth: number, + idealElementHeight: number, + align?: 'center' + }): { + width: number, + height: number, + positions: { + x: number, + y: number, + width: number, + height: number + }[] } } diff --git a/teamapps-client/ts/custom-declarations/jquery.ui.position.d.ts b/teamapps-client/ts/custom-declarations/jquery.ui.position.d.ts index 3fde7e67b..c47b12ac7 100644 --- a/teamapps-client/ts/custom-declarations/jquery.ui.position.d.ts +++ b/teamapps-client/ts/custom-declarations/jquery.ui.position.d.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/teamapps-client/ts/custom-declarations/levenshtein.d.ts b/teamapps-client/ts/custom-declarations/levenshtein.d.ts new file mode 100644 index 000000000..2be5c19fd --- /dev/null +++ b/teamapps-client/ts/custom-declarations/levenshtein.d.ts @@ -0,0 +1,25 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +declare module "levenshtein" { + let Levenshtein: { + new(s1: string, s2: string): { distance: number }; + }; + export = Levenshtein; +} diff --git a/teamapps-client/ts/custom-declarations/mediaelement.d.ts b/teamapps-client/ts/custom-declarations/mediaelement.d.ts index 254ca4085..68202bfbd 100644 --- a/teamapps-client/ts/custom-declarations/mediaelement.d.ts +++ b/teamapps-client/ts/custom-declarations/mediaelement.d.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/teamapps-client/ts/custom-declarations/mediasoup-v3-client.d.ts b/teamapps-client/ts/custom-declarations/mediasoup-v3-client.d.ts new file mode 100644 index 000000000..36ef81bd5 --- /dev/null +++ b/teamapps-client/ts/custom-declarations/mediasoup-v3-client.d.ts @@ -0,0 +1,964 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +declare module 'mediasoup-client' { + + export class CommandQueue { + constructor(); + + close(): void; + + /** + * @param {Function} command - Function that returns a promise. + * + * @async + */ + push(command: () => void): /* CommandQueue.prototype.+Promise */ Promise; + } + + export class Consumer { + /** + * @private + * + * @emits transportclose + * @emits trackended + * @emits @getstats + * @emits @close + */ + constructor({ + id, + localId, + producerId, + track, + rtpParameters, + appData, + }: { + id: string; + localId: string; + producerId: string; + track: MediaStreamTrack; + rtpParameters: RTCRtpParameters; + appData?: object; + }); + + /** + * Consumer id. + * + * @returns {String} + */ + id: string; + + /** + * Local id. + * + * @private + * @returns {String} + */ + localId: string; + + /** + * Associated Producer id. + * + * @returns {String} + */ + producerId: string; + + /** + * Whether the Consumer is closed. + * + * @returns {Boolean} + */ + closed: boolean; + + /** + * Media kind. + * + * @returns {String} + */ + kind: 'audio' | 'video'; + + /** + * The associated track. + * + * @returns {MediaStreamTrack} + */ + track: MediaStreamTrack; + + /** + * RTP parameters. + * + * @returns {RTCRtpParameters} + */ + rtpParameters: RTCRtpParameters; + + /** + * Whether the Consumer is paused. + * + * @returns {Boolean} + */ + paused: boolean; + + /** + * App custom data. + * + * @returns {Object} + */ + appData: object; + + on(type: any, listener: (...params: any) => Promise | void): Promise | void; + + /** + * Closes the Consumer. + */ + close(): void; + + // /** + // * Transport was closed. + // * + // * @private + // */ + // transportClosed(): void; + + /** + * Get associated RTCRtpReceiver stats. + * + * @async + * @returns {RTCStatsReport} + * @throws {InvalidStateError} if Consumer closed. + * @return + */ + getStats(): Promise; + + /** + * Pauses receiving media. + */ + pause(): void; + + /** + * Resumes receiving media. + */ + resume(): void; + } + + export class DataConsumer { + /** + * @private + * + * @emits transportclose + * @emits open + * @emits {Object} error + * @emits close + * @emits {Any} message + * @emits @close + */ + constructor({ + id, + dataProducerId, + dataChannel, + sctpStreamParameters, + appData, + }: { + id: string; + dataProducerId: string; + dataChannel: RTCDataChannel; + sctpStreamParameters: any; + appData: object; + }); + + /** + * DataConsumer id. + * + * @returns {String} + */ + id: string; + + /** + * Associated DataProducer id. + * + * @returns {String} + */ + dataProducerId: string; + + /** + * Whether the DataConsumer is closed. + * + * @returns {Boolean} + */ + closed: boolean; + + /** + * SCTP stream parameters. + * + * @returns {RTCSctpStreamParameters} + */ + sctpStreamParameters: any; + + /** + * DataChannel readyState. + * + * @returns {String} + */ + readyState: string; + + /** + * DataChannel label. + * + * @returns {String} + */ + label: string; + + /** + * DataChannel protocol. + * + * @returns {String} + */ + protocol: string; + + /** + * DataChannel binaryType. + * + * @returns {String} + */ + binaryType: string; + + /** + * App custom data. + * + * @returns {Object} + */ + appData: object; + + /** + * Closes the DataConsumer. + */ + close(): void; + + // /** + // * Transport was closed. + // * + // * @private + // */ + // transportClosed(): void; + } + + export class DataProducer { + /** + * @private + * + * @emits transportclose + * @emits open + * @emits {Object} error + * @emits close + * @emits bufferedamountlow + * @emits @close + */ + constructor({ + id, + dataChannel, + sctpStreamParameters, + appData, + }: { + id: string; + dataChannel: RTCDataChannel; + sctpStreamParameters: any; + appData: object; + }); + + /** + * DataProducer id. + * + * @returns {String} + */ + id: string; + + /** + * Whether the DataProducer is closed. + * + * @returns {Boolean} + */ + closed: boolean; + + /** + * SCTP stream parameters. + * + * @returns {RTCSctpStreamParameters} + */ + sctpStreamParameters: any; + + /** + * DataChannel readyState. + * + * @returns {String} + */ + readyState: string; + + /** + * DataChannel label. + * + * @returns {String} + */ + label: string; + + /** + * DataChannel protocol. + * + * @returns {String} + */ + protocol: string; + + /** + * DataChannel bufferedAmount. + * + * @returns {String} + */ + bufferedAmount: string; + + /** + * DataChannel bufferedAmountLowThreshold. + * + * @returns {String} + */ + bufferedAmountLowThreshold: string; + + /** + * App custom data. + * + * @returns {Object} + */ + appData: object; + + /** + * Closes the DataProducer. + */ + close(): void; + + /** + * Send a message. + * + * @param {String|Blob|ArrayBuffer|ArrayBufferView} data. + * + * @throws {InvalidStateError} if DataProducer closed. + * @throws {TypeError} if wrong arguments. + * @param data + */ + send(data: string | Blob | ArrayBuffer | ArrayBufferView): void; + + // /** + // * Transport was closed. + // * + // * @private + // */ + // transportClosed(): void; + } + + export class Device { + /** + * Create a new Device to connect to mediasoup server. + * + * @param {Class|String} [Handler] - An optional RTC handler class for unsupported or + * custom devices (not needed when running in a browser). If a String, it will + * force usage of the given built-in handler. + * + * @throws {UnsupportedError} if device is not supported. + */ + constructor({Handler}?: { Handler?: any }); + + /** + * Whether the Device is loaded. + * + * @returns {Boolean} + */ + loaded: boolean; + + /** + * The RTC handler class name ('Chrome70', 'Firefox65', etc). + * + * @returns {RTCRtpCapabilities} + */ + handlerName: RTCRtpCapabilities; + + /** + * RTP capabilities of the Device for receiving media. + * + * @returns {RTCRtpCapabilities} + * @throws {InvalidStateError} if not loaded. + */ + rtpCapabilities: string; + + /** + * SCTP capabilities of the Device. + * + * @returns {Object} + * @throws {InvalidStateError} if not loaded. + */ + sctpCapabilities: object; + + /** + * Initialize the Device. + * + * @param {RTCRtpCapabilities} routerRtpCapabilities - Router RTP capabilities. + * + * @async + * @throws {TypeError} if missing/wrong arguments. + * @throws {InvalidStateError} if already loaded. + * @param {routerRtpCapabilities} + */ + load({routerRtpCapabilities}: { routerRtpCapabilities: RTCRtpCapabilities }): Promise; + + /** + * Whether we can produce audio/video. + * + * @param {String} kind - 'audio' or 'video'. + * + * @returns {Boolean} + * @throws {InvalidStateError} if not loaded. + * @throws {TypeError} if wrong arguments. + */ + canProduce(kind: string): boolean; + + /** + * Creates a Transport for sending media. + * + * @param {String} - Server-side Transport id. + * @param {RTCIceParameters} iceParameters - Server-side Transport ICE parameters. + * @param {Array} [iceCandidates] - Server-side Transport ICE candidates. + * @param {RTCDtlsParameters} dtlsParameters - Server-side Transport DTLS parameters. + * @param {Object} [sctpParameters] - Server-side SCTP parameters. + * @param {Array} [iceServers] - Array of ICE servers. + * @param {RTCIceTransportPolicy} [iceTransportPolicy] - ICE transport + * policy. + * @param {Object} [proprietaryConstraints] - RTCPeerConnection proprietary constraints. + * @param {Object} [appData={}] - Custom app data. + * + * @returns {Transport} + * @throws {InvalidStateError} if not loaded. + * @throws {TypeError} if wrong arguments. + */ + createSendTransport({ + id, + iceParameters, + iceCandidates, + dtlsParameters, + sctpParameters, + iceServers, + iceTransportPolicy, + proprietaryConstraints, + appData, + }: { + id: string; + iceParameters: RTCIceParameters; + iceCandidates: RTCIceCandidate[]; + dtlsParameters: RTCDtlsParameters; + sctpParameters?: object; + iceServers?: RTCIceServer[]; + iceTransportPolicy?: RTCIceTransportPolicy; + proprietaryConstraints?: object; + appData?: object; + }): Transport; + + /** + * Creates a Transport for receiving media. + * + * @param {String} - Server-side Transport id. + * @param {RTCIceParameters} iceParameters - Server-side Transport ICE parameters. + * @param {Array} [iceCandidates] - Server-side Transport ICE candidates. + * @param {RTCDtlsParameters} dtlsParameters - Server-side Transport DTLS parameters. + * @param {Object} [sctpParameters] - Server-side SCTP parameters. + * @param {Array} [iceServers] - Array of ICE servers. + * @param {RTCIceTransportPolicy} [iceTransportPolicy] - ICE transport + * policy. + * @param {Object} [proprietaryConstraints] - RTCPeerConnection proprietary constraints. + * @param {Object} [appData={}] - Custom app data. + * + * @returns {Transport} + * @throws {InvalidStateError} if not loaded. + * @throws {TypeError} if wrong arguments. + */ + createRecvTransport({ + id, + iceParameters, + iceCandidates, + dtlsParameters, + sctpParameters, + iceServers, + iceTransportPolicy, + proprietaryConstraints, + appData, + }: { + id: string; + iceParameters: RTCIceParameters; + iceCandidates: RTCIceCandidate[]; + dtlsParameters: RTCDtlsParameters; + sctpParameters?: object; + iceServers?: RTCIceServer[]; + iceTransportPolicy?: RTCIceTransportPolicy; + proprietaryConstraints?: object; + appData?: object; + }): Transport; + } + + export class EnhancedEventEmitter { + /** + * + * @param logger + */ + constructor(logger: any); + + /** + * + * @param event + * @param ...args + */ + safeEmit(event: any, ...args: any): void; + + /** + * + * @param event + * @param ...args + * @return + */ + safeEmitAsPromise(event: any, ...args: any): Promise; + } + + export class Producer { + /** + * @private + * + * @emits transportclose + * @emits trackended + * @emits {track: MediaStreamTrack} @replacetrack + * @emits {spatialLayer: String} @setmaxspatiallayer + * @emits @getstats + * @emits @close + */ + constructor({ + id, + localId, + track, + rtpParameters, + appData, + }: { + id: string; + localId: string; + track: MediaStreamTrack; + rtpParameters: RTCRtpParameters; + appData?: object; + }); + + /** + * Producer id. + * + * @returns {String} + */ + id: string; + + /** + * Local id. + * + * @private + * @returns {String} + */ + localId: string; + + /** + * Whether the Producer is closed. + * + * @returns {Boolean} + */ + closed: boolean; + + /** + * Media kind. + * + * @returns {String} + */ + kind: 'audio' | 'video'; + + /** + * The associated track. + * + * @returns {MediaStreamTrack} + */ + track: MediaStreamTrack; + + /** + * RTP parameters. + * + * @returns {RTCRtpParameters} + */ + rtpParameters: RTCRtpParameters; + + /** + * Whether the Producer is paused. + * + * @returns {Boolean} + */ + paused: boolean; + + /** + * Max spatial layer. + * + * @type {Number} + */ + maxSpatialLayer: number; + + /** + * App custom data. + * + * @type {Object} + */ + appData: object; + + on(type: any, listener: (...params: any) => Promise | void): Promise | void; + + /** + * Closes the Producer. + */ + close(): void; + + // /** + // * Transport was closed. + // * + // * @private + // */ + // transportClosed(); + + /** + * Get associated RTCRtpSender stats. + * + * @promise + * @returns {RTCStatsReport} + * @throws {InvalidStateError} if Producer closed. + */ + getStats(): Promise; + + /** + * Pauses sending media. + */ + pause(): void; + + /** + * Resumes sending media. + */ + resume(): void; + + /** + * Replaces the current track with a new one. + * + * @param {MediaStreamTrack} track - New track. + * + * @async + * @throws {InvalidStateError} if Producer closed or track ended. + * @throws {TypeError} if wrong arguments. + */ + replaceTrack({track}: { track: MediaStreamTrack }): Promise; + + /** + * Sets the video max spatial layer to be sent. + * + * @param {Number} spatialLayer + * + * @async + * @throws {InvalidStateError} if Producer closed. + * @throws {UnsupportedError} if not a video Producer. + * @throws {TypeError} if wrong arguments. + */ + setMaxSpatialLayer(spatialLayer: number): Promise; + } + + export class Transport { + /** + * @private + * + * @emits {transportLocalParameters: Object, callback: Function, errback: Function} connect + * @emits {connectionState: String} connectionstatechange + * @emits {producerLocalParameters: Object, callback: Function, errback: Function} produce + * @emits {dataProducerLocalParameters: Object, callback: Function, errback: Function} producedata + */ + constructor({ + direction, + id, + iceParameters, + iceCandidates, + dtlsParameters, + sctpParameters, + iceServers, + iceTransportPolicy, + proprietaryConstraints, + appData, + Handler, + extendedRtpCapabilities, + canProduceByKind, + }: { + direction: string; + id: string; + iceParameters: RTCIceParameters; + iceCandidates: RTCIceCandidate[]; + dtlsParameters: RTCDtlsParameters; + sctpParameters: any; + iceServers: RTCIceServer[]; + iceTransportPolicy: RTCIceTransportPolicy; + proprietaryConstraints: object; + appData?: object; + Handler: any; + extendedRtpCapabilities: object; + canProduceByKind: object; + }); + + /** + * Transport id. + * + * @returns {String} + */ + id: string; + + /** + * Whether the Transport is closed. + * + * @returns {Boolean} + */ + closed: boolean; + + /** + * Transport direction. + * + * @returns {String} + */ + direction: string; + + /** + * RTC handler instance. + * + * @returns {Handler} + */ + handler: any; + + /** + * Connection state. + * + * @returns {String} + */ + connectionState: string; + + /** + * App custom data. + * + * @returns {Object} + */ + appData: object; + + on(type: any, listener: (...params: any) => Promise | void): Promise | void; + + /** + * Close the Transport. + */ + close(): void; + + /** + * Get associated Transport (RTCPeerConnection) stats. + * + * @async + * @returns {RTCStatsReport} + * @throws {InvalidStateError} if Transport closed. + */ + getStats(): Promise; + + /** + * Restart ICE connection. + * + * @param {RTCIceParameters} iceParameters - New Server-side Transport ICE parameters. + * + * @async + * @throws {InvalidStateError} if Transport closed. + * @throws {TypeError} if wrong arguments. + */ + restartIce({iceParameters}: { iceParameters: RTCIceParameters }): Promise; + + /** + * Update ICE servers. + * + * @param {Array} [iceServers] - Array of ICE servers. + * + * @async + * @throws {InvalidStateError} if Transport closed. + * @throws {TypeError} if wrong arguments. + */ + updateIceServers({iceServers}: { iceServers: RTCIceServer[] }): Promise; + + /** + * Create a Producer. + * + * @param {MediaStreamTrack} track - Track to sent. + * @param {Array} [encodings] - Encodings. + * @param {Object} [codecOptions] - Codec options. + * @param {Object} [appData={}] - Custom app data. + * + * @async + * @returns {Producer} + * @throws {InvalidStateError} if Transport closed or track ended. + * @throws {TypeError} if wrong arguments. + * @throws {UnsupportedError} if Transport direction is incompatible or + * cannot produce the given media kind. + */ + produce({ + track, + encodings, + codecOptions, + appData, + }: { + track: MediaStreamTrack; + encodings?: RTCRtpCodingParameters[]; + codecOptions?: object; + appData?: object; + }): Promise; + + /** + * Create a Consumer to consume a remote Producer. + * + * @param {String} id - Server-side Consumer id. + * @param {String} producerId - Server-side Producer id. + * @param {String} kind - 'audio' or 'video'. + * @param {RTCRtpParameters} rtpParameters - Server-side Consumer RTP parameters. + * @param {Object} [appData={}] - Custom app data. + * + * @async + * @returns {Consumer} + * @throws {InvalidStateError} if Transport closed. + * @throws {TypeError} if wrong arguments. + * @throws {UnsupportedError} if Transport direction is incompatible. + */ + consume({ + id, + producerId, + kind, + rtpParameters, + appData, + }: { + id: string; + producerId: string; + kind: string; + rtpParameters: RTCRtpParameters; + appData?: object; + }): Promise; + + /** + * Create a DataProducer + * + * @param {Boolean} [ordered=true] + * @param {Number} [maxPacketLifeTime] + * @param {Number} [maxRetransmits] + * @param {String} [priority='low'] // 'very-low' / 'low' / 'medium' / 'high' + * @param {String} [label=''] + * @param {String} [protocol=''] + * @param {Object} [appData={}] - Custom app data. + * + * @async + * @returns {DataProducer} + * @throws {InvalidStateError} if Transport closed. + * @throws {TypeError} if wrong arguments. + * @throws {UnsupportedError} if Transport direction is incompatible or remote + * transport does not enable SCTP. + */ + produceData({ + ordered, + maxPacketLifeTime, + maxRetransmits, + priority, + label, + protocol, + appData, + }: { + ordered?: boolean; + maxPacketLifeTime: number; + maxRetransmits: number; + priority?: 'very-low' | 'low' | 'medium' | 'high'; + label?: string; + protocol?: string; + appData?: object; + }): Promise; + + /** + * Create a DataConsumer + * + * @param {String} id - Server-side DataConsumer id. + * @param {String} dataProducerId - Server-side DataProducer id. + * @param {RTCSctpStreamParameters} sctpStreamParameters - Server-side DataConsumer + * SCTP parameters. + * @param {String} [label=''] + * @param {String} [protocol=''] + * @param {Object} [appData={}] - Custom app data. + * + * @async + * @returns {DataConsumer} + * @throws {InvalidStateError} if Transport closed. + * @throws {TypeError} if wrong arguments. + * @throws {UnsupportedError} if Transport direction is incompatible or remote + * transport does not enable SCTP. + */ + consumeData({ + id, + dataProducerId, + sctpStreamParameters, + label, + protocol, + appData, + }: { + id: string; + dataProducerId: string; + sctpStreamParameters: any; + label?: string; + protocol?: string; + appData?: object; + }): Promise; + + /** + * + */ + _handleHandler(): void; + + /** + * + * @param producer + */ + _handleProducer(producer: Producer): void; + + /** + * + * @param consumer + */ + _handleConsumer(consumer: Consumer): void; + + /** + * + * @param dataProducer + */ + _handleDataProducer(dataProducer: DataProducer): void; + + /** + * + * @param dataConsumer + */ + _handleDataConsumer(dataConsumer: DataConsumer): void; + } + + + export const version: string; + + export function parseScalabilityMode(scalabilityMode: any): any; +} diff --git a/teamapps-client/ts/custom-declarations/pickr-widget.d.ts b/teamapps-client/ts/custom-declarations/pickr-widget.d.ts index 338e21b92..0937d3c75 100644 --- a/teamapps-client/ts/custom-declarations/pickr-widget.d.ts +++ b/teamapps-client/ts/custom-declarations/pickr-widget.d.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/teamapps-client/ts/custom-declarations/shaka-player.d.ts b/teamapps-client/ts/custom-declarations/shaka-player.d.ts deleted file mode 100644 index 02528fa1b..000000000 --- a/teamapps-client/ts/custom-declarations/shaka-player.d.ts +++ /dev/null @@ -1,74 +0,0 @@ -/*- - * ========================LICENSE_START================================= - * TeamApps - * --- - * Copyright (C) 2014 - 2019 TeamApps.org - * --- - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * =========================LICENSE_END================================== - */ -declare namespace shaka { - let polyfill: { - installAll: () => void - }; - - class Player { - constructor(video: HTMLMediaElement, dependencyInjector?: (player: shaka.Player) => void); - - static isBrowserSupported(): boolean; - - addEventListener(eventType: string, handler: (event: Event) => void): void; - - load(manifestUri: string, startTime?: number, manifestParserFactory?: any): Promise; - - unload(): Promise; - - getMediaElement(): HTMLMediaElement; - - getTracks(): Track[]; - - destroy(): Promise; - } - - namespace util { - interface Error { - category: number; - code: number; - data: any[]; - severity: number; - } - } - - interface Track { - id: number; - active: boolean; - type: string; - bandwidth: number; - language: string; - label?: string; - kind?: string; - width?: number; - height?: number; - frameRate?: number; - mimeType?: string; - codecs?: string; - audioCodec?: string; - videoCodec?: string; - primary: boolean; - roles: string[]; - videoId?: number; - audioId?: number - } -} - -export = shaka; diff --git a/teamapps-client/ts/custom-declarations/ulp.d.ts b/teamapps-client/ts/custom-declarations/ulp.d.ts new file mode 100644 index 000000000..dd40c3fac --- /dev/null +++ b/teamapps-client/ts/custom-declarations/ulp.d.ts @@ -0,0 +1,30 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +declare module "ulp" { + + let ulp: { + nextUp: (x: number) => number; + nextDown: (x: number) => number; + ulp: (x: number) => number; + monkeypatch: () => void; + }; + export = ulp; + +} diff --git a/teamapps-client/ts/custom-declarations/voice-activity-detection.d.ts b/teamapps-client/ts/custom-declarations/voice-activity-detection.d.ts new file mode 100644 index 000000000..4980ef7b2 --- /dev/null +++ b/teamapps-client/ts/custom-declarations/voice-activity-detection.d.ts @@ -0,0 +1,41 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +declare module "voice-activity-detection" { + export default function vad(audioContext: AudioContext, stream: MediaStream, options: { + fftSize?: number, + bufferLen?: number, + smoothingTimeConstant?: number, + minCaptureFreq?: number, // in Hz + maxCaptureFreq?: number, // in Hz + noiseCaptureDuration?: number, // in ms + minNoiseLevel?: number, // from 0 to 1 + maxNoiseLevel?: number, // from 0 to 1 + avgNoiseMultiplier?: number, + onVoiceStart?: () => void, + onVoiceStop?: () => void, + onUpdate?: (val: number) => void + }): VoiceActivityDetectionHandle; + + export type VoiceActivityDetectionHandle = { + connect: () => void, + disconnect: () => void, + destroy: () => void + } +} diff --git a/teamapps-client/ts/custom-declarations/webui-popover.d.ts b/teamapps-client/ts/custom-declarations/webui-popover.d.ts index 3db92e4c6..48cc51feb 100644 --- a/teamapps-client/ts/custom-declarations/webui-popover.d.ts +++ b/teamapps-client/ts/custom-declarations/webui-popover.d.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/teamapps-client/ts/custom-declarations/worker-loader.d.ts b/teamapps-client/ts/custom-declarations/worker-loader.d.ts index 77a1b2aa3..5f588c36e 100644 --- a/teamapps-client/ts/custom-declarations/worker-loader.d.ts +++ b/teamapps-client/ts/custom-declarations/worker-loader.d.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,9 +18,9 @@ * =========================LICENSE_END================================== */ declare module "worker-loader!*" { - class WebpackWorker extends Worker { - constructor(); - } + class WebpackWorker extends Worker { + constructor(); + } - export default WebpackWorker; + export = WebpackWorker; } diff --git a/teamapps-client/ts/modules/AbstractUiComponent.ts b/teamapps-client/ts/modules/AbstractUiComponent.ts new file mode 100644 index 000000000..2cdb45a14 --- /dev/null +++ b/teamapps-client/ts/modules/AbstractUiComponent.ts @@ -0,0 +1,221 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +import {TeamAppsUiContext} from "./TeamAppsUiContext"; +import * as log from "loglevel"; +import {UiComponentConfig} from "../generated/UiComponentConfig"; +import {TeamAppsEvent} from "./util/TeamAppsEvent"; +import {generateUUID} from "./Common"; +import ResizeObserver from 'resize-observer-polyfill'; +import {debounce, DebounceMode} from "./util/debounce"; +import {DeferredExecutor} from "./util/DeferredExecutor"; +import {UiComponent} from "./UiComponent"; + +export abstract class AbstractUiComponent implements UiComponent { + + protected readonly logger: log.Logger = log.getLogger(((this.constructor)).name || this.constructor.toString().match(/\w+/g)[1]); + + public readonly onVisibilityChanged: TeamAppsEvent = new TeamAppsEvent(); + public readonly deFactoVisibilityChanged: TeamAppsEvent = new TeamAppsEvent(); + public readonly onResized: TeamAppsEvent<{width: number; height: number}> = new TeamAppsEvent(); + + private width: number = 0; + private height: number = 0; + + private visible = true; + + public displayedDeferredExecutor = new DeferredExecutor(); + + constructor(protected _config: C, + protected _context: TeamAppsUiContext) { + if (_config.id == null) { + _config.id = generateUUID(); + } + + this.visible = _config.visible; + } + + public getId(): string { + return this._config.id; + } + + public getTeamAppsType(): string { + return this._config._type; + } + + protected reLayout(width: number, height: number): void { + let hasSize = width > 0 || height > 0; + if (this.width === width && this.height === height) { + return; + } + this.width = width; + this.height = height; + this.displayedDeferredExecutor.ready = hasSize; // do this after onResize gets called, since + if (hasSize) { + this.logger.trace("resize: " + this.getId()); + this.onResized.fire({width: this.width, height: this.height}); + this.onResize(); + } + this.deFactoVisibilityChanged.fireIfChanged(hasSize); + } + + /** + * This method gets called whenever the available size for this component changes. + */ + public onResize(): void { + // do nothing (default implementation) + } + + /** + * This method is called when the component gets destroyed. + * It should be used to release any resources the component holds. This can be: + * - additional DOM elements that are not children of the component's main DOM element, e.g. attached to the document's body. + * - other resources that are not released just by removing the component's main DOM element + */ + public destroy() { + this.getMainElement().remove(); + } + + private firstTimeGetMainElementCalled = true; + /** + * @return The main DOM element of this component. + */ + public getMainElement(): HTMLElement { + let element = this.doGetMainElement(); + + if (this.firstTimeGetMainElementCalled) { + this.firstTimeGetMainElementCalled = false; + + element.setAttribute("data-teamapps-id", this._config.id); + + if (this._config.debuggingId != null) { + element.setAttribute("data-teamapps-debugging-id", this._config.debuggingId); + } + + element.classList.toggle("invisible-component", this.visible == null ? false : !this.visible); + if (this._config.stylesBySelector != null) { // might be null when used via JavaScript API! + Object.keys(this._config.stylesBySelector).forEach(selector => this.setStyle(selector, this._config.stylesBySelector[selector])); + } + if (this._config.classNamesBySelector != null) { // might be null when used via JavaScript API! + Object.keys(this._config.classNamesBySelector).forEach(selector => this.setClassNames(selector, this._config.classNamesBySelector[selector])); + } + if (this._config.attributesBySelector != null) { // might be null when used via JavaScript API! + Object.keys(this._config.attributesBySelector).forEach(selector => this.setAttributes(selector, this._config.attributesBySelector[selector])); + } + + let relayoutCalled = false; + let debouncedRelayout: (size?: {width: number, height: number}) => void = debounce((size?: {width: number, height: number}) => { + relayoutCalled = true; + this.reLayout(size.width, size.height); + }, 100, DebounceMode.BOTH); + const resizeObserver = new ResizeObserver(entries => { + for (let entry of entries) { + debouncedRelayout({width: entry.contentRect.width, height: entry.contentRect.height}); + } + }); + resizeObserver.observe(element); + setTimeout(() => { + // It seems like the resize observer does not always get called when the element gets attached + if (!relayoutCalled) { + debouncedRelayout({width: element.offsetWidth, height: element.offsetHeight}); + } + }, 300); // TODO remove when problems with missing resizeObserver calls are solved... + } + + return element; + }; + + protected abstract doGetMainElement(): HTMLElement; + + public getWidth(): number { + return this.width; + } + + public getHeight(): number { + return this.height; + } + + public isVisible() { + return this.visible; + } + + public setVisible(visible: boolean = true /*undefined == true!!*/, fireEvent = true) { + this.visible = visible; + if (this.getMainElement() != null) { // might not have been rendered yet, if setVisible is already called in the constructor/initializer + this.getMainElement().classList.toggle("invisible-component", !visible); + } + if (fireEvent) { + this.onVisibilityChanged.fire(visible); + } + } + + // TODO change this implementation to generate style sheets instead of setting styles! + public setStyle(selector:string, style: {[property: string]: string}) { + let targetElements = this.getElementForSelector(selector); + if (targetElements.length === 0) { + this.logger.error("Cannot set style on non-existing element. Selector: " + selector); + } else { + targetElements.forEach(tel => { + for (const [name, value] of Object.entries(style)) { + tel.style.setProperty(name, value); + } + }); + } + } + + public setClassNames(selector:string, classNames: {[className: string]: boolean}) { + let targetElement = this.getElementForSelector(selector); + if (targetElement.length === 0) { + this.logger.error("Cannot set css class on non-existing element. Selector: " + selector); + } else { + targetElement.forEach(el => { + for (let [className, enabled] of Object.entries(classNames)) { + el.classList.toggle(className, enabled); + } + }); + } + } + + public setAttributes(selector:string, attributes: {[property: string]: string}) { + let targetElement = this.getElementForSelector(selector); + if (targetElement.length === 0) { + this.logger.error("Cannot set attribute on non-existing element. Selector: " + selector); + } else { + targetElement.forEach(el => { + for (let [attribute, value] of Object.entries(attributes)) { + if (value === "__ta-deleted-attribute__") { + el.removeAttribute(attribute); + } else { + el.setAttribute(attribute, value); + } + } + }); + } + } + + private getElementForSelector(selector: string) { + let targetElement: HTMLElement[]; + if (!selector) { + targetElement = [this.getMainElement()]; + } else { + targetElement = Array.from((this.getMainElement() as HTMLElement).querySelectorAll(":scope " + selector)); + } + return targetElement; + } +} diff --git a/teamapps-client/ts/modules/Common.ts b/teamapps-client/ts/modules/Common.ts index f2f8f078c..3fafcb4a2 100644 --- a/teamapps-client/ts/modules/Common.ts +++ b/teamapps-client/ts/modules/Common.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,21 +17,16 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; -import "bootstrap-notify"; import * as log from "loglevel"; import {UiComponentConfig} from "../generated/UiComponentConfig"; -import {UiComponent} from "./UiComponent"; -import {TeamAppsUiContext} from "./TeamAppsUiContext"; import {UiEntranceAnimation} from "../generated/UiEntranceAnimation"; import {UiExitAnimation} from "../generated/UiExitAnimation"; import {UiPageDisplayMode} from "../generated/UiPageDisplayMode"; -import {UiNotification_Position} from "../generated/UiNotificationConfig"; -import {createUiColorCssString} from "./util/CssFormatUtil"; -import {UiColorConfig} from "../generated/UiColorConfig"; import {UiTemplateConfig} from "../generated/UiTemplateConfig"; -import {UiTextMatchingMode} from "../generated/UiTextMatchingMode"; -import * as moment from "moment-timezone"; +import {UiComponent} from "./UiComponent"; +import {UiPageTransition} from "../generated/UiPageTransition"; +import {UiRepeatableAnimation} from "../generated/UiRepeatableAnimation"; +import rgba from "color-rgba"; export type RenderingFunction = (data: any) => string; @@ -40,96 +35,139 @@ export type Renderer = { template: UiTemplateConfig }; -export type ImageRenderer = (imageIdentifier: string) => string; - const logger = log.getLogger("Commmon"); export class Constants { private static _SCROLLBAR_WIDTH: number; public static ENTRANCE_ANIMATION_CSS_CLASSES = { - [UiEntranceAnimation.LIGHTSPEED_IN]: "lightSpeedIn", - [UiEntranceAnimation.ROLL_IN]: "rollIn", - [UiEntranceAnimation.ZOOM_IN]: "zoomIn", - [UiEntranceAnimation.ZOOM_IN_DOWN]: "zoomInDown", - [UiEntranceAnimation.ZOOM_IN_LEFT]: "zoomInLeft", - [UiEntranceAnimation.ZOOM_IN_RIGHT]: "zoomInRight", - [UiEntranceAnimation.ZOOM_IN_UP]: "zoomInUp", - [UiEntranceAnimation.SLIDE_IN_UP]: "slideInUp", - [UiEntranceAnimation.SLIDE_IN_DOWN]: "slideInDown", - [UiEntranceAnimation.SLIDE_IN_LEFT]: "slideInLeft", - [UiEntranceAnimation.SLIDE_IN_RIGHT]: "slideInRight", - [UiEntranceAnimation.ROTATE_IN]: "rotateIn", - [UiEntranceAnimation.ROTATE_IN_DOWNLEFT]: "rotateInDownLeft", - [UiEntranceAnimation.ROTATE_IN_DOWNRIGHT]: "rotateInDownRight", - [UiEntranceAnimation.ROTATE_IN_UPLEFT]: "rotateInUpLeft", - [UiEntranceAnimation.ROTATE_IN_UPRIGHT]: "rotateInUpRight", - [UiEntranceAnimation.FLIP_IN_X]: "flipInX", - [UiEntranceAnimation.FLIP_IN_Y]: "flipInY", - [UiEntranceAnimation.FADE_IN]: "fadeIn", - [UiEntranceAnimation.FADE_IN_DOWN]: "fadeInDown", - [UiEntranceAnimation.FADE_IN_DOWNBIG]: "fadeInDownBig", - [UiEntranceAnimation.FADE_IN_LEFT]: "fadeInLeft", - [UiEntranceAnimation.FADE_IN_LEFTBIG]: "fadeInLeftBig", - [UiEntranceAnimation.FADE_IN_RIGHT]: "fadeInRight", - [UiEntranceAnimation.FADE_IN_RIGHTBIG]: "fadeInRightBig", - [UiEntranceAnimation.FADE_IN_UP]: "fadeInUp", - [UiEntranceAnimation.FADE_IN_UPBIG]: "fadeInUpBig", - [UiEntranceAnimation.BOUNCE_IN]: "bounceIn", - [UiEntranceAnimation.BOUNCE_IN_DOWN]: "bounceInDown", - [UiEntranceAnimation.BOUNCE_IN_LEFT]: "bounceInLeft", - [UiEntranceAnimation.BOUNCE_IN_RIGHT]: "bounceInRight", - [UiEntranceAnimation.BOUNCE_IN_UP]: "bounceInUp" + [UiEntranceAnimation.BACK_IN_DOWN]: "animate__animated animate__backInDown", + [UiEntranceAnimation.BACK_IN_LEFT]: "animate__animated animate__backInLeft", + [UiEntranceAnimation.BACK_IN_RIGHT]: "animate__animated animate__backInRight", + [UiEntranceAnimation.BACK_IN_UP]: "animate__animated animate__backInUp", + + [UiEntranceAnimation.LIGHT_SPEED_IN_RIGHT]: "animate__animated animate__lightSpeedIn", + [UiEntranceAnimation.LIGHT_SPEED_IN_LEFT]: "animate__animated animate__lightSpeedIn", + [UiEntranceAnimation.JACK_IN_THE_BOX]: "animate__animated animate__lightSpeedIn", + [UiEntranceAnimation.ROLL_IN]: "animate__animated animate__rollIn", + + [UiEntranceAnimation.ZOOM_IN]: "animate__animated animate__zoomIn", + [UiEntranceAnimation.ZOOM_IN_DOWN]: "animate__animated animate__zoomInDown", + [UiEntranceAnimation.ZOOM_IN_LEFT]: "animate__animated animate__zoomInLeft", + [UiEntranceAnimation.ZOOM_IN_RIGHT]: "animate__animated animate__zoomInRight", + [UiEntranceAnimation.ZOOM_IN_UP]: "animate__animated animate__zoomInUp", + + [UiEntranceAnimation.SLIDE_IN_UP]: "animate__animated animate__slideInUp", + [UiEntranceAnimation.SLIDE_IN_DOWN]: "animate__animated animate__slideInDown", + [UiEntranceAnimation.SLIDE_IN_LEFT]: "animate__animated animate__slideInLeft", + [UiEntranceAnimation.SLIDE_IN_RIGHT]: "animate__animated animate__slideInRight", + + [UiEntranceAnimation.ROTATE_IN]: "animate__animated animate__rotateIn", + [UiEntranceAnimation.ROTATE_IN_DOWNLEFT]: "animate__animated animate__rotateInDownLeft", + [UiEntranceAnimation.ROTATE_IN_DOWNRIGHT]: "animate__animated animate__rotateInDownRight", + [UiEntranceAnimation.ROTATE_IN_UPLEFT]: "animate__animated animate__rotateInUpLeft", + [UiEntranceAnimation.ROTATE_IN_UPRIGHT]: "animate__animated animate__rotateInUpRight", + + [UiEntranceAnimation.FLIP_IN_X]: "animate__animated animate__flipInX", + [UiEntranceAnimation.FLIP_IN_Y]: "animate__animated animate__flipInY", + + [UiEntranceAnimation.FADE_IN]: "animate__animated animate__fadeIn", + [UiEntranceAnimation.FADE_IN_DOWN]: "animate__animated animate__fadeInDown", + [UiEntranceAnimation.FADE_IN_DOWNBIG]: "animate__animated animate__fadeInDownBig", + [UiEntranceAnimation.FADE_IN_LEFT]: "animate__animated animate__fadeInLeft", + [UiEntranceAnimation.FADE_IN_LEFTBIG]: "animate__animated animate__fadeInLeftBig", + [UiEntranceAnimation.FADE_IN_RIGHT]: "animate__animated animate__fadeInRight", + [UiEntranceAnimation.FADE_IN_RIGHTBIG]: "animate__animated animate__fadeInRightBig", + [UiEntranceAnimation.FADE_IN_UP]: "animate__animated animate__fadeInUp", + [UiEntranceAnimation.FADE_IN_UPBIG]: "animate__animated animate__fadeInUpBig", + [UiEntranceAnimation.FADE_IN_TOP_LEFT]: "animate__animated animate__fadeInTopLeft", + [UiEntranceAnimation.FADE_IN_TOP_RIGHT]: "animate__animated animate__fadeInTopRight", + [UiEntranceAnimation.FADE_IN_BOTTOM_LEFT]: "animate__animated animate__fadeInBottomLeft", + [UiEntranceAnimation.FADE_IN_BOTTOM_RIGHT]: "animate__animated animate__fadeInBottomRight", + + [UiEntranceAnimation.BOUNCE_IN]: "animate__animated animate__bounceIn", + [UiEntranceAnimation.BOUNCE_IN_DOWN]: "animate__animated animate__bounceInDown", + [UiEntranceAnimation.BOUNCE_IN_LEFT]: "animate__animated animate__bounceInLeft", + [UiEntranceAnimation.BOUNCE_IN_RIGHT]: "animate__animated animate__bounceInRight", + [UiEntranceAnimation.BOUNCE_IN_UP]: "animate__animated animate__bounceInUp" }; public static EXIT_ANIMATION_CSS_CLASSES = { - [UiExitAnimation.LIGHTSPEED_OUT]: "lightSpeedOut", - [UiExitAnimation.ROLL_OUT]: "rollOut", - [UiExitAnimation.HINGE]: "hinge", - [UiExitAnimation.ZOOM_OUT]: "zoomOut", - [UiExitAnimation.ZOOM_OUT_DOWN]: "zoomOutDown", - [UiExitAnimation.ZOOM_OUT_LEFT]: "zoomOutLeft", - [UiExitAnimation.ZOOM_OUT_RIGHT]: "zoomOutRight", - [UiExitAnimation.ZOOM_OUT_UP]: "zoomOutUp", - [UiExitAnimation.SLIDE_OUT_UP]: "slideOutUp", - [UiExitAnimation.SLIDE_OUT_DOWN]: "slideOutDown", - [UiExitAnimation.SLIDE_OUT_LEFT]: "slideOutLeft", - [UiExitAnimation.SLIDE_OUT_RIGHT]: "slideOutRight", - [UiExitAnimation.ROTATE_OUT]: "rotateOut", - [UiExitAnimation.ROTATE_OUT_DOWNLEFT]: "rotateOutDownLeft", - [UiExitAnimation.ROTATE_OUT_DOWNRIGHT]: "rotateOutDownRight", - [UiExitAnimation.ROTATE_OUT_UPLEFT]: "rotateOutUpLeft", - [UiExitAnimation.ROTATE_OUT_UPRIGHT]: "rotateOutUpRight", - [UiExitAnimation.FLIP_OUT_X]: "flipOutX", - [UiExitAnimation.FLIP_OUT_Y]: "flipOutY", - [UiExitAnimation.FADE_OUT]: "fadeOut", - [UiExitAnimation.FADE_OUT_DOWN]: "fadeOutDown", - [UiExitAnimation.FADE_OUT_DOWNBIG]: "fadeOutDownBig", - [UiExitAnimation.FADE_OUT_LEFT]: "fadeOutLeft", - [UiExitAnimation.FADE_OUT_LEFTBIG]: "fadeOutLeftBig", - [UiExitAnimation.FADE_OUT_RIGHT]: "fadeOutRight", - [UiExitAnimation.FADE_OUT_RIGHTBIG]: "fadeOutRightBig", - [UiExitAnimation.FADE_OUT_UP]: "fadeOutUp", - [UiExitAnimation.FADE_OUT_UPBIG]: "fadeOutUpBig", - [UiExitAnimation.BOUNCE_OUT]: "bounceOut", - [UiExitAnimation.BOUNCE_OUT_DOWN]: "bounceOutDown", - [UiExitAnimation.BOUNCE_OUT_LEFT]: "bounceOutLeft", - [UiExitAnimation.BOUNCE_OUT_RIGHT]: "bounceOutRight", - [UiExitAnimation.BOUNCE_OUT_UP]: "bounceOutUp" + [UiExitAnimation.BACK_OUT_DOWN]: "animate__animated animate__backOutDown", + [UiExitAnimation.BACK_OUT_LEFT]: "animate__animated animate__backOutLeft", + [UiExitAnimation.BACK_OUT_RIGHT]: "animate__animated animate__backOutRight", + [UiExitAnimation.BACK_OUT_UP]: "animate__animated animate__backOutUp", + + [UiExitAnimation.LIGHT_SPEED_OUT_RIGHT]: "animate__animated animate__lightSpeedOutRight", + [UiExitAnimation.LIGHT_SPEED_OUT_LEFT]: "animate__animated animate__lightSpeedOutLeft", + [UiExitAnimation.ROLL_OUT]: "animate__animated animate__rollOut", + [UiExitAnimation.HINGE]: "animate__animated animate__hinge", + + [UiExitAnimation.ZOOM_OUT]: "animate__animated animate__zoomOut", + [UiExitAnimation.ZOOM_OUT_DOWN]: "animate__animated animate__zoomOutDown", + [UiExitAnimation.ZOOM_OUT_LEFT]: "animate__animated animate__zoomOutLeft", + [UiExitAnimation.ZOOM_OUT_RIGHT]: "animate__animated animate__zoomOutRight", + [UiExitAnimation.ZOOM_OUT_UP]: "animate__animated animate__zoomOutUp", + + [UiExitAnimation.SLIDE_OUT_UP]: "animate__animated animate__slideOutUp", + [UiExitAnimation.SLIDE_OUT_DOWN]: "animate__animated animate__slideOutDown", + [UiExitAnimation.SLIDE_OUT_LEFT]: "animate__animated animate__slideOutLeft", + [UiExitAnimation.SLIDE_OUT_RIGHT]: "animate__animated animate__slideOutRight", + + [UiExitAnimation.ROTATE_OUT]: "animate__animated animate__rotateOut", + [UiExitAnimation.ROTATE_OUT_DOWNLEFT]: "animate__animated animate__rotateOutDownLeft", + [UiExitAnimation.ROTATE_OUT_DOWNRIGHT]: "animate__animated animate__rotateOutDownRight", + [UiExitAnimation.ROTATE_OUT_UPLEFT]: "animate__animated animate__rotateOutUpLeft", + [UiExitAnimation.ROTATE_OUT_UPRIGHT]: "animate__animated animate__rotateOutUpRight", + + [UiExitAnimation.FLIP_OUT_X]: "animate__animated animate__flipOutX", + [UiExitAnimation.FLIP_OUT_Y]: "animate__animated animate__flipOutY", + + [UiExitAnimation.FADE_OUT]: "animate__animated animate__fadeOut", + [UiExitAnimation.FADE_OUT_DOWN]: "animate__animated animate__fadeOutDown", + [UiExitAnimation.FADE_OUT_DOWNBIG]: "animate__animated animate__fadeOutDownBig", + [UiExitAnimation.FADE_OUT_LEFT]: "animate__animated animate__fadeOutLeft", + [UiExitAnimation.FADE_OUT_LEFTBIG]: "animate__animated animate__fadeOutLeftBig", + [UiExitAnimation.FADE_OUT_RIGHT]: "animate__animated animate__fadeOutRight", + [UiExitAnimation.FADE_OUT_RIGHTBIG]: "animate__animated animate__fadeOutRightBig", + [UiExitAnimation.FADE_OUT_UP]: "animate__animated animate__fadeOutUp", + [UiExitAnimation.FADE_OUT_UPBIG]: "animate__animated animate__fadeOutUpBig", + [UiExitAnimation.FADE_OUT_TOP_LEFT]: "animate__animated animate__fadeOutTopLeft", + [UiExitAnimation.FADE_OUT_TOP_RIGHT]: "animate__animated animate__fadeOutTopRight", + [UiExitAnimation.FADE_OUT_BOTTOM_RIGHT]: "animate__animated animate__fadeOutBottomRight", + [UiExitAnimation.FADE_OUT_BOTTOM_LEFT]: "animate__animated animate__fadeOutBottomLeft", + + [UiExitAnimation.BOUNCE_OUT]: "animate__animated animate__bounceOut", + [UiExitAnimation.BOUNCE_OUT_DOWN]: "animate__animated animate__bounceOutDown", + [UiExitAnimation.BOUNCE_OUT_LEFT]: "animate__animated animate__bounceOutLeft", + [UiExitAnimation.BOUNCE_OUT_RIGHT]: "animate__animated animate__bounceOutRight", + [UiExitAnimation.BOUNCE_OUT_UP]: "animate__animated animate__bounceOutUp" }; - public static POINTER_EVENTS = window.navigator.pointerEnabled ? { + public static REPEATABLE_ANIMATION_CSS_CLASSES = { + [UiRepeatableAnimation.BOUNCE]: "animate__animated animate__bounce", + [UiRepeatableAnimation.FLASH]: "animate__animated animate__flash", + [UiRepeatableAnimation.PULSE]: "animate__animated animate__pulse", + [UiRepeatableAnimation.RUBBER_BAND]: "animate__animated animate__rubberBand", + [UiRepeatableAnimation.SHAKE_X]: "animate__animated animate__shakeX", + [UiRepeatableAnimation.SHAKE_Y]: "animate__animated animate__shakeY", + [UiRepeatableAnimation.HEAD_SHAKE]: "animate__animated animate__headShake", + [UiRepeatableAnimation.SWING]: "animate__animated animate__swing", + [UiRepeatableAnimation.TADA]: "animate__animated animate__tada", + [UiRepeatableAnimation.WOBBLE]: "animate__animated animate__wobble", + [UiRepeatableAnimation.JELLO]: "animate__animated animate__jello", + [UiRepeatableAnimation.HEART_BEAT]: "animate__animated animate__heartBeat", + [UiRepeatableAnimation.FLIP]: "animate__animated animate__flip", + + // custom: + [UiRepeatableAnimation.BLINK]: "ta-blink", + [UiRepeatableAnimation.BLINK_SUBTLE]: "ta-blink-subtle" + } + + public static POINTER_EVENTS = { start: 'pointerdown', move: 'pointermove', end: 'pointerup' - } : window.navigator.msPointerEnabled ? { - start: 'MSPointerDown', - move: 'MSPointerMove', - end: 'MSPointerUp' - } : { - start: 'mousedown touchstart', - move: 'mousemove touchmove', - end: 'mouseup touchend' }; static get SCROLLBAR_WIDTH() { @@ -140,27 +178,24 @@ export class Constants { } private static calculateScrollbarWidth() { - const $div = $(`
`) - .appendTo(document.body); - const widthNoScroll = $div[0].clientWidth; - $div.css("overflow-y", "scroll"); - const widthWithScroll = $div[0].clientWidth; - $div.detach(); + const $div = parseHtml(`
`); + document.body.appendChild($div); + const widthNoScroll = $div.clientWidth; + $div.style.overflowY = "scroll"; + const widthWithScroll = $div.clientWidth; + $div.remove(); return widthNoScroll - widthWithScroll; } } +export function getAutoCompleteOffValue(): string { + return "no"; +} + export function hasVerticalScrollBar(element: HTMLElement): boolean { return element.scrollWidth < element.offsetWidth; } -export const matchingModesMapping: { [x in UiTextMatchingMode]: 'contains' | 'prefix' | 'prefix-word' | 'prefix-levenshtein' | 'levenshtein' } = { - [UiTextMatchingMode.PREFIX]: "prefix", - [UiTextMatchingMode.PREFIX_WORD]: "prefix-word", - [UiTextMatchingMode.CONTAINS]: "contains", - [UiTextMatchingMode.SIMILARITY]: "levenshtein" -}; - export function loadScriptAsynchronously(url: string, callback?: EventListener) { const scriptElement = document.createElement('script'); scriptElement.src = url; @@ -175,10 +210,11 @@ export interface ClickOutsideHandle { cancel: () => void } -export function doOnceOnClickOutsideElement(elements: JQuery, handler: (e?: JQueryMouseEventObject) => any, useCapture = false): ClickOutsideHandle { +export function doOnceOnClickOutsideElement(elements: Element | NodeList | Element[], handler: (e?: MouseEvent) => any, useCapture = false): ClickOutsideHandle { const eventType = "mousedown"; - let handlerWrapper = (e: JQueryMouseEventObject) => { - if (!elements.toArray().some((element) => e.target === element || $(e.target).parents().toArray().indexOf(element) !== -1)) { + const elementsAsArray = elements instanceof Element ? [elements] : Array.from(elements); + let handlerWrapper = (e: MouseEvent) => { + if (closestAncestorMatching(e.target as Element, ancestor => (elementsAsArray.indexOf(ancestor) !== -1), true) == null) { handler(e); removeMouseDownListener(); } @@ -319,101 +355,170 @@ export function formatDecimalNumber(integerNumber: number, precision: number, de return (integerNumber < 0 ? '-' : '') + formattedIntegerPart + decimalSeparator + fractionalPart; } -export function applyDisplayMode($outer: JQuery, $inner: JQuery, displayMode: UiPageDisplayMode | any, options?: { - innerPreferedDimensions?: { // inferred for img +export function applyDisplayMode($outer: HTMLElement, $inner: HTMLElement, displayMode: UiPageDisplayMode | any, options?: { + innerPreferredDimensions?: { // only needed for ORIGINAL_SIZE! width: number, height: number, - } + }, zoomFactor?: number, padding?: number, - considerScrollbars?: Boolean + considerScrollbars?: boolean }) { options = $.extend({}, options); // copy the options as we are potentially making changes to the object... - if (options.innerPreferedDimensions == null || !options.innerPreferedDimensions.width || !options.innerPreferedDimensions.height) { - if ($inner[0] instanceof HTMLImageElement) { - let imgElement = $($inner)[0]; - options.innerPreferedDimensions = { + if (options.innerPreferredDimensions == null || !options.innerPreferredDimensions.width || !options.innerPreferredDimensions.height) { + if ($inner instanceof HTMLImageElement && $inner.naturalHeight > 0) { + let imgElement = $($inner)[0]; + options.innerPreferredDimensions = { width: imgElement.naturalWidth, height: imgElement.naturalHeight } } else { - $inner.css({ - width: "100%", - height: "100%" - }); + $inner.style.width = "100%"; + $inner.style.height = "100%"; return; } } - options.zoomFactor = options.zoomFactor || 1; if (options.padding == null) { - options.padding = parseInt($outer.css("padding-left")) || 0; + options.padding = parseInt($outer.style.paddingLeft) || 0; } + let availableWidth = $outer.offsetWidth - 2 * options.padding; + let availableHeight = $outer.offsetHeight - 2 * options.padding; + + let size = calculateDisplayModeInnerSize({ + width: availableWidth, + height: availableHeight + }, options.innerPreferredDimensions, displayMode, options.zoomFactor, options.considerScrollbars); + $inner.style.width = size.width + "px"; + $inner.style.height = size.height + "px"; +} - let availableWidth = $outer.width() - 2 * options.padding; - let availableHeight = $outer.height() - 2 * options.padding; - let viewPortAspectRatio = availableWidth / availableHeight; - let imageAspectRatio = options.innerPreferedDimensions.width / options.innerPreferedDimensions.height; - - logger.trace(`outer dimensions: ${availableWidth}x${availableHeight}`); - logger.trace(`inner dimensions: ${options.innerPreferedDimensions.width}x${options.innerPreferedDimensions.height}`); +export function calculateDisplayModeInnerSize(containerDimensions: { width: number, height: number }, + innerPreferredDimensions: { width: number, height: number }, + displayMode: UiPageDisplayMode | any, + zoomFactor: number = 1, + considerScrollbars = false +): { width: number, height: number } { + let viewPortAspectRatio = containerDimensions.width / containerDimensions.height; + let imageAspectRatio = innerPreferredDimensions.width / innerPreferredDimensions.height; + + logger.trace(`outer dimensions: ${containerDimensions.width}x${containerDimensions.height}`); + logger.trace(`inner dimensions: ${innerPreferredDimensions.width}x${innerPreferredDimensions.height}`); logger.trace(`displayMode: ${UiPageDisplayMode[displayMode]}`); if (displayMode === UiPageDisplayMode.FIT_WIDTH) { - let width = Math.floor(availableWidth * options.zoomFactor); - if (options.considerScrollbars && options.zoomFactor <= 1 && Math.ceil(width / imageAspectRatio) > availableHeight) { + let width = Math.floor(containerDimensions.width * zoomFactor); + if (considerScrollbars && zoomFactor <= 1 && Math.ceil(width / imageAspectRatio) > containerDimensions.height) { // There will be a vertical scroll bar, so make sure the width will not result in a horizontal scrollbar, too // NOTE: Chrome still shows scrollbars sometimes. https://bugs.chromium.org/p/chromium/issues/detail?id=240772&can=2&start=0&num=100&q=&colspec=ID%20Pri%20M%20Stars%20ReleaseBlock%20Component%20Status%20Owner%20Summary%20OS%20Modified&groupby=&sort= - width = Math.min(width, availableWidth - Constants.SCROLLBAR_WIDTH); + width = Math.min(width, containerDimensions.width - Constants.SCROLLBAR_WIDTH); } - $inner.css({ - width: width + "px", - height: "auto" - }); + return {width: width, height: width / imageAspectRatio}; } else if (displayMode === UiPageDisplayMode.FIT_HEIGHT) { - let height = Math.floor(availableHeight * options.zoomFactor); - if (options.considerScrollbars && options.zoomFactor <= 1 && height * imageAspectRatio > availableWidth) { + let height = Math.floor(containerDimensions.height * zoomFactor); + if (considerScrollbars && zoomFactor <= 1 && height * imageAspectRatio > containerDimensions.width) { // There will be a horizontal scroll bar, so make sure the width will not result in a vertical scrollbar, too // NOTE: Chrome still shows scrollbars sometimes. https://bugs.chromium.org/p/chromium/issues/detail?id=240772&can=2&start=0&num=100&q=&colspec=ID%20Pri%20M%20Stars%20ReleaseBlock%20Component%20Status%20Owner%20Summary%20OS%20Modified&groupby=&sort= - height = Math.min(height, availableHeight - Constants.SCROLLBAR_WIDTH); + height = Math.min(height, containerDimensions.height - Constants.SCROLLBAR_WIDTH); } - $inner.css({ - width: "auto", - height: height + "px" - }); + return {width: height * imageAspectRatio, height: height}; } else if (displayMode === UiPageDisplayMode.FIT_SIZE) { if (imageAspectRatio > viewPortAspectRatio) { - let width = Math.floor(availableWidth * options.zoomFactor); - $inner.css({ - width: width + "px", - height: imageAspectRatio ? width / imageAspectRatio : "auto" - }); + let width = Math.floor(containerDimensions.width * zoomFactor); + return {width: width, height: width / imageAspectRatio}; } else { - let height = Math.floor(availableHeight * options.zoomFactor); - $inner.css({ - width: imageAspectRatio ? height * imageAspectRatio : "auto", - height: height + "px" - }); + let height = Math.floor(containerDimensions.height * zoomFactor); + return {width: height * imageAspectRatio, height: height}; } } else if (displayMode === UiPageDisplayMode.COVER) { if (imageAspectRatio < viewPortAspectRatio) { - $inner.css({ - width: Math.floor(availableWidth * options.zoomFactor) + "px", - height: "auto" - }); + let width = Math.floor(containerDimensions.width * zoomFactor); + return {width: width, height: width / imageAspectRatio}; } else { - $inner.css({ - width: "auto", - height: Math.floor(availableHeight * options.zoomFactor) + "px" - }); + let height = Math.floor(containerDimensions.height * zoomFactor); + return {width: height * imageAspectRatio, height: height}; } } else { // ORIGINAL_SIZE - $inner.css({ - width: (options.innerPreferedDimensions.width * options.zoomFactor) + "px", - height: "auto" - }); + let width = innerPreferredDimensions.width * zoomFactor; + return {width: width, height: width / imageAspectRatio}; } } +export type Direction = "n" | "e" | "s" | "w" | "ne" | "se" | "nw" | "sw"; + +export function boundSelection( + selection: { left: number, top: number, width: number, height: number }, + bounds: { width: number, height: number }, + aspectRatio?: number, + fixedAt?: Direction +) { + let newSelection = {...selection}; + + if (fixedAt == null) { + if (newSelection.width > bounds.width) { + newSelection.width = bounds.width; + } + if (newSelection.height > bounds.height) { + newSelection.height = bounds.height; + } + if (aspectRatio != null && aspectRatio > 0) { + if (newSelection.width / newSelection.height > aspectRatio) { + newSelection.width = newSelection.height * aspectRatio; + } else { + newSelection.height = newSelection.width / aspectRatio; + } + } + if (newSelection.left < 0) { + newSelection.left = 0; + } + if (newSelection.left + newSelection.width > bounds.width) { + newSelection.left = bounds.width - newSelection.width; + } + if (newSelection.top < 0) { + newSelection.top = 0; + } + if (newSelection.top + newSelection.height > bounds.height) { + newSelection.top = bounds.height - newSelection.height; + } + } else { + let selectionXCenter = selection.left + selection.width / 2; + let selectionYCenter = selection.top + selection.height / 2; + let maxWidth = + fixedAt == "n" || fixedAt == "s" ? Math.min(Math.min(selectionXCenter, bounds.width - selectionXCenter) * 2, bounds.width) : + fixedAt == "e" || fixedAt == "ne" || fixedAt == "se" ? Math.min(selection.left + selection.width, bounds.width) : + fixedAt == "w" || fixedAt == "nw" || fixedAt == "sw" ? Math.min(bounds.width - selection.left, bounds.width) + : bounds.width; + let maxHeight = + fixedAt == "e" || fixedAt == "w" ? Math.min(Math.min(selectionYCenter, bounds.height - selectionYCenter) * 2, bounds.height) : + fixedAt == "s" || fixedAt == "se" || fixedAt == "sw" ? Math.min(selection.top + selection.height, bounds.height) : + fixedAt == "n" || fixedAt == "ne" || fixedAt == "nw" ? Math.min(bounds.height - selection.top, bounds.height) + : bounds.height; + + newSelection.width = Math.min(newSelection.width, maxWidth); + newSelection.height = Math.min(newSelection.height, maxHeight); + + if (aspectRatio != null && aspectRatio > 0) { + if (aspectRatio > newSelection.width / newSelection.height) { + newSelection.height = newSelection.width / aspectRatio; + } else if (aspectRatio < newSelection.width / newSelection.height) { + newSelection.width = newSelection.height * aspectRatio; + } + } + + newSelection.left = + fixedAt == "n" || fixedAt == "s" ? selectionXCenter - newSelection.width / 2 : + fixedAt == "e" || fixedAt == "ne" || fixedAt == "se" ? selection.left + selection.width - newSelection.width : + fixedAt == "w" || fixedAt == "nw" || fixedAt == "sw" ? selection.left + : 0; + newSelection.top = + fixedAt == "e" || fixedAt == "w" ? selectionYCenter - newSelection.height / 2 : + fixedAt == "s" || fixedAt == "se" || fixedAt == "sw" ? selection.top + selection.height - newSelection.height : + fixedAt == "n" || fixedAt == "ne" || fixedAt == "nw" ? selection.top + : 0; + } + return newSelection; +} + + // ===== FULLSCREEN HANDLING ===== (maybe extract this to own file...) document.addEventListener("fullscreenchange", fullScreenChangeHandler); @@ -421,28 +526,23 @@ document.addEventListener("webkitfullscreenchange", fullScreenChangeHandler); document.addEventListener("mozfullscreenchange", fullScreenChangeHandler); document.addEventListener("MSFullscreenChange", fullScreenChangeHandler); -export function enterFullScreen(component: UiComponent | JQuery | Element) { - let element: Element; - if (component instanceof UiComponent) { - element = component.getMainDomElement()[0]; - } else { - element = $(component)[0]; - } - $(element).addClass("fullscreen"); +export function enterFullScreen(component: UiComponent) { + let element: Element = component.getMainElement(); + element.classList.add("fullscreen"); if (element.requestFullscreen) { element.requestFullscreen(); } else if ((element as any).msRequestFullscreen) { (element as any).msRequestFullscreen(); } else if ((element as any).mozRequestFullScreen) { (element as any).mozRequestFullScreen(); - } else if (element.webkitRequestFullscreen) { - element.webkitRequestFullscreen(); + } else if ((element as any).webkitRequestFullscreen) { + (element as any).webkitRequestFullscreen(); } } function fullScreenChangeHandler(e: Event) { if (!getFullScreenElement()) { - $(e.target).removeClass("fullscreen"); + (e.target as Element).classList.remove("fullscreen"); } } @@ -472,88 +572,55 @@ export function exitFullScreen() { } } -export function hexColorStringToRgb(hexColorString: string) { - const rgb = parseInt(hexColorString.substring(1), 16); // convert rrggbb to decimal - let r; - let g; - let b; - if (hexColorString.length === 7) { - r = (rgb >> 16) & 0xff; // extract red - g = (rgb >> 8) & 0xff; // extract green - b = (rgb >> 0) & 0xff; // extract blue - } else { - r = (((rgb >> 8) & 0xf) << 4) + ((rgb >> 8) & 0xf); // extract red - g = (((rgb >> 4) & 0xf) << 4) + ((rgb >> 4) & 0xf); // extract green - b = ((rgb & 0xf) << 4) + (rgb & 0xf); // extract blue - logger.trace(`rgb: ${r} ${g} ${b}`); - } - return {r, g, b}; -} - -export function adjustIfColorTooBright(c: string, maxLuma256: number = 210) { - if (c.indexOf('#') !== 0 || (c.length !== 4 && c.length !== 7)) { - return c; // do not normalize. performance is more important than supporting any kind of color coding. - } - const rgb = hexColorStringToRgb(c); - const luma = 0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b; // per ITU-R BT.709 - logger.trace("luma: " + luma); - if (luma > maxLuma256) { - let adjustionFactor = (maxLuma256 / luma); - return `rgb(${Math.floor(rgb.r * adjustionFactor)}, ${Math.floor(rgb.g * adjustionFactor)}, ${Math.floor(rgb.b * adjustionFactor)})`; - } else { - return c; - } -} - -export function positionDropDown($button: JQuery, $dropDown: JQuery, { +export function positionDropDown($button: Element, $dropDown: HTMLElement, { viewPortPadding = 10, minHeightBeforeFlipping = 200 }) { - $dropDown.removeClass("pseudo-hidden"); + $dropDown.classList.remove("pseudo-hidden"); - let maxHeight = window.innerHeight - ($button.offset().top + $button.outerHeight()) - viewPortPadding; - let maxFlippedHeight = $button.offset().top - viewPortPadding; + let boundingClientRect = $button.getBoundingClientRect(); + let maxHeight = window.innerHeight - (boundingClientRect.top + boundingClientRect.height) - viewPortPadding; + let maxFlippedHeight = boundingClientRect.top - viewPortPadding; let flip = maxHeight < minHeightBeforeFlipping && maxFlippedHeight > maxHeight; - ($dropDown).position({ + $($dropDown).position({ my: "left " + (flip ? "bottom" : "top"), at: "left " + (flip ? "top" : "bottom"), of: $button, collision: "fit none" }); - $dropDown.find('> .background-color-div, > .background-color-div > *').css({ - "max-height": (flip ? maxFlippedHeight : maxHeight) + "px" - }); - if ($dropDown[0].offsetWidth > window.innerWidth - 2 * viewPortPadding) { - $dropDown.css("width", "auto"); - $dropDown.css("left", viewPortPadding + "px"); - $dropDown.css("right", viewPortPadding + "px"); - } else if ($dropDown[0].offsetLeft + $dropDown[0].offsetWidth > window.innerWidth - viewPortPadding) { - $dropDown.css("left", "auto"); - $dropDown.css("right", viewPortPadding + "px"); + $dropDown.querySelector(':scope > .background-color-div').style.maxHeight = (flip ? maxFlippedHeight : maxHeight) + "px"; + $dropDown.querySelector(':scope > .background-color-div > *').style.maxHeight = (flip ? maxFlippedHeight : maxHeight) + "px"; + if ($dropDown.offsetWidth > window.innerWidth - 2 * viewPortPadding) { + $dropDown.style.width = "auto"; + $dropDown.style.left = viewPortPadding + "px"; + $dropDown.style.right = viewPortPadding + "px"; + } else if ($dropDown.offsetLeft + $dropDown.offsetWidth > window.innerWidth - viewPortPadding) { + $dropDown.style.left = "auto"; + $dropDown.style.right = viewPortPadding + "px"; } } -export function manipulateWithoutTransitions($element: JQuery, action: Function, transitionEnabled = false) { +export function manipulateWithoutTransitions($element: HTMLElement, action: Function, transitionEnabled = false) { if (!transitionEnabled) { - $element.addClass('notransition'); + $element.classList.add('notransition'); } action(); if (!transitionEnabled) { - $element[0].offsetHeight; // Trigger a reflow, flushing the CSS changes - $element.removeClass('notransition'); + $element.offsetHeight; // Trigger a reflow, flushing the CSS changes + $element.classList.remove('notransition'); } } -export function focusNextByTabIndex(navigatableElements: string | HTMLElement[] | JQuery, navDirection: -1 | 1): boolean { - let selectables: HTMLElement[] = $(navigatableElements).toArray().sort((e1: HTMLElement, e2: HTMLElement) => e2.tabIndex - e1.tabIndex); +export function focusNextByTabIndex(navigatableElements: HTMLElement[], navDirection: -1 | 1): boolean { + navigatableElements.sort((e1: HTMLElement, e2: HTMLElement) => e2.tabIndex - e1.tabIndex); let current = document.activeElement; - let currentIndex = selectables.indexOf(current as HTMLElement); - log.getLogger("Common").trace("selectables: " + selectables.length + "; current: " + currentIndex); + let currentIndex = navigatableElements.indexOf(current as HTMLElement); + log.getLogger("Common").trace("selectables: " + navigatableElements.length + "; current: " + currentIndex); let newIndex = currentIndex + navDirection; - if (newIndex > 0 && newIndex < selectables.length) { - logger.trace(selectables[newIndex]); - selectables[newIndex].focus(); + if (newIndex > 0 && newIndex < navigatableElements.length) { + logger.trace(navigatableElements[newIndex]); + navigatableElements[newIndex].focus(); return true; } else { return false; @@ -600,67 +667,6 @@ export function stableSort(arr: T[], cmpFunc: (a: T, b: T) => number) { }); } -export function showNotification(html: string, config?: { - backgroundColor?: UiColorConfig, - position?: UiNotification_Position, - displayTimeInMillis?: number, - dismissable?: boolean, - showProgressBar?: boolean, - entranceAnimation?: UiEntranceAnimation, - exitAnimation?: UiExitAnimation -}) { - config = { - backgroundColor: {_type: "UiColor", red: 255, green: 255, blue: 255, alpha: 1}, - position: UiNotification_Position.TOP_RIGHT, - displayTimeInMillis: 3000, - dismissable: true, - showProgressBar: true, - entranceAnimation: UiEntranceAnimation.SLIDE_IN_RIGHT, - exitAnimation: UiExitAnimation.SLIDE_OUT_RIGHT, - ...config - }; - - let position2placement = { - [UiNotification_Position.TOP_LEFT]: {from: 'top', align: 'left'}, - [UiNotification_Position.TOP_CENTER]: {from: 'top', align: 'center'}, - [UiNotification_Position.TOP_RIGHT]: {from: 'top', align: 'right'}, - [UiNotification_Position.BOTTOM_LEFT]: {from: 'bottom', align: 'left'}, - [UiNotification_Position.BOTTOM_CENTER]: {from: 'bottom', align: 'center'}, - [UiNotification_Position.BOTTOM_RIGHT]: {from: 'bottom', align: 'right'}, - }; - - $.notify({message: null}, { - // settings - element: 'body', - position: null, - allow_dismiss: config.dismissable, - newest_on_top: false, - showProgressbar: config.showProgressBar, - placement: position2placement[config.position], - offset: 20, - spacing: 10, - z_index: 1031, - delay: config.displayTimeInMillis, - timer: 1000, - mouse_over: null, - animate: { - enter: 'animated ' + Constants.ENTRANCE_ANIMATION_CSS_CLASSES[config.entranceAnimation], - exit: 'animated ' + Constants.EXIT_ANIMATION_CSS_CLASSES[config.exitAnimation] - }, - onShow: null, - onShown: null, - onClose: null, - onClosed: null, - template: `` - }); -} - export function getMicrosoftBrowserVersion() { const ua = window.navigator.userAgent; const msie = ua.indexOf('MSIE '); @@ -683,44 +689,28 @@ export function getMicrosoftBrowserVersion() { return false; } -export function getIconPath(context: TeamAppsUiContext, iconName: string, iconSize: number, ignoreRetina?: boolean): string { - if (!iconName) { - return null; - } - if (!ignoreRetina) { - iconSize = context.isHighDensityScreen ? iconSize * 2 : iconSize; - } - if (iconSize > 128) { // there are currently no icons larger than 128px - iconSize = 128; - } - if (iconName.indexOf("/") === 0) { - return iconName; // hack for static resources instead of icons... - } - return context.config.iconPath + '/' + iconSize + '/' + iconName; -} - -export function enableScrollViaDragAndDrop($scrollContainer: JQuery) { +export function enableScrollViaDragAndDrop($scrollContainer: HTMLElement) { function mousedownHandler(startEvent: MouseEvent) { - $scrollContainer.css("cursor", "move"); + $scrollContainer.style.cursor = "move"; startEvent.preventDefault(); - let initialScrollLeft = $scrollContainer.scrollLeft(); - let initialScrollTop = $scrollContainer.scrollTop(); - const moveEvent = 'mousemove'; - const endEvent = 'mouseup'; - $(document).on(moveEvent, (e) => { + let initialScrollLeft = $scrollContainer.scrollLeft; + let initialScrollTop = $scrollContainer.scrollTop; + let dragHandler = (e: PointerEvent) => { let diffX = e.pageX - startEvent.pageX; let diffY = e.pageY - startEvent.pageY; - $scrollContainer.scrollLeft(initialScrollLeft - diffX); - $scrollContainer.scrollTop(initialScrollTop - diffY); - }); - $(document).on(endEvent, (event) => { - $(document).unbind(moveEvent); - $(document).unbind(endEvent); - $scrollContainer.css("cursor", ""); - }); + $scrollContainer.scrollLeft = initialScrollLeft - diffX; + $scrollContainer.scrollTop = initialScrollTop - diffY; + }; + let dropHandler = (e: PointerEvent) => { + document.removeEventListener('pointermove', dragHandler); + document.removeEventListener('pointerup', dropHandler); + $scrollContainer.style.cursor = ""; + }; + document.addEventListener('pointermove', dragHandler); + document.addEventListener('pointerup', dropHandler); } - $scrollContainer[0].addEventListener("mousedown", (e) => mousedownHandler(e)); + $scrollContainer.addEventListener("mousedown", (e) => mousedownHandler(e)); } export function arraysEqual(a: any[], b: any[]) { @@ -740,31 +730,39 @@ export function arraysEqual(a: any[], b: any[]) { } } -export function convertJavaDateTimeFormatToMomentDateTimeFormat(javaFormat: string): string { - if (javaFormat == null) { - return null; +export function deepEquals(x: any, y: any): boolean { + if (x != null && y != null && typeof x === 'object' && typeof x === typeof y) { + if (Array.isArray(x)) { + return x.length === y.length && x.every((xi, i) => deepEquals(x[i], y[i])); + } else { + return Object.keys(x).length === Object.keys(y).length && + Object.keys(x).every(key => deepEquals(x[key], y[key])); + } } else { - return (moment() as any).toMomentFormatString(javaFormat); + return x === y + || ((x == null) && (y == null)); // make no difference between undefined and null! } } -export function insertAtIndex($parent: JQuery | Element, $child: JQuery | Element | string, index: number) { - let effectiveIndex = Math.min($($parent).children().length, index); +export function insertAtIndex($parent: Element, $child: Element, index: number) { + let effectiveIndex = Math.min($parent.childElementCount, index); if (effectiveIndex === 0) { - $($parent).prepend($child); + $parent.prepend($child); + } else if (effectiveIndex === $parent.childElementCount) { + $parent.insertAdjacentElement('beforeend', $child); } else { - $($parent).find(`>:nth-child(${effectiveIndex})`).after($child); + $parent.children[effectiveIndex].insertAdjacentElement('beforebegin', $child); } } export function maximizeComponent(component: UiComponent, maximizeAnimationCallback?: () => void) { - const $parentDomElement = component.getMainDomElement().parent(); - const scrollTop = $(document).scrollTop(); - const scrollLeft = $(document).scrollLeft(); - const offset = component.getMainDomElement().offset(); + const $parentDomElement = component.getMainElement().parentElement; + const scrollTop = window.scrollY; + const scrollLeft = window.scrollX; + const offset = component.getMainElement().getBoundingClientRect(); - const changingCssProperties: (keyof CSSStyleDeclaration)[] = ["position", "top", "left", "width", "height", "zIndex"]; - const style = component.getMainDomElement()[0].style as CSSStyleDeclaration; + const changingCssProperties: ["position", "top", "left", "width", "height", "zIndex"] = ["position", "top", "left", "width", "height", "zIndex"]; + const style = component.getMainElement().style as CSSStyleDeclaration; const originalCssValues = changingCssProperties.reduce((properties, cssPropertyName) => { properties[cssPropertyName] = style[cssPropertyName]; return properties; @@ -773,24 +771,21 @@ export function maximizeComponent(component: UiComponent, maximizeAnimationCallb const animationStartCssValues = { top: (offset.top - scrollTop) + "px", left: (offset.left - scrollLeft) + "px", - width: component.getMainDomElement().width(), - height: component.getMainDomElement().height(), + width: offset.width, + height: offset.height, }; - component.getMainDomElement().css({ - position: 'fixed', - "z-index": 1000000, + Object.assign(component.getMainElement().style, { ...animationStartCssValues - }).appendTo( - $('body') - ).addClass( - "teamapps-component-maximized" - ).animate({ + }); + document.body.appendChild(component.getMainElement()); + component.getMainElement().classList.add("teamapps-component-maximized"); + $(component.getMainElement()).animate({ top: "5px", left: "5px", - width: ($(window).width() - 10), - height: ($(window).height() - 10) + width: (window.innerWidth - 10), + height: (window.innerHeight - 10) }, 100, 'swing', () => { - component.getMainDomElement().css({ + css(component.getMainElement(), { width: "calc(100% - 10px)", height: "calc(100% - 10px)" }); @@ -798,10 +793,10 @@ export function maximizeComponent(component: UiComponent, maximizeAnimationCallb }); let restore = (restoreAnimationCallback?: () => void) => { - component.getMainDomElement().animate(animationStartCssValues, 100, 'swing', () => { - component.getMainDomElement().css(originalCssValues); - component.getMainDomElement().removeClass("teamapps-component-maximized"); - component.getMainDomElement().appendTo($parentDomElement); + $(component.getMainElement()).animate(animationStartCssValues, 100, 'swing', () => { + Object.assign(component.getMainElement().style, originalCssValues); + component.getMainElement().classList.remove("teamapps-component-maximized"); + $parentDomElement.appendChild(component.getMainElement()); restoreAnimationCallback && restoreAnimationCallback(); }); }; @@ -809,12 +804,6 @@ export function maximizeComponent(component: UiComponent, maximizeAnimationCallb return restore; } -export function flattenArray(array: (T | T[])[]): T[] { - return array.reduce(function (flat: T[], toFlatten: T | T[]) { - return flat.concat(Array.isArray(toFlatten) ? flattenArray(toFlatten) : toFlatten); - }, [] as any); -} - export function removeTags(value: string, ...tagNames: string[]) { if (value == null) { value = ""; @@ -852,12 +841,47 @@ export function selectElementContents(domElement: Node, start?: number, end?: nu } export function parseHtml(htmlString: string): E { - const node: E = new DOMParser().parseFromString(htmlString, 'text/html').querySelector('body > *'); - node.remove(); // detach from DOMParser ! - return node; + // let tagStartCount = (htmlString.match(/<\w+/g) || []).length; + // let tagEndCount = (htmlString.match(/<\//g) || []).length; + // if (tagStartCount > 1 && tagStartCount !== tagEndCount) { + // throw new Error("HTML strings need to have explicit closing tags! " + htmlString); + // } + // const node: E = new DOMParser().parseFromString(htmlString, 'text/html').querySelector('html > * > *'); + // node.remove(); // detach from DOMParser ! + // return node; + + // let tmpl = document.createElement('template'); + // tmpl.innerHTML = htmlString; + // return tmpl.content.cloneNode(true).firstChild as E; + htmlString = htmlString.trim(); + if (!htmlString.startsWith("<") || !htmlString.endsWith(">")) { + htmlString = "
" + htmlString + "
"; + } + return $(htmlString)[0] as E; } -export function prependChild(parent: Element, child: Element) { +export function parseSvg(htmlString: string): E { + // let tagStartCount = (htmlString.match(/<\w+/g) || []).length; + // let tagEndCount = (htmlString.match(/<\//g) || []).length; + // if (tagStartCount !== tagEndCount) { + // throw "SVG strings need to have explicit closing tags! " + htmlString; + // } + // const node: E = new DOMParser().parseFromString(htmlString, 'application/xml').getRootNode() as E; + // node.remove(); // detach from DOMParser ! + // return node; + + return $(htmlString)[0] as unknown as E; +} + +export function elementIndex(node: Element) { + let i = 0; + while ((node = node.previousElementSibling) != null) { + i++; + } + return i; +} + +export function prependChild(parent: Node, child: Node) { if (parent.childNodes.length > 0) { parent.insertBefore(child, parent.firstChild); } else { @@ -865,6 +889,14 @@ export function prependChild(parent: Element, child: Element) { } } +export function insertBefore(newNode: Node, referenceNode: Node) { + referenceNode.parentNode.insertBefore(newNode, referenceNode); +} + +export function insertAfter(newNode: Node, referenceNode: Node) { + referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling /* may be null ==> inserted at end!*/); +} + export function outerWidthIncludingMargins(el: HTMLElement) { var width = el.offsetWidth; var style = getComputedStyle(el); @@ -872,12 +904,47 @@ export function outerWidthIncludingMargins(el: HTMLElement) { return width; } -export function closestAncestor(el: HTMLElement, selector: string, includeSelf = false) { +export function outerHeightIncludingMargins(el: HTMLElement) { + var height = el.offsetHeight; + var style = getComputedStyle(el); + height += parseInt(style.marginTop) + parseInt(style.marginBottom); + return height; +} + +export function addDelegatedEventListener(rootElement: HTMLElement, selector: string, eventTypes: K | K[], listener: (element: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions) { + if (!Array.isArray(eventTypes)) { + eventTypes = [eventTypes]; + } + for (const eventType of eventTypes) { + rootElement.addEventListener(eventType, ev => { + const target = selector != null ? closestAncestor(ev.target as HTMLElement, selector, true, rootElement) : ev.target as HTMLElement; + if (target != null) { + listener(target, ev); + } + }, options) + } +} + +export function closestAncestor(el: HTMLElement, selector: string, includeSelf = false, $root: Element = document.body) { let currentNode: HTMLElement = (includeSelf ? el : el.parentNode) as HTMLElement; while (currentNode) { if (currentNode.matches(selector)) { return currentNode; } + if (currentNode == $root) { + break; + } + currentNode = currentNode.parentNode as HTMLElement; + } + return null; +} + +export function closestAncestorMatching(el: Element, predicate: (ancestor: Element) => boolean, includeSelf = false) { + let currentNode: Element = (includeSelf ? el : el.parentNode) as Element; + while (currentNode) { + if (predicate(currentNode)) { + return currentNode; + } currentNode = currentNode.parentNode as HTMLElement; } return null; @@ -897,7 +964,7 @@ export function isDescendantOf(child: Element, potentialAncestor: Element, inclu export async function createImageThumbnailUrl(file: File): Promise { if (["image/bmp", "image/gif", "image/heic", "image/heic-sequence", "image/heif", "image/heif-sequence", "image/ief", "image/jls", "image/jp2", "image/jpeg", "image/jpm", "image/jpx", "image/ktx", "image/png", "image/sgi", "image/svg+xml", "image/tiff", "image/webp", "image/wmf"].includes(file.type)) { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { var reader = new FileReader(); reader.onloadend = function () { resolve(reader.result); @@ -906,8 +973,445 @@ export async function createImageThumbnailUrl(file: File): Promise { reject("Error while reading file."); }; reader.readAsDataURL(file); - }); + }) as Promise; } else { return Promise.reject("Not a known image file type."); } } + +export function removeClassesByFunction(classList: DOMTokenList, deleteDecider: (className: string) => boolean) { + let matches = findClassesByFunction(classList, deleteDecider); + matches.forEach(function (value) { + classList.remove(value); + }); +} + +export function findClassesByFunction(classList: DOMTokenList, matcher: (className: string) => boolean) { + let matches: string[] = []; + classList.forEach(function (className) { + if (matcher(className)) { + matches.push(className); + } + }); + return matches; +} + +function animate(el: HTMLElement, animationClassNames: string[], animationDuration: number = 300, callback?: () => any) { + if (animationClassNames == null || animationClassNames.length == 0) { + callback(); + return; + } + if (!document.body.contains(el)) { + console.warn("Cannot animate detached element! Will fire callback directly."); + callback && callback(); + return; + } + let oldAnimationDurationValue = el.style.animationDuration; + el.style.animationDuration = animationDuration + "ms"; + el.classList.add(...animationClassNames); + + function handleAnimationEnd() { + el.classList.remove(...animationClassNames); + el.removeEventListener('animationend', handleAnimationEnd); + el.style.animationDuration = oldAnimationDurationValue; + + if (typeof callback === 'function') { + callback(); + } + } + + el.addEventListener('animationend', handleAnimationEnd); +} + +export function animateCSS(el: HTMLElement, animationCssClasses: string, animationDuration: number = 300, callback?: () => any) { + animate(el, animationCssClasses ? animationCssClasses.split(/ +/) : null, animationDuration, callback); +} + +export function fadeOut(el: HTMLElement) { + animateCSS(el, Constants.EXIT_ANIMATION_CSS_CLASSES[UiExitAnimation.FADE_OUT], 300, () => el.classList.add("hidden")); +} + +export function fadeIn(el: HTMLElement) { + el.classList.remove("hidden"); + animateCSS(el, Constants.ENTRANCE_ANIMATION_CSS_CLASSES[UiEntranceAnimation.FADE_IN]); +} + +export var pageTransitionAnimationPairs = { + 'moveToLeftVsMoveFromRight': { + outClass: ['pt-page-moveToLeft'], + inClass: ['pt-page-moveFromRight'] + }, + 'moveToRightVsMoveFromLeft': { + outClass: ['pt-page-moveToRight'], + inClass: ['pt-page-moveFromLeft'] + }, + 'moveToTopVsMoveFromBottom': { + outClass: ['pt-page-moveToTop'], + inClass: ['pt-page-moveFromBottom'] + }, + 'moveToBottomVsMoveFromTop': { + outClass: ['pt-page-moveToBottom'], + inClass: ['pt-page-moveFromTop'] + }, + 'fadeVsMoveFromRight': { + outClass: ['pt-page-fade'], + inClass: ['pt-page-moveFromRight', 'pt-page-ontop'] + }, + 'fadeVsMoveFromLeft': { + outClass: ['pt-page-fade'], + inClass: ['pt-page-moveFromLeft', 'pt-page-ontop'] + }, + 'fadeVsMoveFromBottom': { + outClass: ['pt-page-fade'], + inClass: ['pt-page-moveFromBottom', 'pt-page-ontop'] + }, + 'fadeVsMoveFromTop': { + outClass: ['pt-page-fade'], + inClass: ['pt-page-moveFromTop', 'pt-page-ontop'] + }, + 'moveToLeftFadeVsMoveFromRightFade': { + outClass: ['pt-page-moveToLeftFade'], + inClass: ['pt-page-moveFromRightFade'] + }, + 'moveToRightFadeVsMoveFromLeftFade': { + outClass: ['pt-page-moveToRightFade'], + inClass: ['pt-page-moveFromLeftFade'] + }, + 'moveToTopFadeVsMoveFromBottomFade': { + outClass: ['pt-page-moveToTopFade'], + inClass: ['pt-page-moveFromBottomFade'] + }, + 'moveToBottomFadeVsMoveFromTopFade': { + outClass: ['pt-page-moveToBottomFade'], + inClass: ['pt-page-moveFromTopFade'] + }, + 'scaleDownVsMoveFromRight': { + outClass: ['pt-page-scaleDown'], + inClass: ['pt-page-moveFromRight', 'pt-page-ontop'] + }, + 'scaleDownVsMoveFromLeft': { + outClass: ['pt-page-scaleDown'], + inClass: ['pt-page-moveFromLeft', 'pt-page-ontop'] + }, + 'scaleDownVsMoveFromBottom': { + outClass: ['pt-page-scaleDown'], + inClass: ['pt-page-moveFromBottom', 'pt-page-ontop'] + }, + 'scaleDownVsMoveFromTop': { + outClass: ['pt-page-scaleDown'], + inClass: ['pt-page-moveFromTop', 'pt-page-ontop'] + }, + 'scaleDownVsScaleUpDown': { + outClass: ['pt-page-scaleDown'], + inClass: ['pt-page-scaleUpDown'] + }, + 'scaleDownUpVsScaleUp': { + outClass: ['pt-page-scaleDownUp'], + inClass: ['pt-page-scaleUp'] + }, + 'moveToLeftVsScaleUp': { + outClass: ['pt-page-moveToLeft', 'pt-page-ontop'], + inClass: ['pt-page-scaleUp'] + }, + 'moveToRightVsScaleUp': { + outClass: ['pt-page-moveToRight', 'pt-page-ontop'], + inClass: ['pt-page-scaleUp'] + }, + 'moveToTopVsScaleUp': { + outClass: ['pt-page-moveToTop', 'pt-page-ontop'], + inClass: ['pt-page-scaleUp'] + }, + 'moveToBottomVsScaleUp': { + outClass: ['pt-page-moveToBottom', 'pt-page-ontop'], + inClass: ['pt-page-scaleUp'] + }, + 'scaleDownCenterVsScaleUpCenter': { + outClass: ['pt-page-scaleDownCenter'], + inClass: ['pt-page-scaleUpCenter'] + }, + 'rotateRightSideFirstVsMoveFromRight': { + outClass: ['pt-page-rotateRightSideFirst'], + inClass: ['pt-page-moveFromRight', 'pt-page-ontop'] + }, + 'rotateLeftSideFirstVsMoveFromLeft': { + outClass: ['pt-page-rotateLeftSideFirst'], + inClass: ['pt-page-moveFromLeft', 'pt-page-ontop'] + }, + 'rotateTopSideFirstVsMoveFromTop': { + outClass: ['pt-page-rotateTopSideFirst'], + inClass: ['pt-page-moveFromTop', 'pt-page-ontop'] + }, + 'rotateBottomSideFirstVsMoveFromBottom': { + outClass: ['pt-page-rotateBottomSideFirst'], + inClass: ['pt-page-moveFromBottom', 'pt-page-ontop'] + }, + 'flipOutRightVsFlipInLeft': { + outClass: ['pt-page-flipOutRight'], + inClass: ['pt-page-flipInLeft'] + }, + 'flipOutLeftVsFlipInRight': { + outClass: ['pt-page-flipOutLeft'], + inClass: ['pt-page-flipInRight'] + }, + 'flipOutTopVsFlipInBottom': { + outClass: ['pt-page-flipOutTop'], + inClass: ['pt-page-flipInBottom'] + }, + 'flipOutBottomVsFlipInTop': { + outClass: ['pt-page-flipOutBottom'], + inClass: ['pt-page-flipInTop'] + }, + 'rotateFallVsScaleUp': { + outClass: ['pt-page-rotateFall', 'pt-page-ontop'], + inClass: ['pt-page-scaleUp'] + }, + 'rotateOutNewspaperVsRotateInNewspaper': { + outClass: ['pt-page-rotateOutNewspaper'], + inClass: ['pt-page-rotateInNewspaper'] + }, + 'rotatePushLeftVsMoveFromRight': { + outClass: ['pt-page-rotatePushLeft'], + inClass: ['pt-page-moveFromRight'] + }, + 'rotatePushRightVsMoveFromLeft': { + outClass: ['pt-page-rotatePushRight'], + inClass: ['pt-page-moveFromLeft'] + }, + 'rotatePushTopVsMoveFromBottom': { + outClass: ['pt-page-rotatePushTop'], + inClass: ['pt-page-moveFromBottom'] + }, + 'rotatePushBottomVsMoveFromTop': { + outClass: ['pt-page-rotatePushBottom'], + inClass: ['pt-page-moveFromTop'] + }, + 'rotatePushLeftVsRotatePullRight': { + outClass: ['pt-page-rotatePushLeft'], + inClass: ['pt-page-rotatePullRight'] + }, + 'rotatePushRightVsRotatePullLeft': { + outClass: ['pt-page-rotatePushRight'], + inClass: ['pt-page-rotatePullLeft'] + }, + 'rotatePushTopVsRotatePullBottom': { + outClass: ['pt-page-rotatePushTop'], + inClass: ['pt-page-rotatePullBottom'] + }, + 'rotatePushBottomVsRotatePullTop': { + outClass: ['pt-page-rotatePushBottom'], + inClass: ['pt-page-rotatePullTop'] + }, + 'rotateFoldLeftVsMoveFromRightFade': { + outClass: ['pt-page-rotateFoldLeft'], + inClass: ['pt-page-moveFromRightFade'] + }, + 'rotateFoldRightVsMoveFromLeftFade': { + outClass: ['pt-page-rotateFoldRight'], + inClass: ['pt-page-moveFromLeftFade'] + }, + 'rotateFoldTopVsMoveFromBottomFade': { + outClass: ['pt-page-rotateFoldTop'], + inClass: ['pt-page-moveFromBottomFade'] + }, + 'rotateFoldBottomVsMoveFromTopFade': { + outClass: ['pt-page-rotateFoldBottom'], + inClass: ['pt-page-moveFromTopFade'] + }, + 'moveToRightFadeVsRotateUnfoldLeft': { + outClass: ['pt-page-moveToRightFade'], + inClass: ['pt-page-rotateUnfoldLeft'] + }, + 'moveToLeftFadeVsRotateUnfoldRight': { + outClass: ['pt-page-moveToLeftFade'], + inClass: ['pt-page-rotateUnfoldRight'] + }, + 'moveToBottomFadeVsRotateUnfoldTop': { + outClass: ['pt-page-moveToBottomFade'], + inClass: ['pt-page-rotateUnfoldTop'] + }, + 'moveToTopFadeVsRotateUnfoldBottom': { + outClass: ['pt-page-moveToTopFade'], + inClass: ['pt-page-rotateUnfoldBottom'] + }, + 'rotateRoomLeftOutVsRotateRoomLeftIn': { + outClass: ['pt-page-rotateRoomLeftOut', 'pt-page-ontop'], + inClass: ['pt-page-rotateRoomLeftIn'] + }, + 'rotateRoomRightOutVsRotateRoomRightIn': { + outClass: ['pt-page-rotateRoomRightOut', 'pt-page-ontop'], + inClass: ['pt-page-rotateRoomRightIn'] + }, + 'rotateRoomTopOutVsRotateRoomTopIn': { + outClass: ['pt-page-rotateRoomTopOut', 'pt-page-ontop'], + inClass: ['pt-page-rotateRoomTopIn'] + }, + 'rotateRoomBottomOutVsRotateRoomBottomIn': { + outClass: ['pt-page-rotateRoomBottomOut', 'pt-page-ontop'], + inClass: ['pt-page-rotateRoomBottomIn'] + }, + 'rotateCubeLeftOutVsRotateCubeLeftIn': { + outClass: ['pt-page-rotateCubeLeftOut', 'pt-page-ontop'], + inClass: ['pt-page-rotateCubeLeftIn'] + }, + 'rotateCubeRightOutVsRotateCubeRightIn': { + outClass: ['pt-page-rotateCubeRightOut', 'pt-page-ontop'], + inClass: ['pt-page-rotateCubeRightIn'] + }, + 'rotateCubeTopOutVsRotateCubeTopIn': { + outClass: ['pt-page-rotateCubeTopOut', 'pt-page-ontop'], + inClass: ['pt-page-rotateCubeTopIn'] + }, + 'rotateCubeBottomOutVsRotateCubeBottomIn': { + outClass: ['pt-page-rotateCubeBottomOut', 'pt-page-ontop'], + inClass: ['pt-page-rotateCubeBottomIn'] + }, + 'rotateCarouselLeftOutVsRotateCarouselLeftIn': { + outClass: ['pt-page-rotateCarouselLeftOut', 'pt-page-ontop'], + inClass: ['pt-page-rotateCarouselLeftIn'] + }, + 'rotateCarouselRightOutVsRotateCarouselRightIn': { + outClass: ['pt-page-rotateCarouselRightOut', 'pt-page-ontop'], + inClass: ['pt-page-rotateCarouselRightIn'] + }, + 'rotateCarouselTopOutVsRotateCarouselTopIn': { + outClass: ['pt-page-rotateCarouselTopOut', 'pt-page-ontop'], + inClass: ['pt-page-rotateCarouselTopIn'] + }, + 'rotateCarouselBottomOutVsRotateCarouselBottomIn': { + outClass: ['pt-page-rotateCarouselBottomOut', 'pt-page-ontop'], + inClass: ['pt-page-rotateCarouselBottomIn'] + }, + 'rotateSidesOutVsRotateSidesIn': { + outClass: ['pt-page-rotateSidesOut'], + inClass: ['pt-page-rotateSidesIn'] + }, + 'rotateSlideOutVsRotateSlideIn': { + outClass: ['pt-page-rotateSlideOut'], + inClass: ['pt-page-rotateSlideIn'] + }, +}; + +export function animatePageTransition(outEl: HTMLElement, inEl: HTMLElement, animationName: keyof typeof pageTransitionAnimationPairs, animationDuration: number = 300, callback?: () => any) { + let animationCallbackCount = 0; + + function invokeCallbackIfBothReturned() { + animationCallbackCount++; + if (animationCallbackCount == 2) { + callback(); + } + } + + if (outEl != null) { + animate(outEl, pageTransitionAnimationPairs[animationName].outClass, animationDuration, invokeCallbackIfBothReturned); + } else { + animationCallbackCount++; + } + if (inEl != null) { + animate(inEl, pageTransitionAnimationPairs[animationName].inClass, animationDuration, invokeCallbackIfBothReturned); + } else { + animationCallbackCount++; + } +} + +export function pageTransition(outEl: HTMLElement, inEl: HTMLElement, pageTransition: UiPageTransition, animationDuration: number = 300, callback?: () => any) { + let s = UiPageTransition[pageTransition].toLowerCase().replace(/_{1,1}([a-z])/g, (g0, g1) => g1.toUpperCase()) as keyof typeof pageTransitionAnimationPairs; + animatePageTransition(outEl, inEl, s, animationDuration, callback); +} + +export function toggleElementCollapsed($element: HTMLElement, collapsed: boolean, animationDuration: number = 0, hiddenClass: string = "hidden", completeHandler?: () => any) { + if (collapsed) { + if (animationDuration > 0) { + animateCollapse($element, true, animationDuration, () => { + $element.classList.add(hiddenClass); + completeHandler?.(); + }); + } else { + $element.classList.add(hiddenClass); + completeHandler?.(); + } + } else { + if (animationDuration > 0) { + $element.classList.remove(hiddenClass) + animateCollapse($element, false, animationDuration, completeHandler); + } else { + $element.classList.remove(hiddenClass); + completeHandler?.(); + } + } +} + +export function animateCollapse(element: HTMLElement, collapsed: boolean, duration: number, onTransitionEnd?: () => void) { + const isCollapsed = element.getAttribute("ta-collapsed") != null; + if (isCollapsed == collapsed) { + onTransitionEnd?.(); + return; + } + const initialMaxHeight = collapsed ? element.scrollHeight + "px" : "0px"; + const targetMaxHeight = collapsed ? "0px" : element.scrollHeight + "px"; + if (element.style.maxHeight == null || element.style.maxHeight == "") { + element.style.maxHeight = initialMaxHeight; + } + const oldTransitionStyle = element.style.transition; + element.style.transition = `max-height ${duration}ms`; + + let transitionEndListener = (ev: Event) => { + ["transitionend", "transitioncancel"].forEach(eventName => element.removeEventListener(eventName, transitionEndListener)); + window.clearTimeout(timeout); + element.style.transition = oldTransitionStyle; + if (!collapsed) { + element.style.removeProperty("max-height"); + } + onTransitionEnd?.(); + }; + ["transitionend", "transitioncancel"].forEach(eventName => element.addEventListener(eventName, transitionEndListener)); + let timeout = window.setTimeout(transitionEndListener, duration + 100); // make sure the listener is removed no matter what! + + element.offsetHeight; // force reflow to make sure there is a transition animation + element.style.maxHeight = targetMaxHeight; + element.toggleAttribute("ta-collapsed", collapsed); +} + +export function css(el: HTMLElement, values: object) { + Object.assign(el.style, values); +} + +let lastPointerCoordinates: [number, number] = [0, 0]; +document.body.addEventListener("pointermove", ev => lastPointerCoordinates = [ev.clientX, ev.clientY], {capture: true}); + +export function getLastPointerCoordinates() { + return lastPointerCoordinates; +} + +export function insertAtCursorPosition(input: HTMLInputElement | HTMLTextAreaElement, text: string) { + if (input.selectionStart != null) { + var startPos = input.selectionStart; + var endPos = input.selectionEnd; + input.value = input.value.substring(0, startPos) + + text + + input.value.substring(endPos, input.value.length); + } else { + input.value += text; + } +} + +export function isVisibleColor(c: string) { + return c != null && rgba(c)[3] > 0; +} + +export function createUiLocation() { + return { + href: location.href, + origin: location.origin, + protocol: location.protocol, + host: location.host, + hostname: location.hostname, + port: location.port && Number(location.port), + pathname: location.pathname ?? '', + search: location.search ?? '', + hash: location.hash ?? '' + }; +} + +export function stopEventPropagations(element: HTMLElement, ...eventNames: string[]) { + eventNames.forEach(name => element.addEventListener(name, (e) => e.stopPropagation())); +} diff --git a/teamapps-client/ts/modules/DefaultTeamAppsUiContext.ts b/teamapps-client/ts/modules/DefaultTeamAppsUiContext.ts index 57e313e59..2e97723e2 100644 --- a/teamapps-client/ts/modules/DefaultTeamAppsUiContext.ts +++ b/teamapps-client/ts/modules/DefaultTeamAppsUiContext.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,58 +19,74 @@ */ "use strict"; -import * as $ from "jquery"; -import {WebWorkerTeamAppsConnection} from "./WebWorkerTeamAppsConnection"; -import {generateUUID, getIconPath, logException} from "./Common"; +import {createUiLocation, generateUUID, logException} from "./Common"; import {TeamAppsUiContextInternalApi} from "./TeamAppsUiContext"; import {UiConfigurationConfig} from "../generated/UiConfigurationConfig"; -import {UiWeekDay} from "../generated/UiWeekDay"; -import {UiComponent} from "./UiComponent"; import {UiComponentConfig} from "../generated/UiComponentConfig"; import {UiEvent} from "../generated/UiEvent"; import {UiRootPanel} from "./UiRootPanel"; -import {EventRegistrator} from "../generated/EventRegistrator"; +import {ComponentEventSubscriptionManager} from "./util/ComponentEventSubscriptionManager"; import {UiCommand} from '../generated/UiCommand'; import {CommandExecutor} from "../generated/CommandExecutor"; -import {TeamAppsConnection, TeamAppsConnectionListener} from "../shared/TeamAppsConnection"; +import {TeamAppsConnection, TeamAppsConnectionListener} from "./communication/TeamAppsConnection"; import * as log from "loglevel"; import * as jstz from "jstz"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; import {TemplateRegistry} from "./TemplateRegistry"; -import {createUiClientInfoConfig, UiClientInfoConfig} from "../generated/UiClientInfoConfig"; +import {createUiClientInfoConfig} from "../generated/UiClientInfoConfig"; import {UiGenericErrorMessageOption} from "../generated/UiGenericErrorMessageOption"; import {TeamAppsEvent} from "./util/TeamAppsEvent"; import {bind} from "./util/Bind"; -import {RefreshableComponentProxyHandle} from "./util/RefreshableComponentProxyHandle"; -import {TeamAppsConnectionImpl} from "../shared/TeamAppsConnectionImpl"; +import {isRefreshableComponentProxyHandle, RefreshableComponentProxyHandle} from "./util/RefreshableComponentProxyHandle"; +import {TeamAppsConnectionImpl} from "./communication/TeamAppsConnectionImpl"; +import {UiComponent} from "./UiComponent"; +import {UiSessionClosingReason} from "../generated/UiSessionClosingReason"; +import {UiClientObject} from "./UiClientObject"; +import {UiClientObjectConfig} from "../generated/UiClientObjectConfig"; +import {UiWindow} from "./UiWindow"; +import {QueryFunctionAdder} from "../generated/QueryFunctionAdder"; +import {UiQuery} from "../generated/UiQuery"; +import {componentEventDescriptors, staticComponentEventDescriptors} from "../generated/ComponentEventDescriptors"; +import {ClosedSessionHandlingType} from "../generated/ClosedSessionHandlingType"; +import {UiRootPanel_CustomMessageEvent} from "../generated/UiRootPanelConfig"; + +declare var __TEAMAPPS_VERSION__: string; + +function isComponent(o: UiClientObject): o is UiComponent { + return o != null && (o as any).getMainElement; +} export class DefaultTeamAppsUiContext implements TeamAppsUiContextInternalApi { private static logger = log.getLogger("DefaultTeamAppsUiContext"); - public readonly onStaticMethodCommandInvocation: TeamAppsEvent = new TeamAppsEvent(this); + public readonly onStaticMethodCommandInvocation: TeamAppsEvent = new TeamAppsEvent(); public readonly sessionId: string; public isHighDensityScreen: boolean; public config: UiConfigurationConfig = { _type: "UiConfigurationConfig", - isoLanguage: "en", + locale: "en", themeClassName: null, - optimizedForTouch: false, - iconPath: "icons", - timeZoneId: "Europe/Berlin", - firstDayOfWeek: UiWeekDay.MONDAY, - dateFormat: "yyyy-MM-dd", - timeFormat: "HH:mm", - decimalSeparator: ".", - thousandsSeparator: "" + optimizedForTouch: false }; public readonly templateRegistry: TemplateRegistry = new TemplateRegistry(this); - private components: { [identifier: string]: RefreshableComponentProxyHandle } = {}; // only for debugging!!! + private components: { [identifier: string]: UiClientObject | RefreshableComponentProxyHandle } = {}; private _executingCommand: boolean = false; private connection: TeamAppsConnection; - constructor(webSocketUrl: string, clientParameters: {[key: string]: string|number} = {}) { + private commandExecutor = new CommandExecutor(); + + private expiredMessageWindow: UiWindow; + private errorMessageWindow: UiWindow; + private terminatedMessageWindow: UiWindow; + + private componentEventSubscriptionManager: ComponentEventSubscriptionManager; + + constructor(webSocketUrl: string, clientParameters: { [key: string]: string | number } = {}) { + this.componentEventSubscriptionManager = new ComponentEventSubscriptionManager(); + this.componentEventSubscriptionManager.registerComponentTypes(componentEventDescriptors, staticComponentEventDescriptors, this.sendEvent); + this.sessionId = generateUUID(); let clientInfo = createUiClientInfoConfig({ @@ -82,29 +98,66 @@ export class DefaultTeamAppsUiContext implements TeamAppsUiContextInternalApi { timezoneOffsetMinutes: new Date().getTimezoneOffset(), timezoneIana: jstz.determine().name(), clientTokens: UiRootPanel.getClientTokens(), - clientUrl: location.href, - clientParameters: clientParameters + location: createUiLocation(), + clientParameters: clientParameters, + teamAppsVersion: __TEAMAPPS_VERSION__ }); let connectionListener: TeamAppsConnectionListener = { onConnectionInitialized: () => { }, onConnectionErrorOrBroken: (reason, message) => { - DefaultTeamAppsUiContext.logger.error("Connection broken."); + DefaultTeamAppsUiContext.logger.error(`Connection broken. ${message != null ? 'Message: ' + message : ""}`); sessionStorage.clear(); - UiRootPanel.showGenericErrorMessage("Server-side Error", "A server error has occurred! You might experience unexpected behavior until you reload this web page.", - [UiGenericErrorMessageOption.OK, UiGenericErrorMessageOption.RELOAD], this); + if (reason == UiSessionClosingReason.WRONG_TEAMAPPS_VERSION) { + // NOTE that there is a special handling for wrong teamapps client versions on the server side, which sends the client a goToUrl() command for a page with a cache-prevention GET parameter. + // This is only in case the server-side logic does not work. + document.body.innerHTML = `
+

Caching problem!

+

Your browser uses an old client version to connect to our server. Please refresh this page. If this does not help, please clear your browser's cache.

+
`; + } else if (this.config.closedSessionHandling == ClosedSessionHandlingType.MESSAGE_WINDOW) { + if (reason == UiSessionClosingReason.SESSION_NOT_FOUND || reason == UiSessionClosingReason.SESSION_TIMEOUT) { + if (this.expiredMessageWindow != null) { + this.expiredMessageWindow.show(500); + } else { + UiRootPanel.createGenericErrorMessageWindow("Session Expired", "

Your session has expired.

Please reload this page or click OK if you want to refresh later. The application will however remain unresponsive until you reload this page.

", + false, [UiGenericErrorMessageOption.OK, UiGenericErrorMessageOption.RELOAD], this).show(500); + } + } else if (reason == UiSessionClosingReason.TERMINATED_BY_APPLICATION) { + if (this.terminatedMessageWindow != null) { + this.terminatedMessageWindow.show(500); + } else { + UiRootPanel.createGenericErrorMessageWindow("Session Terminated", "

Your session has been terminated.

Please reload this page or click OK if you want to refresh later. The application will however remain unresponsive until you reload this page.

", + true, [UiGenericErrorMessageOption.OK, UiGenericErrorMessageOption.RELOAD], this).show(500); + } + } else { + if (this.errorMessageWindow != null) { + this.errorMessageWindow.show(500); + } else { + UiRootPanel.createGenericErrorMessageWindow("Error", "

A server-side error has occurred.

Please reload this page or click OK if you want to refresh later. The application will however remain unresponsive until you reload this page.

", + true, [UiGenericErrorMessageOption.OK, UiGenericErrorMessageOption.RELOAD], this).show(500); + } + } + } else { + location.reload(); + } }, - executeCommand: (uiCommand: UiCommand) => this.executeCommand(uiCommand), - executeCommands: (uiCommands: UiCommand[]) => this.executeCommands(uiCommands) + executeCommand: (uiCommand: UiCommand) => this.executeCommand(uiCommand) }; this.connection = new TeamAppsConnectionImpl(webSocketUrl, this.sessionId, clientInfo, connectionListener); this.isHighDensityScreen = ((window.matchMedia && (window.matchMedia('only screen and (min-resolution: 124dpi), only screen and (min-resolution: 1.3dppx), only screen and (min-resolution: 48.8dpcm)').matches || window.matchMedia('only screen and (-webkit-min-device-pixel-ratio: 1.3), only screen and (-o-min-device-pixel-ratio: 2.6/2), only screen and (min--moz-device-pixel-ratio: 1.3), only screen and (min-device-pixel-ratio: 1.3)').matches)) || (window.devicePixelRatio && window.devicePixelRatio > 1.3)); if (this.isHighDensityScreen) { - $(document.body).addClass('high-density-screen'); + document.body.classList.add('high-density-screen'); } + + window.addEventListener('unload', () => { + if (!navigator.sendBeacon) return; + var status = navigator.sendBeacon("/leave", this.sessionId); + console.log(`Beacon returned: ${status}`); + }) } public get executingCommand() { @@ -112,96 +165,118 @@ export class DefaultTeamAppsUiContext implements TeamAppsUiContextInternalApi { } @bind - public fireEvent(eventObject: UiEvent) { + public sendEvent(eventObject: UiEvent) { this.connection.sendEvent(eventObject); } - public registerComponent(component: UiComponent): void { - DefaultTeamAppsUiContext.logger.debug("registering component: ", component.getId()); - if (this.components[component.getId()] == null) { - this.components[component.getId()] = new RefreshableComponentProxyHandle(component); + @bind + public async sendQuery(query: UiQuery): Promise { + let result = await this.connection.sendQuery(query); + let resultWrapper = [result]; + this.replaceComponentReferencesWithInstances(resultWrapper) + return resultWrapper[0]; + } + + public registerClientObject(clientObject: UiClientObject, id: string, teamappsType: string): void { + DefaultTeamAppsUiContext.logger.debug("registering ClientObject: ", id); + if (isComponent(clientObject)) { + let existingProxy = this.components[id]; + if (existingProxy != null) { + (existingProxy as RefreshableComponentProxyHandle).component = clientObject; + } else { + this.components[id] = new RefreshableComponentProxyHandle(clientObject); + } } else { - this.components[component.getId()].component = component; + this.components[id] = clientObject; } - EventRegistrator.registerForEvents(component, component.getTeamAppsType(), this.fireEvent); + this.componentEventSubscriptionManager.registerEventListener(clientObject, teamappsType, this.sendEvent, {componentId: id}); } - public createAndRegisterComponent(config: UiComponentConfig) { - let component: UiComponent; - if (TeamAppsUiComponentRegistry.getComponentClassForName(config._type)) { - component = new (TeamAppsUiComponentRegistry.getComponentClassForName(config._type))(config, this); - this.registerComponent(component); - return this.getComponentById(component.getId()); // return the proxied component!!! + public createClientObject(config: UiClientObjectConfig): UiClientObject { + let componentClass = TeamAppsUiComponentRegistry.getComponentClassForName(config._type); + if (componentClass) { + QueryFunctionAdder.addQueryFunctionsToConfig(config, (componentId, queryTypeId, queryObject) => { + return this.sendQuery({...queryObject, _type: queryTypeId, componentId: componentId}); + }); + return new componentClass(config, this); } else { DefaultTeamAppsUiContext.logger.error("Unknown component type: " + config._type); - return; + return null; } } - destroyComponent(component: UiComponent): void { - component.getMainDomElement().detach(); - component.destroy(); - delete this.components[component.getId()]; + refreshComponent(config: UiComponentConfig): void { + let clientObject = this.createClientObject(config) as UiComponent; + if (this.components[config.id] != null) { + (this.components[config.id] as RefreshableComponentProxyHandle).component = clientObject; + } else { + this.registerClientObject(clientObject, config.id, config._type); + } } - refreshComponent(config: UiComponentConfig): void { - this.createAndRegisterComponent(config); + destroyClientObject(id: string): void { + let o = this.components[id]; + if (o != null) { + if (isRefreshableComponentProxyHandle(o)) { + o = o.component; + } + if (isComponent(o)) { + o.getMainElement().remove(); + } + o.destroy(); + delete this.components[id]; + this.componentEventSubscriptionManager.unregisterEventListener(o); + } else { + DefaultTeamAppsUiContext.logger.warn("Could not find component to destroy: " + id); + } } - public getComponentById(id: string): UiComponent { - const componentProxyHandle = this.components[id]; - if (componentProxyHandle == null) { + public getClientObjectById(id: string): UiClientObject { + const clientObject = this.components[id]; + if (clientObject == null) { DefaultTeamAppsUiContext.logger.error(`Cannot find component with id ${id}`); return null; } else { - return componentProxyHandle.proxy; + return isRefreshableComponentProxyHandle(clientObject) ? clientObject.proxy : clientObject; } } - public getIconPath(iconName: string, iconSize: number, ignoreRetina?: boolean): string { - return getIconPath(this, iconName, iconSize, ignoreRetina); - } - - private executeCommands(uiCommands: UiCommand[]): Promise[] { - return uiCommands.map(c => { - try { - return this.executeCommand(c); - } catch (error) { - logException(error); - } - }); - } - private replaceComponentReferencesWithInstances(o: any) { - let replaceOrRecur = (key: number|string) => { - const value = o[key]; - if (value != null && value._type && typeof (value._type) === "string" && value._type.indexOf("UiComponentReference") !== -1) { - const componentById = this.getComponentById(value.id); + let replaceOrRecur = (value: any) => { + if (value != null && value._type && typeof (value._type) === "string" && value._type.indexOf("UiClientObjectReference") !== -1) { + const componentById = this.getClientObjectById(value.id); if (componentById != null) { - o[key] = componentById; + return componentById; } else { throw new Error("Could not find component with id " + value.id); } } else { this.replaceComponentReferencesWithInstances(value); + return value; } }; if (o == null || typeof o === "string" || typeof o === "boolean" || typeof o === "function" || typeof o === "number" || typeof o === "symbol") { - return o; + return; } else if (Array.isArray(o)) { for (let i = 0; i < o.length; i++) { - replaceOrRecur(i); + let value = o[i]; + const replacingValue = replaceOrRecur(value); + if (replacingValue !== value) { + o[i] = replacingValue; + } } } else if (typeof o === "object") { Object.keys(o).forEach(key => { - replaceOrRecur(key); + let value = o[key]; + const replacingValue = replaceOrRecur(value); + if (replacingValue !== value) { + o[key] = replacingValue; + } }) } } - private commandExecutor = new CommandExecutor(reference => this.getComponentById(reference.id)); - private async executeCommand(command: UiCommand): Promise { try { // console.warn(command); @@ -210,7 +285,7 @@ export class DefaultTeamAppsUiContext implements TeamAppsUiContextInternalApi { const commandMethodName = command._type.substring(command._type.lastIndexOf('.') + 1); if (command.componentId) { DefaultTeamAppsUiContext.logger.trace(`Trying to call ${command.componentId}.${commandMethodName}()`); - let component: any = this.getComponentById(command.componentId); + let component: any = this.getClientObjectById(command.componentId); if (!component) { throw new Error("The component " + command.componentId + " does not exist, so cannot call " + commandMethodName + "() on it."); } else if (typeof component[commandMethodName] !== "function") { @@ -229,4 +304,19 @@ export class DefaultTeamAppsUiContext implements TeamAppsUiContextInternalApi { this._executingCommand = false; } } + + public setSessionMessageWindows(expiredMessageWindow: UiWindow, errorMessageWindow: UiWindow, terminatedMessageWindow: UiWindow) { + this.expiredMessageWindow = expiredMessageWindow; + this.errorMessageWindow = errorMessageWindow; + this.terminatedMessageWindow = terminatedMessageWindow; + } + + public sendCustomMessage(type: string, message: string) { + const event: UiRootPanel_CustomMessageEvent = { + _type: "UiRootPanel.customMessage", + type, + message: message + }; + this.connection.sendEvent(event); + } } diff --git a/teamapps-client/ts/modules/TeamAppsUiComponentRegistry.ts b/teamapps-client/ts/modules/TeamAppsUiComponentRegistry.ts index 7c9182738..fac2411ce 100644 --- a/teamapps-client/ts/modules/TeamAppsUiComponentRegistry.ts +++ b/teamapps-client/ts/modules/TeamAppsUiComponentRegistry.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,14 +18,15 @@ * =========================LICENSE_END================================== */ import * as log from "loglevel"; -import {UiComponent} from "./UiComponent"; +import {AbstractUiComponent} from "./AbstractUiComponent"; import {UiField} from "./formfield/UiField"; import {UiFieldConfig} from "../generated/UiFieldConfig"; import {UiComponentConfig} from "../generated/UiComponentConfig"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; +import {UiComponent} from "./UiComponent"; -type ComponentClass> = { new (config: UiComponentConfig, context: TeamAppsUiContext): T }; -type FieldClass = { new (config: UiFieldConfig, context: TeamAppsUiContext): T }; +type ComponentClass> = { new(config: UiComponentConfig, context: TeamAppsUiContext): T }; +type FieldClass = { new(config: UiFieldConfig, context: TeamAppsUiContext): T }; export class TeamAppsUiComponentRegistry { @@ -34,13 +35,13 @@ export class TeamAppsUiComponentRegistry { private static componentClasses: { [componentName: string]: ComponentClass> } = {}; private static fieldClasses: { [fieldName: string]: FieldClass } = {}; - public static registerComponentClass>(componentName: string, componentClass: ComponentClass): void { + public static registerComponentClass>(componentName: string, componentClass: ComponentClass): void { this.componentClasses[componentName] = componentClass; } - public static getComponentClassForName(componentName: string): ComponentClass> { + public static getComponentClassForName(componentName: string, logErrorIfNotFound = true): ComponentClass> { let componentClass = this.componentClasses[componentName]; - if (!componentClass) { + if (!componentClass && logErrorIfNotFound) { TeamAppsUiComponentRegistry.logger.error("There is no registered component type with name: " + componentName); } return componentClass; @@ -62,4 +63,6 @@ export class TeamAppsUiComponentRegistry { } -(window as any).TeamAppsUiComponentRegistry = TeamAppsUiComponentRegistry; +if (!(window as any).TeamAppsUiComponentRegistry) { + (window as any).TeamAppsUiComponentRegistry = TeamAppsUiComponentRegistry; +} diff --git a/teamapps-client/ts/modules/TeamAppsUiContext.ts b/teamapps-client/ts/modules/TeamAppsUiContext.ts index 4adc5bb2f..f75472136 100644 --- a/teamapps-client/ts/modules/TeamAppsUiContext.ts +++ b/teamapps-client/ts/modules/TeamAppsUiContext.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,38 +18,38 @@ * =========================LICENSE_END================================== */ import {UiConfigurationConfig} from "../generated/UiConfigurationConfig"; -import {UiComponent} from "./UiComponent"; import {UiComponentConfig} from "../generated/UiComponentConfig"; import {UiEvent} from "../generated/UiEvent"; -import {UiTemplateConfig} from "../generated/UiTemplateConfig"; -import {Renderer} from "./Common"; import {TemplateRegistry} from "./TemplateRegistry"; import {TeamAppsEvent} from "./util/TeamAppsEvent"; -import {UiRootPanelConfig} from "../generated/UiRootPanelConfig"; import {UiCommand} from "../generated/UiCommand"; +import {UiComponent} from "./UiComponent"; +import {UiClientObject} from "./UiClientObject"; +import {UiClientObjectConfig} from "../generated/UiClientObjectConfig"; export const typescriptDeclarationFixConstant = 1; -export interface IconPathProvider { - getIconPath(iconName: string, iconSize: number, ignoreRetina?: boolean): string; -} - -export interface TeamAppsUiContext extends IconPathProvider { +export interface TeamAppsUiContext { readonly sessionId: string; readonly isHighDensityScreen: boolean; readonly executingCommand: boolean; readonly config: UiConfigurationConfig; readonly templateRegistry: TemplateRegistry; - getComponentById(id: string): UiComponent; + getClientObjectById(id: string): UiClientObject; } // TeamAppsUiContext implementations should implement this too. See usages. -export interface TeamAppsUiContextInternalApi extends TeamAppsUiContext{ +export interface TeamAppsUiContextInternalApi extends TeamAppsUiContext { readonly onStaticMethodCommandInvocation: TeamAppsEvent; - registerComponent(component: UiComponent): void; - createAndRegisterComponent(config: UiComponentConfig): UiComponent; - destroyComponent(component: UiComponent): void; + + registerClientObject(component: UiClientObject, id: string, teamappsType: string): void; + + createClientObject(config: UiClientObjectConfig): UiClientObject; + refreshComponent(config: UiComponentConfig): void; - fireEvent(eventObject: UiEvent): void; + + destroyClientObject(componentId: string): void; + + sendEvent(eventObject: UiEvent): void; } diff --git a/teamapps-client/ts/modules/TemplateRegistry.ts b/teamapps-client/ts/modules/TemplateRegistry.ts index db395189d..43a24bb8e 100644 --- a/teamapps-client/ts/modules/TemplateRegistry.ts +++ b/teamapps-client/ts/modules/TemplateRegistry.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,11 +23,13 @@ import {TeamAppsUiContext} from "./TeamAppsUiContext"; import {TeamAppsEvent} from "./util/TeamAppsEvent"; import {UiGridTemplateConfig} from "../generated/UiGridTemplateConfig"; import {createGridTemplateRenderer} from "./util/UiGridTemplates"; -import {createTextCellTemplateRenderer} from "./util/UiTextCellTemplates"; import {UiTemplateReferenceConfig} from "../generated/UiTemplateReferenceConfig"; -import {UiTextCellTemplateConfig} from "../generated/UiTextCellTemplateConfig"; -import {wrapWithDefaultTagWrapper} from "trivial-components"; +import {wrapWithDefaultTagWrapper} from "./trivial-components/TrivialCore"; import {Renderer} from "./Common"; +import {createHtmlTemplateRenderer} from "./util/UiHtmlTemplates"; +import {UiHtmlTemplateConfig} from "../generated/UiHtmlTemplateConfig"; +import {createMustacheTemplateRenderer} from "./util/UiMustacheTemplates"; +import {UiMustacheTemplateConfig} from "../generated/UiMustacheTemplateConfig"; export class TemplateRegistry { @@ -38,7 +40,7 @@ export class TemplateRegistry { } }; - public readonly onTemplateRegistered: TeamAppsEvent = new TeamAppsEvent(this); + public readonly onTemplateRegistered: TeamAppsEvent = new TeamAppsEvent(); private static logger: log.Logger = log.getLogger("TemplateRegistry"); private renderersByName: { [name: string]: Renderer } = {}; @@ -77,10 +79,12 @@ export class TemplateRegistry { public createTemplateRenderer(template: UiTemplateConfig, idPropertyName?: string): Renderer { if (isTemplateReference(template)) { return this.getTemplateRendererByName(template.templateId); - } else if (isTextCellTemplate(template)) { - return createTextCellTemplateRenderer(template, this.context, idPropertyName); } else if (isGridTemplate(template)) { - return createGridTemplateRenderer(template as UiGridTemplateConfig, this.context, idPropertyName); + return createGridTemplateRenderer(template, idPropertyName); + } else if (isHtmlTemplate(template)) { + return createHtmlTemplateRenderer(template, idPropertyName); + } else if (isMustacheTemplate(template)) { + return createMustacheTemplateRenderer(template, idPropertyName); } } @@ -91,28 +95,20 @@ export class TemplateRegistry { }, <{ [name: string]: Renderer }> {}); } - public convertToWrappedTagMustacheTemplates(templates: { [name: string]: UiTemplateConfig }, idPropertyName?: string) { - return Object.keys(templates).reduce((templateStringMapObject, templateName) => { - let templateConfig = templates[templateName]; - let textCellTemplateRenderer = this.createTemplateRenderer(templateConfig, idPropertyName); - templateStringMapObject[templateName] = { - render: (view) => wrapWithDefaultTagWrapper(textCellTemplateRenderer.render(view)), - template: templateConfig - }; - return templateStringMapObject; - }, <{ [name: string]: Renderer }> {}); - } - } export function isTemplateReference(template: UiTemplateConfig): template is UiTemplateReferenceConfig { return template._type === "UiTemplateReference"; } -export function isTextCellTemplate(template: any): template is UiTextCellTemplateConfig { - return template._type === 'UiTextCellTemplate'; -} - export function isGridTemplate(template: UiTemplateConfig): template is UiGridTemplateConfig { return template._type === "UiGridTemplate"; } + +export function isHtmlTemplate(template: UiTemplateConfig): template is UiHtmlTemplateConfig { + return template._type === "UiHtmlTemplate"; +} + +export function isMustacheTemplate(template: UiTemplateConfig): template is UiMustacheTemplateConfig { + return template._type === "UiMustacheTemplate"; +} diff --git a/teamapps-client/ts/modules/UiAbsoluteLayout.ts b/teamapps-client/ts/modules/UiAbsoluteLayout.ts index d5aec8f18..14981cf3f 100644 --- a/teamapps-client/ts/modules/UiAbsoluteLayout.ts +++ b/teamapps-client/ts/modules/UiAbsoluteLayout.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,7 +17,7 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import {UiComponent} from "./UiComponent"; +import {AbstractUiComponent} from "./AbstractUiComponent"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; import {UiAbsoluteLayoutCommandHandler, UiAbsoluteLayoutConfig} from "../generated/UiAbsoluteLayoutConfig"; import {UiAbsolutePositionedComponentConfig} from "../generated/UiAbsolutePositionedComponentConfig"; @@ -25,6 +25,7 @@ import {UiAnimationEasing} from "../generated/UiAnimationEasing"; import {parseHtml} from "./Common"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; import {bind} from "./util/Bind"; +import {UiComponent} from "./UiComponent"; const animationEasingCssValues = { [UiAnimationEasing.EASE]: "ease", @@ -36,7 +37,7 @@ const animationEasingCssValues = { [UiAnimationEasing.STEP_END]: "step-end" }; -export class UiAbsoluteLayout extends UiComponent implements UiAbsoluteLayoutCommandHandler { +export class UiAbsoluteLayout extends AbstractUiComponent implements UiAbsoluteLayoutCommandHandler { private $main: HTMLElement; private $style: HTMLStyleElement; private components: UiAbsolutePositionedComponentConfig[]; @@ -50,8 +51,8 @@ export class UiAbsoluteLayout extends UiComponent implem this.update(config.components, 0, UiAnimationEasing.EASE); } - getMainDomElement(): JQuery { - return $(this.$main); + doGetMainElement(): HTMLElement { + return this.$main; } private transitionEndEventListener = (e: Event) => { @@ -68,10 +69,9 @@ export class UiAbsoluteLayout extends UiComponent implem components .map(c => c.component) .forEach((c: UiComponent) => { - const mainDomElementElement = c.getMainDomElement()[0]; + const mainDomElementElement = c.getMainElement(); mainDomElementElement.removeEventListener("transitionend", this.transitionEndEventListener); this.$main.appendChild(mainDomElementElement); - c.attachedToDom = this.attachedToDom; }); this.$main.offsetWidth; // trigger reflow @@ -80,9 +80,9 @@ export class UiAbsoluteLayout extends UiComponent implem transition: top ${animationDuration}ms ${animationEasingCssValues[easing]}, right ${animationDuration}ms ${animationEasingCssValues[easing]}, bottom ${animationDuration}ms ${animationEasingCssValues[easing]}, left ${animationDuration}ms ${animationEasingCssValues[easing]}, width ${animationDuration}ms ${animationEasingCssValues[easing]}, height ${animationDuration}ms ${animationEasingCssValues[easing]}; }`; components.forEach(c => { - const component = c.component as UiComponent; - component.getMainDomElement()[0].addEventListener('transitionend', this.transitionEndEventListener); - component.getMainDomElement().attr("data-absolute-positioning-id", component.getId()); + const component = c.component as AbstractUiComponent; + component.getMainElement().addEventListener('transitionend', this.transitionEndEventListener); + component.getMainElement().setAttribute("data-absolute-positioning-id", component.getId()); styles += `[data-teamapps-id=${this.getId()}] > [data-absolute-positioning-id=${component.getId()}] { top: ${c.position.topCss != null ? c.position.topCss : "initial"}; right: ${c.position.rightCss != null ? c.position.rightCss : "initial"}; @@ -97,13 +97,6 @@ export class UiAbsoluteLayout extends UiComponent implem this.$style.innerText = styles; } - protected onAttachedToDom(): void { - this.components.forEach(c => (c.component as UiComponent).attachedToDom = true); - } - - onResize(): void { - this.components.forEach(c => (c.component as UiComponent).reLayout()); - } } TeamAppsUiComponentRegistry.registerComponentClass("UiAbsoluteLayout", UiAbsoluteLayout); diff --git a/teamapps-client/ts/modules/UiApplicationLayout.ts b/teamapps-client/ts/modules/UiApplicationLayout.ts index a9722e2dc..c39d1fe5a 100644 --- a/teamapps-client/ts/modules/UiApplicationLayout.ts +++ b/teamapps-client/ts/modules/UiApplicationLayout.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,78 +17,67 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; + import {UiToolbar} from "./tool-container/toolbar/UiToolbar"; import {UiToolbarConfig} from "../generated/UiToolbarConfig"; import {UiSplitPaneConfig} from "../generated/UiSplitPaneConfig"; import {UiSplitPane} from "./UiSplitPane"; -import {UiComponent} from "./UiComponent"; +import {AbstractUiComponent} from "./AbstractUiComponent"; import {UiApplicationLayoutConfig} from "../generated/UiApplicationLayoutConfig"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; +import {parseHtml} from "./Common"; -export class UiApplicationLayout extends UiComponent { - private $mainDiv: JQuery; +export class UiApplicationLayout extends AbstractUiComponent { + private $mainDiv: HTMLElement; private _toolbar: UiToolbar; private _rootSplitPane: UiSplitPane; - private _$toolbarContainer: JQuery; - private _$contentContainer: JQuery; + private _$toolbarContainer: HTMLElement; + private _$contentContainer: HTMLElement; constructor(config: UiApplicationLayoutConfig, context: TeamAppsUiContext) { super(config, context); - this.$mainDiv = $('
'); - - this._$toolbarContainer = $('
').appendTo(this.$mainDiv); - this.setToolbar(config.toolbar); + this.$mainDiv = parseHtml('
'); - var $contentContainerWrapper = $('
').appendTo(this.$mainDiv); - this._$contentContainer = $('
').appendTo($contentContainerWrapper); - this.setRootSplitPane(config.rootSplitPane); - } + this._$toolbarContainer = parseHtml('
'); + this.$mainDiv.appendChild(this._$toolbarContainer); + this.setToolbar(config.toolbar as UiToolbar); - public onResize(): void { - this._toolbar && this._toolbar.reLayout(); - this._rootSplitPane && this._rootSplitPane.reLayout(); + var $contentContainerWrapper = parseHtml('
'); + this.$mainDiv.appendChild($contentContainerWrapper); + this._$contentContainer = parseHtml('
'); + $contentContainerWrapper.appendChild(this._$contentContainer); + this.setRootSplitPane(config.rootSplitPane as UiSplitPane); } public setToolbar(toolbar: UiToolbar): void { if (this._toolbar) { - this._$toolbarContainer[0].innerHTML = ''; + this._$toolbarContainer.innerHTML = ''; } this._toolbar = toolbar; - this._$toolbarContainer.toggleClass('hidden', !toolbar); + this._$toolbarContainer.classList.toggle('hidden', !toolbar); if (toolbar) { - this._toolbar.getMainDomElement().appendTo(this._$toolbarContainer); - this._toolbar.attachedToDom = this.attachedToDom; + this._$toolbarContainer.appendChild(this._toolbar.getMainElement()); } } public setRootSplitPane(splitPane: UiSplitPane): void { if (this._rootSplitPane) { - this._$contentContainer[0].innerHTML = ''; + this._$contentContainer.innerHTML = ''; this._rootSplitPane = null; } if (splitPane) { this._rootSplitPane = splitPane; - this._rootSplitPane.getMainDomElement().appendTo(this._$contentContainer); - this._rootSplitPane.attachedToDom = this.attachedToDom; + this._$contentContainer.appendChild(this._rootSplitPane.getMainElement()); } } - public getMainDomElement(): JQuery { + public doGetMainElement(): HTMLElement { return this.$mainDiv; } - - protected onAttachedToDom() { - if (this._toolbar) this._toolbar.attachedToDom = true; - if (this._rootSplitPane) this._rootSplitPane.attachedToDom = true; - } - - public destroy(): void { - } } TeamAppsUiComponentRegistry.registerComponentClass("UiApplicationLayout", UiApplicationLayout); diff --git a/teamapps-client/ts/modules/UiAudioLevelIndicator.ts b/teamapps-client/ts/modules/UiAudioLevelIndicator.ts new file mode 100644 index 000000000..29e0d89f6 --- /dev/null +++ b/teamapps-client/ts/modules/UiAudioLevelIndicator.ts @@ -0,0 +1,170 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ + +import {parseHtml} from "./Common"; +import {AbstractUiComponent} from "./AbstractUiComponent"; +import {UiAudioLevelIndicatorCommandHandler, UiAudioLevelIndicatorConfig} from "../generated/UiAudioLevelIndicatorConfig"; +import {TeamAppsUiContext} from "./TeamAppsUiContext"; +import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; + +export class UiAudioLevelIndicator extends AbstractUiComponent implements UiAudioLevelIndicatorCommandHandler { + private $main: HTMLElement; + private $activityDisplay: HTMLElement; + private $canvas: HTMLCanvasElement; + private mediaStreamSource: MediaStreamAudioSourceNode; + private audioContext: AudioContext; + private mediaStreamToBeClosedWhenUnbinding: MediaStream; + private canvasContext: CanvasRenderingContext2D; + + private maxLevel = 0; + private maxLevel2 = 0; + private lastDrawingTimestamp = 0; + + constructor(config: UiAudioLevelIndicatorConfig, context: TeamAppsUiContext) { + super(config, context) + + this.$main = parseHtml(` +
+ +
`); + this.$activityDisplay = this.$main; + this.$canvas = this.$main.querySelector(':scope canvas') as HTMLCanvasElement; + this.canvasContext = this.$canvas.getContext("2d"); + + this.setDeviceId(config.deviceId); + } + + private analyserNode: AnalyserNode; + + public bindToStream(mediaStream: MediaStream, mediaStreamIsExclusiveToThisComponent: boolean) { + this.unbind(); + + if (mediaStreamIsExclusiveToThisComponent) { + this.mediaStreamToBeClosedWhenUnbinding = mediaStream; + } + + (window as any).AudioContext = (window as any).AudioContext || (window as any).webkitAudioContext; + this.audioContext = new AudioContext(); + + let scriptProcessor = this.audioContext.createScriptProcessor(2048, 1, 1); + scriptProcessor.connect(this.audioContext.destination); + + this.analyserNode = this.audioContext.createAnalyser(); + this.analyserNode.smoothingTimeConstant = 0.3; + this.analyserNode.fftSize = 32; // 16 spectral lines - that's the minimum. we don't want to get a spectral analysis anyway... + this.analyserNode.connect(scriptProcessor); + + this.mediaStreamSource = this.audioContext.createMediaStreamSource(mediaStream); + this.mediaStreamSource.connect(this.analyserNode, 0, 0); + + scriptProcessor.onaudioprocess = event => { + var buf = event.inputBuffer.getChannelData(0); + var bufLength = buf.length; + for (var i = 0; i < bufLength; i++) { + let x = buf[i]; + if (x > this.maxLevel) { + this.maxLevel2 = this.maxLevel; + this.maxLevel = x; + } + } + + if (Date.now() - this.lastDrawingTimestamp > 100) { + requestAnimationFrame(() => { + let displayedLevel = this.maxLevel2 == 0 ? 0 : Math.max(-10, Math.log2(this.maxLevel)) / 10 + 1; + this.draw(displayedLevel); + this.maxLevel = 0; + this.maxLevel2 = 0; + }); + } + }; + } + + private draw(level: number) { + let canvasWidth = this.canvasContext.canvas.width; + let canvasHeight = this.canvasContext.canvas.height; + + var imageData = this.canvasContext.getImageData(this._config.barWidth, 0, canvasWidth - this._config.barWidth, canvasHeight); + this.canvasContext.putImageData(imageData, 0, 0); + this.canvasContext.clearRect(canvasWidth - this._config.barWidth, 0, this._config.barWidth, canvasHeight); + + var grd = this.canvasContext.createLinearGradient(0, canvasHeight, 0, 0); + grd.addColorStop(0, "#192e83") + grd.addColorStop(.4, "#0fb83f") + grd.addColorStop(.6, "#0fb83f") + grd.addColorStop(.8, "orange") + grd.addColorStop(.95, "#e00") + this.canvasContext.fillStyle = grd; + + // draw a bar based on the current volume + let volumeBarSize = canvasHeight * level; + this.canvasContext.fillRect(canvasWidth - this._config.barWidth, canvasHeight - volumeBarSize, this._config.barWidth, volumeBarSize); + + this.lastDrawingTimestamp = Date.now(); + } + + /** + * Returns a function, that will limit the invocation of the specified function to once in delay. Invocations that come before the end of delay are ignored! + */ + private throttle(func: Function, delay: number): (() => void) { + let previousCall = 0; + return function () { + const time = new Date().getTime(); + if ((time - previousCall) >= delay) { + previousCall = time; + func.apply(this, arguments); + } + }; + } + + public unbind() { + this.audioContext && this.audioContext?.close() + .catch(reason => console.log(reason)); + this.mediaStreamSource?.disconnect(); + if (this.mediaStreamToBeClosedWhenUnbinding != null) { + this.mediaStreamToBeClosedWhenUnbinding.getTracks().forEach(t => t.stop()); + } + this.canvasContext.clearRect(0, 0, this.canvasContext.canvas.width, this.canvasContext.canvas.height); + } + + onResize() { + this.$canvas.width = this.getWidth(); + this.$canvas.height = this.getHeight(); + } + + public doGetMainElement() { + return this.$main; + } + + public async setDeviceId(deviceId: string) { + if (deviceId == null) { + this.unbind(); + return; + } else { + let mediaStream = await navigator.mediaDevices.getUserMedia({ + audio: { + deviceId + } + }); + this.bindToStream(mediaStream, true); + } + } +} + +TeamAppsUiComponentRegistry.registerComponentClass("UiAudioLevelIndicator", UiAudioLevelIndicator); diff --git a/teamapps-client/ts/modules/UiCalendar.ts b/teamapps-client/ts/modules/UiCalendar.ts index 6e14be980..085079e4b 100644 --- a/teamapps-client/ts/modules/UiCalendar.ts +++ b/teamapps-client/ts/modules/UiCalendar.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,357 +17,456 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; -import * as moment from "moment"; -import * as momentTimeZone from "moment-timezone"; -import * as FullCalendar from 'fullcalendar'; + import * as log from "loglevel"; -import {EventObjectInput, EventSourceExtendedInput, EventSourceFunction, OptionsInput} from "fullcalendar/src/types/input-types"; import {TeamAppsEvent} from "./util/TeamAppsEvent"; import { UiCalendar_DataNeededEvent, UiCalendar_DayClickedEvent, + UiCalendar_DayHeaderClickedEvent, UiCalendar_EventClickedEvent, UiCalendar_EventMovedEvent, + UiCalendar_IntervalSelectedEvent, + UiCalendar_MonthHeaderClickedEvent, UiCalendar_ViewChangedEvent, + UiCalendar_WeekHeaderClickedEvent, UiCalendarCommandHandler, UiCalendarConfig, UiCalendarEventSource } from "../generated/UiCalendarConfig"; -import View from "fullcalendar/View"; -import {UiComponent} from "./UiComponent"; +import {AbstractUiComponent} from "./AbstractUiComponent"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; import {UiCalendarViewMode} from "../generated/UiCalendarViewMode"; import {UiCalendarEventRenderingStyle} from "../generated/UiCalendarEventRenderingStyle"; -import {EventFactory} from "../generated/EventFactory"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; import {Interval, IntervalManager} from "./util/IntervalManager"; -import {createUiColorCssString} from "./util/CssFormatUtil"; -import Default from "fullcalendar/Calendar"; -import {MultiMonthView} from "./util/FullCalendarMultiMonthView"; -import * as jstz from "jstz"; -import {Renderer} from "./Common"; +import {parseHtml, prependChild, Renderer} from "./Common"; import {UiCalendarEventClientRecordConfig} from "../generated/UiCalendarEventClientRecordConfig"; import {UiTemplateConfig} from "../generated/UiTemplateConfig"; -(window as any).FullCalendar = FullCalendar; // needed for dynamically reloading locales -($.fullCalendar as any).views.multiMonth = MultiMonthView; -($.fullCalendar as any).views.year = MultiMonthView; -($.fullCalendar as any).views.year = { - type: 'multiMonth', - duration: {months: 12} -}; - -import Moment = moment.Moment; +import {addDays, Calendar} from '@fullcalendar/core'; +import dayGridPlugin, {DayGridView} from '@fullcalendar/daygrid'; +import timeGridPlugin, {TimeGridView} from '@fullcalendar/timegrid'; +import interactionPlugin from '@fullcalendar/interaction'; +import momentTimeZone from './util/fullcalendar-moment-timezone'; +import {EventInput, EventRenderingChoice} from "@fullcalendar/core/structs/event"; +import {EventSourceError, ExtendedEventSourceInput} from "@fullcalendar/core/structs/event-source"; +import {bind} from "./util/Bind"; +import {View} from "@fullcalendar/core/View"; +import EventApi from "@fullcalendar/core/api/EventApi"; +import {Duration} from "@fullcalendar/core/datelib/duration"; +import {monthGridViewPlugin} from "./util/FullCalendarMonthGrid"; +import {OptionsInputBase} from "@fullcalendar/core/types/input-types"; const VIEW_MODE_2_FULL_CALENDAR_CONFIG_STRING: { [index: number]: string } = { [UiCalendarViewMode.YEAR]: "year", - [UiCalendarViewMode.MONTH]: "month", - [UiCalendarViewMode.WEEK]: "agendaWeek", - [UiCalendarViewMode.DAY]: "agendaDay" + [UiCalendarViewMode.MONTH]: "dayGridMonth", + [UiCalendarViewMode.WEEK]: "timeGridWeek", + [UiCalendarViewMode.DAY]: "timeGridDay" }; -const RENDERING_STYLE_2_FULL_CALENDAR_CONFIG_STRING: { [index: number]: string } = { - [UiCalendarEventRenderingStyle.DEFAULT]: undefined, +const RENDERING_STYLE_2_FULL_CALENDAR_CONFIG_STRING: { [index: number]: EventRenderingChoice } = { + [UiCalendarEventRenderingStyle.DEFAULT]: '', // [UiCalendarEventRenderingStyle.HIGHLIGHTED]: 'highlighted', [UiCalendarEventRenderingStyle.BACKGROUND]: 'background', [UiCalendarEventRenderingStyle.INVERSE_BACKGROUND]: 'inverse-background', }; -export class UiCalendar extends UiComponent implements UiCalendarCommandHandler, UiCalendarEventSource { +export class UiCalendar extends AbstractUiComponent implements UiCalendarCommandHandler, UiCalendarEventSource { - public readonly onEventClicked: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onEventMoved: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onDayClicked: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onViewChanged: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onDataNeeded: TeamAppsEvent = new TeamAppsEvent(this); + public readonly onEventClicked: TeamAppsEvent = new TeamAppsEvent(); + public readonly onEventMoved: TeamAppsEvent = new TeamAppsEvent(); + public readonly onDayClicked: TeamAppsEvent = new TeamAppsEvent(); + public readonly onIntervalSelected: TeamAppsEvent = new TeamAppsEvent(); + public readonly onViewChanged: TeamAppsEvent = new TeamAppsEvent(); + public readonly onDataNeeded: TeamAppsEvent = new TeamAppsEvent(); + public readonly onDayHeaderClicked: TeamAppsEvent = new TeamAppsEvent(); + public readonly onWeekHeaderClicked: TeamAppsEvent = new TeamAppsEvent(); + public readonly onMonthHeaderClicked: TeamAppsEvent = new TeamAppsEvent(); - private $main: JQuery; - private $fullCalendar: JQuery; + private $main: HTMLElement; private eventSource: UiCalendarFullCalendarEventSource; private templateRenderers: { [name: string]: Renderer }; + private calendar: Calendar; constructor(config: UiCalendarConfig, context: TeamAppsUiContext) { super(config, context); - this.$main = $('
'); - this.$fullCalendar = $('
').appendTo(this.$main); - this.eventSource = new UiCalendarFullCalendarEventSource(context, config.id); - this.eventSource.onViewChanged.addListener(eventObject => this.onViewChanged.fire(eventObject)); - this.eventSource.onDataNeeded.addListener(eventObject => this.onDataNeeded.fire(eventObject)); - config.initialData && this.eventSource.addEvents(0, Number.MAX_SAFE_INTEGER, config.initialData.map(e => this.convertToFullCalendarEvent(e))); + this.$main = parseHtml(`
+
+
`); + let $fullCalendarElement: HTMLElement = this.$main.querySelector(':scope > .calendar'); + this.templateRenderers = context.templateRegistry.createTemplateRenderers(config.templates); - let viewModeNames = ["year", "month", "agendaWeek", "agendaDay"]; - let options: OptionsInput & { lang?: string } = { - lang: context.config.isoLanguage, - header: config.showHeader ? { - left: 'prevYear,prev,next,nextYear today newEvent', - center: 'title', - right: viewModeNames.join(",") - } : false, + this.eventSource = new UiCalendarFullCalendarEventSource(context, config.id, () => this.calendar); + this.calendar = new Calendar($fullCalendarElement, { + plugins: [momentTimeZone, interactionPlugin, dayGridPlugin, timeGridPlugin, monthGridViewPlugin], + header: false, defaultView: VIEW_MODE_2_FULL_CALENDAR_CONFIG_STRING[config.activeViewMode], defaultDate: config.displayedDate, weekNumbers: config.showWeekNumbers, businessHours: { start: config.businessHoursStart + ':00', end: config.businessHoursEnd + ':00', - dow: config.workingDays + daysOfWeek: config.workingDays }, - firstDay: config.firstDayOfWeek != null ? config.firstDayOfWeek : context.config.firstDayOfWeek, // 1 = monday + firstDay: config.firstDayOfWeek, // 1 = monday fixedWeekCount: true, - eventBackgroundColor: createUiColorCssString(config.defaultBackgroundColor), - eventBorderColor: createUiColorCssString(config.defaultBorderColor), + eventBackgroundColor: config.defaultBackgroundColor, + eventBorderColor: config.defaultBorderColor, eventTextColor: "#000", handleWindowResize: false, // we handle this ourselves! - eventSources: [this.eventSource], lazyFetching: false, // no intelligent fetching from fullcalendar. We handle all that! selectable: true, unselectAuto: false, - timezone: context.config.timeZoneId || jstz.determine().name(), - eventRender: (event: EventObject, $event: JQuery) => { - if (event.templateId != null && event.rendering == null || event.rendering == "default") { - const $contentWrapper = $event.find('.fc-content'); - $contentWrapper[0].innerHTML = ''; - $contentWrapper.append(this.renderEventObject(event)); - $($event).on('click dblclick', (e) => { - this.onEventClicked.fire(EventFactory.createUiCalendar_EventClickedEvent(config.id, event.id as number, e.type === 'dblclick')); - }); + timeZone: config.timeZoneId, + height: 600, + slotEventOverlap: false, + locale: config.locale, + views: { + timeGrid: { + slotLabelFormat: { + hour: '2-digit', + minute: '2-digit' + } + }, + day: { + columnHeaderFormat: {weekday: 'long', month: 'numeric', day: 'numeric'} + }, + year: { + minMonthTileWidth: config.minYearViewMonthTileWidth, + maxMonthTileWidth: config.maxYearViewMonthTileWidth + } + }, + eventSources: [this.eventSource], + datesRender: (arg: { + view: View; + el: HTMLElement + }) => { + this.onViewChanged.fire({ + viewMode: parseInt(Object.keys(VIEW_MODE_2_FULL_CALENDAR_CONFIG_STRING).filter((enumValue: any) => VIEW_MODE_2_FULL_CALENDAR_CONFIG_STRING[enumValue] === arg.view.type)[0]), + mainIntervalStart: +arg.view.currentStart, + mainIntervalEnd: +arg.view.currentEnd, + displayedIntervalStart: +arg.view.activeStart, + displayedIntervalEnd: +arg.view.activeEnd + }); + + this.$main.classList.toggle("table-border", config.tableBorder); + // this.$main.querySelectorAll(":scope .fc-bg td.fc-week-number.fc-widget-content").forEach($e => $e.classList.add('teamapps-blurredBackgroundImage')); + // this.$main.querySelectorAll(":scope .fc-head td.fc-widget-header").forEach($e => $e.classList.add('teamapps-blurredBackgroundImage')); + // if (view.type.toLowerCase().indexOf('agenda') !== -1 && moment().isAfter(view.intervalStart) && moment().isBefore(view.intervalEnd)) { + // element.find(".fc-day-header.fc-" + ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'][moment().day()]).addClass("fc-today"); + // } + }, + eventRender: (arg: { + isMirror: boolean; + isStart: boolean; + isEnd: boolean; + event: EventApi; + el: HTMLElement; + view: View; + }) => { + let templateId = arg.view instanceof TimeGridView ? arg.event.extendedProps.timeGridTemplateId + : arg.view instanceof DayGridView ? arg.event.extendedProps.dayGridTemplateId + : arg.event.extendedProps.monthGridTemplateId; + + let $fcContent = arg.el.querySelector(':scope .fc-content'); + if (templateId != null && !arg.event.rendering) { + if (this.templateRenderers[templateId] != null) { + arg.el.classList.add('template-content'); + const renderer = this.templateRenderers[templateId]; + // arg.el.appendChild(parseHtml()); + $fcContent.innerHTML = renderer.render(arg.event.extendedProps.data); + } + } else { + prependChild($fcContent, parseHtml(`
`)); + } + if (arg.event.allDay) { + arg.el.classList.add("all-day"); } + + arg.el.addEventListener('click', (e) => { + this.onEventClicked.fire({ + eventId: parseInt(arg.event.id), + isDoubleClick: false + }); + }); + arg.el.addEventListener('dblclick', (e) => { + this.onEventClicked.fire({ + eventId: parseInt(arg.event.id), + isDoubleClick: true + }); + }); }, - dayClick: (() => { + dateClick: (() => { let lastClickTimeStamp = 0; - let lastClickClickedDate: Moment; - return (date: moment.Moment, jsEvent: MouseEvent, view: View, resourceObj?: any) => { - let isDoubleClick = lastClickClickedDate != null && lastClickClickedDate.valueOf() == date.valueOf() && jsEvent.timeStamp - lastClickTimeStamp < 600; + let lastClickClickedDate: Date; + return (arg: { + date: Date; + dateStr: string; + allDay: boolean; + resource?: any; + dayEl: HTMLElement; + jsEvent: MouseEvent; + view: View; + }) => { + let isDoubleClick = lastClickClickedDate != null && lastClickClickedDate.valueOf() == arg.date.valueOf() && arg.jsEvent.timeStamp - lastClickTimeStamp < 600; if (isDoubleClick) { lastClickTimeStamp = 0; lastClickClickedDate = null; - this.onDayClicked.fire(EventFactory.createUiCalendar_DayClickedEvent(config.id, date.valueOf(), true)); + this.onDayClicked.fire({ + date: arg.date.valueOf(), + isDoubleClick: true + }); } else { - lastClickTimeStamp = jsEvent.timeStamp; - lastClickClickedDate = date; - this.onDayClicked.fire(EventFactory.createUiCalendar_DayClickedEvent(config.id, date.valueOf(), false)); + lastClickTimeStamp = arg.jsEvent.timeStamp; + lastClickClickedDate = arg.date; + this.onDayClicked.fire({ + date: arg.date.valueOf(), + isDoubleClick: false + }); } }; })(), - eventResize: (event: EventObject, delta: moment.Duration, revertFunc: Function, jsEvent: Event, ui: any, view: View) => { - const masterEvent = this.eventSource.getEvent(event.id); - masterEvent.start = event.start; - masterEvent.end = event.end; - this.onEventMoved.fire(EventFactory.createUiCalendar_EventMovedEvent(config.id, event.id as number, moment(event.start).valueOf(), moment(event.end).valueOf())); + select: (selectionInfo) => { + this.onIntervalSelected.fire({start: selectionInfo.start.valueOf(), end: selectionInfo.end.valueOf(), allDay: selectionInfo.allDay}) }, - eventDrop: (event: EventObject, delta, revertFunc) => { - const masterEvent = this.eventSource.getEvent(event.id); - masterEvent.start = event.start; - masterEvent.end = event.end; - this.onEventMoved.fire(EventFactory.createUiCalendar_EventMovedEvent(config.id, event.id as number, moment(event.start).valueOf(), moment(event.end).valueOf())); + eventResize: (arg: { + el: HTMLElement; + startDelta: Duration; + endDelta: Duration; + prevEvent: EventApi; + event: EventApi; + revert: () => void; + jsEvent: Event; + view: View; + }) => { + const masterEvent = this.eventSource.getEvent(arg.event.id); + masterEvent.start = arg.event.start; + masterEvent.end = arg.event.end; + this.onEventMoved.fire({ + eventId: parseInt(arg.event.id), + newStart: arg.event.start.valueOf(), + newEnd: arg.event.end.valueOf() + }); }, - height: 600, - slotEventOverlap: false, - slotLabelFormat: "hh:mm", - viewRender: (view, element) => { - this.$main.toggleClass("table-border", config.tableBorder); - - this.$fullCalendar.find(".fc-bg td.fc-week-number.fc-widget-content").addClass('teamapps-blurredBackgroundImage'); - this.$fullCalendar.find(".fc-head td.fc-widget-header").addClass('teamapps-blurredBackgroundImage'); - - if (view.type.toLowerCase().indexOf('agenda') !== -1 && moment().isAfter(view.intervalStart) && moment().isBefore(view.intervalEnd)) { - element.find(".fc-day-header.fc-" + ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'][moment().day()]).addClass("fc-today"); + eventDrop: (arg: { + el: HTMLElement; + event: EventApi; + oldEvent: EventApi; + delta: Duration; + revert: () => void; + jsEvent: Event; + view: View; + }) => { + const masterEvent = this.eventSource.getEvent(arg.event.id); + masterEvent.start = arg.event.start; + masterEvent.end = arg.event.end; + this.onEventMoved.fire({ + eventId: parseInt(arg.event.id), + newStart: arg.event.start.valueOf(), + newEnd: arg.event.end.valueOf() + }); + }, + navLinks: true, + navLinkDayClick: (date, jsEvent) => { + this.onDayHeaderClicked.fire({ + date: date.valueOf() + }); + if (this._config.navigateOnHeaderClicks) { + this.calendar.changeView("timeGridDay", date); + } + }, + navLinkWeekClick: (weekStart, jsEvent) => { + this.onWeekHeaderClicked.fire({ + year: addDays(weekStart, 6).getFullYear(), + week: this.calendar.dateEnv.computeWeekNumber(weekStart), + weekStartDate: weekStart.valueOf() + }); + if (this._config.navigateOnHeaderClicks) { + this.calendar.changeView("timeGridWeek", weekStart); + } + }, + navLinkMonthClick: (monthStart: Date, jsEvent: Event) => { + this.onMonthHeaderClicked.fire({ + year: monthStart.getFullYear(), + month: monthStart.getMonth(), + monthStartDate: monthStart.valueOf() + }); + if (this._config.navigateOnHeaderClicks) { + this.calendar.changeView("dayGridMonth", monthStart); } } - }; - this.$fullCalendar.fullCalendar(options); + } as OptionsInputBase); - this.$main.append(``); - } - - private renderEventObject(record: EventObject): string { - const templateId = record.templateId; - if (templateId != null && this.templateRenderers[templateId] != null) { - const renderer = this.templateRenderers[templateId]; - return renderer.render(record.data); - } else { - return `
`; - } + `)); } registerTemplate(id: string, template: UiTemplateConfig): void { this.templateRenderers[id] = this._context.templateRegistry.createTemplateRenderer(template); } - protected onAttachedToDom() { - this.reLayout(); - } - - private convertToFullCalendarEvent(event: UiCalendarEventClientRecordConfig): EventObject { - - let backgroundColor = event.backgroundColor; - let backgroundColorCssString = typeof backgroundColor === 'string' ? backgroundColor - : backgroundColor != null ? createUiColorCssString(backgroundColor) : null; - - let borderColor = event.borderColor; - let borderColorCssString = typeof borderColor === 'string' ? borderColor - : borderColor != null ? createUiColorCssString(borderColor) : null; - - return { - id: event.id, - start: moment(event.start), - end: moment(event.end), - title: event.asString, - rendering: RENDERING_STYLE_2_FULL_CALENDAR_CONFIG_STRING[event.rendering], - editable: event.allowDragOperations, - templateId: event.templateId, - allDay: event.allDay, - backgroundColor: backgroundColorCssString, - borderColor: borderColorCssString, - data: event.values - }; - } - public setViewMode(viewMode: UiCalendarViewMode) { - this.$fullCalendar.fullCalendar('changeView' as any /* not in declarations...*/, VIEW_MODE_2_FULL_CALENDAR_CONFIG_STRING[viewMode]); + this.calendar.changeView(VIEW_MODE_2_FULL_CALENDAR_CONFIG_STRING[viewMode]); } public setDisplayedDate(date: number) { - this.logger.debug("setDisplayedDate: " + moment(date).toString()); - this.$fullCalendar.fullCalendar('gotoDate', date); + this.calendar.gotoDate(new Date(date)); } public addEvent(theEvent: any) { this.eventSource.addEvent(this.convertToFullCalendarEvent(theEvent)); - this.$fullCalendar.fullCalendar('refetchEvents'); + this.refreshEventsDisplay(); } public removeEvent(eventId: any) { this.eventSource.removeEvent(eventId); - this.$fullCalendar.fullCalendar('refetchEvents'); + this.refreshEventsDisplay(); } public setCalendarData(events: UiCalendarEventClientRecordConfig[]) { this.eventSource.removeAllEvents(); this.eventSource.addEvents(0, Number.MAX_SAFE_INTEGER, events.map(e => this.convertToFullCalendarEvent(e))); - this.$fullCalendar.fullCalendar('refetchEvents'); + this.refreshEventsDisplay(); } public clearCalendar() { this.logger.debug(`clearCalendar()`); this.eventSource.removeAllEvents(); - this.$fullCalendar.fullCalendar('refetchEvents'); + this.refreshEventsDisplay(); + } + + private refreshEventsDisplay() { + this.eventSource.setQueriesDisabled(true); + try { + this.calendar.refetchEvents(); + } finally { + this.eventSource.setQueriesDisabled(false); + } } onResize(): void { - this.$fullCalendar.fullCalendar('render'); - this.$fullCalendar.fullCalendar('option', 'height', this.getHeight()); + // this.$fullCalendar.fullCalendar('render'); + this.calendar.setOption('height', this.getHeight()); } - public getMainDomElement(): JQuery { + public doGetMainElement(): HTMLElement { return this.$main; } public destroy(): void { - // nothing to do + super.destroy(); + this.calendar && this.calendar.destroy(); + } + + public convertToFullCalendarEvent(event: UiCalendarEventClientRecordConfig): EventInput { + return { + id: "" + event.id, + start: new Date(event.start), + end: new Date(event.end), + title: event.title, + rendering: RENDERING_STYLE_2_FULL_CALENDAR_CONFIG_STRING[event.rendering], + editable: event.allowDragOperations, + startEditable: event.allowDragOperations, + durationEditable: event.allowDragOperations, + allDay: event.allDay, + backgroundColor: event.backgroundColor, + borderColor: event.borderColor, + textColor: "#000", + extendedProps: { + timeGridTemplateId: event.timeGridTemplateId, + dayGridTemplateId: event.dayGridTemplateId, + monthGridTemplateId: event.monthGridTemplateId, + data: event.values, + icon: event.icon + } + }; + } + + setTimeZoneId(timeZoneId: string): void { + this.calendar.setOption("timeZone", timeZoneId); } -} -interface EventObject extends EventObjectInput { - templateId: string; - data: any; } export /* for testing ... */ -class UiCalendarFullCalendarEventSource implements EventSourceExtendedInput { +class UiCalendarFullCalendarEventSource implements ExtendedEventSourceInput { - public readonly onViewChanged: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onDataNeeded: TeamAppsEvent = new TeamAppsEvent(this); + public readonly onViewChanged: TeamAppsEvent = new TeamAppsEvent(); + public readonly onDataNeeded: TeamAppsEvent = new TeamAppsEvent(); private logger = log.getLogger((UiCalendarFullCalendarEventSource.prototype).name || this.constructor.toString().match(/\w+/g)[1]); private intervalManager: IntervalManager = new IntervalManager(); private cachedEvents: any[] = []; - public events: EventSourceFunction; + private queriesDisabled: boolean; - constructor(private teamappsUiContext: TeamAppsUiContext, private componentId: string) { - this.events = this.createEventsFunction(); + constructor(private teamappsUiContext: TeamAppsUiContext, private componentId: string, private fullCalendarAccessor: () => Calendar) { } - private createEventsFunction() { - let me = this; - // FullCalendar messes around with "this" because it uses the method as a plain function... - return function (this: Default, start: Moment, end: Moment, timezone: boolean | string, callback: ((events: EventObject[]) => void)): void { - - start = me.removeTimeZoneOffsetFromAmbiguouslyTimedMoment(start, timezone); - end = me.removeTimeZoneOffsetFromAmbiguouslyTimedMoment(end, timezone); - - let newInterval: Interval = new Interval(start.valueOf(), end.valueOf()); - - if (!me.teamappsUiContext.executingCommand) { - setTimeout(() => { - me.onViewChanged.fire(EventFactory.createUiCalendar_ViewChangedEvent( - me.componentId, - parseInt(Object.keys(VIEW_MODE_2_FULL_CALENDAR_CONFIG_STRING).filter((enumValue: any) => VIEW_MODE_2_FULL_CALENDAR_CONFIG_STRING[enumValue] === view.type)[0]), - view.intervalStart.valueOf(), - view.intervalEnd.valueOf(), - view.start.valueOf(), - view.end.valueOf() - )); - }); - } - - let uncoveredIntervals = me.intervalManager.getUncoveredIntervals(newInterval); - - let queryStart: number; - let queryEnd: number; - if (uncoveredIntervals.length > 0) { - queryStart = Math.min.apply(Math, uncoveredIntervals.map(i => i.start)); - queryEnd = Math.max.apply(Math, uncoveredIntervals.map(i => i.end)); - } else { - queryStart = 0; - queryEnd = 0; - } - let view = this.getView(); - - if (queryStart !== queryEnd) { - me.logger.debug("DataNeededEvent: " + moment(queryStart).toString() + " - " + moment(queryEnd).toString()); - setTimeout(() => { - me.onDataNeeded.fire(EventFactory.createUiCalendar_DataNeededEvent(me.componentId, queryStart, queryEnd)); - }); - } - - let displayedInterval = new Interval(+start, +end); - me.logger.debug(`displayed: ${displayedInterval}`); - let events = me.cachedEvents.filter(event => { - let eventInterval = new Interval(+event.start, +event.end); - let matches = IntervalManager.intervalsOverlap(displayedInterval, eventInterval); - if (matches) { - me.logger.debug(`matching: ${eventInterval}`); - } - return matches; + @bind + public events(query: { start: Date; end: Date; timeZone: string; }, successCallback: (events: EventInput[]) => void, failureCallback: (error: EventSourceError) => void) { + // let uncoveredIntervals = this.intervalManager.getUncoveredIntervals(new Interval(query.start.valueOf(), query.end.valueOf())); + // + // let queryStart: number; + // let queryEnd: number; + // if (uncoveredIntervals.length > 0) { + // queryStart = Math.min.apply(Math, uncoveredIntervals.map(i => i.start)); + // queryEnd = Math.max.apply(Math, uncoveredIntervals.map(i => i.end)); + // } else { + // queryStart = 0; + // queryEnd = 0; + // } + // if (queryStart !== queryEnd) { + // this.logger.debug("DataNeededEvent: " + query.start.toUTCString() + " - " + query.start.toUTCString()); + // this.onDataNeeded.fire(EventFactory.createUiCalendar_DataNeededEvent(this.componentId, queryStart, queryEnd)); + // } + + if (!this.queriesDisabled) { + this.onDataNeeded.fire({ + requestIntervalStart: +query.start, + requestIntervalEnd: +query.end }); - me.logger.debug(`Returning ${events.length} events`); - callback(events); - }; - } + } - // see https://fullcalendar.io/docs/moment - private removeTimeZoneOffsetFromAmbiguouslyTimedMoment(start: Moment, timezone: boolean | string): Moment { - let x = momentTimeZone(start.valueOf()).tz(timezone.toString()); - return moment(x.add(-x.utcOffset(), "minutes").valueOf()); + let displayedInterval: Interval = [+query.start, +query.end]; + this.logger.debug(`displayed: ${displayedInterval}`); + let events = this.cachedEvents.filter(event => { + let eventInterval: Interval = [+event.start, +event.end]; + let matches = IntervalManager.intervalsOverlap(displayedInterval, eventInterval); + if (matches) { + this.logger.debug(`matching: ${eventInterval}`); + } + return matches; + }); + this.logger.debug(`Returning ${events.length} events`); + successCallback(events); } - public addEvents(start: number, end: number, newEvents: EventObject[]) { + public addEvents(start: number, end: number, newEvents: EventInput[]) { newEvents.forEach(e => { if (e.end < start || e.start > end) { - this.logger.error(`Event ${e.id} (${moment(e.start).toString()}-${moment(e.end).toString()}) is outside of specified start/end range (${moment(start).toString()}-${moment(end).toString()})! This will very probably lead to inconsistent client-side behaviour!`) + this.logger.error(`Event ${e.id} (${e.start}-${e.end}) is outside of specified start/end range (${new Date(start).toUTCString()}-${new Date(end).toUTCString()})! This will very probably lead to inconsistent client-side behaviour!`) } if (e.end.valueOf() <= e.start.valueOf()) { this.logger.warn(`Event ${e.id} has zero or negative duration! Changing to one millisecond!`); - e.end = moment((e.start as Moment).valueOf() + 1); + e.end = new Date(+(e.start) + 1); } }); this.cachedEvents = this.cachedEvents.filter(e => { @@ -375,10 +474,10 @@ class UiCalendarFullCalendarEventSource implements EventSourceExtendedInput { }); // remove all obsolete events this.cachedEvents = this.cachedEvents.concat(newEvents); this.cachedEvents.sort((a, b) => a.start < b.start ? -1 : a.start > b.start ? 1 : 0); - this.intervalManager.addInterval(new Interval(start, end)); + this.intervalManager.addInterval([start, end]); } - public addEvent(newEvent: EventObject) { + public addEvent(newEvent: EventInput) { this.logger.debug("Adding 1 event"); this.addEvents(null, null, [newEvent]); } @@ -399,6 +498,11 @@ class UiCalendarFullCalendarEventSource implements EventSourceExtendedInput { this.cachedEvents = []; this.intervalManager = new IntervalManager(); } + + setQueriesDisabled(queriesDisabled: boolean) { + this.queriesDisabled = queriesDisabled; + } } + TeamAppsUiComponentRegistry.registerComponentClass("UiCalendar", UiCalendar); diff --git a/teamapps-client/ts/modules/UiChatDisplay.ts b/teamapps-client/ts/modules/UiChatDisplay.ts index 253255b7c..bce799999 100644 --- a/teamapps-client/ts/modules/UiChatDisplay.ts +++ b/teamapps-client/ts/modules/UiChatDisplay.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,144 +17,240 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import {UiComponent} from "./UiComponent"; +import {AbstractUiComponent} from "./AbstractUiComponent"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; -import {parseHtml, prependChild, removeDangerousTags} from "./Common"; +import {addDelegatedEventListener, humanReadableFileSize, parseHtml, prependChild, removeDangerousTags} from "./Common"; import {UiChatMessageConfig} from "../generated/UiChatMessageConfig"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; -import {UiChatDisplay_PreviousMessagesRequestedEvent, UiChatDisplayCommandHandler, UiChatDisplayConfig, UiChatDisplayEventSource} from "../generated/UiChatDisplayConfig"; -import {TeamAppsEvent} from "./util/TeamAppsEvent"; -import {EventFactory} from "../generated/EventFactory"; +import {UiChatDisplayCommandHandler, UiChatDisplayConfig,} from "../generated/UiChatDisplayConfig"; import {UiSpinner} from "./micro-components/UiSpinner"; +import {executeWhenFirstDisplayed} from "./util/ExecuteWhenFirstDisplayed"; +import {Autolinker} from "autolinker"; +import {ContextMenu} from "./micro-components/ContextMenu"; +import {UiComponent} from "./UiComponent"; +import {UiChatMessageBatchConfig} from "../generated/UiChatMessageBatchConfig"; +import {debounce, debouncedMethod, DebounceMode} from "./util/debounce"; -export class UiChatDisplay extends UiComponent implements UiChatDisplayCommandHandler, UiChatDisplayEventSource { - - public readonly onPreviousMessagesRequested: TeamAppsEvent = new TeamAppsEvent(this); +export class UiChatDisplay extends AbstractUiComponent implements UiChatDisplayCommandHandler { private $main: HTMLElement; private gotFirstMessage: boolean = false; private requestingPreviousMessages: boolean; - private chatMessages: UiChatMessageConfig[] = []; + private uiChatMessages: UiChatMessage[] = []; private $loadingIndicatorWrapper: HTMLElement; private $messages: HTMLElement; + private contextMenu: ContextMenu; constructor(config: UiChatDisplayConfig, context: TeamAppsUiContext) { super(config, context); this.$main = parseHtml(`
-
+ +
`); this.$loadingIndicatorWrapper = this.$main.querySelector(":scope .loading-indicator-wrapper"); - this.$loadingIndicatorWrapper.appendChild(new UiSpinner({fixedSize: 20}).getMainDomElement()[0]); + this.$loadingIndicatorWrapper.appendChild(new UiSpinner({fixedSize: 20}).getMainDomElement()); this.$messages = this.$main.querySelector(":scope .messages"); this.$main.addEventListener("scroll", () => { if (this.$main.scrollTop == 0 && !this.gotFirstMessage) { + this.$loadingIndicatorWrapper.classList.remove('hidden'); this.requestPreviousMessages(); } }); - if (config.messages) { - this.addChatMessages(config.messages, false, config.includesFirstMessage); - } else if (!config.includesFirstMessage) { + if (config.initialMessages) { + this.addMessages(config.initialMessages); + } else { this.requestPreviousMessages(); } + + this.contextMenu = new ContextMenu(); + addDelegatedEventListener(this.$messages, ".message", "contextmenu", (element, ev) => { + if (this._config.contextMenuEnabled) { + let chatMessageId = Number(element.getAttribute("data-id")); + this.contextMenu.open(ev, async requestId => { + let contentComponent = await config.requestContextMenu({chatMessageId}) as UiComponent; + if (contentComponent != null) { + this.contextMenu.setContent(contentComponent, requestId); + } else { + this.contextMenu.close(requestId); + } + }); + } + }) } + @debouncedMethod(500, DebounceMode.LATER) private requestPreviousMessages() { if (!this.requestingPreviousMessages) { this.requestingPreviousMessages = true; - this.onPreviousMessagesRequested.fire(EventFactory.createUiChatDisplay_PreviousMessagesRequestedEvent(this.getId(), this.getEarliestChatMessageId())); - this.$loadingIndicatorWrapper.classList.remove('hidden'); + this._config.requestPreviousMessages({}).then(batch => { + const heightBefore = this.$messages.offsetHeight; + batch.messages.reverse().forEach(messageConfig => { + const uiChatMessage = new UiChatMessage(messageConfig); + prependChild(this.$messages, uiChatMessage.getMainDomElement()); + this.uiChatMessages.splice(0, 0, uiChatMessage); + }); + const heightAfter = this.$messages.offsetHeight; + this.$main.scroll({top: heightAfter - heightBefore}); + + if (batch.containsFirstMessage) { + this.gotFirstMessage = true; + } + this.requestingPreviousMessages = false; + this.$loadingIndicatorWrapper.classList.add('hidden'); + }); } } - getMainDomElement(): JQuery { - return $(this.$main); + doGetMainElement(): HTMLElement { + return this.$main; } - addChatMessages(chatMessages: UiChatMessageConfig[], prepend: boolean, includesFirstMessage: boolean): void { - if (prepend) { - const heightBefore = this.$messages.offsetHeight; - chatMessages.reverse().forEach(messageConfig => { - const chatMessage = new UiChatMessage(messageConfig, this._context); - prependChild(this.$messages, chatMessage.getMainDomElement()); - this.chatMessages.splice(0, 0, messageConfig); - }); - const heightAfter = this.$messages.offsetHeight; - this.$main.scroll({top: heightAfter - heightBefore}); - } else { - chatMessages.forEach(messageConfig => { - const chatMessage = new UiChatMessage(messageConfig, this._context); - this.$messages.appendChild(chatMessage.getMainDomElement()); - this.chatMessages.push(messageConfig); - }); - this.scrollToBottom(); - } - if (includesFirstMessage) { + addMessages(batch: UiChatMessageBatchConfig): void { + batch.messages.forEach(messageConfig => { + const chatMessage = new UiChatMessage(messageConfig); + this.$messages.appendChild(chatMessage.getMainDomElement()); + this.uiChatMessages.push(chatMessage); + }); + if (batch.containsFirstMessage) { this.gotFirstMessage = true; } - this.requestingPreviousMessages = false; - this.$loadingIndicatorWrapper.classList.add('hidden'); + this.scrollToBottom(); + } + + updateMessage(message: UiChatMessageConfig) { + this.uiChatMessages.find(m => m.id === message.id) + ?.update(message); + } + + deleteMessage(messageId: number) { + let messageIndex = this.uiChatMessages.findIndex(m => m.id === messageId); + if (messageIndex >= 0) { + let uiChatMessage = this.uiChatMessages[messageIndex]; + uiChatMessage.getMainDomElement().remove(); + this.uiChatMessages.splice(messageIndex, 1); + } } - replaceChatMessages(chatMessages: UiChatMessageConfig[], includesFirstMessage: boolean): void { - this.chatMessages = []; + clearMessages(batch: UiChatMessageBatchConfig): void { + this.uiChatMessages = []; this.$messages.innerHTML = ''; this.gotFirstMessage = false; this.requestingPreviousMessages = false; - this.addChatMessages(chatMessages, true, includesFirstMessage); + this.addMessages(batch); } + @executeWhenFirstDisplayed(true) private scrollToBottom() { - this.$main.scroll({ - top: 100000000, - behavior: 'smooth' - }); + const scrollToBottom = ()=> { + this.$main.scroll({ + top: 100000000, + behavior: 'smooth' + }); + } + this.doWhenAllImagesAreLoaded(scrollToBottom); } - protected onAttachedToDom(): void { - this.scrollToBottom(); + private doWhenAllImagesAreLoaded(action: () => void) { + let allImages = Array.from(this.$messages.querySelectorAll(":scope img")); + let incompleteImages: HTMLImageElement[] = allImages + .map(img => img as HTMLImageElement) + .filter(img => !img.complete); + if (incompleteImages.length > 0) { + Promise.all(incompleteImages.map(img => new Promise((resolve, reject) => { + img.addEventListener("load", resolve, {once:true}); + img.addEventListener("error", resolve, {once:true}); // since we are only interested in when loading activity is done + }))).then(action) + } else { + action(); + } } - private getEarliestChatMessageId() { - return this.chatMessages[0] && this.chatMessages[0].id; + onResize() { + this.scrollToBottom(); // hack. actually, I want to scrollToBottom only when this component gets re-attached... + } + + closeContextMenu(): void { + this.contextMenu.close(); } } TeamAppsUiComponentRegistry.registerComponentClass("UiChatDisplay", UiChatDisplay); class UiChatMessage { + + private static readonly AUTOLINKER = new Autolinker({ + urls: { + schemeMatches: true, + wwwMatches: true, + tldMatches: true + }, + email: true, + phone: true, + mention: false, + hashtag: false, + + stripPrefix: false, + stripTrailingSlash: false, + newWindow: true, + + truncate: { + length: 70, + location: 'smart' + }, + + className: '' + }); + private $main: HTMLElement; private $photos: HTMLElement; private $files: HTMLElement; + private config: UiChatMessageConfig; + + constructor(config: UiChatMessageConfig) { + this.$main = parseHtml(`
`); + this.update(config); + } + + public update(config: UiChatMessageConfig) { + this.config = config; + this.$main.classList.toggle("deleted", config.deleted); + this.$main.innerHTML = ""; + let text = removeDangerousTags(this.config.text); + text = UiChatMessage.AUTOLINKER.link(text); + this.$main.appendChild(parseHtml(``)) + this.$main.appendChild(parseHtml(`
${this.config.userNickname}
`)) + this.$main.appendChild(parseHtml(`
${text}
`)) + this.$main.appendChild(parseHtml(`
`)) + this.$main.appendChild(parseHtml(`
`)) + this.$main.appendChild(parseHtml(`
`)) - constructor(private config: UiChatMessageConfig, private context: TeamAppsUiContext) { - this.$main = parseHtml(`
- -
${config.userNickname}
-
${removeDangerousTags(config.text)}
-
-
-
`); this.$photos = this.$main.querySelector(":scope .photos"); this.$files = this.$main.querySelector(":scope .files"); - if (config.photos != null) { - config.photos.forEach(photo => { - this.$photos.appendChild(parseHtml(` `)) + if (this.config.photos != null) { + this.config.photos.forEach(photo => { + this.$photos.appendChild(parseHtml(``)) }); } - if (config.files != null) { - config.files.forEach(file => { + if (this.config.files != null) { + this.config.files.forEach(file => { this.$files.appendChild(parseHtml(` -
-
${file.name}
-
23.4 kB
-
`)) +
+
${file.name}
+
${humanReadableFileSize(file.length)}
+ `)) }); } } + public get id() { + return this.config.id; + } + public getMainDomElement(): HTMLElement { return this.$main; } diff --git a/teamapps-client/ts/modules/UiChatInput.ts b/teamapps-client/ts/modules/UiChatInput.ts index 351e0ae84..4e7a9526e 100644 --- a/teamapps-client/ts/modules/UiChatInput.ts +++ b/teamapps-client/ts/modules/UiChatInput.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,9 +17,9 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import {UiComponent} from "./UiComponent"; +import {AbstractUiComponent} from "./AbstractUiComponent"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; -import {createImageThumbnailUrl, parseHtml} from "./Common"; +import {createImageThumbnailUrl, fadeOut, insertAtCursorPosition, parseHtml} from "./Common"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; import { UiChatInput_FileItemClickedEvent, @@ -38,52 +38,55 @@ import {FileUploader} from "./util/FileUploader"; import {ProgressBar} from "./micro-components/ProgressBar"; import {TeamAppsEvent} from "./util/TeamAppsEvent"; import * as log from "loglevel"; -import {EventFactory} from "../generated/EventFactory"; import {createUiNewChatMessageConfig} from "../generated/UiNewChatMessageConfig"; import {createUiChatNewFileConfig} from "../generated/UiChatNewFileConfig"; -import Mouse = JQuery.Mouse; -export class UiChatInput extends UiComponent implements UiChatInputCommandHandler, UiChatInputEventSource { +export class UiChatInput extends AbstractUiComponent implements UiChatInputCommandHandler, UiChatInputEventSource { - onFileItemClicked: TeamAppsEvent = new TeamAppsEvent(this); - onFileItemRemoved: TeamAppsEvent = new TeamAppsEvent(this); - onMessageSent: TeamAppsEvent = new TeamAppsEvent(this); - onUploadCanceled: TeamAppsEvent = new TeamAppsEvent(this); - onUploadFailed: TeamAppsEvent = new TeamAppsEvent(this); - onUploadStarted: TeamAppsEvent = new TeamAppsEvent(this); - onUploadSuccessful: TeamAppsEvent = new TeamAppsEvent(this); - onUploadTooLarge: TeamAppsEvent = new TeamAppsEvent(this); + onFileItemClicked: TeamAppsEvent = new TeamAppsEvent(); + onFileItemRemoved: TeamAppsEvent = new TeamAppsEvent(); + onMessageSent: TeamAppsEvent = new TeamAppsEvent(); + onUploadCanceled: TeamAppsEvent = new TeamAppsEvent(); + onUploadFailed: TeamAppsEvent = new TeamAppsEvent(); + onUploadStarted: TeamAppsEvent = new TeamAppsEvent(); + onUploadSuccessful: TeamAppsEvent = new TeamAppsEvent(); + onUploadTooLarge: TeamAppsEvent = new TeamAppsEvent(); private $main: HTMLElement; private $uploadItems: HTMLElement; private uploadItems: FileUploadItem[] = []; private $textInput: HTMLInputElement; private $sendButton: HTMLElement; + private $attachmentButton: Element; constructor(config: UiChatInputConfig, context: TeamAppsUiContext) { super(config, context); this.$main = parseHtml(`
- +
- +
`); this.$uploadItems = this.$main.querySelector(":scope .upload-items"); this.$textInput = this.$main.querySelector(":scope .text-input"); this.$sendButton = this.$main.querySelector(":scope .send-button"); - const $attachmentButton = this.$main.querySelector(":scope .attachment-button"); + this.$attachmentButton = this.$main.querySelector(":scope .attachment-button"); const $fileInput = this.$main.querySelector(":scope .file-input"); this.$textInput.addEventListener("keyup", () => this.updateSendability()); this.$textInput.addEventListener("keydown", (e: MouseEvent) => { - if ((e as any).key === 'Enter'){ - this.send(); + if ((e as any).key === 'Enter') { + if (e.shiftKey) { + insertAtCursorPosition(this.$textInput, "\n"); + } else { + this.send(); + } e.preventDefault(); // no new-line character in the text input, please } }); - $attachmentButton.addEventListener("click", () => $fileInput.click()); + this.$attachmentButton.addEventListener("click", () => $fileInput.click()); $fileInput.addEventListener("change", e => { this.upload($fileInput.files); $fileInput.value = ""; @@ -92,6 +95,9 @@ export class UiChatInput extends UiComponent implements UiCha this.$sendButton.addEventListener("click", () => this.send()); this.$main.addEventListener("dragover", (e) => { + if (!this._config.attachmentsEnabled) { + return; + } this.$main.classList.add("drop-zone-active"); // preventDefault() is important as it indicates that the drop is possible!!! see https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Drag_operations#droptargets e.preventDefault(); @@ -111,28 +117,41 @@ export class UiChatInput extends UiComponent implements UiCha const files = e.dataTransfer.files; this.upload(files); }); - + + this.setAttachmentsEnabled(config.attachmentsEnabled); this.updateSendability(); } private send() { - this.onMessageSent.fire(EventFactory.createUiChatInput_MessageSentEvent(this.getId(), createUiNewChatMessageConfig({ - text: this.$textInput.value, - uploadedFiles: this.uploadItems - .filter(item => item.state === UploadState.SUCCESS) - .map(item => createUiChatNewFileConfig({uploadedFileUuid: item.uploadedFileUuid, fileName: item.file.name})) - }))); + if (!this.sendable()) { + return; + } + + this.onMessageSent.fire({ + message: createUiNewChatMessageConfig({ + text: this.$textInput.value, + uploadedFiles: this.uploadItems + .filter(item => item.state === UploadState.SUCCESS) + .map(item => createUiChatNewFileConfig({ + uploadedFileUuid: item.uploadedFileUuid, + fileName: item.file.name + })) + }) + }); this.$uploadItems.innerHTML = ""; this.uploadItems = []; this.$textInput.value = ""; this.updateSendability(); } - getMainDomElement(): JQuery { - return $(this.$main); + doGetMainElement(): HTMLElement { + return this.$main; } private upload(files: FileList) { + if (!this._config.attachmentsEnabled) { + return; + } for (let i = 0; i < files.length; i++) { const file = files[i]; const uploadItem = new FileUploadItem(file, this._config.defaultFileIcon, this._config.uploadUrl, this._context); @@ -155,13 +174,20 @@ export class UiChatInput extends UiComponent implements UiCha } private updateSendability() { + this.$sendButton.classList.toggle("disabled", !this.sendable()); + } + + private sendable() { const uploading = this.uploadItems.some(item => item.state === UploadState.IN_PROGRESS); const hasSuccessfulFileUploads = this.uploadItems.filter(item => item.state === UploadState.SUCCESS).length > 0; const hasTextInput = this.$textInput.value.length > 0; - const sendable = !uploading && (hasSuccessfulFileUploads || hasTextInput); - this.$sendButton.classList.toggle("disabled", !sendable); + return !uploading && (hasSuccessfulFileUploads || hasTextInput); } + public setAttachmentsEnabled(enabled: boolean) { + this._config.attachmentsEnabled = enabled; + this.$attachmentButton.classList.toggle("hidden", !enabled); + } } enum UploadState { @@ -171,7 +197,7 @@ enum UploadState { class FileUploadItem { private static LOGGER: log.Logger = log.getLogger("UploadItem"); - public readonly onComplete: TeamAppsEvent = new TeamAppsEvent(this); + public readonly onComplete: TeamAppsEvent = new TeamAppsEvent(); private $main: HTMLElement; public state: UploadState = UploadState.IN_PROGRESS; @@ -179,16 +205,16 @@ class FileUploadItem { constructor(public file: File, defaultFileIcon: string, uploadUrl: string, context: TeamAppsUiContext) { this.$main = parseHtml(`
-
+
${file.name}
`); const $icon = this.$main.querySelector(":scope .icon"); createImageThumbnailUrl(file) - .then(url => $icon.style.backgroundImage = `url(${url})`) + .then(url => $icon.style.backgroundImage = `url('${url}')`) .catch(reason => FileUploadItem.LOGGER.debug(`Could not create thumbnail for file ${file}. Reason: ${reason}.`)); const progressBar = new ProgressBar(0, {}); - this.$main.appendChild(progressBar.getMainDomElement()[0]); + this.$main.appendChild(progressBar.getMainDomElement()); const uploader = new FileUploader(); uploader.onProgress.addListener(progress => progressBar.setProgress(progress)); @@ -201,7 +227,7 @@ class FileUploadItem { this.state = UploadState.SUCCESS; this.uploadedFileUuid = uuid; this.onComplete.fire(null); - progressBar.getMainDomElement().fadeOut(); + fadeOut(progressBar.getMainDomElement()); }); uploader.upload(file, uploadUrl) } diff --git a/teamapps-client/ts/modules/UiClientObject.ts b/teamapps-client/ts/modules/UiClientObject.ts new file mode 100644 index 000000000..dc00c8de3 --- /dev/null +++ b/teamapps-client/ts/modules/UiClientObject.ts @@ -0,0 +1,28 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +import {UiComponentCommandHandler, UiComponentConfig} from "../generated/UiComponentConfig"; +import {TeamAppsEvent} from "./util/TeamAppsEvent"; +import {UiClientObjectConfig} from "../generated/UiClientObjectConfig"; + +export interface UiClientObject { + + destroy(): void; + +} diff --git a/teamapps-client/ts/modules/UiCollapsible.ts b/teamapps-client/ts/modules/UiCollapsible.ts new file mode 100644 index 000000000..34ce0b747 --- /dev/null +++ b/teamapps-client/ts/modules/UiCollapsible.ts @@ -0,0 +1,99 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +import {AbstractUiComponent} from "./AbstractUiComponent"; +import {TeamAppsUiContext} from "./TeamAppsUiContext"; +import {parseHtml, toggleElementCollapsed} from "./Common"; +import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; +import {UiComponent} from "./UiComponent"; +import { + UiCollapsible_CollapseStateChangedEvent, + UiCollapsibleCommandHandler, + UiCollapsibleConfig, + UiCollapsibleEventSource +} from "../generated/UiCollapsibleConfig"; +import {TeamAppsEvent} from "./util/TeamAppsEvent"; + +export class UiCollapsible extends AbstractUiComponent implements UiCollapsibleCommandHandler, UiCollapsibleEventSource { + + readonly onCollapseStateChanged: TeamAppsEvent = new TeamAppsEvent(); + + private $main: HTMLDivElement; + private $icon: HTMLElement; + private $caption: HTMLElement; + private $body: HTMLElement; + private $expander: Element; + + constructor(config: UiCollapsibleConfig, context: TeamAppsUiContext) { + super(config, context); + this.$main = parseHtml(`
+
+
+
+
+
+
+
`); + this.$expander = this.$main.querySelector(":scope .expander"); + this.$icon = this.$main.querySelector(":scope .icon"); + this.$caption = this.$main.querySelector(":scope .caption"); + this.$body = this.$main.querySelector(":scope .collapsible-body"); + + this.setIconAndCaption(config.icon, config.caption); + this.setContent(config.content); + this.setCollapsed(config.collapsed); + + this.$expander.addEventListener("click", evt => { + let collapsed = !this._config.collapsed; + this.setCollapsed(collapsed); + this.onCollapseStateChanged.fire({collapsed}); + }); + } + + public doGetMainElement(): HTMLElement { + return this.$main; + } + + setContent(content: unknown): void { + this.$body.innerHTML = ""; + if (content != null) { + this.$body.append((content as UiComponent).getMainElement()); + } + } + + setCollapsed(collapsed: boolean): any { + this._config.collapsed = collapsed; + this.$main.classList.toggle("collapsed", collapsed); + this.$expander.classList.toggle("expanded", !collapsed); + + this.$main.classList.add("expanding") + toggleElementCollapsed(this.$body, collapsed, 300, "hidden", () => { + this.$main.classList.remove("expanding"); + }); + } + + setIconAndCaption(icon: string, caption: string): any { + this._config.icon = icon; + this._config.caption = caption; + this.$icon.style.backgroundImage = `url('${icon}')`; + this.$caption.innerText = caption; + } +} + +TeamAppsUiComponentRegistry.registerComponentClass("UiCollapsible", UiCollapsible); diff --git a/teamapps-client/ts/modules/UiComponent.ts b/teamapps-client/ts/modules/UiComponent.ts index fa36963d4..668c8b195 100644 --- a/teamapps-client/ts/modules/UiComponent.ts +++ b/teamapps-client/ts/modules/UiComponent.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,155 +17,19 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import {TeamAppsUiContext} from "./TeamAppsUiContext"; -import * as log from "loglevel"; import {UiComponentCommandHandler, UiComponentConfig} from "../generated/UiComponentConfig"; import {TeamAppsEvent} from "./util/TeamAppsEvent"; +import {UiClientObject} from "./UiClientObject"; -export abstract class UiComponent implements UiComponentCommandHandler { - - protected readonly logger: log.Logger = log.getLogger(((this.constructor)).name || this.constructor.toString().match(/\w+/g)[1]); - - public readonly onVisibilityChanged: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onResized: TeamAppsEvent<{width: number; height: number}> = new TeamAppsEvent(this); - public readonly onAttachedToDomChanged: TeamAppsEvent = new TeamAppsEvent(this); - - private _attachedToDom = false; - private width: number = 0; - private height: number = 0; - private firstReLayout = true; - - private visible = true; - - constructor(protected _config: C, - protected _context: TeamAppsUiContext) { - setTimeout(() => { - this.setVisible(_config.visible, false); - if (_config.stylesBySelector != null) { // might be null when used via JavaScript API! - Object.keys(_config.stylesBySelector).forEach(selector => this.setStyle(selector, _config.stylesBySelector[selector])); - } - }, 0); - } - - public getId(): string { - return this._config.id; - } - - public getTeamAppsType(): string { - return this._config._type; - } - - get attachedToDom() { - return this._attachedToDom; - } - - set attachedToDom(attachedToDom) { - let wasAttachedToDom = this._attachedToDom; - this._attachedToDom = attachedToDom; - if (attachedToDom && !wasAttachedToDom) { - this.width = this.getMainDomElement()[0].offsetWidth; - this.height = this.getMainDomElement()[0].offsetHeight; - this.onAttachedToDom(); - this.onAttachedToDomChanged.fire(attachedToDom); - this.reLayout(); - } - } - - /** - * This method should get called from a container component when the available size for a child changes. - */ - public reLayout(force?: boolean): void { - if (this.attachedToDom) { - let availableWidth = this.getMainDomElement()[0].offsetWidth; - let availableHeight = this.getMainDomElement()[0].offsetHeight; - if (this.firstReLayout || force || availableWidth !== this.width || availableHeight !== this.height) { - this.logger.trace("resize: " + this.getId()); - this.firstReLayout = false; - this.width = availableWidth; - this.height = availableHeight; - this.onResized.fire({width: this.width, height: this.height}); - this.onResize(); - } - } - } - - /** - * This method gets called whenever the available size for this component changes. - */ - public onResize(): void { - // do nothing (default implementation) - } - - /** - * This method is called when the component gets destroyed. - * It should be used to release any resources the component holds. This can be: - * - additional DOM elements that are not children of the component's main DOM element, e.g. attached to the document's body. - * - other resources that are not released just by removing the component's main DOM element - */ - public destroy() { - // empty default implementation - } +export interface UiComponent extends UiClientObject, UiComponentCommandHandler { /** * @return The main DOM element of this component. - * This method is used by the main TeamApps UI mechanism to get the element and attach it to the DOM. */ - public abstract getMainDomElement(): JQuery; - - /** - * Override this method to execute code that cannot be executed before the field is attached to the DOM. - * - * Example 1: - * An HTML/Flash video player with autoplay = true will not be able to play until it is attached to the DOM. - * So the code for starting the video will need to be executed in this (overwritten) method. - * Example 2: - * UiTable uses slickgrid. slickgrid can only initialize correctly when attached to the DOM. - * - * Components containing other components should set attachedToDom on their child components. - * This will in turn invoke this method on the children, so the call propagates recursively. - * - * Example: - * A component with child components will call "child.attachedToDom = true" on its children. - */ - protected onAttachedToDom(): void { - // do nothing (default implementation) - } - - public getWidth(): number { - return this.width; - } - - public getHeight(): number { - return this.height; - } - - public isVisible() { - return this.visible; - } + getMainElement(): HTMLElement; - public setVisible(visible: boolean = true /*undefined == true!!*/, fireEvent = true) { - this.visible = visible; - if (this.getMainDomElement() != null) { // might not have been rendered yet, if setVisible is already called in the constructor/initializer - this.getMainDomElement().toggleClass("invisible-component", !visible); - } - if (fireEvent) { - this.onVisibilityChanged.fire(visible); - } - } + readonly onVisibilityChanged: TeamAppsEvent; - // TODO change this implementation to generate style sheets instead of setting styles! - public setStyle(selector:string, style: {[property: string]: string}) { - let targetElement: HTMLElement[]; - if (!selector) { - targetElement = [this.getMainDomElement()[0]]; - } else { - targetElement = Array.from((this.getMainDomElement()[0] as HTMLElement).querySelectorAll(":scope " + selector)); - } - if (targetElement.length === 0) { - this.logger.error("Cannot set style on non-existing element. Selector: " + selector); - } else { - targetElement.forEach(t => Object.assign(t.style, style)); - } - } + isVisible(): boolean; } diff --git a/teamapps-client/ts/modules/UiDefaultMultiProgressDisplay.ts b/teamapps-client/ts/modules/UiDefaultMultiProgressDisplay.ts new file mode 100644 index 000000000..9fde303c2 --- /dev/null +++ b/teamapps-client/ts/modules/UiDefaultMultiProgressDisplay.ts @@ -0,0 +1,68 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ + +import {AbstractUiComponent} from "./AbstractUiComponent"; +import {TeamAppsUiContext} from "./TeamAppsUiContext"; +import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; +import {TeamAppsEvent} from "./util/TeamAppsEvent"; +import {parseHtml} from "./Common"; +import {UiDefaultMultiProgressDisplayCommandHandler, UiDefaultMultiProgressDisplayConfig, UiDefaultMultiProgressDisplayEventSource} from "../generated/UiDefaultMultiProgressDisplayConfig"; +import {UiMultiProgressDisplay_ClickedEvent, UiMultiProgressDisplayCommandHandler, UiMultiProgressDisplayConfig, UiMultiProgressDisplayEventSource} from "../generated/UiMultiProgressDisplayConfig"; + +export abstract class UiMultiProgressDisplay extends AbstractUiComponent implements UiMultiProgressDisplayCommandHandler, UiMultiProgressDisplayEventSource { + public readonly onClicked: TeamAppsEvent = new TeamAppsEvent(); + abstract update(config: C): void; +} + +export class UiDefaultMultiProgressDisplay extends UiMultiProgressDisplay implements UiDefaultMultiProgressDisplayCommandHandler, UiDefaultMultiProgressDisplayEventSource { + + private $main: HTMLElement; + private $spinner: HTMLElement; + private $runningCount: HTMLElement; + + constructor(config: UiDefaultMultiProgressDisplayConfig, context: TeamAppsUiContext) { + super(config, context); + + this.$main = parseHtml(`
+
+
0
+
`); + + this.$spinner = this.$main.querySelector(":scope .spinner"); + this.$runningCount = this.$main.querySelector(":scope .running-count"); + + this.$main.addEventListener("mousedown", ev => this.onClicked.fire({})); + + this.update(config); + } + + doGetMainElement(): HTMLElement { + return this.$main; + } + + update(config: UiDefaultMultiProgressDisplayConfig): void { + this.$main.classList.toggle("no-tasks", config.runningCount === 0); + this.$spinner.classList.toggle('hidden', config.runningCount === 0); + this.$runningCount.innerText = "" + config.runningCount; + } + +} + +TeamAppsUiComponentRegistry.registerComponentClass("UiDefaultMultiProgressDisplay", UiDefaultMultiProgressDisplay); diff --git a/teamapps-client/ts/modules/UiDiv.ts b/teamapps-client/ts/modules/UiDiv.ts new file mode 100644 index 000000000..253a55021 --- /dev/null +++ b/teamapps-client/ts/modules/UiDiv.ts @@ -0,0 +1,50 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +import {AbstractUiComponent} from "./AbstractUiComponent"; +import {TeamAppsUiContext} from "./TeamAppsUiContext"; +import {UiDivConfig} from "../generated/UiDivConfig"; +import {parseHtml} from "./Common"; +import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; +import {UiComponent} from "./UiComponent"; +import {UiDivCommandHandler} from "../generated/UiDivConfig"; + +export class UiDiv extends AbstractUiComponent implements UiDivCommandHandler { + + private $main: HTMLDivElement; + + constructor(config: UiDivConfig, context: TeamAppsUiContext) { + super(config, context); + this.$main = parseHtml(`
`); + this.setContent(config.content); + } + + public doGetMainElement(): HTMLElement { + return this.$main; + } + + setContent(content: unknown): void { + this.$main.innerHTML = ""; + if (content != null) { + this.$main.append((content as UiComponent).getMainElement()); + } + } +} + +TeamAppsUiComponentRegistry.registerComponentClass("UiDiv", UiDiv); diff --git a/teamapps-client/ts/modules/UiDocumentViewer.ts b/teamapps-client/ts/modules/UiDocumentViewer.ts index da9cec9e5..fdaf3fb90 100644 --- a/teamapps-client/ts/modules/UiDocumentViewer.ts +++ b/teamapps-client/ts/modules/UiDocumentViewer.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,35 +17,34 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; -import {UiComponent} from "./UiComponent"; + +import {AbstractUiComponent} from "./AbstractUiComponent"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; import {UiDocumentViewerCommandHandler, UiDocumentViewerConfig} from "../generated/UiDocumentViewerConfig"; import {UiPageDisplayMode} from "../generated/UiPageDisplayMode"; -import {bind} from "./util/Bind"; -import {enableScrollViaDragAndDrop, generateUUID} from "./Common"; +import {css, enableScrollViaDragAndDrop, generateUUID, parseHtml} from "./Common"; import {UiBorderConfig} from "../generated/UiBorderConfig"; import {createUiBorderCssString, createUiShadowCssString} from "./util/CssFormatUtil"; import {UiShadowConfig} from "../generated/UiShadowConfig"; interface Page { - $img: JQuery; + $img: HTMLElement; naturalWidth?: number; naturalHeight?: number; } -export class UiDocumentViewer extends UiComponent implements UiDocumentViewerCommandHandler{ +export class UiDocumentViewer extends AbstractUiComponent implements UiDocumentViewerCommandHandler { - private $componentWrapper: JQuery; - private $pagesContainerWrapper: JQuery; - private $pagesContainer: JQuery; + private $componentWrapper: HTMLElement; + private $pagesContainerWrapper: HTMLElement; + private $pagesContainer: HTMLElement; private zoomFactor: number; private displayMode: UiPageDisplayMode; private pages: Page[] = []; private uuidClass: string; - private $styleTag: JQuery; + private $styleTag: HTMLElement; private pageBorder: UiBorderConfig; private pageSpacing: number; private pageShadow: UiShadowConfig; @@ -55,7 +54,7 @@ export class UiDocumentViewer extends UiComponent implem this.uuidClass = `UiDocumentViewer-${generateUUID()}`; - this.$componentWrapper = $(`
+ this.$componentWrapper = parseHtml(`
@@ -63,9 +62,9 @@ export class UiDocumentViewer extends UiComponent implem
`); - this.$pagesContainerWrapper = this.$componentWrapper.find(".pages-container-wrapper"); - this.$styleTag = this.$componentWrapper.find("style"); - this.$pagesContainer = this.$componentWrapper.find('.pages-container'); + this.$pagesContainerWrapper = this.$componentWrapper.querySelector(":scope .pages-container-wrapper"); + this.$styleTag = this.$componentWrapper.querySelector(":scope style"); + this.$pagesContainer = this.$componentWrapper.querySelector(':scope .pages-container'); enableScrollViaDragAndDrop(this.$pagesContainerWrapper); this.zoomFactor = config.zoomFactor; @@ -82,11 +81,11 @@ export class UiDocumentViewer extends UiComponent implem } public setPageUrls(pageUrls: string[]) { - this.$pagesContainer[0].innerHTML = ''; + this.$pagesContainer.innerHTML = ''; pageUrls.forEach((pageUrl) => { const img = new Image(); let page: Page = { - $img: $(img), + $img: img, naturalWidth: 0, naturalHeight: 0 }; @@ -118,50 +117,50 @@ export class UiDocumentViewer extends UiComponent implem } private updateImageSizes() { - let viewPortWidth = this.$pagesContainerWrapper.width() - 2 * this._config.padding; - let viewPortHeight = this.$pagesContainerWrapper.height() - 2 * this._config.padding; + let viewPortWidth = $(this.$pagesContainerWrapper).width() - 2 * this._config.padding; + let viewPortHeight = $(this.$pagesContainerWrapper).height() - 2 * this._config.padding; let viewPortAspectRatio = viewPortWidth / viewPortHeight; this.pages.forEach((p) => { let imageAspectRatio = p.naturalWidth / p.naturalHeight; this.logger.trace("image: " + p.naturalWidth + "/" + p.naturalHeight + " = " + imageAspectRatio); - this.logger.trace("viewport: " + this.$pagesContainerWrapper.innerWidth() + "/" + this.$pagesContainer.innerHeight() + " = " + viewPortAspectRatio); + this.logger.trace("viewport: " + viewPortWidth + "/" + viewPortHeight + " = " + viewPortAspectRatio); if (this.displayMode === UiPageDisplayMode.FIT_WIDTH) { - p.$img.css({ + css(p.$img, { width: Math.floor(viewPortWidth * this.zoomFactor) + "px", height: "auto" }); } else if (this.displayMode === UiPageDisplayMode.FIT_HEIGHT) { - p.$img.css({ + css(p.$img, { width: "auto", height: Math.floor(viewPortHeight * this.zoomFactor) + "px" }); } else if (this.displayMode === UiPageDisplayMode.FIT_SIZE) { if (imageAspectRatio > viewPortAspectRatio) { - p.$img.css({ + css(p.$img, { width: Math.floor(viewPortWidth * this.zoomFactor) + "px", height: "auto" }); } else { - p.$img.css({ + css(p.$img, { width: "auto", height: Math.floor(viewPortHeight * this.zoomFactor) + "px" }); } } else if (this.displayMode === UiPageDisplayMode.COVER) { if (imageAspectRatio < viewPortAspectRatio) { - p.$img.css({ + css(p.$img, { width: Math.floor(viewPortWidth * this.zoomFactor) + "px", height: "auto" }); } else { - p.$img.css({ + css(p.$img, { width: "auto", height: Math.floor(viewPortHeight * this.zoomFactor) + "px" }); } } else { - p.$img.css({ + css(p.$img, { width: (p.naturalWidth * this.zoomFactor) + "px", height: "auto" }); @@ -173,21 +172,17 @@ export class UiDocumentViewer extends UiComponent implem this.updateImageSizes(); } - public getMainDomElement(): JQuery { + public doGetMainElement(): HTMLElement { return this.$componentWrapper; } - public destroy(): void { - // nothing to do - } - setPageBorder(pageBorder: UiBorderConfig): void { this.pageBorder = pageBorder; this.updateStyles(); } setPaddding(padding: number): void { - this.$pagesContainer.css("padding", padding); + this.$pagesContainer.style.padding = padding + "px"; } setPageSpacing(pageSpacing: number): void { @@ -201,15 +196,15 @@ export class UiDocumentViewer extends UiComponent implem } private updateStyles() { - this.$styleTag[0].innerHTML = ''; - this.$styleTag.text(` + this.$styleTag.innerHTML = ''; + this.$styleTag.innerText = ` .${this.uuidClass} .page { ${createUiBorderCssString(this.pageBorder)} ${createUiShadowCssString(this.pageShadow)} } .${this.uuidClass} .page:not(:last-child) { margin-bottom: ${this.pageSpacing}px !important; - }`); + }`; } } diff --git a/teamapps-client/ts/modules/UiDummyComponent.ts b/teamapps-client/ts/modules/UiDummyComponent.ts index 311e123d4..b8f104bfd 100644 --- a/teamapps-client/ts/modules/UiDummyComponent.ts +++ b/teamapps-client/ts/modules/UiDummyComponent.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,42 +17,42 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; -import * as moment from "moment-timezone"; -import Moment = moment.Moment; +import moment from "moment-timezone"; import {TeamAppsEvent} from "./util/TeamAppsEvent"; import {UiDummyComponent_ClickedEvent, UiDummyComponentCommandHandler, UiDummyComponentConfig, UiDummyComponentEventSource} from "../generated/UiDummyComponentConfig"; -import {UiComponent} from "./UiComponent"; +import {AbstractUiComponent} from "./AbstractUiComponent"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; -import {EventFactory} from "../generated/EventFactory"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; +import {parseHtml} from "./Common"; +import DateTimeFormatOptions = Intl.DateTimeFormatOptions; -export class UiDummyComponent extends UiComponent implements UiDummyComponentCommandHandler, UiDummyComponentEventSource { +export class UiDummyComponent extends AbstractUiComponent implements UiDummyComponentCommandHandler, UiDummyComponentEventSource { - public readonly onClicked: TeamAppsEvent = new TeamAppsEvent(this); + public readonly onClicked: TeamAppsEvent = new TeamAppsEvent(); private static allDummies: UiDummyComponent[] = []; - private $panel: JQuery; - private lastResize: Moment; + private $panel: HTMLElement; + private lastResize: Date; private resizeCount: number = 0; private destroyed: boolean = false; private clickCount: number = 0; private jsClickCount: number = 0; - private hasBeenAttachedToDom: boolean = false; private commandCount: number = 0; private text: string = ""; constructor(config: UiDummyComponentConfig, context: TeamAppsUiContext) { super(config, context); - this.$panel = $('
'); - this.$panel.click(() => { + this.$panel = parseHtml('
'); + this.$panel.addEventListener("click", () => { this.clickCount++; - this.onClicked.fire(EventFactory.createUiDummyComponent_ClickedEvent(this.getId(), this.clickCount)); + this.onClicked.fire({ + clickCount: this.clickCount + }); this.updateContent(); }); - this.$panel[0].addEventListener('click', () => { + this.$panel.addEventListener('click', () => { this.jsClickCount++; this.updateContent(); }); @@ -62,11 +62,12 @@ export class UiDummyComponent extends UiComponent implem } - public getMainDomElement(): JQuery { + public doGetMainElement(): HTMLElement { return this.$panel; } public destroy(): void { + super.destroy(); this.destroyed = true; this.updateContent(); } @@ -80,47 +81,21 @@ text: ${this.text}
clickCount: ${this.clickCount}
jsClickCount: ${this.jsClickCount}
commandCount: ${this.commandCount}
-attached: ${this.attachedToDom}
-hasBeenAttached: ${this.hasBeenAttachedToDom}
resizeCount: ${this.resizeCount}
-lastResize: ${this.lastResize ? this.lastResize.format('HH:mm:ss.SSS') : '-'}
+lastResize: ${this.lastResize ? this.lastResize.toLocaleString(undefined, {dateStyle: 'medium', timeStyle: 'medium'} as DateTimeFormatOptions) : '-'}
size: ${this.getWidth()} x ${this.getHeight()}
destroyed: ${this.destroyed}
-wasDetached: ${this.wasDetached} `; } - get wasDetached() { - if (this.hasBeenAttachedToDom) { - return !($ as any)._data(this.$panel[0], "events") || !($ as any)._data(this.$panel[0], "events").click; - } else { - return false; - } - } - public onResize(): void { - this.lastResize = moment(); + this.lastResize = new Date(); this.resizeCount++; this.updateContent(); } - protected onAttachedToDom(): void { - this.hasBeenAttachedToDom = true; - this.updateContent(); - document.body.addEventListener("DOMNodeRemoved", (e: Event) => { - let selfOrParent: Node = this.getMainDomElement()[0]; - while (selfOrParent != null) { - if (selfOrParent === e.target) { - setTimeout(() => this.updateContent()); - break; - } - selfOrParent = selfOrParent.parentNode; - } - }); - } - private updateContent() { - this.$panel[0].innerHTML = this.generateText(); + this.$panel.innerHTML = this.generateText(); } setText(text: string): void { diff --git a/teamapps-client/ts/modules/UiElegantPanel.ts b/teamapps-client/ts/modules/UiElegantPanel.ts index cc7c381d1..4b7bec36b 100644 --- a/teamapps-client/ts/modules/UiElegantPanel.ts +++ b/teamapps-client/ts/modules/UiElegantPanel.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,24 +17,26 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; -import {UiComponent} from "./UiComponent"; + +import {AbstractUiComponent} from "./AbstractUiComponent"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; import {UiComponentConfig} from "../generated/UiComponentConfig"; import {UiElegantPanelCommandHandler, UiElegantPanelConfig} from "../generated/UiElegantPanelConfig"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; import {createUiSpacingCssString} from "./util/CssFormatUtil"; import {UiHorizontalElementAlignment} from "../generated/UiHorizontalElementAlignment"; +import {parseHtml} from "./Common"; +import {UiComponent} from "./UiComponent"; -export class UiElegantPanel extends UiComponent implements UiElegantPanelCommandHandler { +export class UiElegantPanel extends AbstractUiComponent implements UiElegantPanelCommandHandler { - private $element: JQuery; - private $contentContainer: JQuery; + private $element: HTMLElement; + private $contentContainer: HTMLElement; private contentComponent: UiComponent; constructor(config: UiElegantPanelConfig, context: TeamAppsUiContext) { super(config, context); - this.$element = $(`
+ this.$element = parseHtml(`
@@ -44,42 +46,29 @@ export class UiElegantPanel extends UiComponent implements
`); - let $backgroundColorDiv = this.$element.find('.background-color-div'); - this.$contentContainer = this.$element.find('.content-container'); + let $backgroundColorDiv = this.$element.querySelector(':scope .background-color-div'); + this.$contentContainer = this.$element.querySelector(':scope .content-container'); if (config.bodyBackgroundColor) { - $backgroundColorDiv.css("background-color", config.bodyBackgroundColor); + $backgroundColorDiv.style.backgroundColor = config.bodyBackgroundColor; } if (config.content) { - this.setContent(config.content); + this.setContent(config.content as UiComponent); } } - public getMainDomElement(): JQuery { + public doGetMainElement(): HTMLElement { return this.$element; } public setContent(content: UiComponent) { - this.$contentContainer[0].innerHTML = ''; + this.$contentContainer.innerHTML = ''; this.contentComponent = content; if (content) { - this.contentComponent.getMainDomElement().appendTo(this.$contentContainer); - this.contentComponent.attachedToDom = this.attachedToDom; + this.$contentContainer.appendChild(this.contentComponent.getMainElement()); } } - protected onAttachedToDom() { - if (this.contentComponent) this.contentComponent.attachedToDom = true; - this.reLayout(); - } - - onResize(): void { - if (!this.attachedToDom || this.getMainDomElement()[0].offsetWidth <= 0) return; - this.contentComponent && this.contentComponent.reLayout(); - } - - public destroy(): void { - } } TeamAppsUiComponentRegistry.registerComponentClass("UiElegantPanel", UiElegantPanel); diff --git a/teamapps-client/ts/modules/UiFlexContainer.ts b/teamapps-client/ts/modules/UiFlexContainer.ts index 107064193..02066e995 100644 --- a/teamapps-client/ts/modules/UiFlexContainer.ts +++ b/teamapps-client/ts/modules/UiFlexContainer.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,8 +17,8 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; -import {UiComponent} from "./UiComponent"; + +import {AbstractUiComponent} from "./AbstractUiComponent"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; import {parseHtml} from "./Common"; @@ -26,8 +26,9 @@ import {UiFlexContainerCommandHandler, UiFlexContainerConfig} from "../generated import {UiCssFlexDirection} from "../generated/UiCssFlexDirection"; import {UiCssAlignItems} from "../generated/UiCssAlignItems"; import {UiCssJustifyContent} from "../generated/UiCssJustifyContent"; +import {UiComponent} from "./UiComponent"; -export class UiFlexContainer extends UiComponent implements UiFlexContainerCommandHandler { +export class UiFlexContainer extends AbstractUiComponent implements UiFlexContainerCommandHandler { private $main: HTMLDivElement; private components: UiComponent[] = []; @@ -35,36 +36,29 @@ export class UiFlexContainer extends UiComponent implemen constructor(config: UiFlexContainerConfig, context: TeamAppsUiContext) { super(config, context); this.$main = parseHtml(`
`); - this.$main.style.flexDirection = this.convertToCssValueString(UiCssFlexDirection[config.flexDirection]); - this.$main.style.alignItems = this.convertToCssValueString(UiCssAlignItems[config.alignItems]); - this.$main.style.justifyContent = this.convertToCssValueString(UiCssJustifyContent[config.justifyContent]); - - config.components.forEach(c =>this.addComponent(c)); - } - - private convertToCssValueString(enumValueName: string) { - return enumValueName.toLowerCase().replace("_", "-"); - } - - protected onAttachedToDom(): void { - this.components.forEach(c => c.attachedToDom = true); - } + this.$main.style.flexDirection = config.flexDirection; + this.$main.style.alignItems = config.alignItems; + this.$main.style.justifyContent = config.justifyContent; + this.$main.style.gap = config.gap; - onResize(): void { - this.components.forEach(c => c.reLayout()); + config.components.forEach(c => this.addComponent(c as UiComponent)); } - getMainDomElement(): JQuery { - return $(this.$main); + doGetMainElement(): HTMLElement { + return this.$main; } addComponent(component: UiComponent): void { this.components.push(component); - this.$main.appendChild(component.getMainDomElement()[0]); + this.$main.appendChild(component.getMainElement()); } removeComponent(component: UiComponent): void { - this.$main.removeChild(component.getMainDomElement()[0]); + try { + this.$main.removeChild(component.getMainElement()); + } catch (e) { + // ignore if this is actually not a child... + } this.components = this.components.filter(c => c !== component); } diff --git a/teamapps-client/ts/modules/UiFloatingComponent.ts b/teamapps-client/ts/modules/UiFloatingComponent.ts new file mode 100644 index 000000000..b0ede2500 --- /dev/null +++ b/teamapps-client/ts/modules/UiFloatingComponent.ts @@ -0,0 +1,189 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ + +import {AbstractUiComponent} from "./AbstractUiComponent"; +import {TeamAppsUiContext} from "./TeamAppsUiContext"; +import {UiFloatingComponent_ExpandedOrCollapsedEvent, UiFloatingComponentCommandHandler, UiFloatingComponentConfig, UiFloatingComponentEventSource} from "../generated/UiFloatingComponentConfig"; +import {UiComponent} from "./UiComponent"; +import ResizeObserver from 'resize-observer-polyfill'; +import {parseHtml, prependChild, removeClassesByFunction} from "./Common"; +import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; +import {UiFloatingComponentPosition} from "../generated/UiFloatingComponentPosition"; +import {TeamAppsEvent} from "./util/TeamAppsEvent"; + +export class UiFloatingComponent extends AbstractUiComponent implements UiFloatingComponentCommandHandler, UiFloatingComponentEventSource { + + public readonly onExpandedOrCollapsed: TeamAppsEvent = new TeamAppsEvent(); + + private containerComponent: UiComponent; + private contentComponent: UiComponent; + private $main: HTMLElement; + + private $expanderHandle: HTMLElement; + + constructor(config: UiFloatingComponentConfig, context: TeamAppsUiContext) { + super(config, context); + this.containerComponent = config.containerComponent as UiComponent; + + this.$main = parseHtml(`
`); + + this.setContentComponent(config.contentComponent); + this.$expanderHandle = parseHtml(`
`); + this.$expanderHandle.addEventListener("click", evt => { + this.setExpanded(!this._config.expanded); + this.onExpandedOrCollapsed.fire({expanded: this._config.expanded}); + }); + this.$main.appendChild(this.$expanderHandle); + + prependChild(this.containerComponent.getMainElement(), this.getMainElement()); + + this.setBackgroundColor(config.backgroundColor); + this.setExpanderHandleColor(config.expanderHandleColor); + + const resizeObserver = new ResizeObserver(entries => { + for (let entry of entries) { + this.updateFloatingPosition(); + } + }); + resizeObserver.observe(this.containerComponent.getMainElement()); + resizeObserver.observe(this.contentComponent.getMainElement()); + + this.$main.addEventListener("click", ev => ev.stopPropagation()); + this.$main.addEventListener("pointerdown", ev => ev.stopPropagation()); + this.$main.addEventListener("mousedown", ev => ev.stopPropagation()); + this.$main.addEventListener("mouseclick", ev => ev.stopPropagation()); + this.$main.addEventListener("dblclick", ev => ev.stopPropagation()); + this.$main.addEventListener("scroll", ev => ev.stopPropagation()); + this.$main.addEventListener("wheel", ev => ev.stopPropagation()); + this.$main.addEventListener("keydown", ev => ev.stopPropagation()); + this.$main.addEventListener("keyup", ev => ev.stopPropagation()); + this.$main.addEventListener("keypress", ev => ev.stopPropagation()); + + this.updateFloatingPosition(); + } + + private updateFloatingPosition() { + removeClassesByFunction(this.$expanderHandle.classList, className => className.startsWith("position-")); + this.$expanderHandle.classList.add("position-" + this._config.position); + + let containerWidth = this.containerComponent.getMainElement().offsetWidth; + let containerHeight = this.containerComponent.getMainElement().offsetHeight; + + // width + if (this._config.width === -1) { + this.getMainElement().style.width = null; + this.getMainElement().style.maxWidth = (containerWidth - 2 * this._config.marginX) + "px"; + } else if (this._config.width === 0) { + this.getMainElement().style.width = (containerWidth - 2 * this._config.marginX) + "px"; + this.getMainElement().style.maxWidth = null; + } else { + this.getMainElement().style.width = this._config.width + "px"; + this.getMainElement().style.maxWidth = null; + } + + let contentWidth = this.contentComponent.getMainElement().offsetWidth; + let contentHeight = this.contentComponent.getMainElement().offsetHeight; + + // position X + if (this._config.expanded) { + if (this._config.position === UiFloatingComponentPosition.TOP_LEFT || this._config.position === UiFloatingComponentPosition.BOTTOM_LEFT) { + this.getMainElement().style.left = this._config.marginX + "px"; + this.getMainElement().style.right = null; + } else if (this._config.position === UiFloatingComponentPosition.TOP_RIGHT || this._config.position === UiFloatingComponentPosition.BOTTOM_RIGHT) { + this.getMainElement().style.left = null; + this.getMainElement().style.right = this._config.marginX + "px"; + } + } else { + if (this._config.position === UiFloatingComponentPosition.TOP_LEFT || this._config.position === UiFloatingComponentPosition.BOTTOM_LEFT) { + this.getMainElement().style.left = `-${contentWidth}px`; + this.getMainElement().style.right = null; + } else if (this._config.position === UiFloatingComponentPosition.TOP_RIGHT || this._config.position === UiFloatingComponentPosition.BOTTOM_RIGHT) { + this.getMainElement().style.left = null; + this.getMainElement().style.right = `-${contentWidth}px`; + } + } + + // position Y + if (this._config.position === UiFloatingComponentPosition.TOP_LEFT || this._config.position === UiFloatingComponentPosition.TOP_RIGHT) { + this.getMainElement().style.top = this._config.marginY + "px"; + this.getMainElement().style.bottom = null; + } else if (this._config.position === UiFloatingComponentPosition.BOTTOM_LEFT || this._config.position === UiFloatingComponentPosition.BOTTOM_RIGHT) { + this.getMainElement().style.top = null; + this.getMainElement().style.bottom = this._config.marginY + "px"; + } + // height + if (this._config.height === -1) { + this.getMainElement().style.height = null; + this.getMainElement().style.maxHeight = (containerHeight - 2 * this._config.marginY) + "px"; + } else if (this._config.height === 0) { + this.getMainElement().style.height = (containerHeight - 2 * this._config.marginY) + "px"; + this.getMainElement().style.maxHeight = null; + } else { + this.getMainElement().style.height = this._config.height + "px"; + this.getMainElement().style.maxHeight = (containerHeight - 2 * this._config.marginY) + "px"; + } + } + + + doGetMainElement(): HTMLElement { + return this.$main; + } + + public setContentComponent(contentComponent: unknown) { + this.$main.innerHTML = ''; + this.contentComponent = contentComponent as UiComponent; + if (contentComponent != null) { + this.$main.appendChild(this.contentComponent.getMainElement()); + } + } + + setExpanded(expanded: boolean): void { + this._config.expanded = expanded; + this.$main.classList.toggle("expanded", expanded); + this.updateFloatingPosition(); + } + + setPosition(position: UiFloatingComponentPosition): void { + this._config.position = position; + this.updateFloatingPosition(); + } + + setDimensions(width: number, height: number): void { + this._config.width = width; + this._config.height = height; + this.updateFloatingPosition(); + } + + setMargins(marginX: number, marginY: number): void { + this._config.marginX = marginX; + this._config.marginY = marginY; + this.updateFloatingPosition(); + } + + setBackgroundColor(backgroundColor: string) { + this.getMainElement().style.backgroundColor = backgroundColor; + } + + setExpanderHandleColor(expanderHandleColor: string) { + this.$expanderHandle.style.backgroundColor = expanderHandleColor; + } +} + +TeamAppsUiComponentRegistry.registerComponentClass("UiFloatingComponent", UiFloatingComponent); diff --git a/teamapps-client/ts/modules/UiGauge.ts b/teamapps-client/ts/modules/UiGauge.ts index 1115b3038..15594dc1b 100644 --- a/teamapps-client/ts/modules/UiGauge.ts +++ b/teamapps-client/ts/modules/UiGauge.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,33 +17,34 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; + import {UiGaugeCommandHandler, UiGaugeConfig} from "../generated/UiGaugeConfig"; import {UiGaugeOptionsConfig} from "../generated/UiGaugeOptionsConfig"; -import {UiComponent} from "./UiComponent"; +import {AbstractUiComponent} from "./AbstractUiComponent"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; -import {executeWhenAttached} from "./util/ExecuteWhenAttached"; +import {executeWhenFirstDisplayed} from "./util/ExecuteWhenFirstDisplayed"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; import {LinearGauge, RadialGauge} from "canvas-gauges"; import {debouncedMethod, DebounceMode} from "./util/debounce"; +import {parseHtml} from "./Common"; -export class UiGauge extends UiComponent implements UiGaugeCommandHandler { - private $main: JQuery; +export class UiGauge extends AbstractUiComponent implements UiGaugeCommandHandler { + private $main: HTMLElement; private gauge: LinearGauge; private value: number; constructor(config: UiGaugeConfig, context: TeamAppsUiContext) { super(config, context); - this.$main = $(`
`); + this.$main = parseHtml(`
`); this.value = config.options.value; this.createGauge(); } - @executeWhenAttached() + @executeWhenFirstDisplayed() private createGauge() { if (this.getWidth() > 0 && this.getHeight() > 0) { let options = this.createOptions(this._config.options); - options.renderTo = this.$main.find("canvas")[0]; + options.renderTo = this.$main.querySelector(":scope canvas"); if (this._config.options.linearGauge) { this.gauge = new LinearGauge(options); } else { @@ -53,10 +54,7 @@ export class UiGauge extends UiComponent implements UiGaugeComman } } - destroy(): void { - } - - getMainDomElement(): JQuery { + doGetMainElement(): HTMLElement { return this.$main; } diff --git a/teamapps-client/ts/modules/UiGridForm.ts b/teamapps-client/ts/modules/UiGridForm.ts index f9f89f807..8d656c93a 100644 --- a/teamapps-client/ts/modules/UiGridForm.ts +++ b/teamapps-client/ts/modules/UiGridForm.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,72 +17,85 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; -import {UiGridForm_SectionCollapsedStateChangedEvent, UiGridFormCommandHandler, UiGridFormConfig, UiGridFormEventSource} from "../generated/UiGridFormConfig"; + +import { + UiGridForm_SectionCollapsedStateChangedEvent, + UiGridFormCommandHandler, + UiGridFormConfig, + UiGridFormEventSource +} from "../generated/UiGridFormConfig"; import {UiFormLayoutPolicyConfig} from "../generated/UiFormLayoutPolicyConfig"; -import {UiField} from "./formfield/UiField"; import {UiFormSectionConfig} from "../generated/UiFormSectionConfig"; -import {UiComponent} from "./UiComponent"; +import {AbstractUiComponent} from "./AbstractUiComponent"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; -import {executeWhenAttached} from "./util/ExecuteWhenAttached"; +import {executeWhenFirstDisplayed} from "./util/ExecuteWhenFirstDisplayed"; import { createCssGridRowOrColumnString, createUiBorderCssString, - createUiColorCssString, createUiShadowCssString, createUiSpacingCssString, cssHorizontalAlignmentByUiVerticalAlignment, cssVerticalAlignmentByUiVerticalAlignment } from "./util/CssFormatUtil"; import {TeamAppsEvent} from "./util/TeamAppsEvent"; -import {EventFactory} from "../generated/EventFactory"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; import * as log from "loglevel"; import {UiFormSectionFieldPlacementConfig} from "../generated/UiFormSectionFieldPlacementConfig"; import {UiFormSectionPlacementConfig} from "../generated/UiFormSectionPlacementConfig"; import {UiFormSectionFloatingFieldsPlacementConfig} from "../generated/UiFormSectionFloatingFieldsPlacementConfig"; -import {generateUUID} from "./Common"; +import {generateUUID, parseHtml} from "./Common"; import {bind} from "./util/Bind"; +import {UiComponent} from "./UiComponent"; +import {UiField} from "./formfield/UiField"; -export class UiGridForm extends UiComponent implements UiGridFormCommandHandler, UiGridFormEventSource { +export class UiGridForm extends AbstractUiComponent implements UiGridFormCommandHandler, UiGridFormEventSource { - public readonly onSectionCollapsedStateChanged: TeamAppsEvent = new TeamAppsEvent(this); + public readonly onSectionCollapsedStateChanged: TeamAppsEvent = new TeamAppsEvent(); - private $mainDiv: any; + private $mainDiv: HTMLElement; private sections: UiFormSection[]; private layoutPoliciesFromLargeToSmall: UiFormLayoutPolicyConfig[]; private activeLayoutPolicyIndex: number; - private uiFields: UiField[] = []; + private uiFields: UiComponent[] = []; private fillRemainingHeightCheckerInterval: number; private sectionCollapseOverrides: { [sectionId: string]: boolean }; + private fieldWrappers = new Map(); + constructor(config: UiGridFormConfig, context: TeamAppsUiContext) { super(config, context); - this.$mainDiv = $(`
+ this.$mainDiv = parseHtml(`
`); - config.fields.forEach(fieldConfig => this.addField(fieldConfig)); + config.fields.forEach(f => this.addField(f as UiComponent)); this.updateLayoutPolicies(config.layoutPolicies); + + this.displayedDeferredExecutor.invokeWhenReady(() => { + if (this.fillRemainingHeightCheckerInterval == null) { + this.fillRemainingHeightCheckerInterval = window.setInterval(() => { + this.ensureFillRemainingHeight(); + }, 30000); // chose high delay here, since it makes the scrollbar appear for a few seconds on macos + } + }) } - private addField(uiField: UiField) { + private addField(uiField: UiComponent) { this.uiFields.push(uiField); } - public getMainDomElement(): JQuery { + public doGetMainElement(): HTMLElement { return this.$mainDiv; } - @executeWhenAttached(true) + @executeWhenFirstDisplayed(true) public onResize(): void { const newLayoutPolicyIndex = this.determineLayoutPolicyIndexToApply(); if (newLayoutPolicyIndex !== this.activeLayoutPolicyIndex) { this.activeLayoutPolicyIndex = newLayoutPolicyIndex; this.applyLayoutPolicy(this.layoutPoliciesFromLargeToSmall[newLayoutPolicyIndex]); } - this.uiFields.forEach(field => field.reLayout()); this.ensureFillRemainingHeight(); } @@ -96,19 +109,27 @@ export class UiGridForm extends UiComponent implements UiGridF return policyIndex; } - @executeWhenAttached(true) + @executeWhenFirstDisplayed(true) private applyLayoutPolicy(layoutPolicy: UiFormLayoutPolicyConfig) { - this.uiFields.forEach(uiField => uiField.getMainDomElement().detach()); + this.uiFields.forEach(uiField => { + let fieldWrapper = this.fieldWrappers.get(uiField); + if (fieldWrapper != null) { + fieldWrapper.remove(); + } + }); this.sections && this.sections.forEach(section => { section.destroy(); - section.getMainDomElement().detach(); + section.getMainDomElement().remove(); }); this.sections = layoutPolicy.sections.map(sectionConfig => { - const section = new UiFormSection(sectionConfig, this._context, this.sectionCollapseOverrides[sectionConfig.id]); - section.getMainDomElement().appendTo(this.$mainDiv); + const section = new UiFormSection(sectionConfig, this._context, this.sectionCollapseOverrides[sectionConfig.id], field => this.getFieldWrapper(field)); + this.$mainDiv.appendChild(section.getMainDomElement()); section.placeFields(); section.onCollapsedStateChanged.addListener((collapsed) => { - this.onSectionCollapsedStateChanged.fire(EventFactory.createUiGridForm_SectionCollapsedStateChangedEvent(this.getId(), sectionConfig.id, collapsed)); + this.onSectionCollapsedStateChanged.fire({ + sectionId: sectionConfig.id, + collapsed: collapsed + }); this.sectionCollapseOverrides[sectionConfig.id] = collapsed; }); return section; @@ -120,7 +141,7 @@ export class UiGridForm extends UiComponent implements UiGridF this.sections.filter(s => s.config.id === sectionId).forEach(s => s.setCollapsed(collapsed)); } - @executeWhenAttached(true) + @executeWhenFirstDisplayed(true) public updateLayoutPolicies(layoutPolicies: UiFormLayoutPolicyConfig[]): void { this.sectionCollapseOverrides = {}; this.layoutPoliciesFromLargeToSmall = layoutPolicies.sort((a, b) => b.minWidth - a.minWidth); @@ -129,17 +150,9 @@ export class UiGridForm extends UiComponent implements UiGridF this.applyLayoutPolicy(layoutPolicyToApply); } - protected onAttachedToDom(): void { - if (this.fillRemainingHeightCheckerInterval == null) { - this.fillRemainingHeightCheckerInterval = window.setInterval(() => { - this.ensureFillRemainingHeight(); - }, 30000); // chose high delay here, since it makes the scrollbar appear for a few seconds on macos - } - } - // This hack is needed because css grid does not fill the whole height of a section when as it grows (flex). This is because the height of the (flex) section is dynamic, and not a static value. private ensureFillRemainingHeight() { - let scrollContainer = this.$mainDiv[0]; + let scrollContainer = this.$mainDiv; while (scrollContainer.scrollTop === 0 && scrollContainer.parentElement != null) { scrollContainer = scrollContainer.parentElement; } @@ -149,35 +162,47 @@ export class UiGridForm extends UiComponent implements UiGridF } public destroy(): void { + super.destroy(); window.clearInterval(this.fillRemainingHeightCheckerInterval); } - addOrReplaceField(field: UiField): void { + addOrReplaceField(field: UiComponent): void { this.addField(field); this.applyLayoutPolicy(this.layoutPoliciesFromLargeToSmall[this.determineLayoutPolicyIndexToApply()]); } + + private getFieldWrapper(field: UiComponent): HTMLDivElement { + let wrapper = this.fieldWrappers.get(field); + if (wrapper == null) { + wrapper = document.createElement("div"); + wrapper.classList.add("field-wrapper"); + this.fieldWrappers.set(field, wrapper); + } + return wrapper; + } + } class UiFormSection { private static readonly LOGGER = log.getLogger("UiFormSection"); - public readonly onCollapsedStateChanged: TeamAppsEvent = new TeamAppsEvent(this); + public readonly onCollapsedStateChanged: TeamAppsEvent = new TeamAppsEvent(); - private uiFields: UiField[] = []; + private uiFields: UiComponent[] = []; private uuid: string; - private $div: JQuery; - private $placementStyles: JQuery; - private $header: JQuery; - private $headerTemplateContainer: JQuery; - private $body: JQuery; - private $expander: JQuery; + private $div: HTMLElement; + private $placementStyles: HTMLElement; + private $header: HTMLElement; + private $headerTemplateContainer: HTMLElement; + private $body: HTMLElement; + private $expander: HTMLElement; private collapsed: boolean; - constructor(public config: UiFormSectionConfig, context: TeamAppsUiContext, collapsedOverride: boolean) { + constructor(public config: UiFormSectionConfig, context: TeamAppsUiContext, collapsedOverride: boolean, private getFieldWrapper : (field: UiComponent) => HTMLDivElement) { this.uuid = generateUUID(); - + const headerLineClass = config.drawHeaderLine ? 'draw-header-line' : ''; const hasHeaderTemplateClass = config.headerTemplate ? 'has-header-template' : ''; const hasHeaderDataClass = config.headerData ? 'has-header-data' : ''; @@ -190,42 +215,41 @@ class UiFormSection { const paddingCss = createUiSpacingCssString("padding", config.padding); const borderCss = createUiBorderCssString(config.border); const shadowCss = createUiShadowCssString(config.shadow); - const backgroundColorCss = config.backgroundColor ? `background-color:${createUiColorCssString(config.backgroundColor)};` : ''; + const backgroundColorCss = config.backgroundColor ? `background-color:${(config.backgroundColor ?? '')};` : ''; const gridTemplateColumnsCss = 'grid-template-columns:' + config.columns.map(column => createCssGridRowOrColumnString(column.widthPolicy)).join(" ") + ';'; const gridTemplateRowsCss = 'grid-template-rows:' + config.rows.map(row => createCssGridRowOrColumnString(row.heightPolicy)).join(" ") + ';'; const gridGapCss = 'grid-gap:' + config.gridGap + 'px;'; - this.$div = $(`
+ this.$div = parseHtml(`
-
-
-
+
+
-
+
`); - this.$placementStyles = this.$div.find("style"); - this.$header = this.$div.find("> .header"); - this.$headerTemplateContainer = this.$header.find(".header-template-container"); + this.$placementStyles = this.$div.querySelector(":scope style"); + this.$header = this.$div.querySelector(":scope > .header"); + this.$headerTemplateContainer = this.$header.querySelector(":scope .header-template-container"); if (config.headerTemplate && config.headerData) { - $(context.templateRegistry.createTemplateRenderer(config.headerTemplate).render(config.headerData)).appendTo(this.$headerTemplateContainer); + this.$headerTemplateContainer.appendChild(parseHtml(context.templateRegistry.createTemplateRenderer(config.headerTemplate).render(config.headerData))); } - this.$expander = this.$div.find(".teamapps-expander"); - this.$div.find('.expand-button').click(() => { + this.$expander = this.$div.querySelector(":scope .teamapps-expander"); + this.$div.querySelector(':scope .expand-button').addEventListener('click', () => { if (config.collapsible) { this.setCollapsed(!this.collapsed); } }); - this.$body = this.$div.find(".body"); + this.$body = this.$div.querySelector(":scope .body"); this.setCollapsed(collapsedOverride != null ? collapsedOverride : (config.collapsible && config.collapsed), false); } @@ -247,7 +271,7 @@ class UiFormSection { this.config.fieldPlacements.forEach(placement => { const placementId = generateUUID(true); if (this.isUiFormSectionFieldPlacement(placement)) { - const uiField = placement.field; + const uiField = placement.field as UiComponent; uiField.onVisibilityChanged.addListener(this.updateGroupVisibility); this.uiFields.push(uiField); allCssRules[placementId] = { @@ -255,18 +279,18 @@ class UiFormSection { "min-height": placement.minHeight ? `${placement.minHeight}px` : '', "max-height": placement.maxHeight ? `${placement.maxHeight}px` : '' }; - uiField.getMainDomElement() - .attr("data-placement-id", placementId) - .appendTo(this.$body); - uiField.attachedToDom = true; + let fieldWrapper = this.getFieldWrapper(uiField); + fieldWrapper.appendChild(uiField.getMainElement()); + fieldWrapper.setAttribute("data-placement-id", placementId); + this.$body.appendChild(fieldWrapper); } else if (this.isUiFormSectionFloatingFieldsPlacement(placement)) { - let $container = $(`
`); + let $container = parseHtml(`
`); allCssRules[placementId] = { ...createSectionPlacementStyles(placement), "flex-wrap": placement.wrap ? "wrap" : "nowrap" }; placement.floatingFields.forEach(floatingField => { - const uiField = floatingField.field; + const uiField = floatingField.field as UiComponent; uiField.onVisibilityChanged.addListener(this.updateGroupVisibility); this.uiFields.push(uiField); const floatingFieldPlacementId = generateUUID(true); @@ -277,23 +301,24 @@ class UiFormSection { "max-height": floatingField.maxHeight ? `${floatingField.maxHeight}px` : '', "margin": `${placement.verticalSpacing / 2}px ${placement.horizontalSpacing / 2}px` }; - uiField.getMainDomElement() - .attr("data-placement-id", floatingFieldPlacementId) - .appendTo($container); - uiField.attachedToDom = true + + let fieldWrapper = this.getFieldWrapper(uiField); + fieldWrapper.appendChild(uiField.getMainElement()); + fieldWrapper.setAttribute("data-placement-id", floatingFieldPlacementId); + $container.appendChild(fieldWrapper); }); - $container.appendTo(this.$body); + this.$body.appendChild($container); } }); - this.$placementStyles.text(this.createPlacementStylesCssString(allCssRules)); + this.$placementStyles.textContent = this.createPlacementStylesCssString(allCssRules); this.updateGroupVisibility(); } @bind private updateGroupVisibility() { let hasVisibleFields = this.uiFields.filter(uiField => uiField.isVisible()).length > 0; - this.$div.toggleClass("hidden", !hasVisibleFields && this.config.hideWhenNoVisibleFields); + this.$div.classList.toggle("hidden", !hasVisibleFields && this.config.hideWhenNoVisibleFields); } public destroy() { @@ -318,32 +343,30 @@ class UiFormSection { return placement._type === "UiFormSectionFloatingFieldsPlacement"; } - public getMainDomElement(): JQuery { + public getMainDomElement(): HTMLElement { return this.$div; } updateBodyHeightToFillRemainingHeight() { - this.$body[0].style.position = "absolute"; - this.$body[0].style.minHeight = (this.$div.innerHeight() - this.$header[0].offsetHeight) + "px"; - this.$body[0].style.position = ""; + this.$body.style.position = "absolute"; + this.$body.style.minHeight = (this.$div.offsetHeight - this.$header.offsetHeight) + "px"; + this.$body.style.position = ""; } setCollapsed(collapsed: boolean, animate = true): void { this.collapsed = collapsed; this.onCollapsedStateChanged.fire(this.collapsed); - this.$expander.toggleClass("expanded", !this.collapsed); - this.$div.toggleClass("collapsed", this.collapsed); + this.$expander.classList.toggle("expanded", !this.collapsed); + this.$div.classList.toggle("collapsed", this.collapsed); if (animate) { if (!this.collapsed) { - this.$body.slideDown(200); + this.$body.classList.remove('hidden'); + $(this.$body).slideDown(200); } else { - this.$body.slideUp(200); + $(this.$body).slideUp(200); } } else { - this.$body.toggle(!collapsed); - } - if (!collapsed) { - this.uiFields.forEach(field => field.reLayout()) + this.$body.classList.toggle('hidden', collapsed); } } } diff --git a/teamapps-client/ts/modules/UiHtmlView.ts b/teamapps-client/ts/modules/UiHtmlView.ts new file mode 100644 index 000000000..197b7f4b7 --- /dev/null +++ b/teamapps-client/ts/modules/UiHtmlView.ts @@ -0,0 +1,83 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +import {AbstractUiComponent} from "./AbstractUiComponent"; +import {TeamAppsUiContext} from "./TeamAppsUiContext"; +import {UiHtmlViewCommandHandler, UiHtmlViewConfig} from "../generated/UiHtmlViewConfig"; +import {parseHtml} from "./Common"; +import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; +import {UiComponent} from "./UiComponent"; + +export class UiHtmlView extends AbstractUiComponent implements UiHtmlViewCommandHandler { + + private $main: HTMLDivElement; + + constructor(config: UiHtmlViewConfig, context: TeamAppsUiContext) { + super(config, context); + this.$main = parseHtml(`
${config.html}
`); + for (let selector in config.componentsByContainerElementSelector) { + let components = config.componentsByContainerElementSelector[selector] as UiComponent[]; + for (let c of components) { + this.addComponent(selector, c, false); + } + } + for (let selector in config.contentHtmlByContainerElementSelector) { + this.setContentHtml(selector, config.contentHtmlByContainerElementSelector[selector]); + } + } + + public doGetMainElement(): HTMLElement { + return this.$main; + } + + addComponent(containerElementSelector: string, component: unknown, clearContainer: boolean): void { + let containerElement = this.$main.querySelector(`:scope ${containerElementSelector}`); + if (containerElement != null) { + if (clearContainer) { + containerElement.innerHTML = ''; + } + containerElement.appendChild((component as UiComponent).getMainElement()); + } else { + this.logger.error(`Could not add child component since selector does not match any element: ${containerElementSelector}`); + } + } + + removeComponent(component: unknown): void { + (component as UiComponent).getMainElement().remove(); + } + + setContentHtml(containerElementSelector: string, html: string): void { + let containerElement = this.$main.querySelector(`:scope ${containerElementSelector}`); + if (containerElement != null) { + containerElement.innerHTML = ''; + if (html != null) { + let childNodes = parseHtml(`
${html}
`).childNodes; + // Note that we need to copy childNodes, since childNodes is going to change on containerElement.appendChild(), + // since this effectively removes one child from the childNodes NodeList. + Array.from(childNodes).forEach(cn => containerElement.appendChild(cn)) + } + } else { + this.logger.error(`Could not set content HTML since selector does not match any element: ${containerElementSelector}`); + } + } + + +} + +TeamAppsUiComponentRegistry.registerComponentClass("UiHtmlView", UiHtmlView); diff --git a/teamapps-client/ts/modules/UiIFrame.ts b/teamapps-client/ts/modules/UiIFrame.ts index 99bc9c339..9aeda493a 100644 --- a/teamapps-client/ts/modules/UiIFrame.ts +++ b/teamapps-client/ts/modules/UiIFrame.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,14 +17,14 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import {UiComponent} from "./UiComponent"; +import {AbstractUiComponent} from "./AbstractUiComponent"; import {TeamAppsEvent} from "./util/TeamAppsEvent"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; import {UiIFrameCommandHandler, UiIFrameConfig} from "../generated/UiIFrameConfig"; import {parseHtml} from "./Common"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; -export class UiIFrame extends UiComponent implements UiIFrameCommandHandler { +export class UiIFrame extends AbstractUiComponent implements UiIFrameCommandHandler { private $iframe: HTMLIFrameElement; @@ -39,8 +39,8 @@ export class UiIFrame extends UiComponent implements UiIFrameCom // }); } - public getMainDomElement(): JQuery { - return $(this.$iframe); + public doGetMainElement(): HTMLElement { + return this.$iframe; } diff --git a/teamapps-client/ts/modules/UiImageCropper.ts b/teamapps-client/ts/modules/UiImageCropper.ts index de40c8938..ea1785ece 100644 --- a/teamapps-client/ts/modules/UiImageCropper.ts +++ b/teamapps-client/ts/modules/UiImageCropper.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,59 +17,138 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; -import {UiComponent} from "./UiComponent"; + +import {AbstractUiComponent} from "./AbstractUiComponent"; import {TeamAppsEvent} from "./util/TeamAppsEvent"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; -import {applyDisplayMode} from "./Common"; -import {executeWhenAttached} from "./util/ExecuteWhenAttached"; -import {UiImageCropper_SelectionChangedEvent, UiImageCropperCommandHandler, UiImageCropperConfig, UiImageCropperEventSource} from "../generated/UiImageCropperConfig"; +import {applyDisplayMode, boundSelection, css, Direction, parseHtml} from "./Common"; +import {executeWhenFirstDisplayed} from "./util/ExecuteWhenFirstDisplayed"; +import { + UiImageCropper_SelectionChangedEvent, + UiImageCropperCommandHandler, + UiImageCropperConfig, + UiImageCropperEventSource +} from "../generated/UiImageCropperConfig"; import {UiPageDisplayMode} from "../generated/UiPageDisplayMode"; -import {EventFactory} from "../generated/EventFactory"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; import {createUiImageCropperSelectionConfig, UiImageCropperSelectionConfig} from "../generated/UiImageCropperSelectionConfig"; import {UiImageCropperSelectionMode} from "../generated/UiImageCropperSelectionMode"; +import {draggable} from "./util/draggable"; + +type Selection = Omit; +type Rect = { x: number, y: number, width: number, height: number } -export class UiImageCropper extends UiComponent implements UiImageCropperCommandHandler, UiImageCropperEventSource { +export class UiImageCropper extends AbstractUiComponent implements UiImageCropperCommandHandler, UiImageCropperEventSource { - public readonly onSelectionChanged: TeamAppsEvent = new TeamAppsEvent(this); + public readonly onSelectionChanged: TeamAppsEvent = new TeamAppsEvent(); - private $element: JQuery; - private $selectionFrame: JQuery; + private $element: HTMLElement; + private $selectionFrame: HTMLElement; private htmlImageElement: HTMLImageElement; - private selection: UiImageCropperSelectionConfig; + private selection: Selection; private imageNaturalWidth: number = null; private imageNaturalHeight: number = null; constructor(config: UiImageCropperConfig, - context: TeamAppsUiContext) { + context: TeamAppsUiContext) { super(config, context); - this.$element = $(`
- -
+ this.$element = parseHtml(`
+ +
+
+
+
+
+
`); - this.htmlImageElement = this.$element.find("img")[0] as HTMLImageElement; + this.htmlImageElement = this.$element.querySelector(":scope img") as HTMLImageElement; this.htmlImageElement.onload = () => { this.imageNaturalWidth = this.htmlImageElement.naturalWidth; this.imageNaturalHeight = this.htmlImageElement.naturalHeight; - applyDisplayMode(this.getMainDomElement(), $(this.htmlImageElement), UiPageDisplayMode.FIT_SIZE); + applyDisplayMode(this.getMainElement(), this.htmlImageElement, UiPageDisplayMode.FIT_SIZE); this.resetSelectionFrame(config.aspectRatio); + this.updateCroppingFramePosition(this.selection); }; - // this.$element.css("background-image", `url(${config.imageUrl})`); - this.$selectionFrame = this.$element.find(".cropping-frame") - .resizable({ - handles: 'n, e, s, w, ne, se, nw, sw', - aspectRatio: 1, - containment: $(this.htmlImageElement), - stop: this.handleDragEnd.bind(this) - }) - .draggable({ - containment: $(this.htmlImageElement), - stop: this.handleDragEnd.bind(this) - }); + // this.$element.style.backgroundImage = `url(${config.imageUrl}`); + this.$selectionFrame = this.$element.querySelector(":scope .cropping-frame"); + + let startBox: any; + draggable(this.$selectionFrame, { + validDragStartDecider: e => { + const specialButton = e.button != null && e.button !== 0; + return e.target == this.$selectionFrame && !specialButton; + }, + dragStart: e => { + startBox = { + x: parseFloat(this.$selectionFrame.style.left), + y: parseFloat(this.$selectionFrame.style.top), + width: parseFloat(this.$selectionFrame.style.width), + height: parseFloat(this.$selectionFrame.style.height), + x2() { + return this.x + this.width; + }, + y2() { + return this.y + this.height; + } + }; + }, + drag: (e, eventData) => { + this.$selectionFrame.style.left = (startBox.x + eventData.deltaX) + "px"; + this.$selectionFrame.style.top = (startBox.y + eventData.deltaY) + "px"; + }, + dragEnd: (e, eventData) => { + this.handleDragEnd(); + } + }); + + let direction: Direction; + let fixedAt: Direction; + draggable(this.$selectionFrame.querySelectorAll(":scope .ui-resizable-handle"), { + dragStart: e => { + direction = (e.target as HTMLElement).getAttribute("data-direction") as Direction; + fixedAt = (e.target as HTMLElement).getAttribute("data-fixed-at") as Direction; + startBox = { + x: parseFloat(this.$selectionFrame.style.left), + y: parseFloat(this.$selectionFrame.style.top), + width: parseFloat(this.$selectionFrame.style.width), + height: parseFloat(this.$selectionFrame.style.height), + x2() { + return this.x + this.width; + }, + y2() { + return this.y + this.height; + } + } + }, + drag: (e, eventData) => { + if (direction.indexOf('n') != -1) { + this.$selectionFrame.style.top = (startBox.y + eventData.deltaY) + "px"; + this.$selectionFrame.style.height = (startBox.height - eventData.deltaY) + "px"; + } + if (direction.indexOf('w') != -1) { + this.$selectionFrame.style.left = (startBox.x + eventData.deltaX) + "px"; + this.$selectionFrame.style.width = (startBox.width - eventData.deltaX) + "px"; + } + if (direction.indexOf('s') != -1) { + this.$selectionFrame.style.height = (startBox.height + eventData.deltaY) + "px"; + } + if (direction.indexOf('e') != -1) { + this.$selectionFrame.style.width = (startBox.width + eventData.deltaX) + "px"; + } + let selection = this.frameRectToSelection(this.getSelectionFrameOffsetRect()); + selection = this.boundSelection(selection, fixedAt); + this.updateCroppingFramePosition(selection); + }, + dragEnd: (e, eventData) => { + let selection = this.frameRectToSelection(this.getSelectionFrameOffsetRect()); + selection = this.boundSelection(selection, fixedAt); + this.updateCroppingFramePosition(selection); + this.handleDragEnd(); + } + }); this.setImageUrl(config.imageUrl); this.setSelectionMode(config.selectionMode); @@ -80,7 +159,10 @@ export class UiImageCropper extends UiComponent implements if (this.imageNaturalWidth != null) { this.selection = createUiImageCropperSelectionConfig(0, 0, 0, 0); let naturalImageAspectRatio = this.imageNaturalWidth / this.imageNaturalHeight; - if (aspectRatio / naturalImageAspectRatio > 1) { + if (aspectRatio === 0) { + this.selection.width = 0.8 * this.imageNaturalWidth; + this.selection.height = 0.8 * this.imageNaturalHeight; + } else if (aspectRatio / naturalImageAspectRatio > 1) { this.selection.width = 0.8 * this.imageNaturalWidth; this.selection.height = this.selection.width / aspectRatio; } else { @@ -91,25 +173,47 @@ export class UiImageCropper extends UiComponent implements this.selection.left = 0.5 * (this.imageNaturalWidth - this.selection.width); this.selection.top = 0.5 * (this.imageNaturalHeight - this.selection.height); - this.updateCroppingFramePosition(); - this.onSelectionChanged.fire(EventFactory.createUiImageCropper_SelectionChangedEvent(this.getId(), this.selection)); + this.selection = this.boundSelection(this.selection); + this.updateCroppingFramePosition(this.selection); + this.onSelectionChanged.fire({ + selection: createUiImageCropperSelectionConfig(this.selection.left, this.selection.top, this.selection.width, this.selection.height) + }); } } private handleDragEnd() { - let correctionFactor = this.calculateCoordinateCorrectionFactor(); - this.selection = createUiImageCropperSelectionConfig( - (this.$selectionFrame[0].offsetLeft - this.htmlImageElement.offsetLeft) * correctionFactor, - (this.$selectionFrame[0].offsetTop - this.htmlImageElement.offsetTop) * correctionFactor, - this.$selectionFrame[0].offsetWidth * correctionFactor, - this.$selectionFrame[0].offsetHeight * correctionFactor - ); + let selectionFrameOffsetRect = this.getSelectionFrameOffsetRect(); + let selection = this.frameRectToSelection(selectionFrameOffsetRect); + selection = this.boundSelection(selection); + this.updateCroppingFramePosition(selection); + this.selection = selection; this.logger.debug("selection: ", this.selection); - this.onSelectionChanged.fire(EventFactory.createUiImageCropper_SelectionChangedEvent(this.getId(), this.selection)); + this.onSelectionChanged.fire({ + selection: createUiImageCropperSelectionConfig(this.selection.left, this.selection.top, this.selection.width, this.selection.height) + }); + } + + private getSelectionFrameOffsetRect(): Rect { + return { + x: this.$selectionFrame.offsetLeft, + y: this.$selectionFrame.offsetTop, + width: this.$selectionFrame.offsetWidth, + height: this.$selectionFrame.offsetHeight, + }; + } + + private boundSelection(selection: Selection, fixedAt?: Direction): Selection { + return boundSelection(selection, { + width: this.imageNaturalWidth, + height: this.imageNaturalHeight + }, this._config.aspectRatio, fixedAt); } private calculateCoordinateCorrectionFactor() { - return Math.max(this.imageNaturalWidth / this.getWidth(), this.imageNaturalHeight / this.getHeight()); + let fx = this.imageNaturalWidth / this.getWidth(); + let fy = this.imageNaturalHeight / this.getHeight(); + let factor = Math.max(fx, fy); + return factor; } public setImageUrl(url: string) { @@ -117,60 +221,71 @@ export class UiImageCropper extends UiComponent implements } setAspectRatio(aspectRatio: number): void { - this.$selectionFrame.resizable("option", "aspectRatio", aspectRatio); + this._config.aspectRatio = aspectRatio; + // $(this.$selectionFrame).resizable("option", "aspectRatio", aspectRatio); TODO this.resetSelectionFrame(aspectRatio); } setSelection(selection: UiImageCropperSelectionConfig): void { this.selection = selection; - this.updateCroppingFramePosition(); + this.updateCroppingFramePosition(this.selection); } setSelectionMode(selectionMode: UiImageCropperSelectionMode): void { - this.$selectionFrame[0].className = this.$selectionFrame[0].className.replace(/mode-\w+/, ''); - this.$selectionFrame.addClass(`mode-${UiImageCropperSelectionMode[selectionMode].toLowerCase()}`) + this.$selectionFrame.className = this.$selectionFrame.className.replace(/mode-\w+/, ''); + this.$selectionFrame.classList.add(`mode-${UiImageCropperSelectionMode[selectionMode].toLowerCase()}`) } - @executeWhenAttached(true) + @executeWhenFirstDisplayed(true) public onResize(): void { - applyDisplayMode(this.getMainDomElement(), $(this.htmlImageElement), UiPageDisplayMode.FIT_SIZE, { - innerPreferedDimensions: { + applyDisplayMode(this.getMainElement(), this.htmlImageElement, UiPageDisplayMode.FIT_SIZE, { + innerPreferredDimensions: { width: this.imageNaturalWidth, height: this.imageNaturalHeight } }); - this.updateCroppingFramePosition(); + this.updateCroppingFramePosition(this.selection); } - @executeWhenAttached(true) - private updateCroppingFramePosition() { - if (this.selection != null) { - let correctionFactor = this.calculateCoordinateCorrectionFactor(); - - let left = this.selection.left / correctionFactor; - let top = this.selection.top / correctionFactor; - let width = this.selection.width / correctionFactor; - let height = this.selection.height / correctionFactor; - - this.$selectionFrame.css({ - left: left + (this.htmlImageElement.offsetLeft - this.getMainDomElement()[0].offsetLeft), - top: top + (this.htmlImageElement.offsetTop - this.getMainDomElement()[0].offsetTop), - width, - height + @executeWhenFirstDisplayed(true) + private updateCroppingFramePosition(selection: Selection) { + if (selection != null) { + let frameRect = this.selectionToFrameRect(selection); + css(this.$selectionFrame, { + left: frameRect.x + "px", + top: frameRect.y + "px", + width: frameRect.width + "px", + height: frameRect.height + "px", }); } } - public getMainDomElement(): JQuery { - return this.$element; + + private frameRectToSelection(frameSelectionPos: Rect) { + let correctionFactor = this.calculateCoordinateCorrectionFactor(); + // let correctionOffsetX = + return createUiImageCropperSelectionConfig( + (frameSelectionPos.x - this.htmlImageElement.offsetLeft) * correctionFactor, + (frameSelectionPos.y - this.htmlImageElement.offsetTop) * correctionFactor, + frameSelectionPos.width * correctionFactor, + frameSelectionPos.height * correctionFactor + ); } - protected onAttachedToDom() { - this.reLayout(); + private selectionToFrameRect(selection: Selection): Rect { + let correctionFactor = this.calculateCoordinateCorrectionFactor(); + return { + x: (selection.left / correctionFactor) + this.htmlImageElement.offsetLeft, + y: (selection.top / correctionFactor) + this.htmlImageElement.offsetTop, + width: selection.width / correctionFactor, + height: selection.height / correctionFactor + }; } - public destroy(): void { + public doGetMainElement(): HTMLElement { + return this.$element; } + } TeamAppsUiComponentRegistry.registerComponentClass("UiImageCropper", UiImageCropper); diff --git a/teamapps-client/ts/modules/UiImageDisplay.ts b/teamapps-client/ts/modules/UiImageDisplay.ts index 1dd299e75..6d92875de 100644 --- a/teamapps-client/ts/modules/UiImageDisplay.ts +++ b/teamapps-client/ts/modules/UiImageDisplay.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,43 +17,42 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; -import {UiComponent} from "./UiComponent"; + +import {AbstractUiComponent} from "./AbstractUiComponent"; import {TeamAppsEvent} from "./util/TeamAppsEvent"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; -import {keyCodes} from "trivial-components"; +import {keyCodes} from "./trivial-components/TrivialCore"; import {UiCachedImageConfig} from "../generated/UiCachedImageConfig"; -import {applyDisplayMode, enableScrollViaDragAndDrop} from "./Common"; +import {applyDisplayMode, enableScrollViaDragAndDrop, parseHtml} from "./Common"; import {UiImageDisplay_ImageDisplayedEvent, UiImageDisplay_ImagesRequestEvent, UiImageDisplayCommandHandler, UiImageDisplayConfig, UiImageDisplayEventSource} from "../generated/UiImageDisplayConfig"; import {UiPageDisplayMode} from "../generated/UiPageDisplayMode"; -import {EventFactory} from "../generated/EventFactory"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; interface UiCachedImage { id: string; imageUrl: string; - $img: JQuery; + $img: HTMLElement; naturalWidth?: number; naturalHeight?: number; } -export class UiImageDisplay extends UiComponent implements UiImageDisplayCommandHandler, UiImageDisplayEventSource { +export class UiImageDisplay extends AbstractUiComponent implements UiImageDisplayCommandHandler, UiImageDisplayEventSource { - public readonly onImagesRequest: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onImageDisplayed: TeamAppsEvent = new TeamAppsEvent(this); + public readonly onImagesRequest: TeamAppsEvent = new TeamAppsEvent(); + public readonly onImageDisplayed: TeamAppsEvent = new TeamAppsEvent(); private forwardImageSvg = ` + style="fill:#ffffff"> `; + style="fill:#ffffff">`; - private $componentWrapper: JQuery; - private $imageContainerWrapper: JQuery; - private $imageContainer: JQuery; - private $imageCacheContainer: JQuery; - private $backwardButton: JQuery; - private $forwardButton: JQuery; + private $componentWrapper: HTMLElement; + private $imageContainerWrapper: HTMLElement; + private $imageContainer: HTMLElement; + private $imageCacheContainer: HTMLElement; + private $backwardButton: HTMLElement; + private $forwardButton: HTMLElement; private cachedImages: UiCachedImage[] = []; private totalNumberOfRecords: number; @@ -64,17 +63,17 @@ export class UiImageDisplay extends UiComponent implements constructor(config: UiImageDisplayConfig, context: TeamAppsUiContext) { super(config, context); - this.$componentWrapper = $( + this.$componentWrapper = parseHtml( `
-
+
-
+
${this.forwardImageSvg}
${this.forwardImageSvg}
-
+
`); - this.$componentWrapper.keydown((e) => { + this.$componentWrapper.addEventListener("keydown", (e) => { if (e.keyCode == keyCodes.left_arrow || e.keyCode == keyCodes.up_arrow) { this.jumpToNextImage(-1); @@ -84,18 +83,20 @@ export class UiImageDisplay extends UiComponent implements this.jumpToNextImage(1); } }); - this.$componentWrapper.mousedown((e: any) => { + this.$componentWrapper.addEventListener("mousedown", (e: any) => { this.$componentWrapper.focus(); }); - this.$imageContainerWrapper = this.$componentWrapper.find('.image-container-wrapper'); - this.$imageContainerWrapper.css("background-color", config.backgroundColor); + this.$imageContainerWrapper = this.$componentWrapper.querySelector(':scope .image-container-wrapper'); + this.$imageContainerWrapper.style.backgroundColor = config.backgroundColor; enableScrollViaDragAndDrop(this.$imageContainerWrapper); - this.$imageContainer = this.$componentWrapper.find('.image-container'); - this.$imageContainer.css("padding", config.padding); - this.$imageCacheContainer = this.$componentWrapper.find('.image-cache-container'); - this.$backwardButton = this.$componentWrapper.find('.backward-handle').click(() => this.jumpToNextImage(-1)); - this.$forwardButton = this.$componentWrapper.find('.forward-handle').click(() => this.jumpToNextImage(1)); + this.$imageContainer = this.$componentWrapper.querySelector(':scope .image-container'); + this.$imageContainer.style.padding = config.padding + "px"; + this.$imageCacheContainer = this.$componentWrapper.querySelector(':scope .image-cache-container'); + this.$backwardButton = this.$componentWrapper.querySelector(':scope .backward-handle'); + this.$backwardButton.addEventListener("click", () => this.jumpToNextImage(-1)); + this.$forwardButton = this.$componentWrapper.querySelector(':scope .forward-handle'); + this.$forwardButton.addEventListener("click", () => this.jumpToNextImage(1)); this.zoomFactor = config.zoomFactor; this.displayMode = config.displayMode; @@ -106,15 +107,20 @@ export class UiImageDisplay extends UiComponent implements } } - public getMainDomElement(): JQuery { + public doGetMainElement(): HTMLElement { return this.$componentWrapper; } private jumpToNextImage(direction: number) { this.showImageByIndex(this.currentImageIndex + direction); - this.onImageDisplayed.fire(EventFactory.createUiImageDisplay_ImageDisplayedEvent(this._config.id, this.cachedImages[this.currentImageIndex].id)); + this.onImageDisplayed.fire({ + imageId: this.cachedImages[this.currentImageIndex].id + }); if (this.cachedImages.length - this.currentImageIndex - 1 < this._config.cacheSize) { - this.onImagesRequest.fire(EventFactory.createUiImageDisplay_ImagesRequestEvent(this._config.id, this.cachedImages.length, Math.min(this.totalNumberOfRecords - this.cachedImages.length, this._config.cacheSize))); + this.onImagesRequest.fire({ + startIndex: this.cachedImages.length, + length: Math.min(this.totalNumberOfRecords - this.cachedImages.length, this._config.cacheSize) + }); } }; @@ -126,7 +132,7 @@ export class UiImageDisplay extends UiComponent implements let uiCachedImage: UiCachedImage = { id: imageConfig.id, imageUrl: imageConfig.imageUrl, - $img: $(img), + $img: img, naturalWidth: 0, naturalHeight: 0 }; @@ -155,11 +161,11 @@ export class UiImageDisplay extends UiComponent implements private showImageByIndex(imageIndex: number) { if (this.cachedImages[imageIndex]) { this.currentImageIndex = imageIndex; - this.$imageContainer[0].innerHTML = ''; + this.$imageContainer.innerHTML = ''; this.$imageContainer.append(this.cachedImages[imageIndex].$img); this.updateImageSizes(); - this.$backwardButton.toggleClass("hidden", imageIndex <= 0); - this.$forwardButton.toggleClass("hidden", imageIndex >= this.totalNumberOfRecords - 1); + this.$backwardButton.classList.toggle("hidden", imageIndex <= 0); + this.$forwardButton.classList.toggle("hidden", imageIndex >= this.totalNumberOfRecords - 1); } }; @@ -202,9 +208,6 @@ export class UiImageDisplay extends UiComponent implements this.updateImageSizes(); } - public destroy(): void { - // nothing to do - } } TeamAppsUiComponentRegistry.registerComponentClass("UiImageDisplay", UiImageDisplay); diff --git a/teamapps-client/ts/modules/UiInfiniteItemView.ts b/teamapps-client/ts/modules/UiInfiniteItemView.ts index 413a9ecd5..2322cd3fb 100644 --- a/teamapps-client/ts/modules/UiInfiniteItemView.ts +++ b/teamapps-client/ts/modules/UiInfiniteItemView.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,37 +17,51 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; -import {UiComponent} from "./UiComponent"; + +import {AbstractUiComponent} from "./AbstractUiComponent"; import {TeamAppsEvent} from "./util/TeamAppsEvent"; -import {Constants, generateUUID, Renderer} from "./Common"; +import {Constants, generateUUID, parseHtml, Renderer} from "./Common"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; -import {executeWhenAttached} from "./util/ExecuteWhenAttached"; -import {TableDataProviderItem} from "./table/TableDataProvider"; +import {executeWhenFirstDisplayed} from "./util/ExecuteWhenFirstDisplayed"; import { - UiInfiniteItemView_DataRequestEvent, + UiInfiniteItemView_ContextMenuRequestedEvent, + UiInfiniteItemView_DisplayedRangeChangedEvent, UiInfiniteItemView_ItemClickedEvent, UiInfiniteItemViewCommandHandler, UiInfiniteItemViewConfig, UiInfiniteItemViewEventSource } from "../generated/UiInfiniteItemViewConfig"; -import {EventFactory} from "../generated/EventFactory"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; import {UiTemplateConfig} from "../generated/UiTemplateConfig"; import {itemCssStringsAlignItems, itemCssStringsJustification} from "./UiItemView"; import {UiItemJustification} from "../generated/UiItemJustification"; import {UiIdentifiableClientRecordConfig} from "../generated/UiIdentifiableClientRecordConfig"; -import {UiVerticalElementAlignment} from "../generated/UiVerticalElementAlignment"; import {UiVerticalItemAlignment} from "../generated/UiVerticalItemAlignment"; - -/// +import {ContextMenu} from "./micro-components/ContextMenu"; +import {UiComponent} from "./UiComponent"; +import {loadSensitiveThrottling, throttle} from "./util/throttle"; +import { + createUiInfiniteItemViewDataRequestConfig, + UiInfiniteItemViewDataRequestConfig +} from "../generated/UiInfiniteItemViewDataRequestConfig"; +import {UiTableClientRecordConfig} from "../generated/UiTableClientRecordConfig"; + +const ROW_LOOKAHAED = 10; + +export interface TableDataProviderItem extends UiTableClientRecordConfig { + children: TableDataProviderItem[]; + depth: number; + parentId: number; + expanded: boolean; +} class UiInfiniteItemViewDataProvider implements Slick.DataProvider { private availableWidth: number; - private static ROW_LOOKAHAED = 20; private timerId: number = null; + private totalNumberOfRecords = 0; + constructor(private data: UiIdentifiableClientRecordConfig[], private itemWidthIncludingMargin: number, private dataRequestCallback: (from: number, length: number) => void) { } @@ -55,11 +69,14 @@ class UiInfiniteItemViewDataProvider implements Slick.DataProvider totalNumberOfRecords) { + this.data.length = totalNumberOfRecords; + } + this.totalNumberOfRecords = totalNumberOfRecords; } /** @@ -78,7 +95,7 @@ class UiInfiniteItemViewDataProvider implements Slick.DataProvider to || (lastVisibleRowIndex + 1) * itemsPerRow < from) { // not really necessary to load anything + if (firstVisibleRowIndex * itemsPerRow > toInclusive || (lastVisibleRowIndex + 1) * itemsPerRow < from) { // not really necessary to load anything return; } - if (from == to && this.data[to] !== undefined) { + if (from == toInclusive && this.data[toInclusive] !== undefined) { return; } @@ -122,11 +137,11 @@ class UiInfiniteItemViewDataProvider implements Slick.DataProvider { - for (var i = from; i <= to; i++) { + for (var i = from; i <= toInclusive; i++) { this.data[i] = null; // null indicates a 'requested but not available yet' } - let length = to - from + 1; + let length = toInclusive - from; this.dataRequestCallback(from, length); }, 100); } @@ -136,7 +151,8 @@ class UiInfiniteItemViewDataProvider implements Slick.DataProvider[startIndex, data.length]).concat(data)); + this.data.length = Math.max(this.data.length, startIndex + data.length); + this.data.splice.apply(this.data, ([startIndex, data.length] as any[]).concat(data)); } removeData(ids: number[]) { @@ -144,24 +160,24 @@ class UiInfiniteItemViewDataProvider implements Slick.DataProvider implements UiInfiniteItemViewCommandHandler, UiInfiniteItemViewEventSource { +export class UiInfiniteItemView extends AbstractUiComponent implements UiInfiniteItemViewCommandHandler, UiInfiniteItemViewEventSource { - public readonly onDataRequest: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onItemClicked: TeamAppsEvent = new TeamAppsEvent(this); - private $mainDomElement: JQuery; - private $grid: JQuery; + public readonly onDisplayedRangeChanged: TeamAppsEvent = new TeamAppsEvent(); + public readonly onItemClicked: TeamAppsEvent = new TeamAppsEvent(); + public readonly onContextMenuRequested: TeamAppsEvent = new TeamAppsEvent(); + + private $mainDomElement: HTMLElement; + private $grid: HTMLElement; private grid: Slick.Grid; private dataProvider: UiInfiniteItemViewDataProvider; private itemTemplateRenderer: Renderer; @@ -170,56 +186,67 @@ export class UiInfiniteItemView extends UiComponent im private itemWidth: number; private itemJustification: UiItemJustification; private verticalItemAlignment: UiVerticalItemAlignment; + private contextMenu: ContextMenu; constructor(config: UiInfiniteItemViewConfig, context: TeamAppsUiContext) { super(config, context); this.uuid = generateUUID(); - this.$mainDomElement = $(`
+ this.$mainDomElement = parseHtml(`
`); - this.$grid = this.$mainDomElement.find(".slickgrid"); + this.$grid = this.$mainDomElement.querySelector(":scope .slickgrid"); this.setItemTemplate(config.itemTemplate); this.itemWidth = config.itemWidth; this.horizontalItemMargin = config.horizontalItemMargin; this.itemJustification = config.itemJustification; this.verticalItemAlignment = config.verticalItemAlignment; this.dataProvider = new UiInfiniteItemViewDataProvider(config.data || [], 10 /*cannot know item width until component width is known*/, (fromIndex, length) => { - this.onDataRequest.fire(EventFactory.createUiInfiniteItemView_DataRequestEvent( - config.id, - fromIndex, - length - )); + this.onDisplayedRangeChanged.fire(this.createDisplayRangeChangedEvent(createUiInfiniteItemViewDataRequestConfig(fromIndex, length))); }); if (config.totalNumberOfRecords) { this.dataProvider.setTotalNumberOfRecords(config.totalNumberOfRecords); } this.createGrid(); + this.contextMenu = new ContextMenu(); + let me = this; - (this.getMainDomElement() as JQuery) - .on("click contextmenu", ".item-wrapper", function (e: JQueryMouseEventObject) { + $(this.getMainElement()) + .on("click", ".item-wrapper", function (e: JQueryMouseEventObject) { + let recordId = parseInt((this).getAttribute("data-id")); + me.onItemClicked.fire({ + recordId: recordId, + isDoubleClick: false + }); + }) + .on("contextmenu", ".item-wrapper", function (e: JQueryMouseEventObject) { let recordId = parseInt((this).getAttribute("data-id")); - me.onItemClicked.fire(EventFactory.createUiInfiniteItemView_ItemClickedEvent(me.getId(), recordId, e.button === 2, false)); - } as JQuery.EventHandler) + if (!isNaN(recordId) && me._config.contextMenuEnabled) { + me.contextMenu.open(e as unknown as MouseEvent, requestId => me.onContextMenuRequested.fire({ + recordId: recordId, + requestId + })); + } + }) .on("dblclick", ".item-wrapper", function (e: JQueryMouseEventObject) { let recordId = parseInt((this).getAttribute("data-id")); - me.onItemClicked.fire(EventFactory.createUiInfiniteItemView_ItemClickedEvent(me.getId(), recordId, e.button === 2, true)); - } as JQuery.EventHandler); + me.onItemClicked.fire({ + recordId: recordId, + isDoubleClick: true + }); + }); this.setHorizontalItemMargin(config.horizontalItemMargin); } - @executeWhenAttached() + @executeWhenFirstDisplayed() private createGrid() { let cellFormatter = (row: number, cell: number, value: any, columnDef: Slick.Column, dataContext: any[]) => { - for (var i = 0; i < dataContext.length; i++) { - if (!dataContext[i]) { - return ""; - } - } let html = '
'; for (let record of dataContext) { - html += `
${this.itemTemplateRenderer.render(record.values)}
`; + if (record != null) { // null happens for unknown reasons... + html += `
${this.itemTemplateRenderer.render(record.values)}
`; + } } html += "
"; return html; @@ -244,85 +271,128 @@ export class UiInfiniteItemView extends UiComponent im enableAddRow: false }; - this.dataProvider.setAvailableWidth(this.$mainDomElement[0].offsetWidth - Constants.SCROLLBAR_WIDTH); + this.dataProvider.setAvailableWidth(this.getAvailableWidth()); this.grid = new Slick.Grid(this.$grid, this.dataProvider, columns, options); this.grid.onViewportChanged.subscribe((e, args) => { this.dataProvider.ensureData(this.grid.getViewport().top, this.grid.getViewport().bottom); }); + this.grid.onScroll.subscribe(throttle((eventData) => { + // CAUTION: debounce/throttle scrolling without data requests only!!! (otherwise the tableDataProvider will mark rows as requested but the actual request will not get to the server) + this.onDisplayedRangeChanged.fire(this.createDisplayRangeChangedEvent()); + }, 500)); + + this.updateAutoHeight(); + } + + private createDisplayRangeChangedEvent(dataRequest?: UiInfiniteItemViewDataRequestConfig) { + const viewPort = this.grid.getViewport(); + return { + startIndex: Math.max(0, (viewPort.top - ROW_LOOKAHAED) * this.dataProvider.getItemsPerRow()), + length: (viewPort.bottom - viewPort.top + ROW_LOOKAHAED * 2) * this.dataProvider.getItemsPerRow(), + displayedRecordIds: this.getCurrentlyDisplayedRecordIds(), + dataRequest: dataRequest + }; + } + + private getCurrentlyDisplayedRecordIds() { + const viewPort = this.grid.getViewport(); + const currentlyDisplayedRecordIds: any[] = []; + for (let i = Math.max(0, viewPort.top - ROW_LOOKAHAED); i <= viewPort.bottom + ROW_LOOKAHAED; i++) { + const row = this.dataProvider.getItem(i); + if (row != null) { + row.forEach(item => item != null && currentlyDisplayedRecordIds.push(item.id)); + } + } + return currentlyDisplayedRecordIds; } - private calculateItemWidthInPixels() { + private calculateItemWidthInPixels(includeMargins: boolean) { if (this.itemWidth < 0) { console.error("itemWidth < 0 not allowed! Displaying full-width!"); this.itemWidth = 0; } + let availableWidth = this.getAvailableWidth(); if (this.itemWidth === 0) { - return this.getWidth() - Constants.SCROLLBAR_WIDTH - 1 /*TODO remove -1*/; + return availableWidth; } else if (this.itemWidth < 1) { - return Math.min(this.getWidth() * this.itemWidth + this._config.horizontalItemMargin - Constants.SCROLLBAR_WIDTH - 1 /*TODO remove -1*/, this.getWidth() - Constants.SCROLLBAR_WIDTH - 1 /*TODO remove -1*/); + let widthIncludingMargins = availableWidth * this.itemWidth + this._config.horizontalItemMargin * 2; + if (widthIncludingMargins > availableWidth) { + return availableWidth - this._config.horizontalItemMargin * 2; + } else { + return availableWidth * this.itemWidth + (includeMargins ? this._config.horizontalItemMargin * 2 : 0); + } + return Math.min(widthIncludingMargins, availableWidth); } else if (this.itemWidth >= 1) { - return Math.min(this.itemWidth + this._config.horizontalItemMargin, this.getWidth() - Constants.SCROLLBAR_WIDTH - 1 /*TODO remove -1*/); + return Math.min(this.itemWidth + (includeMargins ? this._config.horizontalItemMargin * 2 : 0), availableWidth); } } - @executeWhenAttached() + @executeWhenFirstDisplayed() public addData(startIndex: number, - data: any[], - totalNumberOfRecords: number, - clearTableCache: boolean) { + data: any[], + totalNumberOfRecords: number, + clearTableCache: boolean) { if (clearTableCache) { this.dataProvider.clear(); } - this.dataProvider.setData(startIndex, data); - - this.grid.invalidateAllRows(); - if (totalNumberOfRecords != this.dataProvider.getLength()) { this.dataProvider.setTotalNumberOfRecords(totalNumberOfRecords); - this.grid.updateRowCount(); } - this.grid.render(); + this.dataProvider.setData(startIndex, data); - // clearTimeout(this.loadingIndicatorFadeInTimer); - // this._$loadingIndicator.fadeOut(); + this.redrawGridContents(); + } + @loadSensitiveThrottling(100, 7, 2000) + private redrawGridContents() { + this.grid.updateRowCount(); + this.updateAutoHeight(); + this.grid.invalidateAllRows(); + this.grid.render(); this.grid.resizeCanvas(); } - @executeWhenAttached() + private updateAutoHeight() { + if (this._config.autoHeight) { + let computedStyle = getComputedStyle(this.$mainDomElement); + let newHeight = Math.min(parseFloat(computedStyle.maxHeight) || Number.MAX_SAFE_INTEGER, this.dataProvider.getLength() * this._config.rowHeight + + parseFloat(computedStyle.paddingTop) + parseFloat(computedStyle.paddingBottom)) + "px"; + if (newHeight != this.$grid.style.height) { + this.$grid.style.height = newHeight; + } + } + } + + @executeWhenFirstDisplayed() removeData(ids: number[]): void { this.dataProvider.removeData(ids); - this.grid.invalidateAllRows(); - this.grid.resizeCanvas(); + this.redrawGridContents(); this.dataProvider.ensureData(this.grid.getViewport().top, this.grid.getViewport().bottom); } - @executeWhenAttached(true) + @executeWhenFirstDisplayed(true) public onResize(): void { - this.logger.debug(this.$mainDomElement[0].offsetWidth - Constants.SCROLLBAR_WIDTH); - this.dataProvider.setAvailableWidth(this.$mainDomElement[0].offsetWidth - Constants.SCROLLBAR_WIDTH); - this.dataProvider.setItemWidthIncludingMargin(this.calculateItemWidthInPixels()); - this.grid.invalidateAllRows(); - this.grid.resizeCanvas(); + this.dataProvider.setAvailableWidth(this.getAvailableWidth()); + this.dataProvider.setItemWidthIncludingMargin(this.calculateItemWidthInPixels(true)); + this.redrawGridContents(); this.dataProvider.ensureData(this.grid.getViewport().top, this.grid.getViewport().bottom); } - - destroy(): void { - // nothing to do... + private getAvailableWidth() { + return this.getWidth() - Constants.SCROLLBAR_WIDTH; } - getMainDomElement(): JQuery { + doGetMainElement(): HTMLElement { return this.$mainDomElement; } private updateStyles() { - this.getMainDomElement().append(``); + `)); } - @executeWhenAttached(true) + @executeWhenFirstDisplayed(true) setHorizontalItemMargin(horizontalItemMargin: number): void { this.horizontalItemMargin = horizontalItemMargin; - this.dataProvider.setItemWidthIncludingMargin(this.calculateItemWidthInPixels()); + this.dataProvider.setItemWidthIncludingMargin(this.calculateItemWidthInPixels(true)); if (this.grid) { - this.grid.invalidateAllRows(); - this.grid.updateRowCount(); - this.grid.render(); + this.redrawGridContents(); } this.updateStyles(); } @@ -353,19 +421,16 @@ export class UiInfiniteItemView extends UiComponent im setItemTemplate(itemTemplate: UiTemplateConfig): void { this.itemTemplateRenderer = this._context.templateRegistry.createTemplateRenderer(itemTemplate); if (this.grid) { - this.grid.invalidateAllRows(); - this.grid.render(); + this.redrawGridContents(); } } - @executeWhenAttached(true) + @executeWhenFirstDisplayed(true) setItemWidth(itemWidth: number): void { this.itemWidth = itemWidth; - this.dataProvider.setItemWidthIncludingMargin(this.calculateItemWidthInPixels()); + this.dataProvider.setItemWidthIncludingMargin(this.calculateItemWidthInPixels(true)); if (this.grid) { - this.grid.invalidateAllRows(); - this.grid.updateRowCount(); - this.grid.render(); + this.redrawGridContents(); } } @@ -374,6 +439,14 @@ export class UiInfiniteItemView extends UiComponent im this.updateStyles(); } + setContextMenuContent(requestId: number, component: UiComponent): void { + this.contextMenu.setContent(component, requestId); + } + + closeContextMenu(requestId: number): void { + this.contextMenu.close(requestId); + } + } TeamAppsUiComponentRegistry.registerComponentClass("UiInfiniteItemView", UiInfiniteItemView); diff --git a/teamapps-client/ts/modules/UiInfiniteItemView2.ts b/teamapps-client/ts/modules/UiInfiniteItemView2.ts new file mode 100644 index 000000000..562795138 --- /dev/null +++ b/teamapps-client/ts/modules/UiInfiniteItemView2.ts @@ -0,0 +1,371 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ + +import {AbstractUiComponent} from "./AbstractUiComponent"; +import {TeamAppsEvent} from "./util/TeamAppsEvent"; +import {addDelegatedEventListener, parseHtml, Renderer} from "./Common"; +import {TeamAppsUiContext} from "./TeamAppsUiContext"; +import {executeWhenFirstDisplayed} from "./util/ExecuteWhenFirstDisplayed"; +import { + UiInfiniteItemView2_ContextMenuRequestedEvent, UiInfiniteItemView2_DisplayedRangeChangedEvent, + UiInfiniteItemView2_ItemClickedEvent, + UiInfiniteItemView2CommandHandler, + UiInfiniteItemView2Config, + UiInfiniteItemView2EventSource +} from "../generated/UiInfiniteItemView2Config"; +import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; +import {UiTemplateConfig} from "../generated/UiTemplateConfig"; +import {UiItemJustification} from "../generated/UiItemJustification"; +import {UiIdentifiableClientRecordConfig} from "../generated/UiIdentifiableClientRecordConfig"; +import {ContextMenu} from "./micro-components/ContextMenu"; +import {UiComponent} from "./UiComponent"; +import {debouncedMethod, DebounceMode} from "./util/debounce"; +import {UiHorizontalElementAlignment} from "../generated/UiHorizontalElementAlignment"; +import {UiVerticalElementAlignment} from "../generated/UiVerticalElementAlignment"; +import {UiInfiniteItemViewClientRecordConfig} from "../generated/UiInfiniteItemViewClientRecordConfig"; + +type RenderedItem = { + item: UiIdentifiableClientRecordConfig, + $element: HTMLElement, + $wrapper: HTMLElement, + x: number, + y: number, + width: number, + height: number +}; + +export var cssJustifyContent = { + [UiHorizontalElementAlignment.LEFT]: "flex-start", + [UiHorizontalElementAlignment.RIGHT]: "flex-end", + [UiHorizontalElementAlignment.CENTER]: "center", + [UiHorizontalElementAlignment.STRETCH]: "stretch", +}; +export var cssAlignItems = { + [UiVerticalElementAlignment.TOP]: "flex-start", + [UiVerticalElementAlignment.CENTER]: "center", + [UiVerticalElementAlignment.BOTTOM]: "flex-end", + [UiVerticalElementAlignment.STRETCH]: "stretch" +}; + +export class UiInfiniteItemView2 extends AbstractUiComponent implements UiInfiniteItemView2CommandHandler, UiInfiniteItemView2EventSource { + + public readonly onDisplayedRangeChanged: TeamAppsEvent = new TeamAppsEvent(); + public readonly onItemClicked: TeamAppsEvent = new TeamAppsEvent(); + public readonly onContextMenuRequested: TeamAppsEvent = new TeamAppsEvent(); + + private $mainDomElement: HTMLElement; + private $grid: HTMLElement; + private $styles: HTMLStyleElement; + private itemTemplateRenderer: Renderer; + private contextMenu: ContextMenu; + + private renderedRange: [number, number] = [0, 0]; + private renderedIds: number[] = []; // in order + private renderedItems: Map = new Map(); + private totalNumberOfRecords: number = null; + + constructor(config: UiInfiniteItemView2Config, context: TeamAppsUiContext) { + super(config, context); + this.$mainDomElement = parseHtml(`
+
+ +
`); + this.$grid = this.$mainDomElement.querySelector(":scope .grid"); + this.$styles = this.$mainDomElement.querySelector(":scope style"); + this.updateStyles(); + this.setItemTemplate(config.itemTemplate); + this.setItemPositionAnimationTime(config.itemPositionAnimationTime); + this.contextMenu = new ContextMenu(); + + this.$mainDomElement.addEventListener("scroll", ev => { + this.requestDataIfNeededDebounced(); + }); + this.$mainDomElement.addEventListener("wheel", ev => { + if (Math.abs(ev.deltaY) < 5) { + this.requestDataIfNeededDebounced(); + } + }); + + addDelegatedEventListener(this.getMainElement(), ".item-wrapper", ["click"], (element, ev) => { + let recordId = parseInt(element.getAttribute("data-id")); + this.setSelectedItem(this.renderedItems.get(recordId)); + this.onItemClicked.fire({ + recordId: recordId, + isDoubleClick: false + }); + }); + addDelegatedEventListener(this.getMainElement(), ".item-wrapper", "contextmenu", (element, ev) => { + let recordId = parseInt(element.getAttribute("data-id")); + if (!isNaN(recordId) && this._config.contextMenuEnabled) { + this.contextMenu.open(ev, requestId => this.onContextMenuRequested.fire({ + recordId: recordId, + requestId + })); + } + }); + addDelegatedEventListener(this.getMainElement(), ".item-wrapper", ["dblclick"], (element, ev) => { + let recordId = parseInt(element.getAttribute("data-id")); + this.onItemClicked.fire({ + recordId: recordId, + isDoubleClick: true + }); + }); + } + + setItemPositionAnimationTime(animationMillis: number): void { + this.$mainDomElement.style.setProperty("--item-position-animation-time", animationMillis + "ms"); + } + + @debouncedMethod(150, DebounceMode.BOTH) + private requestDataIfNeededDebounced() { + this.requestDataIfNeeded(); + } + + private requestDataIfNeeded() { + let visibleItemRange = this.getVisibleItemRange(); + let visibleItemRangeLength = visibleItemRange[1] - visibleItemRange[0]; + if (this._config.visible && visibleItemRangeLength === 0) { + visibleItemRangeLength = this.getItemsPerRow(); // even if this component has zero height, it should at least query one row in order to be able to auto-size! + } + const newRenderedRange: [number, number] = [ + Math.max(0, visibleItemRange[0] - visibleItemRangeLength), + visibleItemRange[1] + visibleItemRangeLength + ]; + if (newRenderedRange[0] != this.renderedRange[0] || newRenderedRange[1] != this.renderedRange[1]) { + this.renderedRange = newRenderedRange; + let eventObject = { + startIndex: newRenderedRange[0], + length: newRenderedRange[1] - newRenderedRange[0] // send the uncut value, so the server will send new records if some are added user scrolled to the end + }; + this.logger.debug("onRenderedItemRangeChanged ", eventObject); + this.onDisplayedRangeChanged.fire(eventObject); + } + } + + private getVisibleItemRange() { + const itemsPerRow = this.getItemsPerRow(); + const viewportTop = this.$mainDomElement.scrollTop; + const visibleStartRowIndex = Math.floor(viewportTop / this._config.itemHeight); + const numberOfVisibleRows = Math.ceil(this.getHeight() / this._config.itemHeight); // exclusive + let startIndex = Math.max(0, visibleStartRowIndex * itemsPerRow); + let endIndex = startIndex + numberOfVisibleRows * itemsPerRow; + return [startIndex, endIndex]; + } + + private getItemsPerRow() { + let itemsPerRow: number; + if (this._config.itemWidth <= 0) { + itemsPerRow = 1; + } else if (this._config.itemWidth < 1) { + itemsPerRow = Math.floor(1 / this._config.itemWidth); + } else { + itemsPerRow = Math.floor(this.$mainDomElement.clientWidth / this._config.itemWidth); + } + return itemsPerRow; + } + + private updateItemPositions() { + const itemsPerRow = this.getItemsPerRow(); + const availableWidth = this.$mainDomElement.clientWidth; + const itemWidth = this._config.itemWidth <= 0 ? availableWidth : this._config.itemWidth < 1 ? availableWidth * this._config.itemWidth : this._config.itemWidth; + const itemHeight = this._config.itemHeight; + for (let i = 0; i < this.renderedIds.length; i++) { + const absoluteIndex = i + this.renderedRange[0]; + const item = this.renderedItems.get(this.renderedIds[i]); + const positionX = itemWidth * (absoluteIndex % itemsPerRow); + const positionY = itemHeight * Math.floor(absoluteIndex / itemsPerRow); + this.updateItemPosition(item, positionX, positionY, itemWidth, itemHeight, absoluteIndex); + } + } + + private createRenderedItem(item: UiInfiniteItemViewClientRecordConfig): RenderedItem { + let $wrapper = document.createElement("div"); + let $element = parseHtml(this.itemTemplateRenderer.render(item.values)); + $wrapper.classList.add("item-wrapper"); + $wrapper.classList.toggle("selected", item.selected); + $wrapper.setAttribute("data-id", "" + item.id); + $wrapper.appendChild($element); + return { + item: item, + $element: $element, + $wrapper: $wrapper, + x: -1, + y: -1, + width: -1, + height: -1 + }; + } + + private setSelectedItem(item: RenderedItem) { + this.renderedItems.forEach(item => item.$wrapper.classList.remove("selected")); + if (item != null) { + item.$wrapper.classList.add("selected"); + } + } + + private updateItemPosition(renderedItem: RenderedItem, positionX: number, positionY: number, itemWidth: number, itemHeight: number, absoluteIndex: number) { + if (positionX !== renderedItem.x + || positionY !== renderedItem.y + || itemWidth !== renderedItem.width + || itemWidth !== renderedItem.height + ) { + renderedItem.$wrapper.style.left = positionX + "px"; + renderedItem.$wrapper.style.top = positionY + "px"; + renderedItem.$wrapper.style.width = itemWidth + "px"; + renderedItem.$wrapper.style.height = itemHeight + "px"; + renderedItem.x = positionX; + renderedItem.y = positionY; + renderedItem.width = itemWidth; + renderedItem.height = itemHeight; + } + } + + private rerenderAllItems() { + if (this.totalNumberOfRecords != null) { // data was ever received + let items = [...this.renderedItems.values()].map(renderdItem => renderdItem.item); + this.renderedItems.forEach(renderedItem => renderedItem.$wrapper.remove()); + this.renderedItems.clear(); + this.setData(this.renderedRange[0], items.map(item => item.id), items, this.totalNumberOfRecords); + } + } + + setData(startIndex: number, recordIds: number[], newRecords: UiIdentifiableClientRecordConfig[], totalNumberOfRecords: number): void { + this.logger.debug("got data ", startIndex, recordIds.length, newRecords.length, totalNumberOfRecords); + this.totalNumberOfRecords = totalNumberOfRecords; + this.updateGridHeight(); + let recordIdsAsSet: Set = new Set(recordIds); + const existingItems = [...this.renderedItems.values()]; + for (let existingItem of existingItems) { + if (!recordIdsAsSet.has(existingItem.item.id)) { + existingItem.$wrapper.remove(); + this.renderedItems.delete(existingItem.item.id); + } + } + newRecords.forEach(newRecord => { + let renderedItem = this.createRenderedItem(newRecord); + this.renderedItems.set(newRecord.id, renderedItem); + this.$grid.prepend(renderedItem.$wrapper); + }); + this.renderedIds = recordIds; + this.updateItemPositions() + } + + private updateGridHeight() { + this.$grid.style.height = this._config.itemHeight * Math.ceil(this.totalNumberOfRecords / this.getItemsPerRow()) + "px"; + } + + @executeWhenFirstDisplayed(true) + public onResize(): void { + this.logger.debug("onResize ", this.getWidth(), this.getHeight()); + this.updateGridHeight(); + this.updateItemPositions(); + this.requestDataIfNeededDebounced(); + } + + doGetMainElement(): HTMLElement { + return this.$mainDomElement; + } + + private updateStyles() { + this.$styles.textContent = ` + .grid-${this._config.id} .item-wrapper { + align-items: ${cssAlignItems[this._config.itemContentVerticalAlignment]}; + justify-content: ${cssJustifyContent[this._config.itemContentHorizontalAlignment]}; + } + .grid-${this._config.id} .item-wrapper > * { + flex: ${this._config.itemContentHorizontalAlignment == UiHorizontalElementAlignment.STRETCH ? "1 1 auto" : "0 1 auto"}; + }`; + } + + setItemTemplate(itemTemplate: UiTemplateConfig): void { + this.itemTemplateRenderer = this._context.templateRegistry.createTemplateRenderer(itemTemplate); + this.rerenderAllItems(); + } + + setItemWidth(itemWidth: number): void { + this._config.itemWidth = itemWidth; + this.updateStyles(); + this.requestDataIfNeeded(); + this.updateItemPositions(); + } + + setItemHeight(itemHeight: number): void { + this._config.itemHeight = itemHeight; + this.updateStyles(); + this.requestDataIfNeeded(); + this.updateItemPositions(); + } + + setHorizontalSpacing(horizontalSpacing: number): void { + this._config.horizontalSpacing = horizontalSpacing; + this.updateStyles(); + this.requestDataIfNeeded(); + this.updateItemPositions(); + } + + setVerticalSpacing(verticalSpacing: number): void { + this.updateStyles(); + this._config.verticalSpacing = verticalSpacing; + this.requestDataIfNeeded(); + this.updateItemPositions(); + } + + setItemContentHorizontalAlignment(itemContentHorizontalAlignment: UiHorizontalElementAlignment): void { + this._config.itemContentHorizontalAlignment = itemContentHorizontalAlignment; + this.updateStyles(); + this.requestDataIfNeeded(); + this.updateItemPositions(); + } + + setItemContentVerticalAlignment(itemContentVerticalAlignment: UiVerticalElementAlignment): void { + this._config.itemContentVerticalAlignment = itemContentVerticalAlignment; + this.updateStyles(); + this.requestDataIfNeeded(); + this.updateItemPositions(); + } + + setRowHorizontalAlignment(rowHorizontalAlignment: UiItemJustification): void { + this._config.rowHorizontalAlignment = rowHorizontalAlignment; + this.updateStyles(); + this.requestDataIfNeeded(); + this.updateItemPositions(); + } + + setContextMenuContent(requestId: number, component: UiComponent): void { + this.contextMenu.setContent(component, requestId); + } + + closeContextMenu(requestId: number): void { + this.contextMenu.close(requestId); + } + + setSelectionEnabled(selectionEnabled: boolean): any { + this._config.selectionEnabled = selectionEnabled; + this.getMainElement().classList.toggle("selection-enabled", selectionEnabled); + } + + setSelectedRecord(uiRecordId: number): any { + let item = this.renderedItems.get(uiRecordId); + this.setSelectedItem(item); + } + +} + +TeamAppsUiComponentRegistry.registerComponentClass("UiInfiniteItemView2", UiInfiniteItemView2); diff --git a/teamapps-client/ts/modules/UiItemView.ts b/teamapps-client/ts/modules/UiItemView.ts index 3fabca884..c01d05a68 100644 --- a/teamapps-client/ts/modules/UiItemView.ts +++ b/teamapps-client/ts/modules/UiItemView.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,22 +17,21 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; + import {UiItemViewItemGroupConfig} from "../generated/UiItemViewItemGroupConfig"; import {TeamAppsEvent} from "./util/TeamAppsEvent"; -import {DEFAULT_TEMPLATES, TrivialTreeBox, trivialMatch} from "trivial-components"; -import {UiComponent} from "./UiComponent"; -import {generateUUID, Renderer} from "./Common"; +import {DEFAULT_TEMPLATES, trivialMatch} from "./trivial-components/TrivialCore"; +import {TrivialTreeBox} from "./trivial-components/TrivialTreeBox"; +import {AbstractUiComponent} from "./AbstractUiComponent"; +import {generateUUID, parseHtml, Renderer} from "./Common"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; import {UiItemView_ItemClickedEvent, UiItemViewCommandHandler, UiItemViewConfig, UiItemViewEventSource} from "../generated/UiItemViewConfig"; import {UiItemViewFloatStyle} from "../generated/UiItemViewFloatStyle"; -import {EventFactory} from "../generated/EventFactory"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; -import {isGridTemplate, isTextCellTemplate} from "./TemplateRegistry"; +import {isGridTemplate} from "./TemplateRegistry"; import {UiItemJustification} from "../generated/UiItemJustification"; import {UiItemViewItemBackgroundMode} from "../generated/UiItemViewItemBackgroundMode"; import * as log from "loglevel"; -import {UiTextCellTemplateElementConfig} from "../generated/UiTextCellTemplateElementConfig"; import {UiIdentifiableClientRecordConfig} from "../generated/UiIdentifiableClientRecordConfig"; import {UiVerticalItemAlignment} from "../generated/UiVerticalItemAlignment"; @@ -41,7 +40,8 @@ export var itemCssStringsJustification = { [UiItemJustification.RIGHT]: "flex-end", [UiItemJustification.CENTER]: "center", [UiItemJustification.SPACE_AROUND]: "space-around", - [UiItemJustification.SPACE_BETWEEN]: "space-between" + [UiItemJustification.SPACE_BETWEEN]: "space-between", + [UiItemJustification.SPACE_EVENLY]: "space-evenly" }; export var itemCssStringsAlignItems = { [UiVerticalItemAlignment.TOP]: "flex-start", @@ -50,11 +50,11 @@ export var itemCssStringsAlignItems = { [UiVerticalItemAlignment.STRETCH]: "stretch" }; -export class UiItemView extends UiComponent implements UiItemViewCommandHandler, UiItemViewEventSource { +export class UiItemView extends AbstractUiComponent implements UiItemViewCommandHandler, UiItemViewEventSource { - public readonly onItemClicked: TeamAppsEvent = new TeamAppsEvent(this); + public readonly onItemClicked: TeamAppsEvent = new TeamAppsEvent(); - private $itemView: JQuery; + private $itemView: HTMLElement; private groupHeaderTemplateRenderer: Renderer; private groupsByGroupId: { [index: string]: ItemGroup } = {}; private filterString: string = ""; @@ -62,12 +62,12 @@ export class UiItemView extends UiComponent implements UiItemV constructor(config: UiItemViewConfig, context: TeamAppsUiContext) { super(config, context); - this.$itemView = $('
'); - this.$itemView.css("padding", config.verticalPadding + "px " + config.horizontalPadding + "px"); + this.$itemView = parseHtml('
'); + this.$itemView.style.padding = config.verticalPadding + "px " + config.horizontalPadding + "px"; if (config.groupHeaderTemplate) { this.groupHeaderTemplateRenderer = context.templateRegistry.createTemplateRenderer(config.groupHeaderTemplate, null); } - this.$itemView.addClass("background-mode-" + UiItemViewItemBackgroundMode[config.itemBackgroundMode].toLowerCase()); + this.$itemView.classList.add("background-mode-" + UiItemViewItemBackgroundMode[config.itemBackgroundMode].toLowerCase()); config.itemGroups.forEach(group => { this.addItemGroup(group); @@ -76,7 +76,7 @@ export class UiItemView extends UiComponent implements UiItemV this.setFilter(config.filter); } - public getMainDomElement(): JQuery { + public doGetMainElement(): HTMLElement { return this.$itemView; } @@ -86,14 +86,17 @@ export class UiItemView extends UiComponent implements UiItemV } const itemGroup = this.createItemGroup(itemGroupConfig); this.groupsByGroupId[itemGroupConfig.id] = itemGroup; - itemGroup.getMainDomElement().appendTo(this.$itemView); + this.$itemView.appendChild(itemGroup.getMainDomElement()); return itemGroup; } private createItemGroup(itemGroupConfig: UiItemViewItemGroupConfig) { const itemGroup = new ItemGroup(this, this._context, itemGroupConfig, this.groupHeaderTemplateRenderer); - itemGroup.onItemClicked.addListener(item => this.onItemClicked.fire(EventFactory.createUiItemView_ItemClickedEvent(this._config.id, itemGroupConfig.id, item.id))); - itemGroup.getMainDomElement().css("padding-bottom", this._config.groupSpacing + "px"); + itemGroup.onItemClicked.addListener(item => this.onItemClicked.fire({ + groupId: itemGroupConfig.id, + itemId: item.id + })); + itemGroup.getMainDomElement().style.paddingBottom = this._config.groupSpacing + "px"; itemGroup.setFilter(this.filterString); return itemGroup; } @@ -106,13 +109,13 @@ export class UiItemView extends UiComponent implements UiItemV } const newGroup = this.createItemGroup(itemGroupConfig); this.groupsByGroupId[itemGroupConfig.id] = newGroup; - newGroup.getMainDomElement().insertAfter(oldGroup.getMainDomElement()); - oldGroup.getMainDomElement().detach(); + oldGroup.getMainDomElement().parentElement.insertBefore(newGroup.getMainDomElement(), oldGroup.getMainDomElement()); + oldGroup.getMainDomElement().remove(); } public removeItemGroup(groupId: string): void { const itemGroup = this.groupsByGroupId[groupId]; - itemGroup.getMainDomElement().detach(); + itemGroup.getMainDomElement().remove(); delete this.groupsByGroupId[groupId]; } @@ -147,6 +150,7 @@ export class UiItemView extends UiComponent implements UiItemV } public destroy(): void { + super.destroy(); Object.keys(this.groupsByGroupId).forEach(groupId => { const group = this.groupsByGroupId[groupId]; group.destroy() @@ -155,7 +159,7 @@ export class UiItemView extends UiComponent implements UiItemV } class ItemGroup { - private $itemGroup: JQuery; + private $itemGroup: HTMLElement; private items: UiIdentifiableClientRecordConfig[]; private trivialTreeBox: TrivialTreeBox; private itemRenderer: Renderer; @@ -163,13 +167,13 @@ class ItemGroup { private static logger = log.getLogger("UiItemView-ItemGroup"); - public readonly onItemClicked: TeamAppsEvent = new TeamAppsEvent(this); + public readonly onItemClicked: TeamAppsEvent = new TeamAppsEvent(); constructor(private itemView: UiItemView, private context: TeamAppsUiContext, private config: UiItemViewItemGroupConfig, groupHeaderTemplateRenderer: Renderer) { this.items = config.items; const groupHtmlId = `item-group-${generateUUID()}`; - this.$itemGroup = $('
'); + this.$itemGroup = parseHtml('
'); let buttonWidthCssValue; if (config.buttonWidth < 0) { @@ -182,7 +186,7 @@ class ItemGroup { buttonWidthCssValue = config.buttonWidth + 'px'; } - this.$itemGroup.append(``); - const $itemGroupHeader = $('
').appendTo(this.$itemGroup); + `)); + const $itemGroupHeader = parseHtml('
'); + this.$itemGroup.appendChild($itemGroupHeader); if (!config.headerVisible) { - $itemGroupHeader.addClass("hidden"); + $itemGroupHeader.classList.add("hidden"); } if (groupHeaderTemplateRenderer && config.headerData) { - $itemGroupHeader.append(groupHeaderTemplateRenderer.render(config.headerData.values)); + $itemGroupHeader.append(parseHtml(groupHeaderTemplateRenderer.render(config.headerData.values))); } - const $itemContainer = $('
').appendTo(this.$itemGroup); + const $itemContainer = parseHtml('
'); + this.$itemGroup.appendChild($itemContainer); - $itemContainer.addClass(UiItemViewFloatStyle[config.floatStyle]); + $itemContainer.classList.add(UiItemViewFloatStyle[config.floatStyle]); - $itemContainer.css({ - "padding": config.verticalPadding + "px " + config.horizontalPadding + "px" - }); + $itemContainer.style.padding = config.verticalPadding + "px " + config.horizontalPadding + "px"; this.itemRenderer = context.templateRegistry.createTemplateRenderer(config.itemTemplate); - this.trivialTreeBox = new TrivialTreeBox($itemContainer, { + this.trivialTreeBox = new TrivialTreeBox({ entryRenderingFunction: (entry) => this.itemRenderer.render(entry.values), spinnerTemplate: DEFAULT_TEMPLATES.defaultSpinnerTemplate, entries: config.items, idFunction: entry => entry && entry.id }); + $itemContainer.append(this.trivialTreeBox.getMainDomElement()); this.trivialTreeBox.onSelectedEntryChanged.addListener(() => { this.onItemClicked.fire(this.trivialTreeBox.getSelectedEntry()); }); @@ -227,11 +232,11 @@ class ItemGroup { private filter() { const matchingElements = this.filterItems(this.filterString); - this.trivialTreeBox.updateEntries(matchingElements); + this.trivialTreeBox.setEntries(matchingElements); if (matchingElements.length < 100) { this.trivialTreeBox.highlightTextMatches(this.filterString); } - this.$itemGroup.toggle(matchingElements.length > 0); + this.$itemGroup.classList.toggle("hidden", matchingElements.length === 0); } private filterItems(queryString: string) { @@ -240,14 +245,10 @@ class ItemGroup { } let relevantFieldNames: string[]; - if (isTextCellTemplate(this.itemRenderer.template)) { - relevantFieldNames = this.itemRenderer.template.textElements.map((e: UiTextCellTemplateElementConfig) => { - return e.propertyName; - }); - } else if (isGridTemplate(this.itemRenderer.template)) { + if (isGridTemplate(this.itemRenderer.template)) { relevantFieldNames = this.itemRenderer.template.elements .filter(e => e._type === "UiTextElement") - .map(e => e.dataKey); + .map(e => e.property); } else { ItemGroup.logger.error("Unknown type of template!"); } diff --git a/teamapps-client/ts/modules/UiLinkButton.ts b/teamapps-client/ts/modules/UiLinkButton.ts new file mode 100644 index 000000000..9d17d0cbb --- /dev/null +++ b/teamapps-client/ts/modules/UiLinkButton.ts @@ -0,0 +1,64 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +import {TeamAppsUiContext} from "./TeamAppsUiContext"; +import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; +import {UiLinkButton_ClickedEvent, UiLinkButtonCommandHandler, UiLinkButtonConfig, UiLinkButtonEventSource} from "../generated/UiLinkButtonConfig"; +import {AbstractUiComponent} from "./AbstractUiComponent"; +import {TeamAppsEvent} from "./util/TeamAppsEvent"; +import {parseHtml} from "./Common"; +import {UiLinkTarget} from "../generated/UiLinkTarget"; + +export class UiLinkButton extends AbstractUiComponent implements UiLinkButtonEventSource, UiLinkButtonCommandHandler { + + public readonly onClicked: TeamAppsEvent = new TeamAppsEvent(); + + private readonly $main: HTMLAnchorElement; + + constructor(config: UiLinkButtonConfig, context: TeamAppsUiContext) { + super(config, context); + this.$main = parseHtml(``) + this.$main.addEventListener("click", ev => { + if (this._config.onClickJavaScript != null) { + let context = this._context; // make context available in evaluated javascript + eval(this._config.onClickJavaScript); + } + this.onClicked.fire({}); + }); + this.update(config); + } + + + protected doGetMainElement(): HTMLElement { + return this.$main; + } + + public update(config: UiLinkButtonConfig) { + this.$main.text = config.text; + if (config.url) { + this.$main.href= config.url; + } else { + this.$main.removeAttribute("href"); + } + this.$main.target = '_' + UiLinkTarget[config.target].toLocaleLowerCase(); + + } +} + +TeamAppsUiComponentRegistry.registerComponentClass("UiLinkButton", UiLinkButton); diff --git a/teamapps-client/ts/modules/UiLiveStreamComponent.ts b/teamapps-client/ts/modules/UiLiveStreamComponent.ts index 524450117..192c164d1 100644 --- a/teamapps-client/ts/modules/UiLiveStreamComponent.ts +++ b/teamapps-client/ts/modules/UiLiveStreamComponent.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,15 +18,15 @@ * =========================LICENSE_END================================== */ -import * as $ from "jquery"; + import {UiWaitingVideoInfoConfig} from "../generated/UiWaitingVideoInfoConfig"; import {TeamAppsEvent} from "./util/TeamAppsEvent"; import {UiHttpLiveStreamPlayer} from "./live-stream/UiHttpLiveStreamPlayer"; import {UiYoutubePlayer} from "./live-stream/UiYoutubePlayer"; import {UiLiveStreamComPlayer} from "./live-stream/UiLiveStreamComPlayer"; -import {UiComponent} from "./UiComponent"; +import {AbstractUiComponent} from "./AbstractUiComponent"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; -import {applyDisplayMode, generateUUID} from "./Common"; +import {applyDisplayMode, css, fadeIn, fadeOut, generateUUID, parseHtml} from "./Common"; import {LiveStreamPlayer} from "./live-stream/LiveStreamPlayer"; import { UiLiveStreamComponent_ResultOfRequestInputDeviceAccessEvent, @@ -39,31 +39,31 @@ import {UiPageDisplayMode} from "../generated/UiPageDisplayMode"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; -export class UiLiveStreamComponent extends UiComponent implements UiLiveStreamComponentCommandHandler, UiLiveStreamComponentEventSource { +export class UiLiveStreamComponent extends AbstractUiComponent implements UiLiveStreamComponentCommandHandler, UiLiveStreamComponentEventSource { - public readonly onResultOfRequestInputDeviceAccess: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onResultOfRequestInputDeviceInfo: TeamAppsEvent = new TeamAppsEvent(this); + public readonly onResultOfRequestInputDeviceAccess: TeamAppsEvent = new TeamAppsEvent(); + public readonly onResultOfRequestInputDeviceInfo: TeamAppsEvent = new TeamAppsEvent(); - private $componentWrapper: JQuery; + private $componentWrapper: HTMLElement; - private $backgroundImageContainer: JQuery; - private $backgroundImage: JQuery; + private $backgroundImageContainer: HTMLElement; + private $backgroundImage: HTMLElement; - private $waitingVideoContainer: JQuery; + private $waitingVideoContainer: HTMLElement; private waitingVideoPlayer: HTMLVideoElement; private waitingVideoInfos: UiWaitingVideoInfoConfig[]; private currentWaitingVideoIndex: number; - private $liveStreamPlayerContainer: JQuery; + private $liveStreamPlayerContainer: HTMLElement; private hlsPlayer: UiHttpLiveStreamPlayer; private liveStreamComPlayer: UiLiveStreamComPlayer; private youtubePlayer: UiYoutubePlayer; - private $imageOverlayContainer: JQuery; - private $imageOverlay: JQuery; + private $imageOverlayContainer: HTMLElement; + private $imageOverlay: HTMLElement; private imageOverlayDisplayMode: UiPageDisplayMode; - private $infoTextContainer: JQuery; + private $infoTextContainer: HTMLElement; private volume: number; @@ -73,30 +73,30 @@ export class UiLiveStreamComponent extends UiComponent - -
- - - + + + + +
`); - this.$backgroundImageContainer = this.$componentWrapper.find('.background-image-container'); - this.$backgroundImage = this.$backgroundImageContainer.find('img'); - this.$liveStreamPlayerContainer = this.$componentWrapper.find('.livestream-player-container'); - this.$waitingVideoContainer = this.$componentWrapper.find('.waiting-video-container'); - this.waitingVideoPlayer = this.$waitingVideoContainer.find('video')[0]; - this.$imageOverlayContainer = this.$componentWrapper.find('.image-overlay-container'); - this.$imageOverlay = this.$imageOverlayContainer.find('img'); - this.$infoTextContainer = this.$componentWrapper.find('.info-text-container'); + this.$backgroundImageContainer = this.$componentWrapper.querySelector(':scope .background-image-container'); + this.$backgroundImage = this.$backgroundImageContainer.querySelector(':scope img'); + this.$liveStreamPlayerContainer = this.$componentWrapper.querySelector(':scope .livestream-player-container'); + this.$waitingVideoContainer = this.$componentWrapper.querySelector(':scope .waiting-video-container'); + this.waitingVideoPlayer = this.$waitingVideoContainer.querySelector(':scope video'); + this.$imageOverlayContainer = this.$componentWrapper.querySelector(':scope .image-overlay-container'); + this.$imageOverlay = this.$imageOverlayContainer.querySelector(':scope img'); + this.$infoTextContainer = this.$componentWrapper.querySelector(':scope .info-text-container'); - this.$backgroundImage.on("load", () => this.reLayout()); - this.$imageOverlay.on("load", () => this.reLayout()); + this.$backgroundImage.addEventListener("load", () => this.onResize()); + this.$imageOverlay.addEventListener("load", () => this.onResize()); if (config.backgroundImage) { - this.$backgroundImage.attr("src", config.backgroundImage); - this.$backgroundImageContainer.show(); + this.$backgroundImage.setAttribute("src", config.backgroundImage); + this.$backgroundImageContainer.classList.remove("hidden"); } this.waitingVideoPlayer.addEventListener('ended', (e) => { @@ -105,43 +105,42 @@ export class UiLiveStreamComponent extends UiComponent { this.waitingVideoPlayer.pause(); - this.$waitingVideoContainer.hide(); + this.$waitingVideoContainer.classList.add("hidden"); }); } public startHttpLiveStream(url: string) { this.stopLiveStream(); // stop all other live streams! - this.$backgroundImageContainer.fadeOut(1000); + fadeOut(this.$backgroundImageContainer); if (!this.hlsPlayer) { this.hlsPlayer = new UiHttpLiveStreamPlayer({ _type: "UiHttpLiveStreamPlayer", id: generateUUID() }, this._context); - this.$liveStreamPlayerContainer.append(this.hlsPlayer.getMainDomElement()); + this.$liveStreamPlayerContainer.append(this.hlsPlayer.getMainElement()); } this.hlsPlayer.setVolume(this.volume); this.hlsPlayer.play(url); @@ -203,16 +202,16 @@ export class UiLiveStreamComponent extends UiComponent { p.stop(); }); - this.$backgroundImageContainer.fadeIn(1000); - this.reLayout(); - + fadeIn(this.$backgroundImageContainer); this.updatePlayerSizesAndPositions(); } public displayImageOverlay(imageUrl: string, displayMode: UiPageDisplayMode, useVideoAreaAsFrame: Boolean) { this.imageOverlayDisplayMode = displayMode; - this.$imageOverlay.attr("src", imageUrl); - this.$imageOverlayContainer.show(); - this.reLayout(); + this.$imageOverlay.setAttribute("src", imageUrl); + this.$imageOverlayContainer.classList.remove("hidden"); } public removeImageOverlay() { - this.$imageOverlayContainer.hide(); + this.$imageOverlayContainer.classList.add("hidden"); } public displayInfoTextOverlay(text: string) { - this.$infoTextContainer - .text(text) - .fadeIn(1000); + this.$infoTextContainer.textContent = text; + fadeIn(this.$infoTextContainer); } public removeInfoTextOverlay() { - this.$infoTextContainer.fadeOut(1000); + fadeOut(this.$infoTextContainer); } startCustomEmbeddedLiveStreamPlayer(playerEmbedHtml: string, embedContainerId: string): void { @@ -296,7 +291,7 @@ export class UiLiveStreamComponent extends UiComponent { if (p.isPlaying()) { // full size - p.getMainDomElement().css({ + css(p.getMainElement(), { top: "0px", left: "0px", width: "100%", @@ -304,7 +299,7 @@ export class UiLiveStreamComponent extends UiComponent -import * as $ from "jquery"; -import {UiComponent} from "./UiComponent"; -import {Renderer} from "./Common"; + +import {AbstractUiComponent} from "./AbstractUiComponent"; +import {generateUUID, parseHtml, Renderer} from "./Common"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; -import {UiMapPolylineConfig} from "../generated/UiMapPolylineConfig"; +import {createUiMapPolylineConfig, UiMapPolylineConfig} from "../generated/UiMapPolylineConfig"; import {UiMapMarkerClusterConfig} from "../generated/UiMapMarkerClusterConfig"; import {UiHeatMapDataConfig} from "../generated/UiHeatMapDataConfig"; -import {UiMap_LocationChangedEvent, UiMap_MapClickedEvent, UiMap_MarkerClickedEvent, UiMap_ZoomLevelChangedEvent, UiMapCommandHandler, UiMapConfig, UiMapEventSource} from "../generated/UiMapConfig"; +import { + UiMap_LocationChangedEvent, + UiMap_MapClickedEvent, + UiMap_MarkerClickedEvent, + UiMap_ShapeDrawnEvent, + UiMap_ZoomLevelChangedEvent, + UiMapCommandHandler, + UiMapConfig, + UiMapEventSource +} from "../generated/UiMapConfig"; import {UiMapType} from "../generated/UiMapType"; -import * as L from "leaflet"; -import {LatLngExpression, Marker, Polyline} from "leaflet"; +import L, {Circle, LatLngBounds, LatLngExpression, Layer, Marker, PathOptions, Polygon, Polyline, Rectangle} from "leaflet"; +import "leaflet.markercluster"; +import "leaflet.heat"; +import "leaflet-draw"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; import {TeamAppsEvent} from "./util/TeamAppsEvent"; -import {executeWhenAttached} from "./util/ExecuteWhenAttached"; +import {executeWhenFirstDisplayed} from "./util/ExecuteWhenFirstDisplayed"; import {UiTemplateConfig} from "../generated/UiTemplateConfig"; import {isGridTemplate} from "./TemplateRegistry"; import {isUiGlyphIconElement, isUiIconElement, isUiImageElement} from "./util/UiGridTemplates"; -import {EventFactory} from "../generated/EventFactory"; import {UiMapMarkerClientRecordConfig} from "../generated/UiMapMarkerClientRecordConfig"; import {createUiMapLocationConfig, UiMapLocationConfig} from "../generated/UiMapLocationConfig"; import {createUiMapAreaConfig} from "../generated/UiMapAreaConfig"; +import {UiMapShapeType} from "../generated/UiMapShapeType"; +import {createUiMapCircleConfig, UiMapCircleConfig} from "../generated/UiMapCircleConfig"; +import {createUiMapPolygonConfig, UiMapPolygonConfig} from "../generated/UiMapPolygonConfig"; +import {createUiMapRectangleConfig, UiMapRectangleConfig} from "../generated/UiMapRectangleConfig"; +import {AbstractUiMapShapeConfig} from "../generated/AbstractUiMapShapeConfig"; +import {UiShapePropertiesConfig} from "../generated/UiShapePropertiesConfig"; +import {UiMapConfigConfig} from "../generated/UiMapConfigConfig"; + +export function isUiMapCircle(shapeConfig: AbstractUiMapShapeConfig): shapeConfig is UiMapCircleConfig { + return shapeConfig._type === "UiMapCircle"; +} + +export function isUiMapPolygon(shapeConfig: AbstractUiMapShapeConfig): shapeConfig is UiMapPolygonConfig { + return shapeConfig._type === "UiMapPolygon"; +} + +export function isUiMapPolyline(shapeConfig: AbstractUiMapShapeConfig): shapeConfig is UiMapPolylineConfig { + return shapeConfig._type === "UiMapPolyline"; +} -export class UiMap extends UiComponent implements UiMapCommandHandler, UiMapEventSource { +export function isUiMapRectangle(shapeConfig: AbstractUiMapShapeConfig): shapeConfig is UiMapRectangleConfig { + return shapeConfig._type === "UiMapRectangle"; +} + +export class UiMap extends AbstractUiComponent implements UiMapCommandHandler, UiMapEventSource { - public onZoomLevelChanged: TeamAppsEvent = new TeamAppsEvent(this); - public onLocationChanged: TeamAppsEvent = new TeamAppsEvent(this); - public onMapClicked: TeamAppsEvent = new TeamAppsEvent(this); - public onMarkerClicked: TeamAppsEvent = new TeamAppsEvent(this); + public readonly onZoomLevelChanged: TeamAppsEvent = new TeamAppsEvent(); + public readonly onLocationChanged: TeamAppsEvent = new TeamAppsEvent(); + public readonly onMapClicked: TeamAppsEvent = new TeamAppsEvent(); + public readonly onMarkerClicked: TeamAppsEvent = new TeamAppsEvent(); + public readonly onShapeDrawn: TeamAppsEvent = new TeamAppsEvent(); private id: any; private leaflet: L.Map; @@ -55,100 +89,179 @@ export class UiMap extends UiComponent implements UiMapCommandHandl private heatMapLayer: any; private markerTemplateRenderers: { [templateName: string]: Renderer } = {}; - private $map: JQuery; - private polyLinesById: {[lineId: string]: Polyline} = {}; + private $map: HTMLElement; + private shapesById: { [lineId: string]: Layer } = {}; private markersByClientId: { [id: number]: Marker } = {}; + private drawCircleFeature: L.Draw.Circle; + private drawPolygonFeature: L.Draw.Polygon; + private drawPolylineFeature: L.Draw.Polyline; + private drawRectangleFeature: L.Draw.Rectangle; + constructor(config: UiMapConfig, context: TeamAppsUiContext) { super(config, context); - this.$map = $('
'); + this.$map = parseHtml('
'); this.id = this.getId(); this.createLeafletMap(); Object.keys(config.markerTemplates).forEach(templateName => this.registerTemplate(templateName, config.markerTemplates[templateName])); - Object.keys(config.polylines).forEach(lineId => { - this.addPolyline(lineId, config.polylines[lineId]); + Object.keys(config.shapes).forEach(shapeId => { + this.addShape(shapeId, config.shapes[shapeId]); }); config.markers.forEach(m => this.addMarker(m)); + this.setMapMarkerCluster(config.markerCluster); } - public getMainDomElement(): JQuery { + public doGetMainElement(): HTMLElement { return this.$map; } - @executeWhenAttached() + @executeWhenFirstDisplayed() private createLeafletMap(): void { - this.leaflet = L.map(this.$map[0], { + this.leaflet = L.map(this.$map, { zoomControl: false, - attributionControl: false + attributionControl: false, + preferCanvas: true }); - let center:LatLngExpression = [51.505, -0.09]; + let center: LatLngExpression = [51.505, -0.09]; if (this._config.mapPosition != null) { - center = [this._config.mapPosition.latitude, this._config.mapPosition.longitude]; - } + center = [this._config.mapPosition.latitude, this._config.mapPosition.longitude]; + } this.leaflet.setView(center, this._config.zoomLevel); - this.setMapType(this._config.mapType); + if (this._config.mapConfig != null) { + this.setMapConfig(this._config.mapConfig); + } else { + this.setMapType(this._config.mapType); + } this.leaflet.on('click', (event) => { - this.onMapClicked.fire(EventFactory.createUiMap_MapClickedEvent(this.getId(), createUiMapLocationConfig((event as any).latlng.lat, (event as any).latlng.lng))); + this.onMapClicked.fire({ + location: createUiMapLocationConfig((event as any).latlng.lat, (event as any).latlng.lng) + }); }); this.leaflet.on('zoomend', (event) => { - this.onZoomLevelChanged.fire(EventFactory.createUiMap_ZoomLevelChangedEvent(this.getId(), this.leaflet.getZoom())); + this.onZoomLevelChanged.fire({ + zoomLevel: this.leaflet.getZoom() + }); }); this.leaflet.on('moveend', (event) => { const location = this.leaflet.getCenter(); const bounds = this.leaflet.getBounds(); - this.onLocationChanged.fire(EventFactory.createUiMap_LocationChangedEvent(this.getId(), createUiMapLocationConfig(location.lat, location.lng), createUiMapAreaConfig(bounds.getNorth(), bounds.getSouth(), bounds.getWest(), bounds.getEast()))); + this.onLocationChanged.fire({ + center: createUiMapLocationConfig(location.lat, location.lng), + displayedArea: createUiMapAreaConfig(bounds.getNorth(), bounds.getSouth(), bounds.getWest(), bounds.getEast()) + }); + }); + + this.drawCircleFeature = new L.Draw.Circle(this.leaflet as L.DrawMap, {}); + this.drawPolygonFeature = new L.Draw.Polygon(this.leaflet as L.DrawMap, {}); + this.drawPolylineFeature = new L.Draw.Polyline(this.leaflet as L.DrawMap, {}); + this.drawRectangleFeature = new L.Draw.Rectangle(this.leaflet as L.DrawMap, {}); + + //let layerGroup = L.layerGroup().addTo(this.leaflet); + + this.leaflet.on(L.Draw.Event.CREATED, (e: L.DrawEvents.Created) => { + var type = e.layerType, + layer = e.layer; + let shapeId = generateUUID(); + this.shapesById[shapeId] = layer; + if (type === 'circle') { + let circle = layer as Circle; + this.onShapeDrawn.fire({ + shapeId: shapeId, + shape: createUiMapCircleConfig({center: toUiLocation(circle.getLatLng()), radius: circle.getRadius()}) + }); + } else if (type === 'polygon') { + let polygon = layer as Polygon; + let path = flattenArray(polygon.getLatLngs()).map(ll => toUiLocation(ll)); + this.onShapeDrawn.fire({shapeId: shapeId, shape: createUiMapPolygonConfig({path})}); + } else if (type === 'polyline') { + let polyline = layer as Polyline; + let path = flattenArray(polyline.getLatLngs()).map(ll => toUiLocation(ll)); + this.onShapeDrawn.fire({shapeId: shapeId, shape: createUiMapPolylineConfig({path})}); + } else if (type === 'rectangle') { + let rectangle = layer as Rectangle; + this.onShapeDrawn.fire({ + shapeId: shapeId, + shape: createUiMapRectangleConfig({ + l1: toUiLocation(rectangle.getBounds().getNorthWest()), + l2: toUiLocation(rectangle.getBounds().getSouthEast()) + }) + }); + } + this.leaflet.addLayer(layer); }); } - @executeWhenAttached() - public addPolyline(lineId: string, polylineConfig: UiMapPolylineConfig): void { - let polyPath = new Array(polylineConfig.path.length); - for (let i = 0; i < polylineConfig.path.length; i++) { - let loc = polylineConfig.path[i]; - polyPath[i] = this.convertToLatLng(loc); - } - this.polyLinesById[lineId] = L.polyline( - polyPath, { - color: polylineConfig.shapeProperties.strokeColor, - fill: false, - dashArray: polylineConfig.shapeProperties.strokeDashArray, - weight: polylineConfig.shapeProperties.strokeWeight + @executeWhenFirstDisplayed() + public addShape(shapeId: string, shapeConfig: AbstractUiMapShapeConfig): void { + if (isUiMapCircle(shapeConfig)) { + this.shapesById[shapeId] = L.circle( + this.createLeafletLatLng(shapeConfig.center), shapeConfig.radius, createPathOptions(shapeConfig.shapeProperties) + ).addTo(this.leaflet); + } else if (isUiMapPolygon(shapeConfig)) { + let polyPath = new Array(shapeConfig.path.length); + for (let i = 0; i < shapeConfig.path.length; i++) { + let loc = shapeConfig.path[i]; + polyPath[i] = this.convertToLatLng(loc); } - ).addTo(this.leaflet); + this.shapesById[shapeId] = L.polygon(polyPath, createPathOptions(shapeConfig.shapeProperties)).addTo(this.leaflet); + } else if (isUiMapPolyline(shapeConfig)) { + let polyPath = new Array(shapeConfig.path.length); + for (let i = 0; i < shapeConfig.path.length; i++) { + let loc = shapeConfig.path[i]; + polyPath[i] = this.convertToLatLng(loc); + } + this.shapesById[shapeId] = L.polyline(polyPath, createPathOptions(shapeConfig.shapeProperties)).addTo(this.leaflet); + } else if (isUiMapRectangle(shapeConfig)) { + this.shapesById[shapeId] = L.rectangle( + new LatLngBounds(this.createLeafletLatLng(shapeConfig.l1), this.createLeafletLatLng(shapeConfig.l2)), + createPathOptions(shapeConfig.shapeProperties) + ).addTo(this.leaflet); + } return; } - private convertToLatLng(loc: UiMapLocationConfig): LatLngExpression { - return [loc.latitude, loc.longitude]; + @executeWhenFirstDisplayed() + removeShape(shapeId: string): void { + let shape = this.shapesById[shapeId]; + if (shape == null) { + this.logger.warn(`There is no shape with id ${shapeId}`); + return; + } + this.leaflet.removeLayer(shape); } - @executeWhenAttached() - public addPolylinePoints(lineId: string, points: UiMapLocationConfig[]): void { - let polyline = this.polyLinesById[lineId]; - if (polyline == null) { - this.logger.warn(`There is no polyline with id ${lineId}`); + @executeWhenFirstDisplayed() + updateShape(shapeId: string, shapeConfig: AbstractUiMapShapeConfig): void { + let shape = this.shapesById[shapeId]; + if (shape == null) { + this.logger.warn(`There is no shape with id ${shapeId}`); return; } - points.forEach(p => polyline.addLatLng(this.convertToLatLng(p))); + this.removeShape(shapeId); + this.addShape(shapeId, shapeConfig); // todo might get optimized... } - @executeWhenAttached() - public removePolyline(lineId: string): void { - let polyline = this.polyLinesById[lineId]; + @executeWhenFirstDisplayed() + public addPolylinePoints(lineId: string, points: UiMapLocationConfig[]): void { + let polyline = this.shapesById[lineId] as Polyline; if (polyline == null) { this.logger.warn(`There is no polyline with id ${lineId}`); return; } - this.leaflet.removeLayer(polyline); + points.forEach(p => polyline.addLatLng(this.convertToLatLng(p))); } - @executeWhenAttached() + private convertToLatLng(loc: UiMapLocationConfig): LatLngExpression { + return [loc.latitude, loc.longitude]; + } + + @executeWhenFirstDisplayed() public addMarker(markerConfig: UiMapMarkerClientRecordConfig): void { this.createMarker(markerConfig).addTo(this.leaflet); } - @executeWhenAttached() + @executeWhenFirstDisplayed() removeMarker(id: number): void { const marker = this.markersByClientId[id]; if (marker != null) { @@ -157,12 +270,26 @@ export class UiMap extends UiComponent implements UiMapCommandHandl delete this.markersByClientId[id]; } + @executeWhenFirstDisplayed() + clearMarkers() { + Object.keys(this.markersByClientId).forEach(clientId => { + this.removeMarker(Number(clientId)); + }); + } + + @executeWhenFirstDisplayed() + clearShapes() { + Object.keys(this.shapesById).forEach(clientId => { + this.removeShape(clientId); + }); + } + private createMarker(markerConfig: UiMapMarkerClientRecordConfig) { - let renderer = this.markerTemplateRenderers[markerConfig.templateId] || this._context.templateRegistry.getTemplateRendererByName(markerConfig.templateId); + let renderer = this.markerTemplateRenderers[markerConfig.templateId] || this._context.templateRegistry.getTemplateRendererByName(markerConfig.templateId); let iconWidth: number = 0; if (isGridTemplate(renderer.template)) { - let iconElement = renderer.template.elements.filter(e => isUiGlyphIconElement(e) || isUiImageElement(e) || isUiIconElement(e))[0]; - if (!iconElement || !markerConfig.values[iconElement.dataKey]) { + let iconElement = renderer.template.elements.filter(e => isUiGlyphIconElement(e) || isUiImageElement(e) || isUiIconElement(e))[0]; + if (!iconElement || !markerConfig.values[iconElement.property]) { iconWidth = 0; } else if (iconElement) { if (isUiGlyphIconElement(iconElement)) { @@ -178,6 +305,7 @@ export class UiMap extends UiComponent implements UiMapCommandHandl let divIcon = L.divIcon({ html: renderer.render(markerConfig.values), className: "custom-div-icon", + iconAnchor: [markerConfig.offsetPixelsX, markerConfig.offsetPixelsY], popupAnchor: [(iconWidth / 2) - 6, -5] } as L.DivIconOptions); let marker = L.marker(new L.LatLng(markerConfig.location.latitude, markerConfig.location.longitude), { @@ -185,20 +313,35 @@ export class UiMap extends UiComponent implements UiMapCommandHandl icon: divIcon }); marker.bindPopup(markerConfig.asString); - marker.on("click", event1 => this.onMarkerClicked.fire(EventFactory.createUiMap_MarkerClickedEvent(this.getId(), markerConfig.id))); + marker.on("click", event1 => this.onMarkerClicked.fire({ + markerId: markerConfig.id + })); this.markersByClientId[markerConfig.id] = marker; return marker; }; - @executeWhenAttached() + @executeWhenFirstDisplayed() public setZoomLevel(zoomLevel: number): void { this.leaflet.setZoom(zoomLevel); } + public setMapConfig(mapConfig: UiMapConfigConfig): void { + const token = this._config.accessToken; + let removeLayer = true; + let layer = L.tileLayer(mapConfig.urlTemplate, { + minZoom: mapConfig.minZoom, + maxZoom: mapConfig.maxZoom, + attribution: mapConfig.attribution, + }); + if (removeLayer && this.tileLayer) { + this.leaflet.removeLayer(this.tileLayer); + } + this.tileLayer = layer; + this.leaflet.addLayer(this.tileLayer); + } + public setMapType(mapType: UiMapType): void { - const osmAttr = '© OpenStreetMap contributors, CC-BY-SA'; - const mqTilesAttr = 'Tiles © MapQuest '; - const mapBoxAccessToken = 'pk.eyJ1Ijoiam9obi1zbWl0aCIsImEiOiJjaWp3eWhjZDYwbm96djJtNW0zanRrazE2In0.Hi3S9kQ3RNJUuG-PFmMmYw'; + const token = this._config.accessToken; let layer; let removeLayer = true; switch (mapType) { @@ -207,54 +350,71 @@ export class UiMap extends UiComponent implements UiMapCommandHandl maxZoom: 9, }); break; + case UiMapType.INTERNAL_DARK: + layer = L.tileLayer('http://localhost/styles/dark-matter/{z}/{x}/{y}.png', { + maxZoom: 20, + }); + break; + case UiMapType.INTERNAL_DARK_HIGH_RES: + layer = L.tileLayer('http://localhost/styles/dark-matter/{z}/{x}/{y}@2x.png', { + maxZoom: 20, + }); + break; case UiMapType.MAP_BOX_STREETS: layer = L.tileLayer('https://api.tiles.mapbox.com/v4/{id}/{z}/{x}/{y}.png?access_token={accessToken}', { maxZoom: 18, id: 'mapbox.streets', - accessToken: mapBoxAccessToken - }); + accessToken: token + } as any); break; case UiMapType.MAP_BOX_STREETS_BASIC: layer = L.tileLayer('https://api.tiles.mapbox.com/v4/{id}/{z}/{x}/{y}.png?access_token={accessToken}', { maxZoom: 18, id: 'mapbox.streets-basic', - accessToken: mapBoxAccessToken - }); + accessToken: token + } as any); break; case UiMapType.MAP_BOX_STREETS_SATELLITE: layer = L.tileLayer('https://api.tiles.mapbox.com/v4/{id}/{z}/{x}/{y}.png?access_token={accessToken}', { maxZoom: 18, id: 'mapbox.streets-satellite', - accessToken: mapBoxAccessToken - }); + accessToken: token + } as any); break; case UiMapType.MAP_BOX_SATELLITE: layer = L.tileLayer('https://api.tiles.mapbox.com/v4/{id}/{z}/{x}/{y}.png?access_token={accessToken}', { maxZoom: 18, id: 'mapbox.satellite', - accessToken: mapBoxAccessToken - }); + accessToken: token + } as any); break; case UiMapType.MAP_BOX_RUN_BIKE_HIKE: layer = L.tileLayer('https://api.tiles.mapbox.com/v4/{id}/{z}/{x}/{y}.png?access_token={accessToken}', { maxZoom: 18, id: 'mapbox.run-bike-hike', - accessToken: mapBoxAccessToken - }); + accessToken: token + } as any); + break; + case UiMapType.MAP_BOX_DARK: + layer = L.tileLayer('https://api.tiles.mapbox.com/v4/{id}/{z}/{x}/{y}.png?access_token={accessToken}', { + maxZoom: 18, + id: 'mapbox.dark', + accessToken: token + } as any); break; case UiMapType.MAP_BOX_OUTDOORS: layer = L.tileLayer('https://api.tiles.mapbox.com/v4/{id}/{z}/{x}/{y}.png?access_token={accessToken}', { maxZoom: 18, id: 'mapbox.outdoors', - accessToken: mapBoxAccessToken - }); + accessToken: token + } as any); break; case UiMapType.MAP_BOX_EMERALD: layer = L.tileLayer('https://api.tiles.mapbox.com/v4/{id}/{z}/{x}/{y}.png?access_token={accessToken}', { maxZoom: 18, id: 'mapbox.emerald', - accessToken: mapBoxAccessToken - }); + accessToken: token + } as any); break; case UiMapType.MAP_QUEST_OSM: layer = L.tileLayer('http://otile{s}.mqcdn.com/tiles/1.0.0/osm/{z}/{x}/{y}.png', { @@ -276,7 +436,7 @@ export class UiMap extends UiComponent implements UiMapCommandHandl format: 'jpg', time: '', tilematrixset: 'GoogleMapsCompatible_Level' - }); + } as any); break; case UiMapType.OSM_TOPO_MAP: layer = L.tileLayer('http://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', { @@ -292,6 +452,31 @@ export class UiMap extends UiComponent implements UiMapCommandHandl }); removeLayer = false; break; + + case UiMapType.THUNDERFOREST_DARK: + layer = L.tileLayer('https://{s}.tile.thunderforest.com/{id}/{z}/{x}/{y}.png?apikey={accessToken}', { + //attribution: '© Thunderforest, © OpenStreetMap contributors', + maxZoom: 22, + id: 'transport-dark', + accessToken: token + } as any); + break; + case UiMapType.THUNDERFOREST_TRANSPORT: + layer = L.tileLayer('https://{s}.tile.thunderforest.com/{id}/{z}/{x}/{y}.png?apikey={accessToken}', { + //attribution: '© Thunderforest, © OpenStreetMap contributors', + maxZoom: 22, + id: 'transport', + accessToken: token + } as any); + break; + + case UiMapType.WIKIMEDIA: + layer = L.tileLayer('https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}{r}.png', { + //attribution: 'Wikimedia', + minZoom: 1, + maxZoom: 19, + } as any); + break; } if (removeLayer && this.tileLayer) { this.leaflet.removeLayer(this.tileLayer); @@ -300,16 +485,18 @@ export class UiMap extends UiComponent implements UiMapCommandHandl this.leaflet.addLayer(this.tileLayer); } - @executeWhenAttached() + @executeWhenFirstDisplayed() public setLocation(location: UiMapLocationConfig): void { - this.leaflet.setView(new L.LatLng(location.latitude, location.longitude), this.leaflet.getZoom()); + this.leaflet.setView(this.createLeafletLatLng(location), this.leaflet.getZoom()); } - @executeWhenAttached() + private createLeafletLatLng(location: UiMapLocationConfig) { + return new L.LatLng(location.latitude, location.longitude); + } + + @executeWhenFirstDisplayed() public setMapMarkerCluster(clusterConfig: UiMapMarkerClusterConfig): void { - if (this.clusterLayer) { - this.leaflet.removeLayer(this.clusterLayer); - } + this.clearMarkerCluster(); if (clusterConfig) { this.clusterLayer = (L as any).markerClusterGroup(); for (let i = 0; i < clusterConfig.markers.length; i++) { @@ -320,11 +507,16 @@ export class UiMap extends UiComponent implements UiMapCommandHandl } } - @executeWhenAttached() - public setHeatMap(data: UiHeatMapDataConfig): void { - if (this.heatMapLayer) { - this.leaflet.removeLayer(this.heatMapLayer); + @executeWhenFirstDisplayed() + public clearMarkerCluster() { + if (this.clusterLayer) { + this.leaflet.removeLayer(this.clusterLayer); } + } + + @executeWhenFirstDisplayed() + public setHeatMap(data: UiHeatMapDataConfig): void { + this.clearHeatMap(); if (data) { let elements = new Array(data.elements.length); for (let i = 0; i < data.elements.length; i++) { @@ -340,20 +532,77 @@ export class UiMap extends UiComponent implements UiMapCommandHandl } } + @executeWhenFirstDisplayed() + public clearHeatMap() { + if (this.heatMapLayer) { + this.leaflet.removeLayer(this.heatMapLayer); + } + } + registerTemplate(id: string, template: UiTemplateConfig): void { this.markerTemplateRenderers[id] = this._context.templateRegistry.createTemplateRenderer(template); } + fitBounds(southWest: UiMapLocationConfig, northEast: UiMapLocationConfig): void { + this.leaflet.fitBounds(L.latLngBounds(this.createLeafletLatLng(southWest), this.createLeafletLatLng(northEast))) + } + public onResize(): void { this.leaflet.invalidateSize(); } - public destroy(): void { - // nothing to do + startDrawingShape(shapeType: UiMapShapeType, shapeProperties: UiShapePropertiesConfig): void { + let drawFeature: L.Draw.Feature; + switch (shapeType) { + case UiMapShapeType.CIRCLE : + drawFeature = this.drawCircleFeature; + break; + case UiMapShapeType.POLYGON : + drawFeature = this.drawPolygonFeature; + break; + case UiMapShapeType.POLYLINE : + drawFeature = this.drawPolylineFeature; + break; + case UiMapShapeType.RECTANGLE : + drawFeature = this.drawRectangleFeature; + break; + } + drawFeature.setOptions({shapeOptions: createPathOptions(shapeProperties)}) + drawFeature.enable(); } - protected onAttachedToDom(): void { + stopDrawingShape(): void { + this.drawCircleFeature.disable(); + this.drawPolygonFeature.disable(); + this.drawPolylineFeature.disable(); + this.drawRectangleFeature.disable(); } } +function createPathOptions(shapePropertiesConfig: UiShapePropertiesConfig): PathOptions { + return { + color: shapePropertiesConfig.strokeColor, + fill: shapePropertiesConfig.fillColor != null, + fillColor: shapePropertiesConfig.fillColor, + dashArray: shapePropertiesConfig.strokeDashArray, + weight: shapePropertiesConfig.strokeWeight + }; +} + +function toUiLocation(latlng: L.LatLng) { + return createUiMapLocationConfig(latlng.lat, latlng.lng); +} + +function flattenArray(arr: T[] | T[][] | T[][][]): T[] { + return (arr as any[]).reduce((acc, cur) => { + if (Array.isArray(cur)) { + acc.push.apply(acc, flattenArray(cur)); + } else { + acc.push(cur); + } + + return acc; + }, []); +} + TeamAppsUiComponentRegistry.registerComponentClass("UiMap", UiMap); diff --git a/teamapps-client/ts/modules/UiMap2.ts b/teamapps-client/ts/modules/UiMap2.ts new file mode 100644 index 000000000..b2b73b2a7 --- /dev/null +++ b/teamapps-client/ts/modules/UiMap2.ts @@ -0,0 +1,974 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ + +import TurfCircle from "@turf/circle"; +import * as d3voronoi from "d3-voronoi"; +import {Feature, Point, Position} from "geojson"; +import maplibregl, {GeoJSONSource, LngLatLike, Marker} from "maplibre-gl"; +import { + HexColor, + TerraDraw, + TerraDrawCircleMode, + TerraDrawLineStringMode, + TerraDrawPolygonMode, + TerraDrawRectangleMode +} from "terra-draw"; +import {TerraDrawMapLibreGLAdapter} from "terra-draw-maplibre-gl-adapter"; +import {AbstractUiMapShapeChangeConfig} from "../generated/AbstractUiMapShapeChangeConfig"; +import {AbstractUiMapShapeConfig} from "../generated/AbstractUiMapShapeConfig"; +import {UiHeatMapDataConfig} from "../generated/UiHeatMapDataConfig"; +import {UiHeatMapDataElementConfig} from "../generated/UiHeatMapDataElementConfig"; +import { + UiMap2_LocationChangedEvent, + UiMap2_MapClickedEvent, + UiMap2_MarkerClickedEvent, + UiMap2_ShapeDrawnEvent, + UiMap2_ZoomLevelChangedEvent, + UiMap2CommandHandler, + UiMap2Config, + UiMap2EventSource +} from "../generated/UiMap2Config"; +import {createUiMapAreaConfig} from "../generated/UiMapAreaConfig"; +import {createUiMapCircleConfig} from "../generated/UiMapCircleConfig"; +import {createUiMapLocationConfig, UiMapLocationConfig} from "../generated/UiMapLocationConfig"; +import {UiMapMarkerClientRecordConfig} from "../generated/UiMapMarkerClientRecordConfig"; +import {UiMapMarkerClusterConfig} from "../generated/UiMapMarkerClusterConfig"; +import {UiMapMarkersConfig} from "../generated/UiMapMarkersConfig"; +import {createUiMapPolygonConfig} from "../generated/UiMapPolygonConfig"; +import {createUiMapPolylineConfig, UiMapPolylineConfig} from "../generated/UiMapPolylineConfig"; +import {createUiMapRectangleConfig} from "../generated/UiMapRectangleConfig"; +import {UiMapShapeType} from "../generated/UiMapShapeType"; +import {UiPolylineAppendConfig} from "../generated/UiPolylineAppendConfig"; +import {UiShapePropertiesConfig} from "../generated/UiShapePropertiesConfig"; +import {UiTemplateConfig} from "../generated/UiTemplateConfig"; +import {AbstractUiComponent} from "./AbstractUiComponent"; +import {parseHtml, Renderer} from "./Common"; +import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; +import {TeamAppsUiContext} from "./TeamAppsUiContext"; +import {isUiMapCircle, isUiMapPolygon, isUiMapPolyline, isUiMapRectangle} from "./UiMap"; +import {DeferredExecutor} from "./util/DeferredExecutor"; +import {TeamAppsEvent} from "./util/TeamAppsEvent"; + +export class UiMap2 extends AbstractUiComponent implements UiMap2EventSource, UiMap2CommandHandler { + + public readonly onZoomLevelChanged: TeamAppsEvent = new TeamAppsEvent({throttlingMode: "throttle", delay: 500}); + public readonly onLocationChanged: TeamAppsEvent = new TeamAppsEvent({throttlingMode: "throttle", delay: 500}); + public readonly onMapClicked: TeamAppsEvent = new TeamAppsEvent(); + public readonly onMarkerClicked: TeamAppsEvent = new TeamAppsEvent(); + public readonly onShapeDrawn: TeamAppsEvent = new TeamAppsEvent(); + + private readonly $map: HTMLElement; + private readonly map: maplibregl.Map; + private readonly draw: TerraDraw; + private drawingProperties: UiShapePropertiesConfig; + private markerTemplateRenderers: { [templateName: string]: Renderer } = {}; + private markersByClientId: { [id: string]: Marker } = {}; + private clusterMarkerCache: { [id: string]: Marker } = {}; + private clusterSourceById: Map = new Map(); + private shapesSourceById: Map = new Map(); + private displayVoronoiCellsEnabled: boolean = false; + + private deferredExecutor: DeferredExecutor = new DeferredExecutor(); + + private readonly fillOpacity = 0.75; + + constructor(config: UiMap2Config, context: TeamAppsUiContext) { + super(config, context); + this.$map = parseHtml('
'); + + //this.addDebugView(); + + this.map = new maplibregl.Map({ + container: this.$map, + style: config.styleUrl, + center: this.convertToLngLatLike(config.mapPosition), + hash: false, // don't change the URL! + zoom: config.zoomLevel, // starting zoom + attributionControl: false, + }); + + if (config.displayAttributionControl) { + this.map.addControl(new maplibregl.AttributionControl()); + } + if (config.displayNavigationControl) { + this.map.addControl(new maplibregl.NavigationControl()); + } + this.map.on('load', () => { + this.onResize(); + this.deferredExecutor.ready = true; + }); + this.map.on('error', (errorEvent) => { + console.log("Error in map!", errorEvent) + this.deferredExecutor.ready = true; + }); + + this.map.on("zoom", ev => { + this.onZoomLevelChanged.fire({zoomLevel: this.map.getZoom()}); + }); + this.map.on("move", ev => { + const center = this.map.getCenter(); + const bounds = this.map.getBounds(); + this.onLocationChanged.fire({ + center: createUiMapLocationConfig(center.lat, center.lng), + displayedArea: createUiMapAreaConfig(bounds.getNorth(), bounds.getSouth(), bounds.getWest(), bounds.getEast()) + }); + this.updateVoronoiCells(); + }); + this.map.on("click", ev => { + this.onMapClicked.fire({location: createUiMapLocationConfig(ev.lngLat.lat, ev.lngLat.lng)}); + }); + + Object.keys(config.markerTemplates).forEach(templateName => this.registerTemplate(templateName, config.markerTemplates[templateName])); + + Object.keys(config.shapes).forEach(shapeId => { + this.addShape(shapeId, config.shapes[shapeId]); + }); + + if (config.markerCluster != null) { + this.setMapMarkerCluster(config.markerCluster); + // initiate markers that are not part of the cluster + const markerIds = config.markerCluster.markers.map(m => m.id); + config.markers.filter(m => !markerIds.includes(m.id)).forEach(marker => this.addMarker(marker)); + } else { + config.markers.forEach(marker => this.addMarker(marker)); + } + + this.setDisplayVoronoiCells(config.displayVoronoiCells ?? false); + + if (config.heatMap != null) { + this.setHeatMap(config.heatMap); + } + const styles = { + fillOpacity: this.fillOpacity, + }; + this.draw = new TerraDraw({ + adapter: new TerraDrawMapLibreGLAdapter({map: this.map}), + modes: [ + new TerraDrawCircleMode({styles}), + new TerraDrawRectangleMode({styles}), + new TerraDrawPolygonMode({styles}), + new TerraDrawLineStringMode(), + ], + }); + + this.draw.on('finish', (shapeId: string, ctx: { action: string, mode: string }) => { + if (ctx.action === 'draw') { + let shape: AbstractUiMapShapeConfig; + const drawFeature = this.draw.getSnapshotFeature(shapeId); + if (drawFeature) { + const positions = this.flattenPositionArray(drawFeature.geometry.coordinates); + switch (ctx.mode) { + case 'circle': + //const src = this.map.getSource('td-polygon') as GeoJSONSource; + shape = createUiMapCircleConfig({ + center: this.calcCenterUiLocation(positions), + radius: Number(drawFeature.properties?.radiusKilometers) * 1000 + }); + break; + case 'rectangle': + //const src = this.map.getSource('td-polygon') as GeoJSONSource; + shape = createUiMapRectangleConfig({ + l1: this.convertToUiLocation(positions[0]), + l2: this.convertToUiLocation(positions[2]), + }); + break; + case 'polygon': + //const src = this.map.getSource('td-polygon') as GeoJSONSource; + shape = createUiMapPolygonConfig({path: positions.map(this.convertToUiLocation)}); + break; + case 'linestring': + //const src = this.map.getSource('td-linestring') as GeoJSONSource; + shape = createUiMapPolylineConfig({path: positions.map(this.convertToUiLocation)}); + break; + default: + throw new Error(`Unknown draw mode: ${ctx.mode}`); + } + } + shape.shapeProperties = this.drawingProperties; + this.onShapeDrawn.fire({shapeId, shape}); + } + }); + } + + public setStyleUrl(styleUrl: string): void { + this.map.setStyle(styleUrl); + } + + private createShapeFeatureByConfig(shapeId: string, shapeConfig: AbstractUiMapShapeConfig) { + if (isUiMapCircle(shapeConfig)) { + return TurfCircle(this.createPointFeature(shapeConfig.center, shapeId), shapeConfig.radius, { + units: 'meters', + steps: 90 + }); + } else if (isUiMapPolyline(shapeConfig)) { + return this.createLineStringFeature(shapeConfig, shapeId); + } else if (isUiMapPolygon(shapeConfig)) { + const path = shapeConfig.path.map(loc => this.convertToPosition(loc)); + return this.createPolygonFeature(path, shapeId, shapeConfig); + } else if (isUiMapRectangle(shapeConfig)) { + const path = [ + this.convertToPosition(shapeConfig.l1), + [shapeConfig.l1.longitude, shapeConfig.l2.latitude], + this.convertToPosition(shapeConfig.l2), + [shapeConfig.l2.longitude, shapeConfig.l1.latitude], + this.convertToPosition(shapeConfig.l1) // geojson spec demands that polygons be closed! + ]; + return this.createPolygonFeature(path, shapeId, shapeConfig); + } + } + + public async addShape(shapeId: string, shapeConfig: AbstractUiMapShapeConfig) { + return this.deferredExecutor.invokeWhenReady(() => { + if (this.map.getSource('shapes') == null) { + this.map.addSource('shapes', { + type: 'geojson', + data: this.createFeatureCollection([]), + }); + } + const shapeSrc = this.map.getSource('shapes') as GeoJSONSource; + this.shapesSourceById.set(shapeId, this.createShapeFeatureByConfig(shapeId, shapeConfig)); + shapeSrc.setData(this.createFeatureCollection(Array.from(this.shapesSourceById.values()))); + const paintProperties = {} as any; + if (shapeConfig.shapeProperties.strokeColor != null) { + paintProperties['line-color'] = shapeConfig.shapeProperties.strokeColor; + } + if (shapeConfig.shapeProperties.strokeWeight != null && shapeConfig.shapeProperties.strokeWeight !== 0) { + paintProperties['line-width'] = shapeConfig.shapeProperties.strokeWeight; + } + if (shapeConfig.shapeProperties.strokeDashArray != null) { + paintProperties["line-dasharray"] = shapeConfig.shapeProperties.strokeDashArray; + } + const beforeLayer = this.map.getLayer('markers_cluster') ? 'markers_cluster' : null; + if (isUiMapCircle(shapeConfig) || isUiMapPolygon(shapeConfig) || isUiMapRectangle(shapeConfig)) { + if (shapeConfig.shapeProperties.fillColor != null) { + this.map.addLayer({ + id: shapeId, + source: 'shapes', + filter: ['==', 'id', shapeId], + type: 'fill', + paint: { + 'fill-color': shapeConfig.shapeProperties.fillColor, + 'fill-opacity': this.fillOpacity + }, + }, beforeLayer); + } + } + this.map.addLayer({ + id: shapeId + '-outline', + type: 'line', + source: 'shapes', + filter: ['==', 'id', shapeId], + layout: { + 'line-join': 'round', + 'line-cap': 'round' + }, + paint: paintProperties + }, beforeLayer); + }); + } + + public async updateShape(shapeId: string, shape: AbstractUiMapShapeConfig) { + return this.deferredExecutor.invokeWhenReady(async () => { + await this.removeShape(shapeId); + await this.addShape(shapeId, shape); + }); + } + + public async changeShape(shapeId: string, change: AbstractUiMapShapeChangeConfig) { + return this.deferredExecutor.invokeWhenReady(() => { + if (isPolyLineAppend(change)) { + const source = this.map.getSource('shapes') as GeoJSONSource; + const feature = this.shapesSourceById.get(shapeId); + if (feature == null || source == null) { + return; + } + const newPositions = change.appendedPath.map(this.convertToPosition); + (feature.geometry as GeoJSON.LineString).coordinates = (feature.geometry as GeoJSON.LineString).coordinates.concat(newPositions); + this.shapesSourceById.set(shapeId, feature); + source.setData(this.createFeatureCollection(Array.from(this.shapesSourceById.values()))); + } + }); + } + + public async removeShape(shapeId: string) { + return this.deferredExecutor.invokeWhenReady(() => { + if (this.map.getLayer(shapeId)) { + this.map.removeLayer(shapeId); + } + if (this.map.getLayer(shapeId + '-outline')) { + this.map.removeLayer(shapeId + '-outline'); + } + const shapeSrc = this.map.getSource('shapes') as GeoJSONSource; + this.shapesSourceById.delete(shapeId); + shapeSrc.setData(this.createFeatureCollection(Array.from(this.shapesSourceById.values()))); + this.removeShapeFromTerraDraw(shapeId); + }); + } + + private removeShapeFromTerraDraw(shapeId: string) { + if (this.draw.hasFeature(shapeId)) { + this.draw.removeFeatures([shapeId]); + } + } + + public async clearShapes() { + const shapeIds = Array.from(this.shapesSourceById.keys()); + await Promise.all(shapeIds.map(id => this.removeShape(id))); + this.draw.clear(); + } + + private initializeMarkerCluster(sourceName: string, clusterRadius?: number): void { + this.map.addSource(sourceName, { + type: 'geojson', + data: this.createFeatureCollection([]), + cluster: true, + clusterRadius: clusterRadius ?? 100 + }); + this.map.addLayer({ + id: sourceName + '_cluster', + type: 'circle', + source: sourceName, + filter: ['has', 'point_count'], + paint: { + 'circle-color': [ + 'step', + ['get', 'point_count'], + 'rgb(181, 226, 140)', + 10, + 'rgb(241, 211, 87)', + 100, + 'rgb(253, 156, 115)', + ], + 'circle-stroke-width': 1, + 'circle-stroke-color': '#fff', + 'circle-stroke-opacity': 0.2, + 'circle-radius': 21, + 'circle-opacity': 0.6, + } + }); + this.map.addLayer({ + id: sourceName + '_cluster_inner', + type: 'circle', + source: sourceName, + filter: ['has', 'point_count'], + paint: { + 'circle-color': [ + 'step', + ['get', 'point_count'], + 'rgb(110, 204, 57)', + 10, + 'rgb(240, 194, 12)', + 100, + 'rgb(241, 128, 23)', + ], + 'circle-radius': 14, + 'circle-opacity': 0.6, + } + }); + this.map.addLayer({ + id: sourceName + '_cluster_count', + type: 'symbol', + source: sourceName, + filter: ['has', 'point_count'], + layout: { + 'text-field': '{point_count_abbreviated}', + 'text-size': 12, + }, + paint: { + 'text-halo-color': 'rgba(255,255,255,0.4)', + 'text-halo-width': 1, + 'text-halo-blur': 2, + } + }); + this.map.on('click', sourceName + '_cluster', async (e) => { + const features = this.map.queryRenderedFeatures(e.point, { + layers: [sourceName + '_cluster'] + }); + const coordinates = (features[0].geometry as Point).coordinates; + const props = features[0].properties; + const clusterId = props.cluster_id; + const zoom = await (this.map.getSource(sourceName) as GeoJSONSource).getClusterExpansionZoom(clusterId); + this.map.once('moveend', () => setTimeout(updateMarkers, 100)); // make sure this happens after everything else, so we can actually update the map + this.map.easeTo({ + center: coordinates as LngLatLike, + zoom + }); + }); + this.map.addSource(sourceName + '_spider_legs', { + type: 'geojson', + data: this.createFeatureCollection([]), + }); + this.map.addLayer({ + id: sourceName + '_spider_legs', + type: 'line', + source: sourceName + '_spider_legs', + paint: { + 'line-color': '#fff', + 'line-width': 2, + 'line-opacity': 0.8, + } + }); + + this.clearClusterMarkerCache(); + let markersOnScreen: { [id: string]: Marker } = {}; + + const updateMarkers = async () => { + const newMarkersOnScreen: { [id: string]: Marker } = {}; + const srcFeatures = this.map.querySourceFeatures(sourceName); + const coordinatesMap = srcFeatures.reduce((map, f) => { + const key = (f.geometry as Point).coordinates.join('x'); + if (!map[key]) { + map[key] = {}; + } + f.properties.id = f.properties.cluster_id != null ? "cluster-" + f.properties.cluster_id : f.properties.id; + map[key][String(f.properties.id)] = f; + return map; + }, {} as { [key: string]: { [id: string]: Feature } }); + const spiderLegFeatures: Feature[] = []; + Object.values(coordinatesMap).forEach(features => { + const firstFeature = Object.values(features)[0]; + if (Object.keys(features).length > 1) { + const center = (firstFeature.geometry as Point).coordinates as Position; + const markers = spiderfyOverlappingMarkers(features); + Object.entries(markers).forEach(([id, m]) => { + const marker = this.clusterMarkerCache[id] ?? m; + this.clusterMarkerCache[id] = newMarkersOnScreen[id] = marker; + if (!markersOnScreen[id]) { + marker.addTo(this.map); + } + spiderLegFeatures.push(this.createLineStringFeature({path: [ + {longitude: marker.getLngLat().lng, latitude: marker.getLngLat().lat}, + {longitude: center[0], latitude: center[1]} + ]})); + }); + } else if (!firstFeature.properties.cluster && firstFeature.properties.id != null) { + const id = firstFeature.properties.id; + const marker = this.clusterMarkerCache[id] ?? this.createMarker(JSON.parse(firstFeature.properties.marker)); + this.clusterMarkerCache[id] = newMarkersOnScreen[id] = marker; + if (!markersOnScreen[id]) { + marker.addTo(this.map); + } + } + }); + for (const id in markersOnScreen) { + if (!newMarkersOnScreen[id]) { + markersOnScreen[id].remove(); + } + } + markersOnScreen = newMarkersOnScreen; + (this.map.getSource(sourceName + '_spider_legs') as GeoJSONSource).setData(this.createFeatureCollection(spiderLegFeatures)); + }; + + const spiderfyOverlappingMarkers = (features: { [id: string]: Feature }) => { + const leaves = Object.values(features); + const centerPx = (leaves[0].geometry as Point).coordinates as Position; + const isSpiral = leaves.length > 9; + const angleStep = (2 * Math.PI) / Math.min(Math.max(3, leaves.length), 10.7); + let legLength = isSpiral ? 0.0001 : 0.0002; + const legLengthFactor = 0.000005; + + return Object.entries(features).reduce((markers, feature, i) => { + const [id, leaf] = feature; + if (leaf.properties.cluster || leaf.properties.marker == null) { + return markers; + } + const angle = (i + 1) * angleStep; + const offsetPx = [ + legLength * Math.sin(angle), + legLength * Math.cos(angle) / Math.PI * 2, + ]; + const lngLat = [ + centerPx[0] + offsetPx[0], + centerPx[1] + offsetPx[1], + ] as LngLatLike; + if (isSpiral) { + legLength += ((Math.PI * 2) * legLengthFactor) / angle; + } + + markers[id] = this.createMarker(JSON.parse(leaf.properties.marker), lngLat); + return markers; + }, {} as { [id: string]: Marker }); + }; + + this.map.on('sourcedata', (e) => { + if (e.sourceId === sourceName && e.isSourceLoaded) { + updateMarkers(); + } + }); + + this.map.on('moveend', updateMarkers); + } + + public setMapMarkerCluster(config: UiMapMarkerClusterConfig): void { + this.deferredExecutor.invokeWhenReady(() => { + if (this.map.getSource('markers') == null) { + this.initializeMarkerCluster('markers', config.clusterRadius); + } else { + (this.map.getSource('markers') as GeoJSONSource).setClusterOptions({ + cluster: true, + clusterRadius: config.clusterRadius ?? 100 + }); + } + this.clearClusterMarkerCache(); + this.clusterSourceById = config.markers.reduce((map, m) => map.set(String(m.id), this.createMarkerFeature(m)), new Map()); + const markerFeatures = Array.from(this.clusterSourceById.values()); + (this.map.getSource('markers') as GeoJSONSource).setData(this.createFeatureCollection(markerFeatures)); + }); + } + + public async addMarkerToCluster(marker: UiMapMarkerClientRecordConfig) { + return this.deferredExecutor.invokeWhenReady(async () => { + if (this.map.getSource('markers') == null) { + this.initializeMarkerCluster('markers'); + } + const source = this.map.getSource('markers') as GeoJSONSource; + this.clusterSourceById.set(String(marker.id), this.createMarkerFeature(marker)); + source.setData(this.createFeatureCollection(Array.from(this.clusterSourceById.values()))); + }); + } + + private removeMarkerFromCluster(id?: string) { + const source = this.map.getSource('markers') as GeoJSONSource; + if (source != null) { + if (id == null) { + this.clusterSourceById.clear(); + this.clearClusterMarkerCache(); + source.setData(this.createFeatureCollection([])); + } else if (this.clusterSourceById.delete(id)) { + this.clearClusterMarkerCache(id); + source.setData(this.createFeatureCollection(Array.from(this.clusterSourceById.values()))); + } + } + } + + private clearClusterMarkerCache(id?: string) { + if (id != null) { + this.clusterMarkerCache[id].remove(); + delete this.clusterMarkerCache[id]; + } else { + Object.values(this.clusterMarkerCache).forEach(m => m.remove()); + this.clusterMarkerCache = {}; + } + } + + public async addMarkers(markers: UiMapMarkersConfig) { + return Promise.all(markers.markers.map(m => this.addMarker(m))); + } + + public async addMarker(markerConfig: UiMapMarkerClientRecordConfig) { + return this.deferredExecutor.invokeWhenReady(() => { + const marker = this.createMarker(markerConfig); + this.addMarkerToMap(String(markerConfig.id), marker); + }); + } + + private addMarkerToMap(id: string, marker: Marker): void { + if (this.markersByClientId[id]) { + this.markersByClientId[id].remove(); // just in case... + } + this.markersByClientId[id] = marker; + marker.addTo(this.map); + } + + public async removeMarker(id: number | string) { + return this.deferredExecutor.invokeWhenReady(() => { + id = String(id); + this.markersByClientId[id]?.remove(); + delete this.markersByClientId[id]; + this.removeMarkerFromCluster(id); + }); + } + + public async clearMarkers() { + return Promise.all(Object.keys(this.markersByClientId).map(id => this.removeMarker(id))); + } + + private createMarker(markerConfig: UiMapMarkerClientRecordConfig, lngLat?: LngLatLike) { + const renderer = this.markerTemplateRenderers[markerConfig.templateId] || this._context.templateRegistry.getTemplateRendererByName(markerConfig.templateId); + const marker = new Marker({ + element: parseHtml(renderer.render(markerConfig.values)), + anchor: markerConfig.anchor, + offset: [markerConfig.offsetPixelsX, markerConfig.offsetPixelsY] + }); + marker.setLngLat(lngLat ?? [markerConfig.location.longitude, markerConfig.location.latitude]); + marker.getElement().addEventListener('click', e => { + this.onMarkerClicked.fire({markerId: markerConfig.id}); + e.stopPropagation(); + }); + return marker; + } + + private async updateVoronoiCells() { + return this.deferredExecutor.invokeWhenReady(() => { + if (!this.displayVoronoiCellsEnabled) { + return; + } + if (this.map.getSource('voronoi') == null) { + this.map.addSource('voronoi', { + type: 'geojson', + data: this.createFeatureCollection([]), + }); + this.map.addLayer({ + id: 'voronoi', + type: 'line', + source: 'voronoi', + paint: { + 'line-color': '#fff', + 'line-width': 2, + 'line-opacity': 0.8, + } + }); + } + const source = this.map.getSource('voronoi') as GeoJSONSource; + const bounds = this.map.getBounds(); + const points = Object.values(this.markersByClientId).map(m => this.createPointFeature(this.convertToUiLocation(m.getLngLat().toArray()))); + const polygons = Object.values(d3voronoi + .voronoi>() + .x((feature) => feature.geometry.coordinates[0]) + .y((feature) => feature.geometry.coordinates[1]) + .extent([ + [bounds.getWest(), bounds.getSouth()], + [bounds.getEast(), bounds.getNorth()], + ]) + .polygons(points)); + source.setData(this.createFeatureCollection(polygons.map((coords) => this.createPolygonFeature(coords)))); + }); + } + + public setDisplayVoronoiCells(enable: boolean) { + this.displayVoronoiCellsEnabled = enable; + if (enable) { + this.updateVoronoiCells(); + } else { + const source = this.map.getSource('voronoi') as GeoJSONSource; + if (source != null) { + source.setData(this.createFeatureCollection([])); + } + } + } + + private initializeHeatMap(sourceName: string, data: UiHeatMapDataConfig): void { + this.map.addSource(sourceName, { + type: 'geojson', + data: this.createFeatureCollection([]), + }); + const paint: any = {}; + if (data.blur == null || data.blur < 1 || data.blur > 23) { + data.blur = 23; + } + const maxzoom = data.blur; + paint['heatmap-opacity'] = [ + 'interpolate', + ['linear'], + ['zoom'], + data.blur - 1, + this.fillOpacity, + data.blur, // fade out at zoom level defined by parameter "blur" + 0 + ]; + if (data.radius == null) { + data.radius = 30; + } + paint['heatmap-radius'] = [ + 'interpolate', + ['linear'], + ['zoom'], + 0, + data.radius / 5, + 10, + data.radius + ]; + if (data.maxCount == null || data.maxCount < 1) { + data.maxCount = 1; + } + paint['heatmap-intensity'] = [ + 'interpolate', + ['linear'], + ['zoom'], + 0, + 0.01 / data.maxCount, + data.blur, + 1 / data.maxCount + ]; + paint['heatmap-weight'] = ['get', 'count']; + this.map.addLayer({ + id: sourceName, + type: 'heatmap', + source: sourceName, + maxzoom, + paint, + }, this.map.getLayer('markers_cluster') ? 'markers_cluster' : null); + } + + public setHeatMap(data: UiHeatMapDataConfig): void { + this.deferredExecutor.invokeWhenReady(() => { + if (this.map.getSource('heatmap') == null) { + this.initializeHeatMap('heatmap', data); + } + const elements = data.elements.map(el => this.createHeatMapDataElementFeature(el)); + (this.map.getSource('heatmap') as GeoJSONSource).setData(this.createFeatureCollection(elements)); + }); + } + + private createDrawShapeStyles(drawMode: string, shapeProperties: UiShapePropertiesConfig): Record { + let styles: any = { + fillColor: shapeProperties.fillColor as HexColor, + outlineColor: shapeProperties.strokeColor as HexColor, + outlineWidth: shapeProperties.strokeWeight, + }; + if (drawMode === 'linestring') { + styles = { + lineStringColor: shapeProperties.strokeColor as HexColor, + lineStringWidth: shapeProperties.strokeWeight, + }; + } + if (shapeProperties.strokeColor == null) { + delete styles.outlineColor; + delete styles.lineStringColor; + } + if (shapeProperties.strokeWeight == null || shapeProperties.strokeWeight === 0) { + delete styles.outlineWidth; + delete styles.lineStringWidth; + } + if (shapeProperties.fillColor == null) { + delete styles.fillColor; + } + return styles; + } + + private mapShapeTypeToTerraDrawMode(shapeType: UiMapShapeType): string { + switch (shapeType) { + case UiMapShapeType.CIRCLE: + return 'circle'; + case UiMapShapeType.RECTANGLE: + return 'rectangle'; + case UiMapShapeType.POLYGON: + return 'polygon'; + case UiMapShapeType.POLYLINE: + return 'linestring'; + default: + return 'static'; // just to be sure + } + } + + public startDrawingShape(shapeType: UiMapShapeType, shapeProperties: UiShapePropertiesConfig): void { + this.deferredExecutor.invokeWhenReady(() => { + if (this.draw.getMode() !== 'static') { + this.stopDrawingShape(); + } + const mode = this.mapShapeTypeToTerraDrawMode(shapeType); + const styles = this.createDrawShapeStyles(mode, shapeProperties); + this.drawingProperties = shapeProperties; + this.draw.start(); + this.draw.setMode(mode); + this.draw.updateModeOptions(mode, {styles}); + }); + } + + public stopDrawingShape(): void { + this.deferredExecutor.invokeWhenReady(() => { + this.draw.setMode('static'); + //this.draw.stop(); + this.drawingProperties = null; + }); + } + + public setZoomLevel(zoom: number): void { + this.map.zoomTo(zoom); + } + + public setLocation(location: UiMapLocationConfig, animationDurationMillis: number, targetZoomLevel: number): void { + this.deferredExecutor.invokeWhenReady(() => { + this.map.easeTo({ + center: [location.longitude, location.latitude], + duration: animationDurationMillis, + zoom: targetZoomLevel + }); + }); + } + + public fitBounds(southWest: UiMapLocationConfig, northEast: UiMapLocationConfig): void { + this.deferredExecutor.invokeWhenReady(() => { + this.map.fitBounds([ + [southWest.longitude, southWest.latitude], + [northEast.longitude, northEast.latitude], + ]); + }); + } + + public registerTemplate(id: string, template: UiTemplateConfig): void { + this.markerTemplateRenderers[id] = this._context.templateRegistry.createTemplateRenderer(template); + } + + private createFeatureCollection(features: Feature[]): GeoJSON.FeatureCollection { + return { + type: "FeatureCollection", + features + }; + } + + private createPolygonFeature(path: Position[], id?: string, config?: AbstractUiMapShapeConfig): Feature { + if (String(path[0]) !== String(path[path.length - 1])) { + path.push(path[0]); // geojson spec demands that polygons be closed! + } + return { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [ + path + ] + }, + id, + properties: {id, config} + }; + } + + private createPointFeature(config: UiMapLocationConfig, id?: string): Feature { + return { + type: "Feature", + id, + properties: {id, config}, + geometry: { + type: "Point", + coordinates: [ + config.longitude, + config.latitude + ] + } + }; + } + + private createMarkerFeature(m: UiMapMarkerClientRecordConfig): Feature { + return { + type: "Feature", + properties: { + id: m.id, + marker: m + }, + geometry: { + type: "Point", + coordinates: [ + m.location.longitude, + m.location.latitude + ] + } + }; + } + + private createHeatMapDataElementFeature(c: UiHeatMapDataElementConfig): Feature { + return { + type: "Feature", + properties: { + count: c.count + }, + geometry: { + type: "Point", + coordinates: [ + c.longitude, + c.latitude + ] + } + }; + } + + private createLineStringFeature(config: UiMapPolylineConfig, id?: string): Feature { + return { + type: "Feature", + id, + properties: {id, config}, + geometry: { + type: "LineString", + coordinates: config.path.map(loc => this.convertToPosition(loc)) + } + }; + } + + private convertToPosition(loc: UiMapLocationConfig): Position { + return [loc.longitude, loc.latitude]; + } + + private convertToLngLatLike(loc: UiMapLocationConfig): LngLatLike { + return this.convertToPosition(loc) as LngLatLike; + } + + private convertToUiLocation(pos: Position): UiMapLocationConfig { + return createUiMapLocationConfig(pos[1], pos[0]); + } + private flattenPositionArray(arr: Position | Position[] | Position[][] | Position[][][]): Position[] { + if (!Array.isArray(arr) || arr.length === 0) { + return []; + } + if (arr.length === 2 && (typeof arr[0] === 'number' || typeof arr[1] === 'number')) { + return [arr] as Position[]; + } + return (arr as any[]).reduce((acc, cur) => acc.concat(this.flattenPositionArray(cur)), []); + } + + private calcCenterUiLocation(positions: Position[]): UiMapLocationConfig { + const lngs = positions.map(m => m[0]); + const lats = positions.map(m => m[1]); + return createUiMapLocationConfig( + (Math.min(...lats) + Math.max(...lats)) / 2, + (Math.min(...lngs) + Math.max(...lngs)) / 2, + ); + } + + public doGetMainElement(): HTMLElement { + return this.$map; + } + + public onResize() { + this.map.resize(); + } + + private addDebugView() { + this.deferredExecutor.invokeWhenReady(() => { + this.$map.appendChild(parseHtml('
'));
+			this.map.on('mousemove', (e) => {
+				const features = this.map.queryRenderedFeatures(e.point);
+				const displayProperties = [
+					'type',
+					'properties',
+					'id',
+					'layer',
+					'source',
+					'sourceLayer',
+					'state'
+				];
+				const displayFeatures = features.map((feat) => {
+					const displayFeat = {};
+					displayProperties.forEach((prop) => {
+						// @ts-ignore
+						displayFeat[prop] = feat[prop];
+					});
+					return displayFeat;
+				});
+				this.$map.querySelector('pre').innerHTML = JSON.stringify(displayFeatures, null, 2);
+			});
+		});
+	}
+}
+
+function isPolyLineAppend(change: AbstractUiMapShapeChangeConfig): change is UiPolylineAppendConfig {
+	return change._type === "UiPolylineAppend";
+}
+
+TeamAppsUiComponentRegistry.registerComponentClass("UiMap2", UiMap2);
diff --git a/teamapps-client/ts/modules/UiMediaTrackGraph.ts b/teamapps-client/ts/modules/UiMediaTrackGraph.ts
index 41698a968..4952485ef 100644
--- a/teamapps-client/ts/modules/UiMediaTrackGraph.ts
+++ b/teamapps-client/ts/modules/UiMediaTrackGraph.ts
@@ -2,7 +2,7 @@
  * ========================LICENSE_START=================================
  * TeamApps
  * ---
- * Copyright (C) 2014 - 2019 TeamApps.org
+ * Copyright (C) 2014 - 2026 TeamApps.org
  * ---
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -19,14 +19,14 @@
  */
 ///
 
-import * as $ from "jquery";
+
 import * as d3 from "d3v3";
-import {UiComponent} from "./UiComponent";
+import {AbstractUiComponent} from "./AbstractUiComponent";
 import {TeamAppsEvent} from "./util/TeamAppsEvent";
 import {TeamAppsUiContext} from "./TeamAppsUiContext";
 import {UiMediaTrackGraph_HandleTimeSelectionEvent, UiMediaTrackGraphCommandHandler, UiMediaTrackGraphConfig, UiMediaTrackGraphEventSource} from "../generated/UiMediaTrackGraphConfig";
-import {EventFactory} from "../generated/EventFactory";
 import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry";
+import {parseHtml} from "./Common";
 
 interface DataPoint {
 	date: Date,
@@ -41,13 +41,13 @@ interface Marker {
 	bg: string
 }
 
-export class UiMediaTrackGraph extends UiComponent implements UiMediaTrackGraphCommandHandler, UiMediaTrackGraphEventSource {
+export class UiMediaTrackGraph extends AbstractUiComponent implements UiMediaTrackGraphCommandHandler, UiMediaTrackGraphEventSource {
 
-	public readonly onHandleTimeSelection: TeamAppsEvent = new TeamAppsEvent(this);
+	public readonly onHandleTimeSelection: TeamAppsEvent = new TeamAppsEvent();
 
 	private static MARGINS = {top: 5, right: 5, bottom: 15, left: 5};
 
-	private $graph: JQuery;
+	private $graph: HTMLElement;
 	private brush: any;
 	private brushExtent: any;
 	private x: d3.time.Scale;
@@ -70,7 +70,7 @@ export class UiMediaTrackGraph extends UiComponent impl
 
 	constructor(config: UiMediaTrackGraphConfig, context: TeamAppsUiContext) {
 		super(config, context);
-		this.$graph = $('
'); + this.$graph = parseHtml('
'); this.trackCount = config.trackCount; var data: DataPoint[] = []; @@ -89,11 +89,7 @@ export class UiMediaTrackGraph extends UiComponent impl //this.createAudioPlayer(); } - protected onAttachedToDom() { - this.reLayout(); - } - - public getMainDomElement(): JQuery { + public doGetMainElement(): HTMLElement { return this.$graph; } @@ -262,7 +258,6 @@ export class UiMediaTrackGraph extends UiComponent impl } this.cursor = this.markerGroup.append("line") - .style("stroke", "black") .attr("x1", this.x(new Date(1000 * 0))) .attr("x2", this.x(new Date(1000 * 0))) .attr("y1", y(0)) @@ -358,13 +353,16 @@ export class UiMediaTrackGraph extends UiComponent impl let end: number = 0; if (this.brush.empty( )) { if ((d3.event as d3.BaseEvent).sourceEvent) { - start = end = this.x.invert(((d3.event as d3.BaseEvent).sourceEvent as MouseEvent).pageX - UiMediaTrackGraph.MARGINS.left - this.getMainDomElement().offset().left).getTime(); + start = end = this.x.invert(((d3.event as d3.BaseEvent).sourceEvent as MouseEvent).pageX - UiMediaTrackGraph.MARGINS.left - (this.getMainElement().getBoundingClientRect().left + document.body.scrollLeft)).getTime(); } } else { start = (this.brush.extent()[0]).getTime(); end = (this.brush.extent()[1]).getTime(); } - this.onHandleTimeSelection.fire(EventFactory.createUiMediaTrackGraph_HandleTimeSelectionEvent(this.getId(), start, end)); + this.onHandleTimeSelection.fire({ + start: start, + end: end + }); } public setCursorPosition(time: number) { @@ -376,17 +374,14 @@ export class UiMediaTrackGraph extends UiComponent impl } public onResize(): void { - var width = this.$graph.width(); - var height = this.$graph.height(); + var width = $(this.$graph).width(); + var height = $(this.$graph).height(); if (width > 0 && height > 0 && this.data) { this.createBrush(width, height, this.data, this.markerData, this.trackCount); } } - public destroy(): void { - // nothing to do - } } TeamAppsUiComponentRegistry.registerComponentClass("UiMediaTrackGraph", UiMediaTrackGraph); diff --git a/teamapps-client/ts/modules/UiMobileLayout.ts b/teamapps-client/ts/modules/UiMobileLayout.ts index 85e150bae..6d167055d 100644 --- a/teamapps-client/ts/modules/UiMobileLayout.ts +++ b/teamapps-client/ts/modules/UiMobileLayout.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,167 +17,102 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; -import {UiComponentConfig} from "../generated/UiComponentConfig"; + import {UiToolbar} from "./tool-container/toolbar/UiToolbar"; -import {UiToolbarConfig} from "../generated/UiToolbarConfig"; import {UiNavigationBar} from "./UiNavigationBar"; -import {UiNavigationBarConfig} from "../generated/UiNavigationBarConfig"; -import {UiComponent} from "./UiComponent"; +import {AbstractUiComponent} from "./AbstractUiComponent"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; import {UiMobileLayoutCommandHandler, UiMobileLayoutConfig} from "../generated/UiMobileLayoutConfig"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; -import {UiMobileLayoutAnimation} from "../generated/UiMobileLayoutAnimation"; +import {pageTransition, pageTransitionAnimationPairs, parseHtml} from "./Common"; +import {UiComponent} from "./UiComponent"; +import {UiPageTransition} from "../generated/UiPageTransition"; -class View { - component: UiComponent; - $container: JQuery; -} -export class UiMobileLayout extends UiComponent implements UiMobileLayoutCommandHandler { +export class UiMobileLayout extends AbstractUiComponent implements UiMobileLayoutCommandHandler { - private $mainDiv: JQuery; - private $toolbarContainer: JQuery; - private $contentContainerWrapper: JQuery; - private $navBarContainer: JQuery; + private $mainDiv: HTMLElement; + private $toolbarContainer: HTMLElement; + private $contentContainerWrapper: HTMLElement; + private $navBarContainer: HTMLElement; private toolbar: UiToolbar; private navBar: UiNavigationBar; - private views: { [id: string]: View } = {}; - private currentView: View; + + private content: UiComponent; + private $contentContainer: HTMLElement; constructor(config: UiMobileLayoutConfig, context: TeamAppsUiContext) { super(config, context); - this.$mainDiv = $(`
+ this.$mainDiv = parseHtml(`
`); - this.$toolbarContainer = this.$mainDiv.find('>.toolbar-container'); - this.$contentContainerWrapper = this.$mainDiv.find('>.content-container-wrapper'); - this.$navBarContainer = this.$mainDiv.find('>.navigation-bar-container'); - - this.setToolbar(config.toolbar); - this.setNavigationBar(config.navigationBar); + this.$toolbarContainer = this.$mainDiv.querySelector(':scope >.toolbar-container'); + this.$contentContainerWrapper = this.$mainDiv.querySelector(':scope >.content-container-wrapper'); + this.$navBarContainer = this.$mainDiv.querySelector(':scope >.navigation-bar-container'); - if (config.views) { - config.views.forEach(v => this.addView(v)); - } + this.setToolbar(config.toolbar as UiToolbar); + this.setNavigationBar(config.navigationBar as UiNavigationBar); - if (config.initialViewId) { - this.showView(config.initialViewId, null); + if (config.initialView) { + this.showView(config.initialView as UiComponent, null); } } - public addView(viewComponent: UiComponent) { - var $container = $(`
`); - this.$contentContainerWrapper.append($container); - viewComponent.getMainDomElement().appendTo($container); - this.views[viewComponent.getId()] = { - component: viewComponent, - $container: $container - }; - this.resizeChildren(); - viewComponent.attachedToDom = this.attachedToDom; - } + public showView(view: UiComponent, transition: UiPageTransition = null, animationDuration = 0) { + if (view === this.content) { + return; + } - public removeView(viewId: string) { - let view = this.views[viewId]; - view.$container.detach(); - delete this.views[viewId]; - } + let oldContent = this.content; + let $oldContentContainer = this.$contentContainer; - public showView(viewId: string, animationType: UiMobileLayoutAnimation) { - var newView = this.views[viewId]; + this.content = view; - if (newView == null) { - this.logger.warn("View with id " + viewId + " not registered!"); - return; - } - if (newView === this.currentView) { - return; + this.$contentContainer = parseHtml(`
`); + if (view != null) { + this.$contentContainer.appendChild(view.getMainElement()); } + this.$contentContainerWrapper.appendChild(this.$contentContainer); - if (animationType == UiMobileLayoutAnimation.FORWARD) { - this.doViewTransition(newView, "forward-offsite", "backward-offsite"); - } else if (animationType == UiMobileLayoutAnimation.BACKWARD) { - this.doViewTransition(newView, "backward-offsite", "forward-offsite"); + if (transition != null && animationDuration > 0) { + pageTransition($oldContentContainer, this.$contentContainer, transition, animationDuration, () => { + $oldContentContainer && $oldContentContainer.remove(); + }); } else { - newView.$container.addClass("active"); - this.currentView && this.currentView.$container.removeClass("active"); + $oldContentContainer && $oldContentContainer.remove(); } - - newView.component.attachedToDom = true; - newView.component.reLayout(); - - this.currentView = newView; - } - - private doViewTransition(newView: View, inwardStyle: string, outwardStyle: string) { - newView.$container.addClass(inwardStyle + " active")[0].offsetWidth; - this.$mainDiv.addClass("transitions")[0].offsetWidth; - this.currentView && this.currentView.$container.addClass(outwardStyle); - newView.$container.removeClass(inwardStyle); - - let currentViewLocalVar = this.currentView; - setTimeout(() => { - currentViewLocalVar && currentViewLocalVar.$container.removeClass("active " + outwardStyle); - this.$mainDiv.removeClass("transitions"); - }, 500); - }; - - public onResize(): void { - this.toolbar && this.toolbar.reLayout(); - this.resizeChildren(); - this.navBar && this.navBar.reLayout(true); - } - - private resizeChildren() { - let dimensionCss = this.$contentContainerWrapper.css(["width", "height"]); - Object.keys(this.views).forEach(viewId => { - let view = this.views[viewId]; - view.$container.css(dimensionCss); - view.component.reLayout(); - }); } public setToolbar(toolbar: UiToolbar): void { if (this.toolbar) { - this.$toolbarContainer[0].innerHTML = ''; + this.$toolbarContainer.innerHTML = ''; } this.toolbar = toolbar; - this.$toolbarContainer.toggleClass('hidden', !toolbar); + this.$toolbarContainer.classList.toggle('hidden', !toolbar); if (toolbar) { - this.toolbar.getMainDomElement().appendTo(this.$toolbarContainer); - this.toolbar.attachedToDom = this.attachedToDom; + this.$toolbarContainer.appendChild(this.toolbar.getMainElement()); } } public setNavigationBar(navBar: UiNavigationBar) { if (this.navBar) { - this.$navBarContainer[0].innerHTML = ''; + this.$navBarContainer.innerHTML = ''; } this.navBar = navBar; - this.$navBarContainer.toggleClass('hidden', !navBar); + this.$navBarContainer.classList.toggle('hidden', !navBar); if (navBar) { - this.navBar.getMainDomElement().appendTo(this.$navBarContainer); - this.navBar.attachedToDom = this.attachedToDom; + this.$navBarContainer.appendChild(this.navBar.getMainElement()); } } - public getMainDomElement(): JQuery { + public doGetMainElement(): HTMLElement { return this.$mainDiv; } - protected onAttachedToDom() { - if (this.toolbar) this.toolbar.attachedToDom = true; - if (this.navBar) this.navBar.attachedToDom = true; - if (this.currentView) this.currentView.component.attachedToDom = true; - } - - public destroy(): void { - } } TeamAppsUiComponentRegistry.registerComponentClass("UiMobileLayout", UiMobileLayout); diff --git a/teamapps-client/ts/modules/UiNavigationBar.ts b/teamapps-client/ts/modules/UiNavigationBar.ts index 9b6483c0c..eb244543b 100644 --- a/teamapps-client/ts/modules/UiNavigationBar.ts +++ b/teamapps-client/ts/modules/UiNavigationBar.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,12 +17,12 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; + import {TeamAppsEvent} from "./util/TeamAppsEvent"; import {UiComponentConfig} from "../generated/UiComponentConfig"; import {UiNavigationBarButtonConfig} from "../generated/UiNavigationBarButtonConfig"; -import {UiComponent} from "./UiComponent"; -import {ClickOutsideHandle, doOnceOnClickOutsideElement, Renderer} from "./Common"; +import {AbstractUiComponent} from "./AbstractUiComponent"; +import {ClickOutsideHandle, doOnceOnClickOutsideElement, outerHeightIncludingMargins, parseHtml, Renderer} from "./Common"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; import { UiNavigationBar_ButtonClickedEvent, @@ -31,109 +31,114 @@ import { UiNavigationBarConfig, UiNavigationBarEventSource } from "../generated/UiNavigationBarConfig"; -import {EventFactory} from "../generated/EventFactory"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; -import {createUiColorCssString} from "./util/CssFormatUtil"; -import {UiColorConfig} from "../generated/UiColorConfig"; +import {UiComponent} from "./UiComponent"; +import {UiMultiProgressDisplay} from "./UiDefaultMultiProgressDisplay"; interface Button { data: any; - $button: JQuery; + $button: HTMLElement; } -export class UiNavigationBar extends UiComponent implements UiNavigationBarCommandHandler, UiNavigationBarEventSource { +export class UiNavigationBar extends AbstractUiComponent implements UiNavigationBarCommandHandler, UiNavigationBarEventSource { - public readonly onButtonClicked: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onFanoutClosedDueToClickOutsideFanout: TeamAppsEvent = new TeamAppsEvent(this); + public readonly onButtonClicked: TeamAppsEvent = new TeamAppsEvent(); + public readonly onFanoutClosedDueToClickOutsideFanout: TeamAppsEvent = new TeamAppsEvent(); - private $bar: JQuery; - private $buttonsWrapper: JQuery; + private $bar: HTMLElement; + private $buttonsContainer: HTMLElement; private buttons: { [id: string]: Button } = {}; private buttonTemplateRenderer: Renderer; - private $fanOutContainerWrapper: JQuery; - private $fanOutContainer: JQuery; + private $fanOutContainerWrapper: HTMLElement; + private $fanOutContainer: HTMLElement; private fanOutComponents: UiComponent[] = []; private currentFanOutComponent: UiComponent; private fanoutClickOutsideHandle: ClickOutsideHandle; + private multiProgressDisplay: UiMultiProgressDisplay; + private $multiProgressDisplayContainer: HTMLElement; constructor(config: UiNavigationBarConfig, context: TeamAppsUiContext) { super(config, context); this.buttonTemplateRenderer = context.templateRegistry.createTemplateRenderer(config.buttonTemplate, null); - this.$bar = $(`
+ this.$bar = parseHtml(`
-
+
-
+
+
+
`); - this.$buttonsWrapper = this.$bar.find(">.buttons-wrapper"); - this.$fanOutContainerWrapper = this.$bar.find(">.fan-out-container-wrapper"); - this.$fanOutContainer = this.$fanOutContainerWrapper.find(">.fan-out-container"); + this.$buttonsContainer = this.$bar.querySelector(":scope >.buttons-container"); + this.$multiProgressDisplayContainer = this.$buttonsContainer.querySelector(":scope > .progress-container"); + this.$fanOutContainerWrapper = this.$bar.querySelector(":scope >.fan-out-container-wrapper"); + this.$fanOutContainer = this.$fanOutContainerWrapper.querySelector(":scope >.fan-out-container"); + this.setBackgroundColor(config.backgroundColor); this.setBorderColor(config.borderColor); if (config.fanOutComponents) { - config.fanOutComponents.forEach(c => this.addFanOutComponent(c)); + config.fanOutComponents.forEach(c => this.addFanOutComponent(c as UiComponent)); } - config.buttons.forEach(button => this.addButton(button)); + this.setMultiProgressDisplay(config.multiProgressDisplay as UiMultiProgressDisplay); - this.reLayout(); + config.buttons.forEach(button => this.addButton(button)); } - public setBackgroundColor(color: UiColorConfig) { - this.$buttonsWrapper.css("background-color", createUiColorCssString(color)); + public setBackgroundColor(color: string) { + this.$buttonsContainer.style.backgroundColor = color; } - public setBorderColor(color: UiColorConfig) { - this.$bar.css("border-color", createUiColorCssString(color)); + public setBorderColor(color: string) { + this.$bar.style.borderColor = color; } - public getMainDomElement(): JQuery { + public doGetMainElement(): HTMLElement { return this.$bar; } - protected onAttachedToDom() { - // do nothing. - } - setButtons(buttons: UiNavigationBarButtonConfig[]): void { - this.$buttonsWrapper[0].innerHTML = ''; + this.$buttonsContainer.innerHTML = ''; this.buttons = {}; buttons.forEach(button => this.addButton(button)); } private addButton(button: UiNavigationBarButtonConfig) { - let $button = $(``); - let $innerWrapper = $button.find(".nav-button-inner-wrapper"); - $innerWrapper.append(this.buttonTemplateRenderer.render(button.data)); - $button.click(() => { - this.onButtonClicked.fire(EventFactory.createUiNavigationBar_ButtonClickedEvent(this.getId(), button.id, this.currentFanOutComponent && this.currentFanOutComponent.getId())); + let $button = parseHtml(``); + let $innerWrapper = $button.querySelector(":scope .nav-button-inner-wrapper"); + $innerWrapper.appendChild(parseHtml(this.buttonTemplateRenderer.render(button.data))); + $button.addEventListener("click", () => { + this.onButtonClicked.fire({ + buttonId: button.id, + visibleFanOutComponentId: this.currentFanOutComponent && (this.currentFanOutComponent as AbstractUiComponent).getId() + }); }); this.buttons[button.id] = { data: button.data, $button: $button }; this.setButtonVisible(button.id, button.visible); - this.$buttonsWrapper.append($button); + this.$buttonsContainer.append($button); } public setButtonVisible(buttonId: string, visible: boolean) { let button = this.buttons[buttonId]; if (button) { - button.$button.toggleClass("hidden", !visible); + button.$button.classList.toggle("hidden", !visible); } } public addFanOutComponent(fanOutComponent: UiComponent) { if (this.fanOutComponents.indexOf(fanOutComponent) === -1) { - fanOutComponent.getMainDomElement().addClass("pseudo-hidden").appendTo(this.$fanOutContainer); + fanOutComponent.getMainElement().classList.add("pseudo-hidden"); + this.$fanOutContainer.appendChild(fanOutComponent.getMainElement()); this.fanOutComponents.push(fanOutComponent); } }; public removeFanOutComponent(fanOutComponent: UiComponent) { - fanOutComponent.getMainDomElement().detach(); + fanOutComponent.getMainElement().remove(); this.fanOutComponents = this.fanOutComponents.filter(c => c !== fanOutComponent); }; @@ -141,16 +146,15 @@ export class UiNavigationBar extends UiComponent implemen this.addFanOutComponent(fanOutComponent); const showFanout = () => { this.currentFanOutComponent = fanOutComponent; - this.fanOutComponents.forEach(c => c.getMainDomElement().addClass("pseudo-hidden")); - this.currentFanOutComponent.getMainDomElement().removeClass("pseudo-hidden"); - this.currentFanOutComponent.attachedToDom = true; - this.$fanOutContainerWrapper.addClass("open"); - this.$fanOutContainerWrapper.css("bottom", this.$bar.outerHeight(true) + "px"); - this.$fanOutContainerWrapper.slideDown(200); + this.fanOutComponents.forEach(c => c.getMainElement().classList.add("pseudo-hidden")); + this.currentFanOutComponent.getMainElement().classList.remove("pseudo-hidden"); + this.$fanOutContainerWrapper.classList.add("open"); + this.$fanOutContainerWrapper.style.bottom = outerHeightIncludingMargins(this.$bar) + "px"; + $(this.$fanOutContainerWrapper).slideDown(200); this.onResize(); - this.fanoutClickOutsideHandle = doOnceOnClickOutsideElement(this.getMainDomElement().add(this.$fanOutContainerWrapper), e => { + this.fanoutClickOutsideHandle = doOnceOnClickOutsideElement([this.getMainElement(), this.$fanOutContainerWrapper], e => { this.hideFanOutComponent(); - this.onFanoutClosedDueToClickOutsideFanout.fire(EventFactory.createUiNavigationBar_FanoutClosedDueToClickOutsideFanoutEvent(this.getId())); + this.onFanoutClosedDueToClickOutsideFanout.fire({}); }); }; if (this.currentFanOutComponent) { @@ -162,38 +166,42 @@ export class UiNavigationBar extends UiComponent implemen } public hideFanOutComponent() { - this.$fanOutContainerWrapper.removeClass("open"); - this.$fanOutContainerWrapper.slideUp(200); + this.$fanOutContainerWrapper.classList.remove("open"); + $(this.$fanOutContainerWrapper).slideUp(200); this.currentFanOutComponent = null; this.fanoutClickOutsideHandle.cancel(); } public onResize(): void { - if (this.$fanOutContainerWrapper.is(".open")) { + if (this.$fanOutContainerWrapper.classList.contains("open")) { let $clippingParent = this.findNearestParentWithHiddenVerticalOverflow(this.$fanOutContainerWrapper); - let maxFanOutHeight = this.$bar.offset().top - $clippingParent.offset().top; + + let maxFanOutHeight = this.$bar.offsetTop - $clippingParent.offsetTop; if (maxFanOutHeight <= 0) { this.logger.warn("Fanout height is 0 due to clipping parent component..."); } - this.$fanOutContainerWrapper.css("max-height", maxFanOutHeight + "px")[0].offsetHeight; - this.currentFanOutComponent && this.currentFanOutComponent.reLayout(); + this.$fanOutContainerWrapper.style.maxHeight = maxFanOutHeight + "px"; + this.$fanOutContainerWrapper.offsetHeight; // reflow } } - private findNearestParentWithHiddenVerticalOverflow(child: JQuery): JQuery { - let el = child[0]; + private findNearestParentWithHiddenVerticalOverflow(child: HTMLElement): HTMLElement { + let el = child; while (el != null) { el = el.parentElement; - if ($(el).css("overflow-y") !== "visible") { + if (getComputedStyle(el).overflowY !== "visible") { break; } } - return $(el); + return el; } - public destroy(): void { - // do nothing + public setMultiProgressDisplay(multiProgressDisplay: UiMultiProgressDisplay): void { + this.multiProgressDisplay && this.multiProgressDisplay.getMainElement().remove(); + this.multiProgressDisplay = multiProgressDisplay; + multiProgressDisplay && this.$multiProgressDisplayContainer.appendChild(multiProgressDisplay.getMainElement()); } + } TeamAppsUiComponentRegistry.registerComponentClass("UiNavigationBar", UiNavigationBar); diff --git a/teamapps-client/ts/modules/UiNetworkGraph.ts b/teamapps-client/ts/modules/UiNetworkGraph.ts index aaffb6885..2538fad92 100644 --- a/teamapps-client/ts/modules/UiNetworkGraph.ts +++ b/teamapps-client/ts/modules/UiNetworkGraph.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,132 +19,117 @@ */ /// -import * as $ from "jquery"; -import * as d3 from "d3v3"; -import {UiComponent} from "./UiComponent"; + +import * as d3 from "d3"; +import {ForceLink, Simulation, SimulationLinkDatum, ZoomBehavior} from "d3"; +import {AbstractUiComponent} from "./AbstractUiComponent"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; -import {UiNetworkGraphConfig} from "../generated/UiNetworkGraphConfig"; -import {EventFactory} from "../generated/EventFactory"; +import { + UiNetworkGraph_NodeClickedEvent, + UiNetworkGraph_NodeDoubleClickedEvent, + UiNetworkGraph_NodeExpandedOrCollapsedEvent, + UiNetworkGraphCommandHandler, + UiNetworkGraphConfig, + UiNetworkGraphEventSource +} from "../generated/UiNetworkGraphConfig"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; +import {parseHtml} from "./Common"; +import {UiNetworkNode_ExpandState, UiNetworkNodeConfig} from "../generated/UiNetworkNodeConfig"; +import {executeWhenFirstDisplayed} from "./util/ExecuteWhenFirstDisplayed"; +import {TeamAppsEvent} from "./util/TeamAppsEvent"; +import {patternify} from "./UiTreeGraph"; +import {UiTreeGraphNodeImage_CornerShape} from "../generated/UiTreeGraphNodeImageConfig"; +import {UiNetworkLinkConfig} from "../generated/UiNetworkLinkConfig"; -export class UiNetworkGraph extends UiComponent { +export class UiNetworkGraph extends AbstractUiComponent implements UiNetworkGraphCommandHandler, UiNetworkGraphEventSource { - private $graph: JQuery; - private svg: any; - private svgContainer: any; - private rect: any; - private container: any; - private node: any; - private link: any; - private force: any; - private zoom: any; - private linkedByIndex:any = {}; - private toggle = 0; + public readonly onNodeClicked: TeamAppsEvent = new TeamAppsEvent(); + public readonly onNodeDoubleClicked: TeamAppsEvent = new TeamAppsEvent(); - constructor(config: UiNetworkGraphConfig, context: TeamAppsUiContext) { - super(config, context); - this.$graph = $('
'); + public readonly onNodeExpandedOrCollapsed: TeamAppsEvent = new TeamAppsEvent(); - var width = this.$graph.width(); - var height = this.$graph.height(); + private $graph: HTMLElement; + private svg: d3.Selection; + private pointerEventsRect: d3.Selection; + private container: d3.Selection; + private linksContainer: d3.Selection; + private zoom: ZoomBehavior; - this.logger.debug("width:" + width + ", height:" + height); - if (width < 100) { - width = 300; - } - if (height < 100) { - height = 300; - } + private simulation: Simulation; + private linkForce: ForceLink; - this.createGraph(width, height, config.gravity, config.images, config.nodes, config.links); - } + private nodes: (UiNetworkNodeConfig & any)[]; + private links: (UiNetworkLinkConfig & any)[]; - public getMainDomElement(): JQuery { - return this.$graph; - } + private lastDraggedNode: any; - protected onAttachedToDom(): void { - this.reLayout(); - } - - public createGraph(width: any, height: any, gravity: any, images: any, nodesData: any, linksData: any) { - let me = this; - - let color = d3.scale.category20(); + constructor(config: UiNetworkGraphConfig, context: TeamAppsUiContext) { + super(config, context); + this.$graph = parseHtml('
'); - this.force = d3.layout.force() - .charge((d) => d.charge) - .linkDistance((d) => (d as any).distance) - .gravity(gravity) - .size([width, height]); + this.links = config.links; + this.nodes = config.nodes; + this.createGraph(config.gravity, config.images); + } - this.zoom = d3.behavior.zoom().translate([500, 500]).scale(.1) - .scaleExtent([0.02, 50]) - .on("zoom", function () { - me.container.attr("transform", "translate(" + (d3.event as d3.ZoomEvent).translate + ")scale(" + (d3.event as d3.ZoomEvent).scale + ")"); - }); + public doGetMainElement(): HTMLElement { + return this.$graph; + } - let drag = d3.behavior.drag() - .origin((d) => d) - .on("dragstart", function (d) { - (d3.event as d3.BaseEvent).sourceEvent.stopPropagation(); - d3.select(this).classed("dragging", true); - //test: - //d3.select(this).classed("fixed", d.fixed = true); - d3.select(this).classed("fixed", true); - me.force.start(); + @executeWhenFirstDisplayed() + public createGraph(gravity: any, images: any) { + this.linkForce = d3.forceLink(this.links) + .id(d => (d as UiNetworkNodeConfig).id) + .distance((link: UiNetworkLinkConfig) => { + return link.linkLength; }) - .on("drag", function (d) { - d3.select(this).attr("cx", d.x = (d3.event as DragEvent).x).attr("cy", d.y = (d3.event as DragEvent).y); - }) - // .on("dblclick", function(d) { - // d3.select(this).classed("fixed", d.fixed = false); - // }) - .on("dragend", function (d) { - d3.select(this).classed("dragging", false); - }); + // .distance((link: SimulationLinkDatum) => { + // return (Math.max(link.source.width, link.source.height) + Math.max(link.target.width, link.target.height)) * 0.75 + ; + // }); + let force = d3.forceManyBody(); + force.strength(-30) + this.simulation = d3.forceSimulation() + .nodes(this.nodes) + .force("charge", force) + .force("link", this.linkForce) + .force("center", d3.forceCenter(this.getWidth() / 2, this.getWidth() / 2)) + .force("collide", d3.forceCollide((a: UiNetworkNodeConfig & any) => { + return Math.sqrt(a.width * a.width + a.height * a.height) * a.distanceFactor; + })) + .stop(); + + this.simulation.on("tick", () => { + this.updateLinks(); + this.updateNodes(); + }); + this.calculateFinalNodePositions(); - if (this.svgContainer) { + if (this.svg) { this.logger.debug("remove svg-container"); - this.svgContainer.remove(); + this.svg.remove(); } - //this.svg = d3.select("#" + this.getId()) - // .append("div") - // .classed("svg-container", true) //container class to make it responsive - // .append("svg") - // //responsive SVG needs these 2 attributes and no width and height attr - // .attr("preserveAspectRatio", "xMinYMin meet") - // .attr("viewBox", "0 0 600 400") - // //class to make it responsive - // .classed("svg-content-responsive", true) - // .append("g") - // .call(zoom); - - - //this.svg = d3.select("#" + this.getId()).append("svg") - // .attr("width", width) - // .attr("height", height) - // .append("g") - // .call(zoom); - - this.logger.debug("Add svg:" + "#" + this.getId() + ":" + d3.select("#" + this.getId())); - this.svgContainer = d3.select(this.getMainDomElement()[0]).append("svg") - .attr("width", width) - .attr("height", height); - this.svg = this.svgContainer - .append("g") - .call(this.zoom); - - this.rect = this.svg.append("rect") - .attr("width", width) - .attr("height", height) + this.zoom = d3.zoom() + .extent([[0, 0], [this.getWidth(), this.getHeight()]]) + .scaleExtent([.01, 8]) + .on("zoom", () => { + this.container.attr("transform", d3.event.transform); + }); + + this.svg = d3.select(this.getMainElement()) + .append("svg") + .call(this.zoom) + .on("dblclick.zoom", null); // disable doubleclick zoom!; + + this.pointerEventsRect = this.svg.append("rect") + .attr("width", this.getWidth()) + .attr("height", this.getHeight()) .style("fill", "none") .style("pointer-events", "all"); - this.container = this.svg.append("g").attr("transform", "translate(500,500)scale(.1,.1)"); + this.container = this.svg.append("g"); var defs = this.svg.append("defs").attr("id", "imgdefs"); for (let i = 0; i < images.length; i++) { @@ -164,266 +149,444 @@ export class UiNetworkGraph extends UiComponent { } - this.force.nodes(nodesData).links(linksData).start(); - this.link = this.container.append("g") + this.linksContainer = this.container.append("g") .attr("class", "links") - .selectAll(".link") - .data(linksData) - .enter().append("path") - .attr("class", "link") - .style("stroke-width", (d: any) => { - if (d.width && d.width > 0) { - return d.width; - } else { - return 1.5; - } - }) - .style("stroke", (d: any) => { - if (d.color) { - return d.color; - } else { - return "#555"; - } - }); - - this.node = this.container.append("g") - .attr("class", "nodes") - .selectAll(".node") - .data(nodesData) - .enter().append("g") - .attr("class", "node") - .attr("cx", (d: any) => { - return d.x; - }) - .attr("cy", (d: any) => { - return d.y; - }) - .call(drag); + .attr("stroke", "#999"); - var circle = this.node.append("circle") - //.attr("r", (d)=> { return d.weight + (d.size / 2); }) - .attr("r", (d: any) => { - return (d.size + Math.sqrt(d.weight)) * 1; - }) - .style("fill", (d: any) => { - if (d.imageId) { - return "url('#" + d.imageId + "')"; - } else { - return color((1 / d.rating) as any); - } - }) - .style("stroke-width", (d: any) => { - if (d.border) { - return d.border; - } else { - return 0; - } - }) - .style("stroke", (d: any) => { - if (d.borderColor) { - return d.borderColor; - } else { - return "none"; - } - }); + // set zoom to center before animating... + this.svg.call( + this.zoom.transform, + d3.zoomIdentity.scale(1).translate(this.getWidth()/2, this.getHeight()/2) + ); - //this.svg.append("rect") - // .attr("x", 10) - // .attr("y", 10) - // .attr("width", 50) - // .attr("height", 25) - // .on("click", (d,i) => { - // if (this.force.gravity() < 0.3) { - // this.force.gravity(0.3).resume(); - // } else { - // this.force.gravity(0.05).resume(); - // } - // }); - - this.node.append("text") - .attr("x", (d: any) => { - return (d.size + Math.sqrt(d.weight)) * 1 + 4; - }) - .text((d: any) => { - return d.caption - }); + this.updateLinks(this._config.animationDuration); + this.updateNodes(this._config.animationDuration); + this.zoomAllNodesIntoView(this._config.animationDuration); + } + private calculateFinalNodePositions() { + let iterations = Math.ceil(Math.log(this.simulation.alphaMin()) / Math.log(1 - this.simulation.alphaDecay())); + for (var i = 0, n = iterations; i < n; ++i) { + this.simulation.tick(); + } + } - var tickCount = 0; - var tickRate = 3; - this.force.on("tick", () => { - tickCount++; - if (tickCount > 30) { - if (tickRate > 0) { - tickRate--; - } else { - tickRate = 3; - this.link.attr("d", (d: any) => { - var dx = d.target.x - d.source.x, - dy = d.target.y - d.source.y, - dr = Math.sqrt(dx * dx + dy * dy); - return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + d.target.x + "," + d.target.y; - }); - this.node.attr("transform", (d: any) => { - return "translate(" + d.x + "," + d.y + ")"; - }); - } + public zoomAllNodesIntoView(animationDuration: number) { + let bounds = this.nodes.reduce((globalBounds, node) => { + let leftBound = node.x - node.width / 2; + let rightBound = node.x + node.width / 2; + let topBound = node.y - node.height / 2; + let bottomBound = node.y + 10 + node.height / 2; + if (leftBound < globalBounds.minX) { + globalBounds.minX = leftBound; + } + if (rightBound > globalBounds.maxX) { + globalBounds.maxX = rightBound; + } + if (topBound < globalBounds.minY) { + globalBounds.minY = topBound; + } + if (bottomBound > globalBounds.maxY) { + globalBounds.maxY = bottomBound; + } + return globalBounds; + }, { + minX: 0, + maxX: 0, + minY: 0, + maxY: 0, + get width() { + return this.maxX - this.minX; + }, + get height() { + return this.maxY - this.minY; + }, + get centerX() { + return (this.maxX + this.minX) / 2 + }, + get centerY() { + return (this.maxY + this.minY) / 2 } }); + // console.log(bounds.minX, bounds.maxX, bounds.minY, bounds.maxY, bounds.centerX, bounds.centerY); + const k = Math.min(this.getWidth() / bounds.width, this.getHeight() / bounds.height); + + let translateX: number; + let translateY: number; + if (bounds.width / bounds.height > this.getWidth() / this.getHeight()) { + translateX = -bounds.minX; + translateY = (this.getHeight() / 2) / k - bounds.centerY; + } else { + translateX = (this.getWidth() / 2) / k - bounds.centerX; + translateY = -bounds.minY; + } + // console.log(translateX, translateY); + this.svg.transition().duration(animationDuration).call( + this.zoom.transform, + d3.zoomIdentity.scale(k).translate(translateX, translateY) + ); + } + public onResize(): void { + this.pointerEventsRect.attr('width', this.getWidth()).attr('height', this.getHeight()); + } - this.force.on("end", () => { - this.link.attr("d", (d: any) => { - var dx = d.target.x - d.source.x, - dy = d.target.y - d.source.y, - dr = Math.sqrt(dx * dx + dy * dy); - return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + d.target.x + "," + d.target.y; - }); - }); + public setZoomFactor(zoomFactor: number): void { + this.zoom.scaleTo(this.svg, zoomFactor); + this.logger.debug(zoomFactor); + } + + public setGravity(gravity: number): void { + // this.force.gravity(gravity).start(); + this.logger.debug(gravity); + } - linksData.forEach((d: any) => { - this.linkedByIndex[d.source.index + "," + d.target.index] = 1; + public setDistance(linkDistance: number, nodeDistance: number): void { + this.linkForce.distance((link: SimulationLinkDatum) => { + return (Math.max(link.source.width, link.source.height) + Math.max(link.target.width, link.target.height)) * linkDistance; }); + this.simulation.force("collide", d3.forceCollide((a: UiNetworkNodeConfig & any) => { + return Math.sqrt(a.width * a.width + a.height * a.height) * a.distanceFactor * nodeDistance; + })); - this.node.on("mouseover", function (d: any) { + this.simulation.nodes(this.nodes); + this.linkForce.links(this.links); + this.simulation.alphaTarget(0.3).restart() + .stop(); + this.calculateFinalNodePositions(); + this.updateNodes(this._config.animationDuration); + this.updateLinks(this._config.animationDuration); - me.node.classed("node-active", function (o: any) { - let thisOpacity = me.isConnected(d, o) ? true : false; - this.setAttribute('fill-opacity', thisOpacity); - return thisOpacity; - }); - - me.link.classed("link-active", (o: any) => { - return o.source === d || o.target === d ? true : false; - }); + this.logger.debug("distance:" + linkDistance + ", " + nodeDistance); + } - d3.select(this).classed("node-active", true); - d3.select(this).select("circle").transition() - .duration(500) - .attr("r", (d.size + Math.sqrt(d.weight)) * 1.5); + public setCharge(charge: number, overrideNodeCharge: boolean): void { + // this.force.charge(charge).start(); + this.logger.debug("charge:" + charge); + } - d3.select(this).select("text").transition().duration(500) - .attr("x", (d) => { - return (d.size + Math.sqrt(d.weight)) * 1.5 + 4; - }) - .style("font-size", "20px") - .style("stroke", "red"); + private updateLinks(animationDuration: number = 0) { + this.linksContainer.selectAll("line") + .data(this.links) + .join("line") + .attr("stroke-width", (d: UiNetworkLinkConfig) => d.lineWidth || 2) + .attr('stroke', (d: UiNetworkLinkConfig) => d.lineColor) + .attr('stroke-dasharray', (d: UiNetworkLinkConfig) => d.lineDashArray ? d.lineDashArray : null) + .transition() + .duration(animationDuration) + .attr("x1", (d: any) => d.source.x) + .attr("y1", (d: any) => d.source.y) + .attr("x2", (d: any) => d.target.x) + .attr("y2", (d: any) => d.target.y) + } - d = (d3.select(this).node() as any).__data__; - me.link.transition().duration(250).style("opacity", function (o: any) { - return d.index == o.source.index || d.index == o.target.index ? 1 : 0.3; - }); - me.node.transition().duration(750).style("opacity", function (o: any) { - return me.isConnected(d, o) ? 1 : 0.2; - }); + private updateNodes(animationDuration: number = 0): void { + const nodesSelection: d3.Selection = this.container.selectAll('g.node') + .data(this.nodes, ({ + id + }: UiNetworkNodeConfig) => id); + // Enter any new nodes at the parent's previous position. + const nodeEnter = nodesSelection.enter().append('g') + .attr('class', 'node') + .attr('cursor', 'pointer') + .on('click', (d: UiNetworkNodeConfig) => { + if (d3.event.srcElement.classList.contains('node-button-circle')) { + return; + } + this.onNodeClicked.fire({nodeId: d.id}); + }) + .on('dblclick', (d: UiNetworkNodeConfig) => { + if (d3.event.srcElement.classList.contains('node-button-circle')) { + return; + } + this.onNodeDoubleClicked.fire({nodeId: d.id}); + }) + ; + // Add background rectangle for the nodes + patternify(nodeEnter, { + tag: 'rect', + selector: 'node-rect', + data: (d: UiNetworkNodeConfig) => [d] }); - this.node.on("mouseout", function (d: any) { - me.node.classed("node-active", false); - me.link.classed("link-active", false); - d3.select(this).select("circle").transition() - .duration(750) - .attr("r", (d.size + Math.sqrt(d.weight)) * 1); + nodeEnter.call(d3.drag() + .on("start", (d: any, i: number, nodes: Element[]) => { + // if (!d3.event.active) { + // this.simulation.alphaTarget(0.3).restart(); + // } + d3.select(nodes[i]).raise(); - d3.select(this).select("text").transition().duration(750) - .attr("x", (d) => (d.size + Math.sqrt(d.weight)) * 1 + 4) - .style("font-size", "10px") - .style("stroke", "black"); + if (this.lastDraggedNode) { + this.lastDraggedNode.fx = null; + this.lastDraggedNode.fy = null; + } + d.fx = d.x; + d.fy = d.y; - me.link.transition().duration(1000).style("opacity", 1); - me.node.transition().duration(500).style("opacity", 1); + this.container.attr("cursor", "grabbing"); + }) + .on("drag", (d: any, i: number, nodes: Element[]) => { + d.fx = d.x = d3.event.x; + d.fy = d.y = d3.event.y; + this.updateLinks(); + this.updateNodes(); + }) + .on("end", (d: any) => { + // if (!d3.event.active) { + // this.simulation.alphaTarget(0); + // } + // d.fx = null; + // d.fy = null; + this.lastDraggedNode = d; + this.container.attr("cursor", "grab") + }) + ); + + + // Add node icon image inside node + patternify(nodeEnter, { + tag: 'image', + selector: 'node-icon-image', + data: (d: UiNetworkNodeConfig) => d.icon ? [d] : [] + }) + .attr('width', (data: UiNetworkNodeConfig) => data.icon.size) + .attr('height', (data: UiNetworkNodeConfig) => data.icon.size) + .attr("xlink:href", (data: UiNetworkNodeConfig) => data.icon.icon) + .attr('x', (data: UiNetworkNodeConfig) => -data.width / 2 - data.icon.size / 2) + .attr('y', (data: UiNetworkNodeConfig) => -data.height / 2 - data.icon.size / 2); + + // Defined node images wrapper group + const imageGroups = patternify(nodeEnter, { + tag: 'g', + selector: 'node-image-group', + data: (d: UiNetworkNodeConfig) => [d] + }); + // Add background rectangle for node image + patternify(imageGroups, { + tag: 'rect', + selector: 'node-image-rect', + data: (d: UiNetworkNodeConfig) => [d] }); - this.node.on('click', function () { - let d = (d3.select(this).node() as any).__data__; - var id = d.nodeId; - this._context.fireEvent(EventFactory.createUiNetworkGraph_HandleNodeClickEvent(me.getId(), id)); + // Node update styles + const nodeUpdate = nodeEnter.merge(nodesSelection) + .style('font', '12px sans-serif'); + nodeUpdate + .transition() + .duration(animationDuration) + .attr("transform", (d: any) => `translate(${d.fx != null ? d.fx : d.x},${d.fy != null ? d.fy : d.y})`); - var evt = d3.event; - if (evt != null) { - if ((evt as MouseEvent).shiftKey) { - this.connectedNodes(); - } else if ((evt as MouseEvent).ctrlKey) { - } else { - this.logger.debug(id); - } - } + // Add foreignObject element inside rectangle + const foreignObject = patternify(nodeUpdate, { + tag: 'foreignObject', + selector: 'node-foreign-object', + data: (d: UiNetworkNodeConfig) => [d] }); - } - + let foreignObjectInner = foreignObject.selectAll('.node-foreign-object-div') + .data((d: UiNetworkNodeConfig) => [d], d => (d as UiNetworkNodeConfig).id); + foreignObjectInner + .enter() + .append('xhtml:div') + .classed('node-foreign-object-div', true) + .html((d: UiNetworkNodeConfig) => this._context.templateRegistry.createTemplateRenderer(d.template).render(d.record.values)); + foreignObjectInner.exit().remove(); + + this.restyleForeignObjectElements(); + + // Add Node button circle's group (expand-collapse button) + const nodeButtonGroups = patternify(nodeEnter, { + tag: 'g', + selector: 'node-button-g', + data: (d: UiNetworkNodeConfig) => [d] + }) + .on('mousedown', (d: UiNetworkNodeConfig) => { + if (d.expandState == UiNetworkNode_ExpandState.EXPANDED) { + d.expandState = UiNetworkNode_ExpandState.COLLAPSED; + } else { + d.expandState = UiNetworkNode_ExpandState.EXPANDED; + } - private connectedNodes() { - if (this.toggle == 0) { - let d = (d3.select(this as any).node() as any).__data__; - this.node.style("opacity", function (o: any) { - return this.isConnected(d, o) ? 1 : 0.1; - }); - this.link.style("opacity", function (o: any) { - return d.index == o.source.index || d.index == o.target.index ? 1 : 0.1; + this.onNodeExpandedOrCollapsed.fire({nodeId: d.id, expanded: d.expandState == UiNetworkNode_ExpandState.EXPANDED}); + this.updateNodes(); }); - this.toggle = 1; - } else { - this.node.style("opacity", 1); - this.link.style("opacity", 1); - this.toggle = 0; - } - } - private isConnected(a: any, b: any) { - if (a.index == b.index) return true; - return this.linkedByIndex[a.index + "," + b.index] || this.linkedByIndex[b.index + "," + a.index]; - } + // Add expand collapse button circle + patternify(nodeButtonGroups, { + tag: 'circle', + selector: 'node-button-circle', + data: (d: UiNetworkNodeConfig) => [d] + }); + // Restyle node button circle + nodeUpdate.select('.node-button-circle') + .attr('r', 10) + .attr('stroke-width', (d: UiNetworkNodeConfig) => d.borderWidth) + .attr('fill', (d: UiNetworkNodeConfig) => d.backgroundColor) + .attr('stroke', (d: UiNetworkNodeConfig) => d.borderColor); + + // Add button text + patternify(nodeButtonGroups, { + tag: 'text', + selector: 'node-button-text', + data: (d: UiNetworkNodeConfig) => [d] + }) + .attr('pointer-events', 'none') + // Restyle button texts + nodeUpdate.select('.node-button-text') + .attr('text-anchor', 'middle') + .attr('alignment-baseline', 'middle') + .attr('stroke', 'none') + .attr('fill', 'black') + .attr('font-size', 22) + .text((d: UiNetworkNodeConfig) => d.expandState === UiNetworkNode_ExpandState.EXPANDED ? '–' : '+'); + + // Move node button group to the desired position + nodeUpdate.select('.node-button-g') + .attr('transform', (d: UiNetworkNodeConfig) => `translate(0,${d.height / 2})`) + .attr('display', (data: UiNetworkNodeConfig) => data.expandState === UiNetworkNode_ExpandState.NOT_EXPANDABLE ? 'none' : 'inherit'); + + // Move images to desired positions + nodeUpdate.selectAll('.node-image-group') + .attr('transform', (d: UiNetworkNodeConfig) => { + if (d.image) { + let x = -d.image.width / 2 - d.width / 2; + let y = -d.image.height / 2 - d.height / 2; + return `translate(${x},${y})` + } else { + return null; + } + }); - private dottype(d: any) { - d.x = +d.x; - d.y = +d.y; - return d; + // Style node image rectangles + nodeUpdate.select('.node-image-rect') + .attr('fill', (d: UiNetworkNodeConfig) => `url('#${d.id}')`) + .attr('width', (d: UiNetworkNodeConfig) => d.image && d.image.width) + .attr('height', (d: UiNetworkNodeConfig) => d.image && d.image.height) + .attr('stroke', (d: UiNetworkNodeConfig) => d.image && (d.image.borderColor)) + .attr('stroke-width', (d: UiNetworkNodeConfig) => d.image && d.image.borderWidth) + .attr('rx', (d: UiNetworkNodeConfig) => d.image && (d.image.cornerShape == UiTreeGraphNodeImage_CornerShape.CIRCLE ? Math.max(d.image.width, d.image.height) + : d.image.cornerShape == UiTreeGraphNodeImage_CornerShape.ROUNDED ? Math.min(d.image.width, d.image.height) / 10 + : 0)) + .attr('y', (d: UiNetworkNodeConfig) => d.image && d.image.centerTopDistance) + .attr('x', (d: UiNetworkNodeConfig) => d.image && d.image.centerLeftDistance); + // .attr('filter', (d: UiNetworkNodeConfig) => d.image && d.image.shadowId); + + // Style node rectangles + nodeUpdate.select('.node-rect') + .attr('width', (d: UiNetworkNodeConfig) => d.width) + .attr('height', (d: UiNetworkNodeConfig) => d.height) + .attr('x', (d: UiNetworkNodeConfig) => -d.width / 2) + .attr('y', (d: UiNetworkNodeConfig) => -d.height / 2) + .attr('rx', (d: UiNetworkNodeConfig) => d.borderRadius || 0) + .attr('stroke-width', (d: UiNetworkNodeConfig) => d.borderWidth) + .attr('cursor', 'pointer') + .attr('stroke', ({borderColor}: UiNetworkNodeConfig) => borderColor) + .style("fill", ({backgroundColor}: UiNetworkNodeConfig) => backgroundColor); + + // Remove any exiting nodes after transition + const nodeExitTransition = nodesSelection.exit() + .remove(); } - public onResize(): void { - var width = this.$graph.width(); - var height = this.$graph.height(); - - this.logger.debug("resize w:" + width + ", h:" + height); - this.svgContainer.attr('width', width).attr('height', height); - this.rect.attr('width', width).attr('height', height); - this.container.attr('width', width).attr('height', height); - if (this.force) { - this.force.size([width, height]).resume(); - } + restyleForeignObjectElements() { + this.container.selectAll('.node-foreign-object') + .attr('width', ({ + width + }: UiNetworkNodeConfig) => width) + .attr('height', ({ + height + }: UiNetworkNodeConfig) => height) + .attr('x', ({ + width + }: UiNetworkNodeConfig) => -width / 2) + .attr('y', ({ + height + }: UiNetworkNodeConfig) => -height / 2); + this.container.selectAll('.node-foreign-object-div') + .style('width', ({ + width + }: UiNetworkNodeConfig) => `${width}px`) + .style('height', ({ + height + }: UiNetworkNodeConfig) => `${height}px`) + .select('*') + .style('display', 'inline-grid') } - public setZoomFactor(zoomFactor: number): void { - this.zoom.scale(zoomFactor); - this.logger.debug(zoomFactor); - } + public addNodesAndLinks(newNodes: UiNetworkNodeConfig[], newLinks: UiNetworkLinkConfig[]): void { + this.nodes.push(...newNodes); + this.links.push(...newLinks); - public setGravity(gravity: number): void { - this.force.gravity(gravity).start(); - this.logger.debug(gravity); - } + this.placeNewNodesNearExistingParentsOnes(newLinks, newNodes); - public setDistance(distance: number, overrideNodeCharge: boolean): void { - this.force.distance(distance).start(); - this.logger.debug("distance:" + distance); + this.simulation.nodes(this.nodes); + this.linkForce.links(this.links); + this.simulation.alphaTarget(0.3).restart() + .stop(); + this.calculateFinalNodePositions(); + this.updateNodes(this._config.animationDuration); + this.updateLinks(this._config.animationDuration); } - public setCharge(charge: number, overrideNodeCharge: boolean): void { - this.force.charge(charge).start(); - this.logger.debug("charge:" + charge); + private placeNewNodesNearExistingParentsOnes(newLinks: UiNetworkLinkConfig[], newNodes: UiNetworkNodeConfig[]) { + let changed: boolean; + do { + changed = false; + newLinks = newLinks.filter((l) => { + let newSourceNode = newNodes.filter(n => n.id === l.source)[0]; + let newTargetNode = newNodes.filter(n => n.id === l.target)[0]; + if (newSourceNode != null && newTargetNode == null) { + let existingTargetNode = this.nodes.filter(n => n.id === l.target)[0]; + if (existingTargetNode != null) { + (newSourceNode as any).x = (existingTargetNode as any).x; + (newSourceNode as any).y = (existingTargetNode as any).y; + } + newNodes = newNodes.filter(n => n !== newSourceNode); + changed = true; + return false; + } else if (newSourceNode == null && newTargetNode != null) { + let existingSourceNode = this.nodes.filter(n => n.id === l.source)[0]; + if (existingSourceNode != null) { + (newTargetNode as any).x = (existingSourceNode as any).x; + (newTargetNode as any).y = (existingSourceNode as any).y; + } + newNodes = newNodes.filter(n => n !== newTargetNode); + changed = true; + return false; + } + return true; + }); + } while (changed); } - public destroy(): void { - // nothing to do + public removeNodesAndLinks(nodeIds: string[], linksBySourceNodeId: {[name: string]: string[]}): void { + const nodeIdsSet = new Set(nodeIds); + this.nodes = this.nodes.filter(n => { + return !nodeIdsSet.has(n.id); + }); + this.links = this.links.filter(l => { + let targetIdsToDelete = linksBySourceNodeId[l.source && l.source.id]; + if (targetIdsToDelete == null) { + return true; + } + return !targetIdsToDelete.filter(targetId => (l.target && l.target.id) === targetId)[0] + }); + + this.simulation.nodes(this.nodes); + this.linkForce.links(this.links); + this.simulation.alphaTarget(0.3).restart() + .stop(); + this.calculateFinalNodePositions(); + this.updateNodes(this._config.animationDuration); + this.updateLinks(this._config.animationDuration); } } diff --git a/teamapps-client/ts/modules/UiNotification.ts b/teamapps-client/ts/modules/UiNotification.ts new file mode 100644 index 000000000..1f1718637 --- /dev/null +++ b/teamapps-client/ts/modules/UiNotification.ts @@ -0,0 +1,201 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +import {UiNotification_ClosedEvent, UiNotification_OpenedEvent, UiNotificationCommandHandler, UiNotificationConfig, UiNotificationEventSource} from "../generated/UiNotificationConfig"; +import {UiEntranceAnimation} from "../generated/UiEntranceAnimation"; +import {UiNotificationPosition} from "../generated/UiNotificationPosition"; +import {UiExitAnimation} from "../generated/UiExitAnimation"; +import {AbstractUiComponent} from "./AbstractUiComponent"; +import {TeamAppsUiContext} from "./TeamAppsUiContext"; +import {animateCSS, Constants, parseHtml} from "./Common"; +import {createUiSpacingValueCssString} from "./util/CssFormatUtil"; +import {ProgressBar} from "./micro-components/ProgressBar"; +import {TeamAppsEvent} from "./util/TeamAppsEvent"; +import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; +import {UiComponent} from "./UiComponent"; +import {executeWhenFirstDisplayed} from "./util/ExecuteWhenFirstDisplayed"; + +const containersByPosition: { + [UiNotificationPosition.TOP_LEFT]: HTMLElement, + [UiNotificationPosition.TOP_CENTER]: HTMLElement, + [UiNotificationPosition.TOP_RIGHT]: HTMLElement, + [UiNotificationPosition.BOTTOM_LEFT]: HTMLElement, + [UiNotificationPosition.BOTTOM_CENTER]: HTMLElement, + [UiNotificationPosition.BOTTOM_RIGHT]: HTMLElement +} = { + [UiNotificationPosition.TOP_LEFT]: parseHtml(`
`), + [UiNotificationPosition.TOP_CENTER]: parseHtml(`
`), + [UiNotificationPosition.TOP_RIGHT]: parseHtml(`
`), + [UiNotificationPosition.BOTTOM_LEFT]: parseHtml(`
`), + [UiNotificationPosition.BOTTOM_CENTER]: parseHtml(`
`), + [UiNotificationPosition.BOTTOM_RIGHT]: parseHtml(`
`) +}; + +let notifications: { + notification: UiNotification; + position: UiNotificationPosition; + $wrapper: HTMLElement; +}[] = []; + +function getNotificationsByPosition(position: UiNotificationPosition) { + return notifications.filter(n => n.position == position); +} + +let updateContainerVisibilities = function () { + [UiNotificationPosition.TOP_LEFT, + UiNotificationPosition.TOP_CENTER, + UiNotificationPosition.TOP_RIGHT, + UiNotificationPosition.BOTTOM_LEFT, + UiNotificationPosition.BOTTOM_CENTER, + UiNotificationPosition.BOTTOM_RIGHT].forEach(pos => { + let hasNotifications = getNotificationsByPosition(pos).length > 0; + if (hasNotifications && containersByPosition[pos].parentNode !== document.body) { + document.body.appendChild(containersByPosition[pos]); + } else if (!hasNotifications) { + containersByPosition[pos].remove(); + } + }); +}; + +export function showNotification(notification: UiNotification, position: UiNotificationPosition, entranceAnimation: UiEntranceAnimation, exitAnimation: UiExitAnimation) { + let notif = notifications.filter(n => n.notification == notification)[0]; + + if (notif == null || notif.position != position) { + if (notif == null) { + let $wrapper = parseHtml(`
`); + $wrapper.appendChild(notification.getMainElement()); + notif = {notification, position, $wrapper}; + notifications.push(notif) + } else { + notif.position = position; + } + notif.$wrapper.style.height = null; + notif.$wrapper.style.marginBottom = null; + notif.$wrapper.style.zIndex = null; + containersByPosition[position].appendChild(notif.$wrapper); + + updateContainerVisibilities(); + + animateCSS(notification.getMainElement(), Constants.ENTRANCE_ANIMATION_CSS_CLASSES[entranceAnimation] as any, 700); + + let closeListener = () => { + notification.onClosedAnyWay.removeListener(closeListener); + notif.$wrapper.style.height = `${notif.$wrapper.offsetHeight}px`; + notif.$wrapper.offsetHeight; // make sure the style above is applied so we get a transition! + notif.$wrapper.style.height = "0px"; + notif.$wrapper.style.marginBottom = "0px"; + notif.$wrapper.style.zIndex = "0"; + + animateCSS(notification.getMainElement(), Constants.EXIT_ANIMATION_CSS_CLASSES[exitAnimation] as any, 700, () => { + notif.$wrapper.remove(); + updateContainerVisibilities(); + }); + + notifications = notifications.filter(n => n.notification !== notification); + }; + notification.onClosedAnyWay.addListener(closeListener); + + notification.onOpened.fire({}); + } + + setTimeout(() => notification.startCloseTimeout()); +} + +export class UiNotification extends AbstractUiComponent implements UiNotificationCommandHandler, UiNotificationEventSource { + + public readonly onOpened: TeamAppsEvent = new TeamAppsEvent(); + public readonly onClosed: TeamAppsEvent = new TeamAppsEvent(); + public readonly onClosedAnyWay: TeamAppsEvent = new TeamAppsEvent(); + + private $main: HTMLElement; + private $contentContainer: HTMLElement; + private $progressBarContainer: HTMLElement; + private progressBar: ProgressBar; + + constructor(config: UiNotificationConfig, context: TeamAppsUiContext) { + super(config, context); + + this.$main = parseHtml(`
+
+
+
+
`); + this.$contentContainer = this.$main.querySelector(":scope > .content-container"); + this.$progressBarContainer = this.$main.querySelector(":scope > .progress-container"); + this.$main.querySelector(":scope > .close-button").addEventListener("mousedown", () => { + this.close(); + this.onClosed.fire({byUser: true}); + }); + this.update(config); + } + + public update(config: UiNotificationConfig) { + this._config = config; + this.$main.style.backgroundColor = config.backgroundColor; + // this.$main.style.borderColor = createUiColorCssString(config.borderColor, "#00000022"); + this.$contentContainer.style.padding = createUiSpacingValueCssString(config.padding); + this.$main.classList.toggle("dismissible", config.dismissible); + this.$main.classList.toggle("show-progress", config.progressBarVisible && config.displayTimeInMillis > 0); + + if (config.progressBarVisible && this.progressBar == null) { + this.progressBar = new ProgressBar(0, {height: 5, transitionTime: config.displayTimeInMillis}); + this.$progressBarContainer.appendChild(this.progressBar.getMainDomElement()); + } else if (!config.progressBarVisible && this.progressBar != null) { + this.progressBar.getMainDomElement().remove(); + this.progressBar = null; + } + + if (this.$contentContainer.firstChild !== (config.content && (config.content as UiComponent).getMainElement())) { + this.$contentContainer.innerHTML = ''; + if (config.content != null) { + this.$contentContainer.appendChild((config.content as UiComponent).getMainElement()); + } + } + } + + private closeTimeout: number; + + @executeWhenFirstDisplayed(true) + public startCloseTimeout() { + if (this.progressBar != null) { + this.progressBar.setProgress(1); // mind the css transition! + } + if (this.closeTimeout != null) { + window.clearTimeout(this.closeTimeout); + } + if (this._config.displayTimeInMillis > 0) { + this.closeTimeout = window.setTimeout(() => { + this.close(); + this.onClosed.fire({byUser: false}); + this.onClosedAnyWay.fire(); + }, this._config.displayTimeInMillis); + } + } + + doGetMainElement(): HTMLElement { + return this.$main; + } + + close(): void { + this.onClosedAnyWay.fire(); + } + +} + +TeamAppsUiComponentRegistry.registerComponentClass("UiNotification", UiNotification); diff --git a/teamapps-client/ts/modules/UiNotificationBar.ts b/teamapps-client/ts/modules/UiNotificationBar.ts new file mode 100644 index 000000000..38e1da6a4 --- /dev/null +++ b/teamapps-client/ts/modules/UiNotificationBar.ts @@ -0,0 +1,201 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +import {AbstractUiComponent} from "./AbstractUiComponent"; +import {TeamAppsUiContext} from "./TeamAppsUiContext"; +import {animateCSS, Constants, parseHtml, removeClassesByFunction} from "./Common"; +import {TeamAppsEvent} from "./util/TeamAppsEvent"; +import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; +import { + UiNotificationBar_ItemActionLinkClickedEvent, + UiNotificationBar_ItemClickedEvent, + UiNotificationBar_ItemClosedEvent, + UiNotificationBarCommandHandler, + UiNotificationBarConfig, + UiNotificationBarEventSource +} from "../generated/UiNotificationBarConfig"; +import {UiNotificationBarItemConfig} from "../generated/UiNotificationBarItemConfig"; +import {createUiSpacingValueCssString} from "./util/CssFormatUtil"; +import {ProgressBar} from "./micro-components/ProgressBar"; +import {UiExitAnimation} from "../generated/UiExitAnimation"; +import {UiEntranceAnimation} from "../generated/UiEntranceAnimation"; + +export class UiNotificationBar extends AbstractUiComponent implements UiNotificationBarCommandHandler, UiNotificationBarEventSource { + + public readonly onItemClicked: TeamAppsEvent = new TeamAppsEvent(); + public readonly onItemActionLinkClicked: TeamAppsEvent = new TeamAppsEvent(); + public readonly onItemClosed: TeamAppsEvent = new TeamAppsEvent(); + + private $main: HTMLElement; + private itemsById: { [id: string]: UiNotificationBarItem } = {}; + + constructor(config: UiNotificationBarConfig, context: TeamAppsUiContext) { + super(config, context); + this.$main = parseHtml(`
`); + config.initialItems.forEach(item => this.addItem(item)) + } + + doGetMainElement(): HTMLElement { + return this.$main; + } + + addItem(itemConfig: UiNotificationBarItemConfig): void { + this.removeItem(itemConfig.id, null); + let item = new UiNotificationBarItem(itemConfig); + this.itemsById[itemConfig.id] = item; + this.$main.appendChild(item.getMainElement()); + if (itemConfig.entranceAnimation != null) { + animateCSS(item.getMainElement(), Constants.ENTRANCE_ANIMATION_CSS_CLASSES[itemConfig.entranceAnimation]); + } + item.startCloseTimeout(); + item.onClicked.addListener(() => this.onItemClicked.fire({id: itemConfig.id})); + item.onActionLinkClicked.addListener(() => this.onItemActionLinkClicked.fire({id: itemConfig.id})); + item.onClosed.addListener(wasTimeout => { + this.removeItem(itemConfig.id); + this.onItemClosed.fire({id: itemConfig.id, wasTimeout: wasTimeout}); + }); + } + + updateItem(itemConfig: UiNotificationBarItemConfig): any { + let item = this.itemsById[itemConfig.id]; + if (item != null) { + item.update(itemConfig); + } + } + + removeItem(id: string, exitAnimation?: UiExitAnimation): void { + let item = this.itemsById[id]; + if (item != null) { + if (exitAnimation != null || item.config.exitAnimation != null) { + animateCSS(item.getMainElement(), Constants.EXIT_ANIMATION_CSS_CLASSES[exitAnimation || item.config.exitAnimation], 300, () => { + item.getMainElement().remove(); + }); + } else { + item.getMainElement().remove(); + } + } + delete this.itemsById[id]; + } + +} + +class UiNotificationBarItem { + + public readonly onClicked: TeamAppsEvent = new TeamAppsEvent(); + public readonly onActionLinkClicked: TeamAppsEvent = new TeamAppsEvent(); + public readonly onClosed: TeamAppsEvent = new TeamAppsEvent(); + + public config: UiNotificationBarItemConfig; + + private $main: HTMLElement; + private $progressBarContainer: HTMLElement; + private progressBar: ProgressBar; + private $closeButton: HTMLElement; + private $contentContainer: HTMLElement; + private $icon: HTMLElement; + private $text: HTMLElement; + private $actionLink: HTMLElement; + + constructor(config: UiNotificationBarItemConfig) { + this.$main = parseHtml(`
+
+
+
+ + +
+
+
+
+
`); + this.$closeButton = this.$main.querySelector(":scope .close-button"); + this.$contentContainer = this.$main.querySelector(":scope > .content-container"); + this.$icon = this.$main.querySelector(":scope .icon"); + this.$text = this.$main.querySelector(":scope .text"); + this.$actionLink = this.$main.querySelector(":scope .action-link"); + this.$progressBarContainer = this.$main.querySelector(":scope > .progress-container"); + this.progressBar = new ProgressBar(0, {height: 3, transitionTime: 500}); + this.$progressBarContainer.appendChild(this.progressBar.getMainDomElement()); + + this.$main.addEventListener("click", () => this.onClicked.fire()) + this.$actionLink.addEventListener("click", (e) => this.onActionLinkClicked.fire()) + this.$closeButton.addEventListener("click", ev => this.onClosed.fire(false)); + + this.update(config); + } + + public update(config: UiNotificationBarItemConfig) { + const oldConfig = this.config; + this.config = config; + + this.$closeButton.classList.toggle("hidden", !config.dismissible); + this.$main.style.backgroundColor = config.backgroundColor; + this.$main.style.borderColor = config.borderColor; + this.$contentContainer.style.padding = createUiSpacingValueCssString(config.padding); + + this.$text.textContent = config.text; + this.$text.style.color = config.textColor; + + this.$actionLink.textContent = config.actionLinkText; + this.$actionLink.classList.toggle("hidden", config.actionLinkText == null); + this.$actionLink.style.color = config.actionLinkColor; + + this.$main.classList.toggle("with-progress", config.displayTimeInMillis > 0 && config.progressBarVisible) + + if (oldConfig?.icon !== config.icon) { + console.debug("updating icon", oldConfig?.icon, config.icon); + this.$icon.classList.toggle("hidden", config.icon == null) + this.$icon.style.backgroundImage = `url('${config.icon}')`; + } + + if (oldConfig?.iconAnimation !== config.iconAnimation) { + console.debug("updating icon animation", oldConfig?.iconAnimation, config.iconAnimation ); + removeClassesByFunction(this.$icon.classList, className => className.startsWith("animate__")); + if (config.iconAnimation != null) { + this.$icon.classList.add(...Constants.REPEATABLE_ANIMATION_CSS_CLASSES[config.iconAnimation].split(/ +/)); + } + } + } + + public startCloseTimeout() { + if (this.config.displayTimeInMillis > 0) { + if (this.config.progressBarVisible) { + let startTime = +(new Date()); + let interval = setInterval(() => { + let duration = +(new Date()) - startTime + 500; // make sure the bar reaches the end! + let progress = Math.min(1, duration / this.config.displayTimeInMillis); + this.progressBar.setProgress(progress); + (this.progressBar.getMainDomElement().querySelector(":scope .progress-bar") as HTMLElement).style.backgroundColor = this.config.borderColor; + if (progress >= 1) { + window.clearInterval(interval); + } + }, 100); + } + window.setTimeout(() => { + this.onClosed.fire(true); + }, this.config.displayTimeInMillis); + } + } + + public getMainElement() { + return this.$main; + } +} + +TeamAppsUiComponentRegistry.registerComponentClass("UiNotificationBar", UiNotificationBar); diff --git a/teamapps-client/ts/modules/UiPageView.ts b/teamapps-client/ts/modules/UiPageView.ts index 158a40545..2d8dd3dbc 100644 --- a/teamapps-client/ts/modules/UiPageView.ts +++ b/teamapps-client/ts/modules/UiPageView.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,44 +17,49 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; -import {UiComponent} from "./UiComponent"; + +import {AbstractUiComponent} from "./AbstractUiComponent"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; import {UiPageViewBlock_Alignment, UiPageViewBlockConfig} from "../generated/UiPageViewBlockConfig"; import {UiMessagePageViewBlockConfig} from "../generated/UiMessagePageViewBlockConfig"; -import {parseHtml, removeDangerousTags} from "./Common"; +import {insertAfter, insertBefore, parseHtml, removeClassesByFunction, removeDangerousTags} from "./Common"; import {UiComponentConfig} from "../generated/UiComponentConfig"; import {UiCitationPageViewBlockConfig} from "../generated/UiCitationPageViewBlockConfig"; import {UiComponentPageViewBlockConfig} from "../generated/UiComponentPageViewBlockConfig"; import {UiPageViewConfig} from "../generated/UiPageViewConfig"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; import {UiPageViewBlockCreatorImageAlignment} from "../generated/UiPageViewBlockCreatorImageAlignment"; +import {executeWhenFirstDisplayed} from "./util/ExecuteWhenFirstDisplayed"; +import {UiComponent} from "./UiComponent"; +import {UiHorizontalElementAlignment} from "../generated/UiHorizontalElementAlignment"; +import {UiToolButton} from "./micro-components/UiToolButton"; // require("bootstrap/js/transition"); // require("bootstrap/js/carousel"); +import {fixed_partition} from "image-layout"; interface Row { - $row: JQuery; - $headerContainer: JQuery; - $leftColumn: JQuery; - $rightColumn: JQuery; + $row: HTMLElement; + $headerContainer: HTMLElement; + $leftColumn: HTMLElement; + $rightColumn: HTMLElement; blocks: Block[]; } interface Block { - $blockWrapper: JQuery; - $blockContentContainer: JQuery; - block: BlockComponent; + $blockWrapper: HTMLElement; + $blockContentContainer: HTMLElement; + block: AbstractBlockComponent; } -export class UiPageView extends UiComponent { +export class UiPageView extends AbstractUiComponent { - private $component: JQuery; + private $component: HTMLElement; private rows: Row[] = []; constructor(config: UiPageViewConfig, context: TeamAppsUiContext) { super(config, context); - this.$component = $(`
`); + this.$component = parseHtml(`
`); if (config.blocks) { for (var i = 0; i < config.blocks.length; i++) { @@ -63,10 +68,11 @@ export class UiPageView extends UiComponent { } } - public getMainDomElement(): JQuery { + public doGetMainElement(): HTMLElement { return this.$component; } + @executeWhenFirstDisplayed() public addBlock(blockConfig: UiPageViewBlockConfig, before: boolean, otherBlockId?: string) { let row; if (this.rows.length == 0) { @@ -85,30 +91,32 @@ export class UiPageView extends UiComponent { // TODO } - let $blockWrapper = $(`
+ let $blockWrapper = parseHtml(`
`); - let $blockContentContainer = $blockWrapper.find('.background-color-div'); - let block = new (blockTypes[blockConfig._type as keyof typeof blockTypes] as any)(blockConfig, this._context) as BlockComponent; - block.getMainDomElement().appendTo($blockContentContainer); + let $blockContentContainer = $blockWrapper.querySelector(':scope .background-color-div'); + let block = new (blockTypes[blockConfig._type as keyof typeof blockTypes] as any)(blockConfig, this._context) as AbstractBlockComponent; + $blockContentContainer.appendChild(block.getMainDomElement()); row.blocks.push({$blockWrapper, $blockContentContainer, block}); // TODO prepend vs append vs insert... if (blockConfig.alignment === UiPageViewBlock_Alignment.FULL) { - row.$headerContainer.append($blockWrapper); + row.$headerContainer.appendChild($blockWrapper); } else if (blockConfig.alignment === UiPageViewBlock_Alignment.LEFT) { - row.$leftColumn.append($blockWrapper); + row.$leftColumn.appendChild($blockWrapper); } else if (blockConfig.alignment === UiPageViewBlock_Alignment.RIGHT) { - row.$rightColumn.append($blockWrapper); + row.$rightColumn.appendChild($blockWrapper); } - block.attachedToDom = this.attachedToDom; } private addRow(before: boolean, otherRowIndex?: number): Row { - let $row = $('
'); - let $headerContainer = $('
').appendTo($row); - let $leftColumn = $('
').appendTo($row); - let $rightColumn = $('
').appendTo($row); + let $row = parseHtml('
'); + let $headerContainer = parseHtml('
'); + $row.appendChild($headerContainer); + let $leftColumn = parseHtml('
'); + $row.appendChild($leftColumn); + let $rightColumn = parseHtml('
'); + $row.appendChild($rightColumn); let row: Row = { $row: $row, $leftColumn: $leftColumn, @@ -122,13 +130,13 @@ export class UiPageView extends UiComponent { this.$component.prepend($row); } else if (!before && otherRowIndex == null) { this.rows.push(row); - this.$component.append($row); + this.$component.appendChild($row); } else if (before && otherRowIndex != null) { this.rows.splice(otherRowIndex, 0, row); - $row.insertBefore(this.rows[otherRowIndex].$row); + insertBefore($row, this.rows[otherRowIndex].$row) } else if (!before && otherRowIndex != null) { this.rows.splice(otherRowIndex + 1, 0, row); - $row.insertAfter(this.rows[otherRowIndex].$row); + insertAfter($row, this.rows[otherRowIndex].$row) } return row; @@ -141,17 +149,15 @@ export class UiPageView extends UiComponent { } public destroy(): void { + super.destroy(); this.rows.forEach(row => { row.blocks.forEach(block => block.block.destroy()); }); } - protected onAttachedToDom(): void { - this.rows.forEach(row => row.blocks.forEach(block => block.block.attachedToDom = true)); - } } -abstract class BlockComponent { +abstract class AbstractBlockComponent { constructor(protected config: C, protected context: TeamAppsUiContext) { } @@ -159,9 +165,7 @@ abstract class BlockComponent { return this.config.alignment; } - abstract getMainDomElement(): JQuery; - - abstract set attachedToDom(attached: boolean); + abstract getMainDomElement(): HTMLElement; public reLayout() { // default implementation @@ -172,131 +176,131 @@ abstract class BlockComponent { } } -class UiMessagePageViewBlock extends BlockComponent { - private $contentWrapper: JQuery; - private $slider: HTMLElement; - private $images: HTMLImageElement[] = []; - private _attachedToDom: boolean; +class UiMessagePageViewBlock extends AbstractBlockComponent { + private $main: HTMLElement; + private $toolButtons: Element; + private $topRecord: HTMLElement; + private $htmlContainer: HTMLElement; + private $images: HTMLElement; + private images: { + $img: HTMLImageElement, + width: number, + height: number + }[] = []; + + private readonly minIdealImageHeight = 250; constructor(config: UiMessagePageViewBlockConfig, context: TeamAppsUiContext) { super(config, context); - this.$contentWrapper = $(`
`); - if (config.creatorImageUrl) { - this.$contentWrapper.append(`
- ${config.creatorImageUrl ? `` : ''} -
`) - } - if (config.headLine) { - this.$contentWrapper.append(`
${removeDangerousTags(config.headLine)}
`); - } - if (config.text) { - this.$contentWrapper.append(`
${removeDangerousTags(config.text)}
`); - } - if (this.config.imageUrls && this.config.imageUrls.length === 1) { - this.$contentWrapper.append(`
`); - } + this.$main = parseHtml(`
+
+
+
+
+
`); + this.$toolButtons = this.$main.querySelector(":scope .tool-buttons"); + this.$topRecord = this.$main.querySelector(":scope .top-record"); + this.$htmlContainer = this.$main.querySelector(":scope .html"); + this.$images = this.$main.querySelector(":scope .images"); + + this.$toolButtons.innerHTML = ''; + config.toolButtons && config.toolButtons.forEach((tb: UiToolButton) => { + this.$toolButtons.appendChild(tb.getMainElement()); + }); + + removeClassesByFunction(this.$topRecord.classList, className => className.startsWith("align-")); + this.$topRecord.classList.add("align-" + UiHorizontalElementAlignment[config.topRecordAlignment].toLocaleLowerCase()); + let topTemplateRenderer = context.templateRegistry.createTemplateRenderer(config.topTemplate); + this.$topRecord.innerHTML = config.topRecord != null ? topTemplateRenderer.render(config.topRecord.values) : ""; + + this.$htmlContainer.innerHTML = config.html != null ? removeDangerousTags(config.html) : ""; + if (config.imageUrls && config.imageUrls.length > 0) { - this.$slider = parseHtml(`
`); - this.$contentWrapper.append(this.$slider); - - if (this.config.imageUrls && this.config.imageUrls.length > 1) { - - $(this.$slider).slick({ - dots: true, - infinite: true, - speed: 300, - slidesToShow: 1, - centerMode: true, - variableWidth: true, - draggable: true, - - }); - - for (var i = 0; i < this.config.imageUrls.length; i++) { - const $sliderItem = document.createElement("div"); - $sliderItem.classList.add("slider-item"); - const $image = new Image(); - $sliderItem.appendChild($image); - $image.classList.add("slider-item-img"); - $image.onload = () => { - $(this.$slider).slick('slickAdd', $sliderItem); - this.reLayout(); - }; - $image.src = this.config.imageUrls[i]; - this.$images.push($image); - } + for (var i = 0; i < this.config.imageUrls.length; i++) { + const $image = new Image(); + let image = { + width: this.minIdealImageHeight, + height: this.minIdealImageHeight, + $img: $image + }; + this.images.push(image); + $image.classList.add("image"); + $image.onload = (event: Event) => { + image.width = (event.target as HTMLImageElement).naturalWidth; + image.height = (event.target as HTMLImageElement).naturalHeight; + this.reLayout(); + }; + $image.src = this.config.imageUrls[i]; + this.$images.appendChild($image); } } + } reLayout() { - if (this._attachedToDom) { - const minMaxDimensions = this.$images.reduce((minMaxDimensions, $img) => { - if ($img.naturalWidth > 0 && $img.naturalHeight > 0) { - minMaxDimensions.maxWidth = Math.max(minMaxDimensions.maxWidth, $img.naturalWidth); - minMaxDimensions.maxHeight = Math.max(minMaxDimensions.maxHeight, $img.naturalHeight); - minMaxDimensions.minWidth = Math.min(minMaxDimensions.minWidth, $img.naturalWidth); - minMaxDimensions.minHeight = Math.min(minMaxDimensions.minHeight, $img.naturalHeight); - } - return minMaxDimensions; - }, {minWidth: 10000000, minHeight: 1000000, maxWidth: 0, maxHeight: 0}); - const applicableWidth = this.$slider.offsetWidth - (this.$slider.offsetWidth * .2); - let maxHeight = this.$images.reduce((maxHeight, $img) => { - if ($img.naturalWidth > 0 && $img.naturalHeight > 0) { - return Math.max(maxHeight, applicableWidth * $img.naturalHeight / $img.naturalWidth); - } else { - return maxHeight; - } - }, 0); - maxHeight = Math.min(300, maxHeight); - this.$images.forEach($img => { - if ($img.naturalWidth > 0 && $img.naturalHeight > 0) { - const aspectRatio = $img.naturalWidth / $img.naturalHeight; - $img.style.height = maxHeight + "px"; - $img.style.width = (maxHeight * aspectRatio) + "px"; - } + if (this.images.length > 0) { + let availableWidth = this.$images.clientWidth; + let layout = fixed_partition(this.images, { + containerWidth: availableWidth, + idealElementHeight: Math.max(this.minIdealImageHeight, availableWidth / 3), + align: 'center', + spacing: 10 }); + for (let i = 0; i < this.images.length; i++) { + this.images[i].$img.style.left = layout.positions[i].x + "px"; + this.images[i].$img.style.top = layout.positions[i].y + "px"; + this.images[i].$img.style.width = layout.positions[i].width + "px"; + this.images[i].$img.style.height = layout.positions[i].height + "px"; + } + this.$images.style.height = layout.height + "px"; + } else { + this.$images.style.height = "0"; } } - public getMainDomElement(): JQuery { - return this.$contentWrapper; - } - - public set attachedToDom(attachedToDom: boolean) { - this._attachedToDom = attachedToDom; - this.reLayout(); + public getMainDomElement(): HTMLElement { + return this.$main; } public destroy(): void { // nothing to do } + } -class UiCitationPageViewBlock extends BlockComponent { +class UiCitationPageViewBlock extends AbstractBlockComponent { - private $component: JQuery; + private $main: HTMLElement; + private $toolButtons: Element; constructor(config: UiCitationPageViewBlockConfig, context: TeamAppsUiContext) { super(config, context); - this.$component = $(`
-
- ${config.creatorImageUrl ? `` : ''} -
-
- -
+ this.$main = parseHtml(`
+
+
+
+ ${config.creatorImageUrl ? `` : ''} +
+
+
`); - let $contentWrapper = this.$component.find('.content-wrapper'); - $contentWrapper.append(`
${removeDangerousTags(config.citation)}
`); - $contentWrapper.append(`
${removeDangerousTags(config.author)}
`); + let $contentWrapper = this.$main.querySelector(':scope .content-wrapper'); + $contentWrapper.appendChild($(`
${removeDangerousTags(config.citation)}
`)[0]); + $contentWrapper.appendChild($(`
${removeDangerousTags(config.author)}
`)[0]); + + this.$toolButtons = this.$main.querySelector(":scope .tool-buttons"); + this.$toolButtons.innerHTML = ''; + config.toolButtons && config.toolButtons.forEach((tb: UiToolButton) => { + this.$toolButtons.appendChild(tb.getMainElement()); + }); + } - public getMainDomElement(): JQuery { - return this.$component; + public getMainDomElement(): HTMLElement { + return this.$main; } public set attachedToDom(attachedToDom: boolean) { @@ -308,38 +312,38 @@ class UiCitationPageViewBlock extends BlockComponent { +class UiComponentPageViewBlock extends AbstractBlockComponent { - private $div: JQuery; + private $main: HTMLElement; private component: UiComponent; - private $componentWrapper: JQuery; + private $componentWrapper: HTMLElement; + private $toolButtons: Element; constructor(config: UiComponentPageViewBlockConfig, context: TeamAppsUiContext) { super(config, context); - this.$div = $(`
-
+ this.$main = parseHtml(`
+
+
`); - this.$componentWrapper = this.$div.find('.component-wrapper'); + this.$componentWrapper = this.$main.querySelector(':scope .component-wrapper'); + this.$toolButtons = this.$main.querySelector(":scope .tool-buttons"); + this.$toolButtons.innerHTML = ''; + config.toolButtons && config.toolButtons.forEach((tb: UiToolButton) => { + this.$toolButtons.appendChild(tb.getMainElement()); + }); + if (config.title) { - this.$div.prepend(`
${removeDangerousTags(config.title)}
`); + this.$main.prepend($(`
${removeDangerousTags(config.title)}
`)[0]); } - this.component = config.component; - this.component.getMainDomElement().appendTo(this.$componentWrapper); - } - - public getMainDomElement(): JQuery { - return this.$div; - } - - public set attachedToDom(attachedToDom: boolean) { - this.component.attachedToDom = attachedToDom; + this.component = config.component as UiComponent; + this.$componentWrapper.appendChild(this.component.getMainElement()); } - public reLayout(): void { - this.component.reLayout(); + public getMainDomElement(): HTMLElement { + return this.$main; } public destroy(): void { diff --git a/teamapps-client/ts/modules/UiPanel.ts b/teamapps-client/ts/modules/UiPanel.ts index e89a7059d..540b3b189 100644 --- a/teamapps-client/ts/modules/UiPanel.ts +++ b/teamapps-client/ts/modules/UiPanel.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,46 +17,45 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; + import {UiPanelHeaderFieldConfig} from "../generated/UiPanelHeaderFieldConfig"; import {UiField} from "./formfield/UiField"; import {UiToolbar} from "./tool-container/toolbar/UiToolbar"; import {UiComponentConfig} from "../generated/UiComponentConfig"; import {UiToolButton} from "./micro-components/UiToolButton"; -import {UiComponent} from "./UiComponent"; +import {AbstractUiComponent} from "./AbstractUiComponent"; import {UiDropDown} from "./micro-components/UiDropDown"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; -import {executeWhenAttached} from "./util/ExecuteWhenAttached"; +import {executeWhenFirstDisplayed} from "./util/ExecuteWhenFirstDisplayed"; import {UiPanel_HeaderComponentMinimizationPolicy, UiPanel_WindowButtonClickedEvent, UiPanelCommandHandler, UiPanelConfig, UiPanelEventSource,} from "../generated/UiPanelConfig"; import {createUiToolButtonConfig} from "../generated/UiToolButtonConfig"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; -import {createUiColorCssString} from "./util/CssFormatUtil"; import {StaticIcons} from "./util/StaticIcons"; import {UiWindowButtonType} from "../generated/UiWindowButtonType"; import {TeamAppsEvent} from "./util/TeamAppsEvent"; -import {EventFactory} from "../generated/EventFactory"; -import {maximizeComponent} from "./Common"; +import {insertBefore, maximizeComponent, outerWidthIncludingMargins, parseHtml, prependChild} from "./Common"; +import {UiComponent} from "./UiComponent"; interface HeaderField { config: UiPanelHeaderFieldConfig; field: UiField; - $wrapper: JQuery; - $iconAndFieldWrapper: JQuery; - $fieldWrapper: JQuery; - $icon: JQuery; + $wrapper: HTMLElement; + $iconAndFieldWrapper: HTMLElement; + $fieldWrapper: HTMLElement; + $icon: HTMLElement; minimizedWidth?: number; minExpandedWidthWithIcon?: number; minExpandedWidth?: number; } -export class UiPanel extends UiComponent implements UiPanelCommandHandler, UiPanelEventSource { +export class UiPanel extends AbstractUiComponent implements UiPanelCommandHandler, UiPanelEventSource { - public readonly onWindowButtonClicked: TeamAppsEvent = new TeamAppsEvent(this); + public readonly onWindowButtonClicked: TeamAppsEvent = new TeamAppsEvent(); private readonly defaultToolButtons = { - [UiWindowButtonType.MINIMIZE]: new UiToolButton(createUiToolButtonConfig("MINIMIZE", StaticIcons.MINIMIZE, "Minimize"), this._context), - [UiWindowButtonType.MAXIMIZE_RESTORE]: new UiToolButton(createUiToolButtonConfig("MAXIMIZE_RESTORE", StaticIcons.MAXIMIZE, "Maximize/Restore"), this._context), - [UiWindowButtonType.CLOSE]: new UiToolButton(createUiToolButtonConfig("CLOSE", StaticIcons.CLOSE, "Close"), this._context), + [UiWindowButtonType.MINIMIZE]: new UiToolButton(createUiToolButtonConfig(StaticIcons.MINIMIZE, "Minimize", {debuggingId: "window-button-minimize"}), this._context), + [UiWindowButtonType.MAXIMIZE_RESTORE]: new UiToolButton(createUiToolButtonConfig(StaticIcons.MAXIMIZE, "Maximize/Restore", {debuggingId: "window-button-maximize"}), this._context), + [UiWindowButtonType.CLOSE]: new UiToolButton(createUiToolButtonConfig(StaticIcons.CLOSE, "Close", {debuggingId: "window-button-close"}), this._context), }; private readonly orderedDefaultToolButtonTypes = [ UiWindowButtonType.MINIMIZE, @@ -64,86 +63,84 @@ export class UiPanel extends UiComponent implements UiPanelComman UiWindowButtonType.CLOSE ]; - private $panel: JQuery; - private $heading: JQuery; - private $parentDomElement: JQuery; // When maximizing, this component is taken out of the DOM. When minimizing, it has to be reattached to this parent element. - private $toolbarContainer: JQuery; - private $bodyContainer: JQuery; - private $leftComponentWrapper: JQuery; - private $headingSpacer: JQuery; - private $rightComponentWrapper: JQuery; - private $buttonContainer: JQuery; - private $windowButtonContainer: JQuery; - private $icon: JQuery; - private $title: JQuery; - - private contentComponent: UiComponent; + private $panel: HTMLElement; + private $heading: HTMLElement; + private $toolbarContainer: HTMLElement; + private panelBody: HTMLElement; + private $leftComponentWrapper: HTMLElement; + private $headingSpacer: HTMLElement; + private $rightComponentWrapper: HTMLElement; + private $buttonContainer: HTMLElement; + private $windowButtonContainer: HTMLElement; + private $icon: HTMLElement; + private $title: HTMLElement; + private $badge: HTMLElement; + private leftHeaderField: HeaderField; private rightHeaderField: HeaderField; private leftComponentFirstMinimized: boolean; private alwaysShowHeaderFieldIcons: boolean; private toolbar: UiToolbar; private icon: string; - private title: string; private titleNaturalWidth: number; + private badgeNaturalWidth: number; + private toolButtons: UiToolButton[] = []; private windowButtons: UiWindowButtonType[]; - private dropDown: UiDropDown; - private restoreFunction: (animationCallback: () => void) => void; + private restoreFunction: (animationCallback?: () => void) => void; constructor(config: UiPanelConfig, context: TeamAppsUiContext) { super(config, context); - this.$panel = $(`
+ this.$panel = parseHtml(`
-
-
-
-
-
-
-
-
-
-
+
`); - this.$toolbarContainer = this.$panel.find('.toolbar-container'); - this.$bodyContainer = this.$panel.find('.body-container'); - this.$heading = this.$panel.find('>.panel-heading'); - this.$icon = this.$heading.find('>.panel-icon'); - this.$title = this.$heading.find('>.panel-title'); - this.$leftComponentWrapper = this.$heading.find('>.panel-left-component-wrapper'); - this.$headingSpacer = this.$heading.find('>.panel-heading-spacer'); - this.$rightComponentWrapper = this.$heading.find('>.panel-right-component-wrapper'); - this.$buttonContainer = this.$heading.find('>.panel-heading-buttons'); - this.$windowButtonContainer = this.$heading.find('>.panel-heading-window-buttons'); + this.$toolbarContainer = this.$panel.querySelector(':scope .toolbar-container'); + this.panelBody = this.$panel.querySelector(':scope .panel-body'); + this.$heading = this.$panel.querySelector(':scope >.panel-heading'); + this.$icon = this.$heading.querySelector(':scope >.panel-icon'); + this.$title = this.$heading.querySelector(':scope >.panel-title'); + this.$badge = this.$heading.querySelector(':scope >.panel-badge'); + this.$leftComponentWrapper = this.$heading.querySelector(':scope >.panel-left-component-wrapper'); + this.$headingSpacer = this.$heading.querySelector(':scope >.panel-heading-spacer'); + this.$rightComponentWrapper = this.$heading.querySelector(':scope >.panel-right-component-wrapper'); + this.$buttonContainer = this.$heading.querySelector(':scope >.panel-heading-buttons'); + this.$windowButtonContainer = this.$heading.querySelector(':scope >.panel-heading-window-buttons'); this.alwaysShowHeaderFieldIcons = config.alwaysShowHeaderFieldIcons; this.setIcon(config.icon); this.setTitle(config.title); + this.setBadge(config.badge); this.setLeftHeaderField(config.leftHeaderField); this.setRightHeaderField(config.rightHeaderField); - this.setToolButtons(config.toolButtons); + this.setToolButtons(config.toolButtons as UiToolButton[]); if (config.hideTitleBar) { - this.$heading.addClass('hidden'); - this.$panel.addClass("empty-heading"); + this.$heading.classList.add('hidden'); + this.$panel.classList.add("empty-heading"); } else { - this.$heading.removeClass('hidden'); - this.$panel.removeClass("empty-heading"); + this.$heading.classList.remove('hidden'); + this.$panel.classList.remove("empty-heading"); } - this.setToolbar(config.toolbar); + this.setToolbar(config.toolbar as UiToolbar); if (config.content) { - this.setContent(config.content); + this.setContent(config.content as UiComponent); } this.leftComponentFirstMinimized = this._config.headerComponentMinimizationPolicy == UiPanel_HeaderComponentMinimizationPolicy.LEFT_COMPONENT_FIRST; - this.dropDown = new UiDropDown(); - this.defaultToolButtons[UiWindowButtonType.MAXIMIZE_RESTORE].onClicked.addListener(() => { if (this.restoreFunction == null) { this.maximize(); @@ -153,7 +150,9 @@ export class UiPanel extends UiComponent implements UiPanelComman }); this.orderedDefaultToolButtonTypes.forEach(windowButtonType => { this.defaultToolButtons[windowButtonType].onClicked.addListener(() => { - this.onWindowButtonClicked.fire(EventFactory.createUiPanel_WindowButtonClickedEvent(this.getId(), windowButtonType)); + this.onWindowButtonClicked.fire({ + windowButton: windowButtonType + }); }); }); this.setWindowButtons(config.windowButtons); @@ -170,27 +169,26 @@ export class UiPanel extends UiComponent implements UiPanelComman public maximize(): void { this.defaultToolButtons[UiWindowButtonType.MAXIMIZE_RESTORE].setIcon(StaticIcons.RESTORE); - this.restoreFunction = maximizeComponent(this, () => this.reLayout(true)); + this.restoreFunction = maximizeComponent(this); } public restore(): void { this.defaultToolButtons[UiWindowButtonType.MAXIMIZE_RESTORE].setIcon(StaticIcons.MAXIMIZE); if (this.restoreFunction != null) { - this.restoreFunction(() => this.reLayout(true)); + this.restoreFunction(); } this.restoreFunction = null; } public setDraggable(draggable: boolean) { - this.$heading.prop("draggable", draggable); + this.$heading.draggable = draggable; } public setToolButtons(toolButtons: UiToolButton[]) { this.toolButtons = []; - this.$buttonContainer[0].innerHTML = ''; + this.$buttonContainer.innerHTML = ''; toolButtons && toolButtons.forEach(toolButton => { - toolButton.getMainDomElement().appendTo(this.$buttonContainer); - toolButton.attachedToDom = this.attachedToDom; + this.$buttonContainer.appendChild(toolButton.getMainElement()); this.toolButtons.push(toolButton); }); this.relayoutHeader(); @@ -198,7 +196,7 @@ export class UiPanel extends UiComponent implements UiPanelComman public setWindowButtons(buttonTypes: UiWindowButtonType[]): void { this.windowButtons = []; - this.$windowButtonContainer[0].innerHTML = ''; + this.$windowButtonContainer.innerHTML = ''; if (buttonTypes && buttonTypes.length > 0) { buttonTypes.forEach(toolButton => { this.addWindowButton(toolButton); @@ -212,29 +210,29 @@ export class UiPanel extends UiComponent implements UiPanelComman if (this.windowButtons.filter(tb => tb === toolButtonType).length > 0){ this.removeWindowButton(toolButtonType); } - this.$windowButtonContainer.removeClass("hidden"); + this.$windowButtonContainer.classList.remove("hidden"); this.windowButtons.push(toolButtonType); const button = this.defaultToolButtons[toolButtonType]; - if (this.$windowButtonContainer[0].children.length === 0) { - button.getMainDomElement().prependTo(this.$windowButtonContainer); + if (this.$windowButtonContainer.children.length === 0) { + prependChild(this.$windowButtonContainer, button.getMainElement()); } else { let index = this.windowButtons .sort((a, b) =>this.orderedDefaultToolButtonTypes.indexOf(a) - this.orderedDefaultToolButtonTypes.indexOf(b)) .indexOf(toolButtonType); - if (index >= this.$windowButtonContainer[0].childNodes.length) { - button.getMainDomElement().appendTo(this.$windowButtonContainer); + if (index >= this.$windowButtonContainer.childNodes.length) { + this.$windowButtonContainer.appendChild(button.getMainElement()); } else { - button.getMainDomElement().insertBefore(this.$windowButtonContainer[0].children[index]); + insertBefore(button.getMainElement(), this.$windowButtonContainer.children[index]); } } this.relayoutHeader(); } public removeWindowButton(uiToolButton: UiWindowButtonType) { - this.defaultToolButtons[uiToolButton].getMainDomElement().detach(); + this.defaultToolButtons[uiToolButton].getMainElement().remove(); this.windowButtons = this.windowButtons.filter(tb => tb !== uiToolButton); if (this.windowButtons.length === 0) { - this.$windowButtonContainer.addClass("hidden"); + this.$windowButtonContainer.classList.add("hidden"); } } @@ -242,42 +240,31 @@ export class UiPanel extends UiComponent implements UiPanelComman return this.defaultToolButtons[buttonType]; } - public getMainDomElement(): JQuery { + public doGetMainElement(): HTMLElement { return this.$panel; } public setContent(content: UiComponent) { - if (content == this.contentComponent) { - return; + if (content?.getMainElement() !== this.panelBody.firstElementChild) { + this.panelBody.innerHTML = ''; } - this.$bodyContainer[0].innerHTML = ''; - this.contentComponent = content; if (content != null) { - this.contentComponent.getMainDomElement().appendTo(this.$bodyContainer); - this.contentComponent.attachedToDom = this.attachedToDom; + this.panelBody.appendChild(content.getMainElement()); } } - protected onAttachedToDom() { - this.toolButtons.forEach(toolButton => toolButton.attachedToDom = true); - if (this.toolbar) this.toolbar.attachedToDom = true; - if (this.contentComponent) this.contentComponent.attachedToDom = true; - this.reLayout(); - } - - @executeWhenAttached(true) + @executeWhenFirstDisplayed(true) private calculateFieldWrapperSizes() { this.headerFields.forEach(headerField => { if (!headerField.minimizedWidth || !headerField.minExpandedWidth || !headerField.minExpandedWidthWithIcon) { - headerField.$fieldWrapper.css("transition", "none"); - this.setMinimizedFields(headerField); - this.$heading.addClass("has-minimized-header-component"); - headerField.minimizedWidth = headerField.$iconAndFieldWrapper.outerWidth(true); - this.setMinimizedFields(); - headerField.minExpandedWidthWithIcon = headerField.$iconAndFieldWrapper.outerWidth(true) - headerField.$fieldWrapper[0].offsetWidth + headerField.config.minWidth; - this.$heading.removeClass("has-minimized-header-component"); - headerField.minExpandedWidth = headerField.$iconAndFieldWrapper.outerWidth(true) - headerField.$fieldWrapper[0].offsetWidth + headerField.config.minWidth; - headerField.$fieldWrapper.css("transition", ""); + headerField.$fieldWrapper.style.transition = "none"; + this.setHeaderFieldDisplayMode(headerField, true, true); + headerField.minimizedWidth = outerWidthIncludingMargins(headerField.$iconAndFieldWrapper); + this.setHeaderFieldDisplayMode(headerField, false, true); + headerField.minExpandedWidthWithIcon = outerWidthIncludingMargins(headerField.$iconAndFieldWrapper) - headerField.$fieldWrapper.offsetWidth + headerField.config.minWidth; + this.setHeaderFieldDisplayMode(headerField, false, false); + headerField.minExpandedWidth = outerWidthIncludingMargins(headerField.$iconAndFieldWrapper) - headerField.$fieldWrapper.offsetWidth + headerField.config.minWidth; + headerField.$fieldWrapper.style.transition = ""; } }); } @@ -288,11 +275,17 @@ export class UiPanel extends UiComponent implements UiPanelComman private setMinimizedFields(...minimizedHeaderFields: HeaderField[]) { this.headerFields.forEach(headerField => { - headerField.$wrapper.toggleClass("minimized", minimizedHeaderFields.indexOf(headerField) != -1); - headerField.$wrapper.toggleClass("display-icon", minimizedHeaderFields.length > 0 || this.alwaysShowHeaderFieldIcons); + let shouldBeMinimized = minimizedHeaderFields.indexOf(headerField) != -1; + let shouldDisplayIcon = minimizedHeaderFields.length > 0 || this.alwaysShowHeaderFieldIcons; + this.setHeaderFieldDisplayMode(headerField, shouldBeMinimized, shouldDisplayIcon); }); } + private setHeaderFieldDisplayMode(headerField: HeaderField, minimized: boolean, displayIcon: boolean) { + headerField.$wrapper.classList.toggle("minimized", minimized); + headerField.$wrapper.classList.toggle("display-icon", displayIcon); + } + public setLeftHeaderField(headerFieldConfig: UiPanelHeaderFieldConfig) { this.leftHeaderField = this.setHeaderField(headerFieldConfig, this.$leftComponentWrapper, true); this.calculateFieldWrapperSizes(); @@ -305,29 +298,28 @@ export class UiPanel extends UiComponent implements UiPanelComman this.relayoutHeader(); } - private setHeaderField(headerFieldConfig: UiPanelHeaderFieldConfig, $componentWrapper: JQuery, isLeft: boolean): HeaderField { + private setHeaderField(headerFieldConfig: UiPanelHeaderFieldConfig, $componentWrapper: HTMLElement, isLeft: boolean): HeaderField { if (isLeft && this.leftHeaderField) { - this.leftHeaderField.$iconAndFieldWrapper.detach(); + this.leftHeaderField.$iconAndFieldWrapper.remove(); } else if (!isLeft && this.rightHeaderField) { - this.rightHeaderField.$iconAndFieldWrapper.detach(); + this.rightHeaderField.$iconAndFieldWrapper.remove(); } - $componentWrapper[0].innerHTML = ''; - $componentWrapper.hide(); + $componentWrapper.innerHTML = ''; + $componentWrapper.classList.add('hidden'); if (headerFieldConfig) { - let iconPath = this._context.getIconPath(headerFieldConfig.icon, 16); - let $iconAndFieldWrapper = $(`
-
+ let $iconAndFieldWrapper = parseHtml(`
+
`); - let $icon = $iconAndFieldWrapper.find('>.icon'); - $icon.click(() => { + let $icon = $iconAndFieldWrapper.querySelector(':scope >.icon'); + $icon.addEventListener('click', () => { this.leftComponentFirstMinimized = !isLeft; this.relayoutHeader(); }); - let $fieldWrapper = $iconAndFieldWrapper.find('>.field-wrapper'); + let $fieldWrapper = $iconAndFieldWrapper.querySelector(':scope >.field-wrapper'); const field = (headerFieldConfig.field as UiField); - field.getMainDomElement().appendTo($fieldWrapper); + $fieldWrapper.appendChild(field.getMainElement()); field.onVisibilityChanged.addListener(visible => { this.relayoutHeader(); }); @@ -339,109 +331,95 @@ export class UiPanel extends UiComponent implements UiPanelComman $icon: $icon, $fieldWrapper }; - $componentWrapper.append($iconAndFieldWrapper).show(); + $componentWrapper.appendChild($iconAndFieldWrapper); + $componentWrapper.classList.remove('hidden'); return headerField; } else { return null; } }; - public setFieldValue(fieldName: string, value: any): void { - const field = this.getHeaderFieldByName(fieldName); - if (field) { - field.field.setCommittedValue(value); - } - } - - private getHeaderFieldByName(fieldName: string): HeaderField { - return this.headerFields - .filter(field => field != null && field.config.field.fieldName === fieldName)[0]; - } - public setIcon(icon: string) { this.icon = icon; if (icon) { - this.$icon[0].innerHTML = ''; - this.$icon.append('
'); + this.$icon.innerHTML = ''; + this.$icon.appendChild(parseHtml(`
`)); } - this.$icon.toggle(icon != null); + this.$icon.classList.toggle('hidden', icon == null); this.relayoutHeader(); } public setTitle(title: string) { - this.title = title; - this.$title.text(title); - this.recalculateTitleNaturalWidth(); - this.$title.toggle(!!title); + this._config.title = title; + this.$title.textContent = title; + this.titleNaturalWidth = null; + this.relayoutHeader(); + } + + setBadge(badge: string) { + this._config.badge = badge; + this.$badge.textContent = badge; + this.badgeNaturalWidth = null; this.relayoutHeader(); } - private recalculateTitleNaturalWidth() { - if (!this.title) { - this.titleNaturalWidth = 0; + private recalculateNaturalWidth(value: string, element: HTMLElement) { + if (!value) { + return 0; } else { - this.$title.css({ - position: "absolute", - display: "inline-block" - }); // TODO - this.titleNaturalWidth = this.$title[0].offsetWidth; - this.$title.css({ - position: "", - display: "" - }); + element.style.position = "absolute"; + element.style.display = "inline-block"; + let width = element.offsetWidth; + element.style.position = null; + element.style.display = null; + return width; } - }; + } public setToolbar(toolbar: UiToolbar) { if (this.toolbar != null) { - this.toolbar.getMainDomElement().detach(); + this.toolbar.getMainElement().remove(); } this.toolbar = toolbar; if (toolbar) { - this.toolbar.getMainDomElement().appendTo(this.$toolbarContainer); + this.$toolbarContainer.appendChild(this.toolbar.getMainElement()); this.toolbar.onEmptyStateChanged.addListener(() => this.updateToolbarVisibility()) } this.updateToolbarVisibility(); } public setStretchContent(stretch: boolean): void { - this.getMainDomElement().get(0).classList.toggle("stretch-content", stretch); + this.getMainElement().classList.toggle("stretch-content", stretch); } private updateToolbarVisibility() { - this.$toolbarContainer.toggleClass('hidden', this.toolbar == null || this.toolbar.empty); - } - - public focusField(fieldName: string) { - this.getHeaderFieldByName(fieldName).field.focus(); + this.$toolbarContainer.classList.toggle('hidden', this.toolbar == null || this.toolbar.empty); } onResize(): void { - if (!this.attachedToDom || this.getMainDomElement()[0].offsetWidth <= 0) return; this.relayoutHeader(); - this.toolbar && this.toolbar.reLayout(); - this.contentComponent && this.contentComponent.reLayout(); - this.leftHeaderField && this.leftHeaderField.field.reLayout(); - this.rightHeaderField && this.rightHeaderField.field.reLayout(); } - @executeWhenAttached(true) + @executeWhenFirstDisplayed(true) private relayoutHeader() { - let availableHeaderContentWidth = this.$heading[0].offsetWidth - parseInt(this.$heading.css("padding-left")) - parseInt(this.$heading.css("padding-right")); - if (this.title && this.titleNaturalWidth == 0) this.recalculateTitleNaturalWidth(); + const computedHeadingStyle = getComputedStyle(this.$heading); + let availableHeaderContentWidth = this.$heading.offsetWidth - parseInt(computedHeadingStyle.paddingLeft) - parseInt(computedHeadingStyle.paddingRight); + if (this._config.title && this.titleNaturalWidth == null) this.titleNaturalWidth = this.recalculateNaturalWidth(this._config.title, this.$title); + if (this._config.badge && this.badgeNaturalWidth == null) this.badgeNaturalWidth = this.recalculateNaturalWidth(this._config.badge, this.$badge); if (this.headerFields.some(headerField => !headerField.minimizedWidth)) this.calculateFieldWrapperSizes(); - let titleWidth = Math.floor(this.title ? this.titleNaturalWidth : 0); - let iconWidth = (this.icon ? this.$icon[0].offsetWidth + parseInt(this.$icon.css("margin-right")) : 0); - let minSpacerWidth = parseInt(this.$headingSpacer.css("flex-basis")); - let buttonContainerWidth = this.$buttonContainer[0].offsetWidth; - let windowButtonContainerWidth = this.$windowButtonContainer[0].offsetWidth; + let titleWidth = Math.ceil(this._config.title ? this.titleNaturalWidth + parseInt(getComputedStyle(this.$title).marginRight) : 0); + let badgeWidth = Math.ceil(this._config.badge ? this.badgeNaturalWidth + parseInt(getComputedStyle(this.$title).marginRight) : 0); + let iconComputedStyle = getComputedStyle(this.$icon); + let iconWidth = (this.icon ? this.$icon.offsetWidth + parseInt(iconComputedStyle.marginLeft) + parseInt(iconComputedStyle.marginRight) : 0); + let minSpacerWidth = parseInt(getComputedStyle(this.$headingSpacer).flexBasis); + let buttonContainerWidth = this.$buttonContainer.offsetWidth; // TODO 8px??? + let windowButtonContainerWidth = this.$windowButtonContainer.offsetWidth; let minAllExpandedWidth = - // 1 // the width of the title may be no integer - -1 - + iconWidth + iconWidth + titleWidth + + badgeWidth + minSpacerWidth + buttonContainerWidth + windowButtonContainerWidth @@ -450,22 +428,24 @@ export class UiPanel extends UiComponent implements UiPanelComman .reduce((totalWidth, fieldWidth) => (totalWidth + fieldWidth), 0); if (this.numberOfVisibleHeaderFields() == 2) { + this.$heading.classList.remove("no-header-fields"); let firstFieldToGetMinified = this.leftComponentFirstMinimized ? this.leftHeaderField : this.rightHeaderField; let alwaysMaximizedField = this.leftComponentFirstMinimized ? this.rightHeaderField : this.leftHeaderField; let minFirstMinimizedWidth = - 1 // the width of the title may be no integer + iconWidth + titleWidth + + badgeWidth + minSpacerWidth + buttonContainerWidth + windowButtonContainerWidth + firstFieldToGetMinified.minimizedWidth + alwaysMaximizedField.minExpandedWidthWithIcon; - let minWidthNeededWithHiddenHeaderAndOneMinimizedField = minFirstMinimizedWidth - titleWidth; + let minWidthNeededWithHiddenTitleAndOneMinimizedField = minFirstMinimizedWidth - titleWidth; if (availableHeaderContentWidth >= minAllExpandedWidth) { - this.$title.removeClass("hidden").css("width", ""); - this.$heading.removeClass("has-minimized-header-component"); + this.$title.classList.remove("hidden"); + this.$title.style.width = null; + this.$heading.classList.remove("has-minimized-header-component"); let availableAdditionalSpace = availableHeaderContentWidth - minAllExpandedWidth; let minMaxFieldWidthDeltaSum = this.headerFields @@ -480,72 +460,61 @@ export class UiPanel extends UiComponent implements UiPanelComman headerField.config.minWidth + availableAdditionalSpace * ((headerField.config.maxWidth - headerField.config.minWidth) / minMaxFieldWidthDeltaSum), headerField.config.maxWidth ); - headerField.$fieldWrapper.css({ - width: newFieldWidth, - }); + headerField.$fieldWrapper.style.width = newFieldWidth + "px"; }); } else if (availableHeaderContentWidth >= minFirstMinimizedWidth) { - this.$title.removeClass("hidden").css("width", ""); - this.$heading.addClass("has-minimized-header-component"); + this.$title.classList.remove("hidden"); + this.$title.style.width = null; + this.$heading.classList.add("has-minimized-header-component"); this.setMinimizedFields(firstFieldToGetMinified); let availableAdditionalSpace = availableHeaderContentWidth - minFirstMinimizedWidth; let newMaximizedFieldWidth = Math.min(alwaysMaximizedField.config.minWidth + availableAdditionalSpace, alwaysMaximizedField.config.maxWidth); - alwaysMaximizedField.$fieldWrapper.css({ - width: newMaximizedFieldWidth, - }); - } else if (availableHeaderContentWidth >= minWidthNeededWithHiddenHeaderAndOneMinimizedField + 30 /* less does not make sense for title */) { - this.$title.removeClass("hidden"); - this.$heading.addClass("has-minimized-header-component"); + alwaysMaximizedField.$fieldWrapper.style.width = newMaximizedFieldWidth + "px"; + } else if (availableHeaderContentWidth >= minWidthNeededWithHiddenTitleAndOneMinimizedField + 30 /* less does not make sense for title */) { + this.$title.classList.remove("hidden"); + this.$heading.classList.add("has-minimized-header-component"); this.setMinimizedFields(firstFieldToGetMinified); - alwaysMaximizedField.$fieldWrapper.css({ - width: alwaysMaximizedField.config.minWidth, - }); - this.$title.outerWidth(this.titleNaturalWidth - (minFirstMinimizedWidth - availableHeaderContentWidth)); + alwaysMaximizedField.$fieldWrapper.style.width = alwaysMaximizedField.config.minWidth + "px"; + this.$title.style.width = (this.titleNaturalWidth - (minFirstMinimizedWidth - availableHeaderContentWidth)) + "px"; } else { - this.$title.addClass("hidden"); - this.$heading.addClass("has-minimized-header-component"); + this.$title.classList.add("hidden"); + this.$heading.classList.add("has-minimized-header-component"); this.setMinimizedFields(firstFieldToGetMinified); - const width = alwaysMaximizedField.config.minWidth + (availableHeaderContentWidth - minWidthNeededWithHiddenHeaderAndOneMinimizedField); - console.log(alwaysMaximizedField.config.minWidth, (availableHeaderContentWidth - minWidthNeededWithHiddenHeaderAndOneMinimizedField), width); - alwaysMaximizedField.$fieldWrapper.css({ - width: width - }); + const width = alwaysMaximizedField.config.minWidth + (availableHeaderContentWidth - minWidthNeededWithHiddenTitleAndOneMinimizedField); + alwaysMaximizedField.$fieldWrapper.style.width = width + "px"; } } else if (this.numberOfVisibleHeaderFields() == 1) { - this.$heading.removeClass("has-minimized-header-component"); + this.$heading.classList.remove("no-header-fields"); + this.$heading.classList.remove("has-minimized-header-component"); let headerField = this.leftHeaderField || this.rightHeaderField; this.setMinimizedFields(); if (availableHeaderContentWidth >= minAllExpandedWidth) { - this.$title.removeClass("hidden").css("width", ""); + this.$title.classList.remove("hidden"); + this.$title.style.width = null; let availableAdditionalSpace = availableHeaderContentWidth - minAllExpandedWidth; this.headerFields.forEach(headerField => { let newFieldWidth = Math.min( headerField.config.minWidth + availableAdditionalSpace, headerField.config.maxWidth ); - headerField.$fieldWrapper.css({ - width: newFieldWidth, - }); + headerField.$fieldWrapper.style.width = newFieldWidth + "px"; }); } else if (availableHeaderContentWidth >= minAllExpandedWidth - this.titleNaturalWidth + 30 /* less does not make sense for title */) { - this.$title.removeClass("hidden"); - headerField.$fieldWrapper.css({ - width: headerField.config.minWidth, - }); - this.$title.outerWidth(this.titleNaturalWidth - (minAllExpandedWidth - availableHeaderContentWidth)); + this.$title.classList.remove("hidden"); + headerField.$fieldWrapper.style.width = headerField.config.minWidth + "px"; + this.$title.style.width = (this.titleNaturalWidth - (minAllExpandedWidth - availableHeaderContentWidth)) + "px"; } else { - this.$title.addClass("hidden"); + this.$title.classList.add("hidden"); let widthLessThanNeeded = availableHeaderContentWidth - minAllExpandedWidth + this.titleNaturalWidth; - headerField.$fieldWrapper.css({ - width: headerField.config.minWidth + widthLessThanNeeded, - }); + headerField.$fieldWrapper.style.width = (headerField.config.minWidth + widthLessThanNeeded) + "px"; } } else { - this.$heading.removeClass("has-minimized-header-component"); - this.$title.removeClass("hidden"); + this.$heading.classList.add("no-header-fields"); + this.$heading.classList.remove("has-minimized-header-component"); + this.$title.classList.remove("hidden"); let availableAdditionalSpace = availableHeaderContentWidth - minAllExpandedWidth; - this.$title.outerWidth(this.titleNaturalWidth + availableAdditionalSpace); + this.$title.style.width = null; } }; @@ -554,7 +523,8 @@ export class UiPanel extends UiComponent implements UiPanelComman } public destroy(): void { - this.$panel.detach(); // may be currently attached to document.body (maximized) + super.destroy(); + this.$panel.remove(); // may be currently attached to document.body (maximized) } public static isDraggablePanelHeadingElement(target: HTMLElement): boolean { diff --git a/teamapps-client/ts/modules/UiPdfViewer.ts b/teamapps-client/ts/modules/UiPdfViewer.ts new file mode 100644 index 000000000..138fc6003 --- /dev/null +++ b/teamapps-client/ts/modules/UiPdfViewer.ts @@ -0,0 +1,653 @@ +/* tslint:disable:indent */ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2025 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ + +import type {PDFDocumentProxy, PDFPageProxy, PageViewport} from "pdfjs-dist"; +import * as pdfjsLib from "pdfjs-dist"; +import {UiBorderConfig} from "../generated/UiBorderConfig"; +import { + UiPdfViewer_PdfInitializedEvent, UiPdfViewer_PdfLoadFailedEvent, UiPdfViewer_ZoomFactorAutoChangedEvent, + UiPdfViewerCommandHandler, + UiPdfViewerConfig +} from "../generated/UiPdfViewerConfig"; +import {UiPdfViewMode} from "../generated/UiPdfViewMode"; +import {AbstractUiComponent} from "./AbstractUiComponent"; +import {generateUUID, parseHtml} from "./Common"; +import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; +import {TeamAppsUiContext} from "./TeamAppsUiContext"; +import {TeamAppsEvent} from "./util/TeamAppsEvent"; +import {floorToPrecision} from "./util/precise-float-math"; +import {UiPdfZoomMode} from "../generated/UiPdfZoomMode"; +import {ContinuousVirtualRenderer} from "./pdf-viewer/ContinuousVirtualRenderer"; +import type {ContinuousVirtualRenderContext} from "./pdf-viewer/ContinuousVirtualRenderer"; +import {SinglePageRenderer} from "./pdf-viewer/SinglePageRenderer"; +import type {SinglePageRenderContext} from "./pdf-viewer/SinglePageRenderer"; +import {ContinuousRenderer} from "./pdf-viewer/ContinuousRenderer"; +import type {ContinuousRenderContext} from "./pdf-viewer/ContinuousRenderer"; + +type RenderPdfDocumentOptions = { + zoomConfigurationChanged?: boolean; + stabilizationZoomMode?: UiPdfZoomMode; + forceVirtualTeardownBeforeRender?: boolean; +}; + +// @ts-ignore +// import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.mjs'; +// pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker; +// console.log(pdfjsWorker); + +// import pdfjsWorker = require('url-loader!pdfjs-dist/build/pdf.worker.mjs'); +// pdfjsLib.GlobalWorkerOptions.workerSrc = "resources/pdf.worker.mjs"; +pdfjsLib.GlobalWorkerOptions.workerSrc = "worker-libs/pdf.worker.mjs"; + +/** + * Docs for Mozillas pdf.js: https://mozilla.github.io/pdf.js/ + * NPM Package: pdfjs-dist (CAUTION: pdfjs (without -dist) is another package!) + */ +export class UiPdfViewer extends AbstractUiComponent implements UiPdfViewerCommandHandler { + // the config will be set in the constructor + private config: UiPdfViewerConfig; + + // UI Elements + private $main: HTMLDivElement; + private $canvas: HTMLCanvasElement; + private $canvasContainer: HTMLDivElement; + private $pagesContainer: HTMLDivElement; + // Monotonic render token to ignore stale async renders when view mode changes. + private renderRequestId = 0; + private lastViewMode: UiPdfViewMode = null; + + // Dev/Helper elements + private $devToolbar: HTMLElement; + private $devRenderStats: HTMLDivElement; + private devToolsInitialized: boolean = false; + + // UI / internal state + private readonly uuidClass: string; + private pdfDocument: PDFDocumentProxy; + private currentPageNumber: number = 1; + private maxPageNumber: number = 0; + private pendingShowPageNumber: number = null; + private readonly singlePageRenderer: SinglePageRenderer; + private readonly continuousRenderer: ContinuousRenderer; + private readonly continuousVirtualRenderer: ContinuousVirtualRenderer; + // Monotonic token for setUrl() calls: latest URL wins and only the latest load may mutate state or fire init events. + private documentLoadRequestId: number = 0; + + // Events for the server + // --------------------- + public readonly onPdfInitialized: TeamAppsEvent = new TeamAppsEvent(); + + /** + * onZoomFactorAutoChanged will only fire when the factor was changed automatically by + * UiPdfZoomMode.TO_WIDTH or UiPdfZoomMode.TO_HEIGHT + */ + public readonly onZoomFactorAutoChanged: TeamAppsEvent = new TeamAppsEvent(); + public readonly onPdfLoadFailed: TeamAppsEvent = new TeamAppsEvent(); + + // Constructor + // ----------- + constructor(config: UiPdfViewerConfig, context: TeamAppsUiContext) { + super(config, context); + + this.uuidClass = `UiPdfViewer-${generateUUID()}`; + this.config = config; + this.$main = parseHtml(` +
+
+
+
+ +
+
+ +
`); + this.$canvas = this.$main.querySelector(`canvas.${this.uuidClass}`); + this.$canvasContainer = this.$main.querySelector(`div.canvas-container.${this.uuidClass}`); + this.$pagesContainer = this.$main.querySelector(`#pagesContainer`); + this.$devRenderStats = document.createElement("div"); + this.$devRenderStats.className = this.uuidClass; + this.$devRenderStats.id = "dev-render-stats"; + this.$main.appendChild(this.$devRenderStats); + + // Ensure the component itself defines a height context for percentage/flex sizing. + this.$main.style.display = "flex"; + this.$main.style.flexDirection = "column"; + this.$main.style.height = "100%"; + this.$main.style.minHeight = "0"; + this.$main.style.position = "relative"; + + // Dev Helper UI + this.$devToolbar = this.$main.querySelector('#dev-toolbar'); + this.renderDevToolsIfEnabled(); + + this.singlePageRenderer = new SinglePageRenderer({ + calculateZoomScale: (page) => this.calculateZoomScale(page), + updateDevRenderStats: () => this.updateDevRenderStats() + }); + + this.continuousRenderer = new ContinuousRenderer({ + calculateZoomScale: (page) => this.calculateZoomScale(page), + applyPageBorderToCanvas: (canvas) => this.applyPageBorderToCanvas(canvas), + updateDevRenderStats: () => this.updateDevRenderStats() + }); + + this.continuousVirtualRenderer = new ContinuousVirtualRenderer({ + calculateZoomScale: (page) => this.calculateZoomScale(page), + applyPageBorderToCanvas: (canvas) => this.applyPageBorderToCanvas(canvas), + updateDevRenderStats: () => this.updateDevRenderStats() + }); + + this.renderCanvasContainer(); + this.updatePageSpacing(); + this.updateDevRenderStats(); + + // Load the pdf by setting it's url + setTimeout(async () => { + await this.setUrl(config.url); + }, 0); + } + + public doGetMainElement(): HTMLElement { + return this.$main; + } + + // Helper Functions + // ---------------- + + public renderDevToolsIfEnabled() { + if (this.config.showDevTools === true) { + if (!this.devToolsInitialized) { + this.devToolsInitialized = true; + } + this.$devToolbar.style.display = "flex"; + this.$devRenderStats.style.display = "block"; + this.updateDevRenderStats(); + } else { + this.$devToolbar.style.display = "none"; + this.$devRenderStats.style.display = "none"; + } + } + + public renderCanvasContainer() { + const bgColor = this.config.backgroundColor; + if (typeof bgColor === "string" && bgColor.length > 0) { + this.$canvasContainer.style.backgroundColor = bgColor + } + + const borderColor = this.config.borderColor; + if (typeof borderColor === "string" && borderColor.length > 0) { + this.$canvasContainer.style.borderColor = borderColor; + } + } + + public updatePageSpacing() { + this.$pagesContainer.style.gap = `${this.config.pageSpacing}px`; + } + + // Core Class Logic + // ----------------- + + private calculateZoomScale(page: PDFPageProxy): { scale: number, viewport: PageViewport } { + const zoomModeAtInvocation = this.config.zoomMode; + const zoomResult = this.computeZoomScale(page, zoomModeAtInvocation, this.config.zoomFactor ?? 1.0); + this.applyAutoZoomResult(zoomResult, zoomModeAtInvocation); + return {scale: zoomResult.scale, viewport: zoomResult.viewport}; + } + + private computeZoomScale(page: PDFPageProxy, zoomMode: UiPdfZoomMode, zoomFactor: number): { + scale: number, + viewport: PageViewport, + autoZoomFactor?: number + } { + let scale = [UiPdfZoomMode.TO_HEIGHT, UiPdfZoomMode.TO_WIDTH].includes(zoomMode) + ? 1.0 : zoomFactor; + let viewport = page.getViewport({scale}); + + if (zoomMode === UiPdfZoomMode.TO_WIDTH) { + const containerWidth = this.$canvasContainer.clientWidth; + if (containerWidth <= 0) { + return {scale, viewport}; + } + let autoZoomFactor = containerWidth / viewport.width; + autoZoomFactor = floorToPrecision(autoZoomFactor, 2); + autoZoomFactor = Math.max(0.1, autoZoomFactor - 0.1); + scale = autoZoomFactor; + viewport = page.getViewport({scale}); + return {scale, viewport, autoZoomFactor}; + } + + if (zoomMode === UiPdfZoomMode.TO_HEIGHT) { + const containerHeight = this.$canvasContainer.clientHeight; + const availableHeight = containerHeight - this.config.padding * 2; + if (availableHeight <= 0) { + return {scale, viewport}; + } + let autoZoomFactor = availableHeight / viewport.height; + autoZoomFactor = floorToPrecision(autoZoomFactor, 2); + scale = autoZoomFactor; + viewport = page.getViewport({scale}); + return {scale, viewport, autoZoomFactor}; + } + + return {scale, viewport}; + } + + private applyAutoZoomResult(zoomResult: { autoZoomFactor?: number }, zoomModeAtInvocation: UiPdfZoomMode) { + if (zoomResult.autoZoomFactor == null) { + return; + } + // Ignore stale updates when zoom mode changed concurrently. + if (this.config.zoomMode !== zoomModeAtInvocation) { + return; + } + this.config.zoomFactor = zoomResult.autoZoomFactor; + this.config.zoomMode = UiPdfZoomMode.MANUAL; + this.onZoomFactorAutoChanged.fire({ + zoomFactor: zoomResult.autoZoomFactor + }); + } + + /** + * bjesuiter 2025-03-28: only supports page-based rendering for now, sicne it's easier to implement. + * Continuous pdf page rendering is on the roadmap, but delayed indefinitely for now. + * + * @private + */ + private async renderPdfDocument(options: RenderPdfDocumentOptions = {}) { + if (!this.pdfDocument) { + return; + } + + this.applyVirtualLifecyclePolicyBeforeRender(options); + await this.renderPdfDocumentCore(); + + if (options.stabilizationZoomMode != null) { + await this.runVirtualAutoZoomStabilizationPass(options.stabilizationZoomMode); + } + } + + private applyVirtualLifecyclePolicyBeforeRender(options: RenderPdfDocumentOptions) { + if (options.zoomConfigurationChanged === true) { + if (this.config.viewMode === UiPdfViewMode.CONTINUOUS_VIRTUAL) { + this.teardownContinuousVirtualMode(); + } else { + this.continuousVirtualRenderer.markForRecreate(); + } + } + + if (options.forceVirtualTeardownBeforeRender === true) { + this.teardownContinuousVirtualMode(); + } + + if (this.lastViewMode === UiPdfViewMode.CONTINUOUS_VIRTUAL && this.config.viewMode !== UiPdfViewMode.CONTINUOUS_VIRTUAL) { + this.teardownContinuousVirtualMode(); + } + this.lastViewMode = this.config.viewMode; + } + + private async renderPdfDocumentCore() { + this.$canvasContainer.style.padding = `${this.config.padding}px`; + // Incrementing this invalidates all older async rendering work. + const requestId = ++this.renderRequestId; + this.updateDevRenderStats(); + + if (this.config.viewMode === UiPdfViewMode.CONTINUOUS_VIRTUAL) { + await this.renderPdfContinuousVirtualMode(requestId); + } else if (this.config.viewMode === UiPdfViewMode.CONTINUOUS) { + await this.renderPdfContinuousMode(requestId); + } else { + await this.renderPdfSinglePageMode(requestId); + } + this.updateDevRenderStats(); + } + + /** + * Based on Example: + * https://mozilla.github.io/pdf.js/examples/#:~:text=page*%20here%0A%7D)%3B-,Rendering%20the%20Page,-Each%20PDF%20page + * @private + */ + private async renderPdfSinglePageMode(requestId: number) { + // Bail out if a newer render was requested while we were queued. + if (requestId !== this.renderRequestId) { + return; + } + + this.applyPagesContainerLayoutForMode(UiPdfViewMode.SINGLE_PAGE); + await this.singlePageRenderer.render(this.createSinglePageRenderContext(requestId)); + } + + private async renderPdfContinuousMode(requestId: number) { + // Bail out if a newer render was requested while we were queued. + if (requestId !== this.renderRequestId) { + return; + } + + this.applyPagesContainerLayoutForMode(UiPdfViewMode.CONTINUOUS); + await this.continuousRenderer.render(this.createContinuousRenderContext(requestId)); + } + + private async renderPdfContinuousVirtualMode(requestId: number) { + // Bail out if a newer render was requested while we were queued. + if (requestId !== this.renderRequestId) { + return; + } + + this.applyPagesContainerLayoutForMode(UiPdfViewMode.CONTINUOUS_VIRTUAL); + await this.continuousVirtualRenderer.render(this.createContinuousVirtualRenderContext(requestId)); + } + + private createSinglePageRenderContext(requestId: number): SinglePageRenderContext { + return { + requestId, + getCurrentRenderRequestId: () => this.renderRequestId, + pdfDocument: this.pdfDocument, + currentPageNumber: this.currentPageNumber, + pagesContainer: this.$pagesContainer, + canvas: this.$canvas + }; + } + + private createContinuousRenderContext(requestId: number): ContinuousRenderContext { + return { + requestId, + getCurrentRenderRequestId: () => this.renderRequestId, + pdfDocument: this.pdfDocument, + maxPageNumber: this.maxPageNumber, + pagesContainer: this.$pagesContainer, + uuidClass: this.uuidClass, + pageBorder: this.config.pageBorder + }; + } + + private createContinuousVirtualRenderContext(requestId: number): ContinuousVirtualRenderContext { + return { + requestId, + getCurrentRenderRequestId: () => this.renderRequestId, + pdfDocument: this.pdfDocument, + maxPageNumber: this.maxPageNumber, + config: this.config, + pagesContainer: this.$pagesContainer, + canvasContainer: this.$canvasContainer, + uuidClass: this.uuidClass + }; + } + + private applyPagesContainerLayoutForMode(viewMode: UiPdfViewMode) { + if (viewMode === UiPdfViewMode.CONTINUOUS_VIRTUAL) { + this.$pagesContainer.style.display = "block"; + this.$pagesContainer.style.flexFlow = ""; + this.$pagesContainer.style.alignItems = ""; + this.$pagesContainer.style.position = "relative"; + this.$pagesContainer.style.gap = ""; + } else { + this.$pagesContainer.style.display = "flex"; + this.$pagesContainer.style.flexFlow = "column nowrap"; + this.$pagesContainer.style.alignItems = "center"; + this.$pagesContainer.style.position = ""; + this.$pagesContainer.style.gap = `${this.config.pageSpacing}px`; + } + } + + private teardownContinuousVirtualMode() { + this.continuousVirtualRenderer.teardown(); + } + + private async runVirtualAutoZoomStabilizationPass(zoomMode: UiPdfZoomMode) { + if (this.config.viewMode !== UiPdfViewMode.CONTINUOUS_VIRTUAL) { + return; + } + if (zoomMode !== UiPdfZoomMode.TO_WIDTH && zoomMode !== UiPdfZoomMode.TO_HEIGHT) { + return; + } + // IMPORTANT: + // Do not restore/preserve scroll position around virtual auto-zoom transitions. + // Attempts to keep scroll anchors here caused unstable/broken virtual rendering. + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + this.config.zoomMode = zoomMode; + await this.renderPdfDocument({ + forceVirtualTeardownBeforeRender: true + }); + } + + private applyPageBorderToCanvas(canvas: HTMLCanvasElement) { + const border = this.config.pageBorder; + if (border) { + if (border.top) { + canvas.style.borderTop = `${border.top.thickness}px solid ${border.top.color}`; + } + if (border.right) { + canvas.style.borderRight = `${border.right.thickness}px solid ${border.right.color}`; + } + if (border.bottom) { + canvas.style.borderBottom = `${border.bottom.thickness}px solid ${border.bottom.color}`; + } + if (border.left) { + canvas.style.borderLeft = `${border.left.thickness}px solid ${border.left.color}`; + } + if (border.borderRadius) { + canvas.style.borderRadius = `${border.borderRadius}px`; + } + } + } + + private updateDevRenderStats() { + if (!this.$devRenderStats) { + return; + } + const canvasCount = this.$pagesContainer ? this.$pagesContainer.querySelectorAll("canvas").length : 0; + const pageCount = this.pdfDocument?.numPages || this.maxPageNumber || 0; + this.$devRenderStats.textContent = `${canvasCount} / ${pageCount}`; + } + + // Setters for Server API + // -----------------------™ + + public async setUrl(url: string) { + // Every setUrl() starts a new load generation and invalidates all previous in-flight loads. + const loadRequestId = ++this.documentLoadRequestId; + try { + const pdfDocument = await pdfjsLib.getDocument(url).promise; + // Ignore stale completion from an older setUrl() call. + if (loadRequestId !== this.documentLoadRequestId) { + return; + } + + this.pdfDocument = pdfDocument; + this.maxPageNumber = this.pdfDocument.numPages; + this.currentPageNumber = Math.max(1, Math.min(this.currentPageNumber, this.maxPageNumber)); + this.updateDevRenderStats(); + + await this.renderPdfDocument(); + // Rendering may finish after a newer setUrl() call has started. + if (loadRequestId !== this.documentLoadRequestId) { + return; + } + await this.applyPendingShowPageRequest(); + if (loadRequestId !== this.documentLoadRequestId) { + return; + } + + // Guard event emission as well: stale URL loads must not emit onPdfInitialized. + // Tell the server the document has loaded after the first renderPdfDocument + // since it is async, I can simply wait for it to finish + this.onPdfInitialized.fire({ + numberOfPages: this.maxPageNumber, + }); + } catch (error) { + // Report only if this is still the active load request. + if (loadRequestId !== this.documentLoadRequestId) { + return; + } + const errorMessage = error instanceof Error ? error.message : String(error); + console.error("UiPdfViewer: failed to load PDF URL", {url, error}); + this.onPdfLoadFailed.fire({ + url, + errorMessage + }); + } + } + + private async applyPendingShowPageRequest() { + const pendingPage = this.pendingShowPageNumber; + if (pendingPage == null) { + return; + } + this.pendingShowPageNumber = null; + await this.showPage(pendingPage); + } + + public async setShowDevTools(showDevTools:boolean) { + this.config.showDevTools = showDevTools; + this.renderDevToolsIfEnabled(); + } + + public async setViewMode(viewMode: UiPdfViewMode) { + this.config.viewMode = viewMode; + await this.renderPdfDocument(); + } + + public async showPage(page: number) { + if (this.maxPageNumber <= 0) { + if (page >= 1) { + this.pendingShowPageNumber = page; + } + return; + } + + if (page >= 1 && page <= this.maxPageNumber ) { + this.currentPageNumber = page; + this.updateDevRenderStats(); + if (this.config.viewMode === UiPdfViewMode.CONTINUOUS_VIRTUAL) { + if (!this.continuousVirtualRenderer.hasVirtualizer()) { + await this.renderPdfDocument(); + } + this.continuousVirtualRenderer.scrollToIndex(page - 1); + return; + } + await this.renderPdfDocument(); + } + } + + public async setZoomFactor(zoomFactor: number) { + this.config.zoomFactor = zoomFactor; + await this.renderPdfDocument({ + zoomConfigurationChanged: true + }); + } + + public async setZoomMode(zoomMode: UiPdfZoomMode) { + this.config.zoomMode = zoomMode; + await this.renderPdfDocument({ + zoomConfigurationChanged: true, + stabilizationZoomMode: zoomMode + }); + } + + public async setPageBorder(pageBorder: UiBorderConfig) { + this.config.pageBorder = pageBorder; + await this.renderPdfDocument(); + } + + + public async setPadding(padding: number) { + this.config.padding = padding; + await this.renderPdfDocument(); + } + + /** + * This is only relevant in CONTINUOUS render mode + * @param pageSpacing + */ + public async setPageSpacing(pageSpacing: number) { + this.config.pageSpacing = pageSpacing; + this.updatePageSpacing(); + await this.renderPdfDocument(); + } + + public async setBackgroundColor(cssColor: string) { + this.config.backgroundColor = cssColor; + this.renderCanvasContainer(); + } + + public async setBorderColor(cssColor: string) { + this.config.borderColor = cssColor; + this.renderCanvasContainer(); + } + +} + +TeamAppsUiComponentRegistry.registerComponentClass("UiPdfViewer", UiPdfViewer); diff --git a/teamapps-client/ts/modules/UiPieChart.ts b/teamapps-client/ts/modules/UiPieChart.ts index 91a298a76..174034360 100644 --- a/teamapps-client/ts/modules/UiPieChart.ts +++ b/teamapps-client/ts/modules/UiPieChart.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,36 +15,27 @@ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. - * ===== - + * =========================LICENSE_END================================== */ -import { UiComponent } from "./UiComponent"; -import { TeamAppsUiComponentRegistry } from "./TeamAppsUiComponentRegistry"; -import { - UiPieChart_DataPointClickedEvent, - UiPieChartCommandHandler, - UiPieChartConfig, - UiPieChartEventSource -} from "../generated/UiPieChartConfig"; -import { TeamAppsUiContext } from "./TeamAppsUiContext"; -import { TeamAppsEvent } from "./util/TeamAppsEvent"; -import { UiChartNamedDataPointConfig } from "../generated/UiChartNamedDataPointConfig"; +import {AbstractUiComponent} from "./AbstractUiComponent"; +import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; +import {UiPieChart_DataPointClickedEvent, UiPieChartCommandHandler, UiPieChartConfig, UiPieChartEventSource} from "../generated/UiPieChartConfig"; +import {TeamAppsUiContext} from "./TeamAppsUiContext"; +import {TeamAppsEvent} from "./util/TeamAppsEvent"; +import {UiChartNamedDataPointConfig} from "../generated/UiChartNamedDataPointConfig"; import * as d3 from "d3" -import { EventFactory } from "../generated/EventFactory"; -import { UiColorConfig } from '../generated/UiColorConfig'; -import { UiDataPointWeighting } from '../generated/UiDataPointWeighting'; +import {UiDataPointWeighting} from '../generated/UiDataPointWeighting'; import {UiChartLegendStyle} from "../generated/UiChartLegendStyle"; -export class UiPieChart extends UiComponent implements UiPieChartCommandHandler, UiPieChartEventSource { +export class UiPieChart extends AbstractUiComponent implements UiPieChartCommandHandler, UiPieChartEventSource { - readonly onDataPointClicked: TeamAppsEvent = new TeamAppsEvent(this); + readonly onDataPointClicked: TeamAppsEvent = new TeamAppsEvent(); - chart: any; + chart: Chart; config: UiPieChartConfig; - constructor(config: UiPieChartConfig, context: TeamAppsUiContext) { super(config, context); this.config = config; @@ -52,28 +43,31 @@ export class UiPieChart extends UiComponent implements UiPieCh } createChart() { - let htmlDivElement: any = document.createElement("div"); + let htmlDivElement = document.createElement("div"); + htmlDivElement.classList.add("UiPieChart"); //@ts-ignore - this.chart = Chart() + this.chart = new Chart() .container(htmlDivElement) .data(this.config) .onDataPointClicked((name: string) => { - this.onDataPointClicked.fire(EventFactory.createUiPieChart_DataPointClickedEvent(this.getId(), name)); + this.onDataPointClicked.fire({ + dataPointName: name + }); }) .render(); } - getMainDomElement(): JQuery { - return $(this.chart.container()); + + doGetMainElement(): HTMLElement { + return this.chart.container() as HTMLElement; } onResize(): void { // Get our dimensions - let newWidth = this.getWidth() - let newHeight = this.getHeight() + let newWidth = this.getWidth(); + let newHeight = this.getHeight(); - // Update chart dimensions this.chart.svgWidth(newWidth) @@ -142,31 +136,127 @@ This code is based on following convention: https://github.com/bumbeishvili/d3-coding-conventions/blob/84b538fa99e43647d0d4717247d7b650cb9049eb/README.md */ -function Chart() { - // Exposed variables - var attrs: PieChartAttributes = { - id: 'ID' + Math.floor(Math.random() * 1000000), // Id for event handlings - svgWidth: 400, - svgHeight: 400, - marginTop: 5, - marginBottom: 5, - marginRight: 5, - marginLeft: 5, - container: 'body', - defaultTextFill: '#2C3E50', - defaultFont: 'Helvetica', - data: null, - duration: 750, - firstRun: true, - onDataPointClicked: (name: string) => { } - }; - - //InnerFunctions which will update visuals - var updateData: (...x: any[]) => any; - - //Main chart object - var main: any = function () { +// Mixins +interface Chart { + getChartState: () => PieChartAttributes; + + svgWidth(): number, + svgWidth(svgWidth: number): Chart, + svgHeight(): number, + svgHeight(svgHeight: number): Chart, + marginTop(): number, + marginTop(marginTop: number): Chart, + marginBottom(): number, + marginBottom(marginBottom: number): Chart, + marginRight(): number, + marginRight(marginRight: number): Chart, + marginLeft(): number, + marginLeft(marginLeft: number): Chart, + container(): Element, + container(container: Element): Chart, + defaultTextFill(): string, + defaultTextFill(defaultTextFill: string): Chart, + defaultFont(): string, + defaultFont(defaultFont: string): Chart, + data(): UiPieChartConfig, + data(data: UiPieChartConfig): Chart, + duration(): number, + duration(duration: number): Chart, + firstRun(): boolean, + firstRun(firstRun: boolean): Chart, + onDataPointClicked(): (id: string) => void, + onDataPointClicked(onDataPointClicked: (id: string) => void): Chart, + rx(): number, + rx(rx: number): Chart, + ry(): number, + ry(ry: number): Chart, + h(): number, + h(h: number): Chart, + ir(): number + ir(ir: number): Chart + +} + +class Chart { + + /** It feels so wrong declaring this variable + * We don't use it, if we have used it, it won't be in static context + * Basically we use this variable to make typescript compiler to not to complain about this._current usage in the static methods + * where 'this' is bound externally using method.bind(this) syntax + */ + static _current: any; + + + constructor() { + // Exposed variables + const attrs: PieChartAttributes = { + id: 'ID' + Math.floor(Math.random() * 1000000), // Id for event handlings + svgWidth: 400, + svgHeight: 400, + marginTop: 5, + marginBottom: 5, + marginRight: 5, + marginLeft: 5, + container: 'body', + defaultTextFill: '#2C3E50', + defaultFont: 'Helvetica', + data: null, + duration: 750, + firstRun: true, + onDataPointClicked: (name) => { + }, + rx: null, + ry: null, + h: null, + ir: null + }; + + this.getChartState = () => attrs; + + // Dynamically create accessor functions + Object.keys(attrs).forEach((key) => { + //@ts-ignore + this[key] = function (_) { + var string = `attrs['${key}'] = _`; + if (!arguments.length) { + return eval(`attrs['${key}'];`); + } + eval(string); + return this; + }; + + }); + + this.initializeEnterExitUpdatePattern(); + } + + // Make charts updatable + initializeEnterExitUpdatePattern() { + d3.selection.prototype.patternify = function ({selector, tag, data}: PatternifyParameter) { + var container = this; + data = data || [selector]; + // Pattern in action + var selection = container.selectAll('.' + selector).data(data, (d: any, i: number) => { + if (typeof d === 'object') { + if (d.id) { + return d.id; + } + } + return i; + }); + selection.exit().remove(); + selection = selection.enter().append(tag).merge(selection); + selection.attr('class', selector); + return selection; + }; + } + + // Render elements + render() { + const attrs = this.getChartState(); + const self = this; attrs.marginBottom = 5 + attrs.data.height3D; + // Drawing containers var container: any = d3.select(attrs.container); // var containerRect = container.node().getBoundingClientRect(); @@ -187,24 +277,29 @@ function Chart() { calc.chartHeight = attrs.svgHeight - attrs.marginBottom - calc.chartTopMargin; calc.centerX = calc.chartWidth / 2; calc.centerY = calc.chartHeight / 2; - + attrs.calc = calc; // ----------------- LAYOUTS ---------------- - const layouts: { pie?: any } = {}; + const layouts: Layouts = {}; layouts.pie = d3.pie().sort(null).value((d: any) => d.value); + attrs.layouts = layouts; // ------------------ OVERRIDES & CONVERSATION ------------------ - - const rx = Math.min(calc.chartWidth, calc.chartHeight * 90 / attrs.data.rotation3D) / 2; const ry = attrs.data.rotation3D / 90 * rx; const h = attrs.data.height3D; const ir = attrs.data.innerRadiusProportion; const rotation = attrs.data.rotationClockwise; - - const convertedData: { label: string, color: string, value: number, type: string }[] = attrs.data.dataPoints.map(point => { + attrs.rx = rx; + attrs.ry = ry; + attrs.h = h; + attrs.ir = ir; + attrs.rotation = rotation; + + // Converting data to desired format + const convertedData = attrs.data.dataPoints.map(point => { const label = point.name; - const color = colorToRGBAString(point.color); + const color = point.color; const value = point.y; return { label: label, @@ -226,6 +321,7 @@ function Chart() { //Check if sum of them is not more than 1 const sum = d3.sum(convertedData, d => d.value); + // Check if it;s correct weighting if (sum < 1) { // Attach dummy data element, which will be transparent convertedData.push({ @@ -239,12 +335,13 @@ function Chart() { } } + // Storing sum of elements calc.sum = d3.sum(convertedData, d => d.value); // Generate data, which contains info about pie angles const pieData = layouts.pie(convertedData) - .map((d: any) => { + .map((d: { startAngle: number, endAngle: number }) => { // Add configs clockwise rotation, to the generated angles return Object.assign(d, { startAngle: d.startAngle + (Math.PI * 2 * (rotation % 360) / 360), @@ -252,617 +349,704 @@ function Chart() { }) }) - //Add svg var svg = container - .patternify({ tag: 'svg', selector: 'svg-chart-container' }) + .patternify({ + tag: 'svg', + selector: 'svg-chart-container' + }) .attr('width', attrs.svgWidth) .attr('height', attrs.svgHeight) .attr('font-family', attrs.defaultFont); //Add container g element var chart = svg - .patternify({ tag: 'g', selector: 'chart' }) + .patternify({ + tag: 'g', + selector: 'chart' + }) .attr('transform', 'translate(' + calc.chartLeftMargin + ',' + calc.chartTopMargin + ')'); - // Center point - var centerPoint = chart.patternify({ tag: 'g', selector: 'center-point' }) - .attr('transform', `translate(${calc.centerX},${calc.centerY})`) + attrs.centerPoint = chart.patternify({ + tag: 'g', + selector: 'center-point' + }) + .attr('transform', `translate(${calc.centerX},${calc.centerY})`); // Draw pie - draw(pieData, rx, ry, h, ir); + this.draw(pieData, attrs); - // Smoothly handle data updating - updateData = function (transitionTime) { - - - }; + //######################################### UTIL FUNCS ################################## + d3.select(window).on('resize.' + attrs.id, () => { + var containerRect = container.node().getBoundingClientRect(); + if (containerRect.width > 0) this.svgWidth(containerRect.width); + this.render(); + }); + // Store state, whether app was first run or not + attrs.firstRun = false; + return this; + } - // -------------- EVENT HANDLERS ---------------- - function onSliceClick(d: any) { - attrs.onDataPointClicked(d.data.label); - } + draw( + data: PieDataObject[], + { + rx /*radius x*/, + ry /*radius y*/, + h /*height*/, + ir /*inner radius*/ + }: PieChartAttributes + ) { + + const attrs = this.getChartState(); + const calc = attrs.calc; + const self = this; + + // Placeholder data + const _data = data; + + // Create Slices and shape containers + var slices = attrs.centerPoint + .patternify({ + tag: 'g', + selector: 'slices' + }) - function onSliceMouseEnter(d: any) { + // Store reference for func access + attrs.slices = slices; - } + const outerSliceWrapper = attrs.centerPoint + .patternify({ + tag: 'g', + selector: 'outerSliceWrapper' + }) - function onSliceMouseLeave(d: any) { + const topSliceWrapper = attrs.centerPoint + .patternify({ + tag: 'g', + selector: 'topSliceWrapper' + }) - } + // Creating inner slice custom paths + const pieInners = slices + .patternify({ + tag: 'path', + selector: 'innerSlice', + data: _data + }) + .style("fill", function (d: PieDataObject) { + return d3.hsl(d.data.color).darker(2).toString() + }) + .attr("d", function (d: PieDataObject) { + return Chart.pieInner(d, attrs); + }) + .classed('slice-sort', true) + .on('click', (d: PieDataObject) => self.onSliceClick(d)) + .on('mouseenter', (d: PieDataObject) => self.onSliceMouseEnter(d)) + .on('mouseleave', (d: PieDataObject) => self.onSliceMouseLeave(d)) + + // Transition inner elements + pieInners + .transition() + .duration(attrs.duration) + .attrTween("d", function (d: PieDataObject) { + return Chart.arcTweenInner.bind(this)(d, attrs) + }) + .on('end', function (d: PieDataObject) { + this._current = d; + }) - //######################################### UTIL FUNCS ################################## + // Create corner slice paths + const cornerSliceElements = slices + .patternify({ + tag: 'path', + selector: 'cornerSlices', + data: _data.map((d) => Object.assign({}, d)) + }) + .style("fill", (d: PieDataObject) => { + return d3.hsl(d.data.color).darker(0.7).toString(); + }) + .attr("d", function (d: PieDataObject) { + return Chart.pieCorner(d, attrs); + }) + .classed('slice-sort', true) + .attr('pointer-events', '') + .style("stroke", function (d: PieDataObject) { + return d3.hsl(d.data.color).darker(0.7).toString() + }) + .on('click', (d: PieDataObject) => self.onSliceClick(d)) + .on('mouseenter', (d: PieDataObject) => self.onSliceMouseEnter(d)) + .on('mouseleave', (d: PieDataObject) => self.onSliceMouseLeave(d)) + .attr('opacity', (d: PieDataObject, i: number, arr: PieDataObject[]) => { + if (d.endAngle > Math.PI && d.endAngle <= (Math.PI + Math.PI / 2) + ) { + return 0; + } + if (arr.length - 2 == i) { + return 1; + } + return 0; + }) - // Put element sorting logic in sorElements function - function sortElements() { - } + // Store reference for function access + attrs.cornerSliceElements = cornerSliceElements; - //Corner shape transitions - function arcTweenCorner(a: any) { - if (!this._current) { - this._current = Object.assign({}, a, { - startAngle: 0, - endAngle: 0 - }) - } - var i = d3.interpolate(this._current, a); - this._current = i(0); - return function (t: any) { - return pieCorner(i(t), rx + 0.5, ry + 0.5, h, ir); - }; - } + // Transition corner elements + cornerSliceElements + .transition() + .duration(attrs.duration) + .attrTween("d", function (d: PieDataObject) { + return Chart.arcTweenCorner.bind(this)(d, attrs) + }) + .on('end', function (d: PieDataObject) { + this._current = d; + }) - //Corner surface shape transitions - function arcTweenCornerSurface(a: any) { - if (!this._current) { - this._current = Object.assign({}, a, { - startAngle: 0, - endAngle: 0 - }) - } - var i = d3.interpolate(this._current, a); - this._current = i(0); - return function (t: any) { - return pieCornerSurface(i(t), rx + 0.5, ry + 0.5, h, ir); - }; - } + // Create corner slice surface paths + const cornerSliceSurfaceElements = slices + .patternify({ + tag: 'path', + selector: 'cornerSlicesSurface', + data: _data.map((d) => Object.assign({}, d)) + }) + .style("fill", function (d: PieDataObject) { + return d3.hsl(d.data.color).darker(0.7).toString() + }) + .attr("d", function (d: PieDataObject) { + return Chart.pieCornerSurface(d, attrs); + }) + .classed('slice-sort', true) + .style("stroke", function (d: PieDataObject) { + return d3.hsl(d.data.color).darker(0.7).toString() + }) + .on('click', (d: PieDataObject) => self.onSliceClick(d)) + .on('mouseenter', (d: PieDataObject) => self.onSliceMouseEnter(d)) + .on('mouseleave', (d: PieDataObject) => self.onSliceMouseLeave(d)) + .attr('opacity', (d: PieDataObject, i: number, arr: PieDataObject[]) => { + if (d.startAngle < Math.PI / 2 || + d.startAngle > Math.PI * 3 / 2 + ) { + return 0; + } + if (0 == i) { + return 1; + } + return 0; + }) - //Inner shape transitions - function arcTweenInner(a: any) { - if (!this._current) { - this._current = Object.assign({}, a, { - startAngle: 0, - endAngle: 0 - }) - } - var i = d3.interpolate(this._current, a); - this._current = i(0); - return function (t: any) { - return pieInner(i(t), rx + 0.5, ry + 0.5, h, ir); - }; - } + // Store reference for function access + attrs.cornerSliceSurfaceElements = cornerSliceSurfaceElements; - //Top shape transitions - function arcTweenTop(a: any) { - if (!this._current) { - this._current = Object.assign({}, a, { - startAngle: 0, - endAngle: 0 - }) - } - var i = d3.interpolate(this._current, a); - this._current = i(0); - return function (t: any) { - return pieTop(i(t), rx, ry, ir); - }; - } + // Transition corner Surface elements + cornerSliceSurfaceElements + .transition() + .duration(attrs.duration) + .attrTween("d", function (d: PieDataObject) { + return Chart.arcTweenCornerSurface.bind(this)(d, attrs) + }) + .on('end', function (d: PieDataObject) { + this._current = d; + }) - //Outer shape transitions - function arcTweenOuter(a: any) { - if (!this._current) { - this._current = Object.assign({}, a, { - startAngle: 0, - endAngle: 0 - }) - } - var i = d3.interpolate(this._current, a); - this._current = i(0); - return function (t: any) { return pieOuter(i(t), rx - .5, ry - .5, h, ir); }; - } + // Draw outer slices + const outerSlices = outerSliceWrapper + .patternify({ + tag: 'path', + selector: 'outerSlice', + data: _data + }) + .style("fill", function (d: PieDataObject) { + return d3.hsl(d.data.color).darker(0.7).toString() + }) + .on('click', (d: PieDataObject) => self.onSliceClick(d)) + .on('mouseenter', (d: PieDataObject) => self.onSliceMouseEnter(d)) + .on('mouseleave', (d: PieDataObject) => self.onSliceMouseLeave(d)) + + // Transition outer elements + outerSlices.transition() + .duration(attrs.duration) + .attrTween("d", function (d: PieDataObject) { + return Chart.arcTweenOuter.bind(this)(d, attrs) + }) + .on('end', function (d: PieDataObject) { + this._current = d; + }) + // Draw top slices + const topSlices = topSliceWrapper + .patternify({ + tag: 'path', + selector: 'topSlice', + data: _data + }) + .style("fill", function (d: PieDataObject) { + return d.data.color; + }) + .style("stroke", function (d: PieDataObject) { + return d.data.color; + }) + // .attr("d", function (d: any) { return pieTop(d, rx, ry, ir); }) + .on('click', (d: PieDataObject) => self.onSliceClick(d)) + .on('mouseenter', (d: PieDataObject) => self.onSliceMouseEnter(d)) + .on('mouseleave', (d: PieDataObject) => self.onSliceMouseLeave(d)) + + // Transition top elements + topSlices.transition() + .duration(attrs.duration) + .attrTween("d", function (d: PieDataObject) { + return Chart.arcTweenTop.bind(this)(d, attrs) + }) + .on('end', function (d: PieDataObject) { + this._current = d; + }) - //Text transitions - function textTweenTransform(a: any) { - if (!this._current) { - this._current = Object.assign({}, a, { - startAngle: 0, - endAngle: 0 - }) - } - var i = d3.interpolate(this._current, a); - this._current = i(0); - return function (t: any) { - const d = i(t); + // Draw Texts + const slicesTexts = topSliceWrapper + .patternify({ + tag: 'text', + selector: 'pie-labels', + data: _data.map((d) => Object.assign({}, d)) + }) + .attr('text-anchor', 'middle') + .attr('font-size', 10) + .attr('transform', (d: PieDataObject) => { const centerAngle = ((d.startAngle + d.endAngle) / 2) % (Math.PI * 2); const x = rx * 0.8 * Math.cos(centerAngle); const y = ry * 0.8 * Math.sin(centerAngle); return `translate(${x},${y}) ` - }; - } + }) + .text((d: PieDataObject) => d.data.label + ' (' + Math.round(d.value / calc.sum * 100) + '%)') + .attr('opacity', (d: PieDataObject) => d.data.type == "dummy" ? -1 : 1) + + // Transition text elements + slicesTexts.transition() + .duration(attrs.duration) + .attrTween("transform", function (d: PieDataObject) { + return Chart.textTweenTransform.bind(this)(d, attrs) + }) - // This function converts RGBA color object to js compatible rgba string color - function colorToRGBAString(color: UiColorConfig) { - return `rgba(${color.red},${color.green},${color.blue},${color.alpha == undefined ? 0 : color.alpha})` - } + } + updateData(transitionTime: number) { - //******* Function is responsible for building left corner shape paths */ - function pieCornerSurface(d: any, rx: number, ry: number, h: number, ir: number) { - - // Calculating left corner surface key points - var sxFirst = ir * rx * Math.cos(d.startAngle); - var syFirst = ir * ry * Math.sin(d.startAngle) - var sxSecond = rx * Math.cos(d.startAngle); - var sySecond = ry * Math.sin(d.startAngle); - var sxThird = sxSecond; - var syThird = sySecond + h; - var sxFourth = sxFirst; - var syFourth = syFirst + h; - - // Creating custom path based on calculation - return ` - M ${sxFirst} ${syFirst} - L ${sxSecond} ${sySecond} - L ${sxThird} ${syThird} - L ${sxFourth} ${syFourth} - z - ` - } + return this; + } - //********** Function is responsible for building right corner shape paths */ - function pieCorner(d: any, rx: number, ry: number, h: number, ir: number) { - - // Calculating right corner surface key points - var sxFirst = ir * rx * Math.cos(d.endAngle); - var syFirst = ir * ry * Math.sin(d.endAngle); - var sxSecond = rx * Math.cos(d.endAngle); - var sySecond = ry * Math.sin(d.endAngle); - var sxThird = sxSecond; - var syThird = sySecond + h; - var sxFourth = sxFirst; - var syFourth = syFirst + h; - - // Creating custom path based on calculation - return ` - M ${sxFirst} ${syFirst} - L ${sxSecond} ${sySecond} - L ${sxThird} ${syThird} - L ${sxFourth} ${syFourth} - z - ` - } + onSliceClick(d: PieDataObject) { + const attrs = this.getChartState(); + attrs.onDataPointClicked(d.data.label); + } - //********** Function is responsible for building top shape paths */ - function pieTop(d: any, rx: number, ry: number, ir: number) { + onSliceMouseEnter(d: PieDataObject) { - // If angles are equal, then we got nothing to draw - if (d.endAngle - d.startAngle == 0) return "M 0 0"; + } - // Calculating shape key points - var sx = rx * Math.cos(d.startAngle), - sy = ry * Math.sin(d.startAngle), - ex = rx * Math.cos(d.endAngle), - ey = ry * Math.sin(d.endAngle); + onSliceMouseLeave(d: PieDataObject) { - // Creating custom path based on calculation - var ret = []; - ret.push("M", sx, sy, "A", rx, ry, "0", (d.endAngle - d.startAngle > Math.PI ? 1 : 0), "1", ex, ey, "L", ir * ex, ir * ey); - ret.push("A", ir * rx, ir * ry, "0", (d.endAngle - d.startAngle > Math.PI ? 1 : 0), "0", ir * sx, ir * sy, "z"); - return ret.join(" "); - } + } - //********** Function is responsible for building outer shape paths */ - function pieOuter(d: any, rx: number, ry: number, h: number, ir: number) { + // This function converts RGBA color object to js compatible rgba string color + //********** Function is responsible for building outer shape paths */ + static pieOuter(d: PieDataObject, {rx, ry, h, ir}: PieChartAttributes) { - // Process corner Cases - if (d.endAngle == Math.PI * 2 && d.startAngle > Math.PI && d.startAngle < Math.PI * 2) { - return "" - } - if (d.startAngle > Math.PI * 3 && d.startAngle < Math.PI * 4 && - d.endAngle > Math.PI * 3 && d.endAngle <= Math.PI * 4) { - return "" - } + // Process corner Cases + if (d.endAngle == Math.PI * 2 && d.startAngle > Math.PI && d.startAngle < Math.PI * 2) { + return "" + } + if (d.startAngle > Math.PI * 3 && d.startAngle < Math.PI * 4 && + d.endAngle > Math.PI * 3 && d.endAngle <= Math.PI * 4) { + return "" + } - // Reassign startAngle and endAngle based on their positions - var startAngle = d.startAngle; - var endAngle = d.endAngle; - if (d.startAngle > Math.PI && d.startAngle < Math.PI * 2) { - startAngle = Math.PI; - if (d.endAngle > Math.PI * 2) { - startAngle = 0; - } - } - if (d.endAngle > Math.PI && d.endAngle < Math.PI * 2) { - endAngle = Math.PI; - } - if (d.startAngle > Math.PI * 2) { - startAngle = d.startAngle % (Math.PI * 2); - } + // Reassign startAngle and endAngle based on their positions + var startAngle = d.startAngle; + var endAngle = d.endAngle; + if (d.startAngle > Math.PI && d.startAngle < Math.PI * 2) { + startAngle = Math.PI; if (d.endAngle > Math.PI * 2) { - endAngle = d.endAngle % (Math.PI * 2); - if (d.startAngle <= Math.PI) { - endAngle = Math.PI; - startAngle = 0 - } - } - if (d.endAngle > Math.PI * 3) { - endAngle = Math.PI + startAngle = 0; } - if (d.startAngle < Math.PI && d.endAngle >= 2 * Math.PI) { + } + if (d.endAngle > Math.PI && d.endAngle < Math.PI * 2) { + endAngle = Math.PI; + } + if (d.startAngle > Math.PI * 2) { + startAngle = d.startAngle % (Math.PI * 2); + } + if (d.endAngle > Math.PI * 2) { + endAngle = d.endAngle % (Math.PI * 2); + if (d.startAngle <= Math.PI) { endAngle = Math.PI; - startAngle = d.startAngle + startAngle = 0 } + } + if (d.endAngle > Math.PI * 3) { + endAngle = Math.PI + } + if (d.startAngle < Math.PI && d.endAngle >= 2 * Math.PI) { + endAngle = Math.PI; + startAngle = d.startAngle + } - if (d.startAngle >= Math.PI && d.startAngle <= Math.PI * 2 && - d.endAngle >= Math.PI * 2 && d.endAngle <= Math.PI * 3) { - startAngle = 0; - endAngle = d.endAngle % (Math.PI * 2) - } - // Calculating shape key points + if (d.startAngle >= Math.PI && d.startAngle <= Math.PI * 2 && + d.endAngle >= Math.PI * 2 && d.endAngle <= Math.PI * 3) { + startAngle = 0; + endAngle = d.endAngle % (Math.PI * 2) + } + // Calculating shape key points + var sx = rx * Math.cos(startAngle), + sy = ry * Math.sin(startAngle), + ex = rx * Math.cos(endAngle), + ey = ry * Math.sin(endAngle); + + // Creating custom path commands based on calculation + var ret = []; + ret.push("M", sx, h + sy, "A", rx, ry, "0 0 1", ex, h + ey, "L", ex, ey, "A", rx, ry, "0 0 0", sx, sy, "z"); + + // If shape is big enough, that it needs two separate outer shape , then draw second shape as well + if (d.startAngle < Math.PI && d.endAngle >= 2 * Math.PI) { + startAngle = 0; + endAngle = d.endAngle; var sx = rx * Math.cos(startAngle), sy = ry * Math.sin(startAngle), ex = rx * Math.cos(endAngle), ey = ry * Math.sin(endAngle); - - // Creating custom path commands based on calculation - var ret = []; ret.push("M", sx, h + sy, "A", rx, ry, "0 0 1", ex, h + ey, "L", ex, ey, "A", rx, ry, "0 0 0", sx, sy, "z"); + } - // If shape is big enough, that it needs two separate outer shape , then draw second shape as well - if (d.startAngle < Math.PI && d.endAngle >= 2 * Math.PI) { - startAngle = 0; - endAngle = d.endAngle; - var sx = rx * Math.cos(startAngle), - sy = ry * Math.sin(startAngle), - ex = rx * Math.cos(endAngle), - ey = ry * Math.sin(endAngle); - ret.push("M", sx, h + sy, "A", rx, ry, "0 0 1", ex, h + ey, "L", ex, ey, "A", rx, ry, "0 0 0", sx, sy, "z"); - } + // Assemble shape commands + return ret.join(" "); + } - // Assemble shape commands - return ret.join(" "); - } + static pieInner(d: PieDataObject, { + rx, + ry, + h, + ir + }: PieChartAttributes) { - function pieInner(d: any, rx: number, ry: number, h: number, ir: number) { + // Normalize angles before we start any calculations + var startAngle = (d.startAngle < Math.PI ? Math.PI : d.startAngle); + var endAngle = (d.endAngle < Math.PI ? Math.PI : d.endAngle); - // Normalize angles before we start any calculations - var startAngle = (d.startAngle < Math.PI ? Math.PI : d.startAngle); - var endAngle = (d.endAngle < Math.PI ? Math.PI : d.endAngle); + // Take care of corner cases + if (d.startAngle > Math.PI * 2 && d.endAngle < Math.PI * 3) { + return ""; + } + if (d.startAngle >= Math.PI * 2 && d.endAngle >= Math.PI * 2 && d.endAngle <= Math.PI * 3) { + return ""; + } - // Take care of corner cases - if (d.startAngle > Math.PI * 2 && d.endAngle < Math.PI * 3) { - return ""; - } - if (d.startAngle >= Math.PI * 2 && d.endAngle >= Math.PI * 2 && d.endAngle <= Math.PI * 3) { - return ""; - } + // Reassign startAngle and endAngle based on their positions + if (d.startAngle <= Math.PI && d.endAngle > Math.PI * 2) { + startAngle = Math.PI; + endAngle = 2 * Math.PI; + } + if (d.startAngle > Math.PI && d.endAngle >= Math.PI * 3) { + endAngle = 2 * Math.PI; + } + if (d.startAngle > Math.PI && d.endAngle > Math.PI * 2 && d.endAngle < Math.PI * 3) { + endAngle = 2 * Math.PI; + } + if (d.startAngle > Math.PI && d.startAngle < Math.PI * 2 && d.endAngle > Math.PI * 3) { + endAngle = 2 * Math.PI; + startAngle = Math.PI + } + if (d.startAngle > Math.PI && d.startAngle < Math.PI * 2 && d.endAngle > Math.PI * 3) { + endAngle = 2 * Math.PI; + startAngle = Math.PI + } + if (d.startAngle > Math.PI && + d.startAngle < Math.PI * 2 && + d.endAngle > Math.PI * 3) { + startAngle = Math.PI; + endAngle = Math.PI + d.endAngle % Math.PI; + } + if (d.startAngle > Math.PI * 2 && + d.startAngle < Math.PI * 3 && + d.endAngle > Math.PI * 3) { + startAngle = Math.PI; + endAngle = Math.PI + d.endAngle % Math.PI; + } + if (d.startAngle > Math.PI * 3 && + d.endAngle > Math.PI * 3) { + startAngle = d.startAngle % (Math.PI * 2) + endAngle = d.endAngle % (Math.PI * 2) + } - // Reassign startAngle and endAngle based on their positions - if (d.startAngle <= Math.PI && d.endAngle > Math.PI * 2) { - startAngle = Math.PI; - endAngle = 2 * Math.PI; - } - if (d.startAngle > Math.PI && d.endAngle >= Math.PI * 3) { - endAngle = 2 * Math.PI; - } - if (d.startAngle > Math.PI && d.endAngle > Math.PI * 2 && d.endAngle < Math.PI * 3) { - endAngle = 2 * Math.PI; - } - if (d.startAngle > Math.PI && d.startAngle < Math.PI * 2 && d.endAngle > Math.PI * 3) { - endAngle = 2 * Math.PI; - startAngle = Math.PI - } - if (d.startAngle > Math.PI && d.startAngle < Math.PI * 2 && d.endAngle > Math.PI * 3) { - endAngle = 2 * Math.PI; - startAngle = Math.PI - } - if (d.startAngle > Math.PI && - d.startAngle < Math.PI * 2 && - d.endAngle > Math.PI * 3) { - startAngle = Math.PI; - endAngle = Math.PI + d.endAngle % Math.PI; - } - if (d.startAngle > Math.PI * 2 && - d.startAngle < Math.PI * 3 && - d.endAngle > Math.PI * 3) { - startAngle = Math.PI; - endAngle = Math.PI + d.endAngle % Math.PI; - } - if (d.startAngle > Math.PI * 3 && - d.endAngle > Math.PI * 3) { - startAngle = d.startAngle % (Math.PI * 2) - endAngle = d.endAngle % (Math.PI * 2) - } + // Calculating shape key points + var sx = ir * rx * Math.cos(startAngle), + sy = ir * ry * Math.sin(startAngle), + ex = ir * rx * Math.cos(endAngle), + ey = ir * ry * Math.sin(endAngle); + + // Creating custom path commands based on calculation + var ret = []; + ret.push("M", sx, sy, "A", ir * rx, ir * ry, "0 0 1", ex, ey, "L", ex, h + ey, "A", ir * rx, ir * ry, "0 0 0", sx, h + sy, "z"); - // Calculating shape key points + + // If shape is big enough, that it needs two separate outer shape , then draw second shape as well + if (d.startAngle > Math.PI && + d.startAngle < Math.PI * 2 && + d.endAngle > Math.PI * 3) { + startAngle = d.startAngle % (Math.PI * 2); + endAngle = Math.PI * 2; var sx = ir * rx * Math.cos(startAngle), sy = ir * ry * Math.sin(startAngle), ex = ir * rx * Math.cos(endAngle), ey = ir * ry * Math.sin(endAngle); - - // Creating custom path commands based on calculation - var ret = []; ret.push("M", sx, sy, "A", ir * rx, ir * ry, "0 0 1", ex, ey, "L", ex, h + ey, "A", ir * rx, ir * ry, "0 0 0", sx, h + sy, "z"); - - - // If shape is big enough, that it needs two separate outer shape , then draw second shape as well - if (d.startAngle > Math.PI && - d.startAngle < Math.PI * 2 && - d.endAngle > Math.PI * 3) { - startAngle = d.startAngle % (Math.PI * 2); - endAngle = Math.PI * 2; - var sx = ir * rx * Math.cos(startAngle), - sy = ir * ry * Math.sin(startAngle), - ex = ir * rx * Math.cos(endAngle), - ey = ir * ry * Math.sin(endAngle); - ret.push("M", sx, sy, "A", ir * rx, ir * ry, "0 0 1", ex, ey, "L", ex, h + ey, "A", ir * rx, ir * ry, "0 0 0", sx, h + sy, "z"); - } - - // Assemble shape commands - return ret.join(" "); } + // Assemble shape commands + return ret.join(" "); + } - function draw( - data: any, - rx: number/*radius x*/, - ry: number/*radius y*/, - h: number/*height*/, - ir: number/*inner radius*/) { - - // Placeholder data - const _data = data; - - // Create Slices and shape containers - var slices = centerPoint - .patternify({ tag: 'g', selector: 'slices' }) - - // Store reference for func access - attrs.slices = slices; - - const outerSliceWrapper = centerPoint - .patternify({ tag: 'g', selector: 'outerSliceWrapper' }) - - const topSliceWrapper = centerPoint - .patternify({ tag: 'g', selector: 'topSliceWrapper' }) - - - // Creating inner slice custom paths - const pieInners = slices - .patternify({ tag: 'path', selector: 'innerSlice', data: _data }) - .style("fill", function (d: any) { return d3.hsl(d.data.color).darker(2).toString() }) - .attr("d", function (d: any) { return pieInner(d, rx + 0.5, ry + 0.5, h, ir); }) - .classed('slice-sort', true) - .on('click', onSliceClick) - .on('mouseenter', onSliceMouseEnter) - .on('mouseleave', onSliceMouseLeave) - - // Transition inner elements - pieInners - .transition() - .duration(attrs.duration) - .attrTween("d", arcTweenInner) - .on('end', function (d: any) { - //sortElements(); - this._current = d; - }) - - - // Create corner slice paths - const cornerSliceElements = slices - .patternify({ tag: 'path', selector: 'cornerSlices', data: _data.map((d: any) => Object.assign({}, d)) }) - .style("fill", (d: any) => { - return d3.hsl(d.data.color).darker(0.7).toString(); - }) - .attr("d", function (d: any) { return pieCorner(d, rx - .5, ry - .5, h, ir); }) - - .classed('slice-sort', true) - .attr('pointer-events', '') - .style("stroke", function (d: any) { return d3.hsl(d.data.color).darker(0.7).toString() }) - .on('click', onSliceClick) - .on('mouseenter', onSliceMouseEnter) - .on('mouseleave', onSliceMouseLeave) - .attr('opacity', (d: any, i: number, arr: any[]) => { - if (arr.length - 2 == i) { - return 1; - } - return 0; - }) - - // Store reference for function access - attrs.cornerSliceElements = cornerSliceElements; - - // Transition corner elements - cornerSliceElements - .transition() - .duration(attrs.duration) - .attrTween("d", arcTweenCorner) - .on('end', function (d: any) { - this._current = d; - }) - - // Create corner slice surface paths - const cornerSliceSurfaceElements = slices - .patternify({ tag: 'path', selector: 'cornerSlicesSurface', data: _data.map((d: any) => Object.assign({}, d)) }) - .style("fill", function (d: any) { return d3.hsl(d.data.color).darker(0.7).toString() }) - .attr("d", function (d: any) { return pieCornerSurface(d, rx - .5, ry - .5, h, ir); }) - .classed('slice-sort', true) - .style("stroke", function (d: any) { return d3.hsl(d.data.color).darker(0.7).toString() }) - .on('click', onSliceClick) - .on('mouseenter', onSliceMouseEnter) - .on('mouseleave', onSliceMouseLeave) - - .attr('opacity', (d: any, i: number, arr: any[]) => { - if (0 == i) { - return 1; - } - return 0; - }) - - // Store reference for function access - attrs.cornerSliceSurfaceElements = cornerSliceSurfaceElements; - - // Transition corner Surface elements - cornerSliceSurfaceElements - .transition() - .duration(attrs.duration) - .attrTween("d", arcTweenCornerSurface) - .on('end', function (d: any) { - this._current = d; - }) - - - - // Draw outer slices - const outerSlices = outerSliceWrapper - .patternify({ - tag: 'path', selector: 'outerSlice', data: _data - }) - .style("fill", function (d: any) { return d3.hsl(d.data.color).darker(0.7).toString() }) - .on('click', onSliceClick) - .on('mouseenter', onSliceMouseEnter) - .on('mouseleave', onSliceMouseLeave) - - // Transition outer elements - outerSlices.transition() - .duration(attrs.duration) - .attrTween("d", arcTweenOuter) - .on('end', function (d: any) { - this._current = d; - }) - + //********** Function is responsible for building top shape paths */ + static pieTop(d: PieDataObject, { + rx, + ry, + ir + }: PieChartAttributes) { + + // If angles are equal, then we got nothing to draw + if (d.endAngle - d.startAngle == 0) return "M 0 0"; + + // Calculating shape key points + var sx = rx * Math.cos(d.startAngle), + sy = ry * Math.sin(d.startAngle), + ex = rx * Math.cos(d.endAngle), + ey = ry * Math.sin(d.endAngle); + + // Creating custom path based on calculation + var ret = []; + ret.push("M", sx, sy, "A", rx, ry, "0", (d.endAngle - d.startAngle > Math.PI ? 1 : 0), "1", ex, ey, "L", ir * ex, ir * ey); + ret.push("A", ir * rx, ir * ry, "0", (d.endAngle - d.startAngle > Math.PI ? 1 : 0), "0", ir * sx, ir * sy, "z"); + return ret.join(" "); + } - // Draw top slices - const topSlices = topSliceWrapper - .patternify({ - tag: 'path', selector: 'topSlice', data: _data - }) - .style("fill", function (d: any) { return d.data.color; }) - .style("stroke", function (d: any) { return d.data.color; }) - // .attr("d", function (d: any) { return pieTop(d, rx, ry, ir); }) - .on('click', onSliceClick) - .on('mouseenter', onSliceMouseEnter) - .on('mouseleave', onSliceMouseLeave) - - // Transition top elements - topSlices.transition() - .duration(attrs.duration) - .attrTween("d", arcTweenTop) - .on('end', function (d: any) { - this._current = d; - }) + //******* Function is responsible for building left corner shape paths */ + static pieCornerSurface(d: PieDataObject, { + rx, + ry, + h, + ir + }: PieChartAttributes) { + + // Calculating left corner surface key points + var sxFirst = ir * rx * Math.cos(d.startAngle); + var syFirst = ir * ry * Math.sin(d.startAngle) + var sxSecond = rx * Math.cos(d.startAngle); + var sySecond = ry * Math.sin(d.startAngle); + var sxThird = sxSecond; + var syThird = sySecond + h; + var sxFourth = sxFirst; + var syFourth = syFirst + h; + + // Creating custom path based on calculation + return ` + M ${sxFirst} ${syFirst} + L ${sxSecond} ${sySecond} + L ${sxThird} ${syThird} + L ${sxFourth} ${syFourth} + z + ` + } - // Draw Texts - const slicesTexts = topSliceWrapper - .patternify({ tag: 'text', selector: 'pie-labels', data: _data.map((d: any) => Object.assign({}, d)) }) - .attr('text-anchor', 'middle') - .attr('font-size', 10) - .attr('transform', (d: any) => { - const centerAngle = ((d.startAngle + d.endAngle) / 2) % (Math.PI * 2); - const x = rx * 0.8 * Math.cos(centerAngle); - const y = ry * 0.8 * Math.sin(centerAngle); - return `translate(${x},${y}) ` - }) - .text((d: any) => d.data.label + ' (' + Math.round(d.value / calc.sum * 100) + '%)') - .attr('opacity', (d: any) => d.data.type == "dummy" ? -1 : 1) + //********** Function is responsible for building right corner shape paths */ + static pieCorner(d: PieDataObject, { + rx, + ry, + h, + ir + }: PieChartAttributes) { + + // Calculating right corner surface key points + var sxFirst = ir * rx * Math.cos(d.endAngle); + var syFirst = ir * ry * Math.sin(d.endAngle); + var sxSecond = rx * Math.cos(d.endAngle); + var sySecond = ry * Math.sin(d.endAngle); + var sxThird = sxSecond; + var syThird = sySecond + h; + var sxFourth = sxFirst; + var syFourth = syFirst + h; + + // Creating custom path based on calculation + return ` + M ${sxFirst} ${syFirst} + L ${sxSecond} ${sySecond} + L ${sxThird} ${syThird} + L ${sxFourth} ${syFourth} + z + ` + } - // Transition text elements - slicesTexts.transition() - .duration(attrs.duration) - .attrTween("transform", textTweenTransform) + //Text transitions + static textTweenTransform(a: PieDataObject, { + rx, + ry + }: PieChartAttributes) { + if (!this._current) { + this._current = Object.assign({}, a, { + startAngle: 0, + endAngle: 0 + }) } + var i = d3.interpolate(this._current, a); + + this._current = i(0); + return function (t: any) { + const d = i(t); + const centerAngle = ((d.startAngle + d.endAngle) / 2) % (Math.PI * 2); + const x = rx * 0.8 * Math.cos(centerAngle); + const y = ry * 0.8 * Math.sin(centerAngle); + return `translate(${x},${y}) ` + }; + } - d3.select(window).on('resize.' + attrs.id, function () { - var containerRect = container.node().getBoundingClientRect(); - if (containerRect.width > 0) attrs.svgWidth = containerRect.width; - main(); - }); - - // Store state, whether app was first run or not - attrs.firstRun = false; - }; - + //Corner shape transitions + static arcTweenCorner(a: PieDataObject, { + rx, + ry, + h, + ir + }: PieChartAttributes) { + if (!this._current) { + this._current = Object.assign({}, a, { + startAngle: 0, + endAngle: 0 + }) + } + var i = d3.interpolate(this._current, a); + this._current = i(0); + return function (t: any) { + return Chart.pieCorner(i(t), { + rx: rx, + ry: ry, + h: h, + ir: ir + }); + }; + } - //----------- PROTOTYPE FUNCTIONS ---------------------- + //Corner surface shape transitions + static arcTweenCornerSurface(a: PieDataObject, { + rx, + ry, + h, + ir + }: PieChartAttributes) { + + if (!this._current) { + this._current = Object.assign({}, a, { + startAngle: 0, + endAngle: 0 + }) + } + var i = d3.interpolate(this._current, a); + this._current = i(0); + return function (t: any) { + return Chart.pieCornerSurface(i(t), { + rx: rx, + ry: ry, + h: h, + ir: ir + }); + }; + } - d3.selection.prototype.patternify = function (params: PatternifyParameter) { - var container = this; - var selector = params.selector; - var elementTag = params.tag; - var data = params.data || [selector]; + //Inner shape transitions + static arcTweenInner(a: PieDataObject, { + rx, + ry, + h, + ir + }: PieChartAttributes) { + if (!this._current) { + this._current = Object.assign({}, a, { + startAngle: 0, + endAngle: 0 + }) + } + var i = d3.interpolate(this._current, a); + this._current = i(0); + return function (t: any) { + return Chart.pieInner(i(t), { + rx: rx, + ry: ry, + h: h, + ir: ir + }); + }; + } - // Pattern in action - var selection = container.selectAll('.' + selector).data(data, (d: any, i: number) => { - if (typeof d === 'object') { - if (d.id) { - return d.id; - } - } - return i; - }); - selection.exit().remove(); - selection = selection.enter().append(elementTag).merge(selection); - selection.attr('class', selector); - return selection; - }; - - //Dynamic keys functions - Object.keys(attrs).forEach((key) => { - // Attach variables to main function - //@ts-ignore - main[key] = function (_) { - var string = `attrs['${key}'] = _`; - if (!arguments.length) { - return eval(` attrs['${key}'];`); - } - eval(string); - return main; + //Top shape transitions + static arcTweenTop(a: PieDataObject, { + rx, + ry, + ir + }: PieChartAttributes) { + if (!this._current) { + this._current = Object.assign({}, a, { + startAngle: 0, + endAngle: 0 + }) + } + var i = d3.interpolate(this._current, a); + this._current = i(0); + return function (t: any) { + return Chart.pieTop(i(t), { + rx, + ry, + ir + }); }; - return main; - }); - - //Set attrs as property - //@ts-ignore - main['attrs'] = attrs; - - //Exposed update functions - //@ts-ignore - main['data'] = function (value) { - if (!arguments.length) return attrs.data; - attrs.data = value; - if (typeof updateData === 'function') { - updateData(); + } + + //Outer shape transitions + static arcTweenOuter(a: PieDataObject, { + rx, + ry, + h, + ir + }: PieChartAttributes) { + if (!this._current) { + this._current = Object.assign({}, a, { + startAngle: 0, + endAngle: 0 + }) } - return main; - }; + var i = d3.interpolate(this._current, a); + this._current = i(0); + return function (t: any) { + return Chart.pieOuter(i(t), { + rx, + ry, + h, + ir + }); + }; + } - // Run visual - //@ts-ignore - main['render'] = function () { - main(); - return main; - }; - return main; } +export interface Layouts { + [key: string]: Function +} + +export interface PieDataObject { + value: number, + startAngle: number, + endAngle: number, + data: any +} export interface PatternifyParameter { selector: string, @@ -872,15 +1056,20 @@ export interface PatternifyParameter { export interface PieChartAttributes { [key: string]: any, - data: UiPieChartConfig, - svgWidth: number, - svgHeight: number, - marginTop: number, - marginBottom: number, - marginRight: number, - marginLeft: number, - container: any, - defaultTextFill: string, - defaultFont: string, - onDataPointClicked: (name: string) => void -} \ No newline at end of file + + data?: UiPieChartConfig, + svgWidth?: number, + svgHeight?: number, + marginTop?: number, + marginBottom?: number, + marginRight?: number, + marginLeft?: number, + container?: any, + defaultTextFill?: string, + defaultFont?: string, + onDataPointClicked?: (name: string) => void, + rx?: number, + ry?: number, + h?: number, + ir?: number +} diff --git a/teamapps-client/ts/modules/UiPlayground.ts b/teamapps-client/ts/modules/UiPlayground.ts new file mode 100644 index 000000000..a01c04ef1 --- /dev/null +++ b/teamapps-client/ts/modules/UiPlayground.ts @@ -0,0 +1,69 @@ +/* tslint:disable:indent */ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2025 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ + +import { + UiPlayground_RenderedEvent, + UiPlaygroundCommandHandler, + UiPlaygroundConfig +} from "../generated/UiPlaygroundConfig"; +import {UiShadowConfig} from "../generated/UiShadowConfig"; +import {AbstractUiComponent} from "./AbstractUiComponent"; +import {generateUUID, parseHtml} from "./Common"; +import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; +import {TeamAppsUiContext} from "./TeamAppsUiContext"; +import {createUiBorderCssString, createUiShadowCssString} from "./util/CssFormatUtil"; +import {TeamAppsEvent} from "./util/TeamAppsEvent"; + + +export class UiPlayground extends AbstractUiComponent implements UiPlaygroundCommandHandler { + onRendered: TeamAppsEvent = new TeamAppsEvent(); + + // internal state + private uuidClass: string; + private $main: HTMLDivElement; + private $p: HTMLDivElement; + + constructor(config: UiPlaygroundConfig, context: TeamAppsUiContext) { + super(config, context); + + this.uuidClass = `UiPlayground-${generateUUID()}`; + this.$main = parseHtml(`
+

Playground

+

Title: ${this._config.title}

+
`); + this.$p = this.$main.querySelector('p') + } + + public doGetMainElement(): HTMLElement { + return this.$main; + } + + // Setters for Server API + // ----------------------- + + public setTitle(title: string) { + this._config.title = title; + this.$p.innerText = title; + } + +} + +TeamAppsUiComponentRegistry.registerComponentClass("UiPlayground", UiPlayground); diff --git a/teamapps-client/ts/modules/UiPopup.ts b/teamapps-client/ts/modules/UiPopup.ts new file mode 100644 index 000000000..6cae7ec56 --- /dev/null +++ b/teamapps-client/ts/modules/UiPopup.ts @@ -0,0 +1,119 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ + +import {AbstractUiComponent} from "./AbstractUiComponent"; +import {TeamAppsUiContext} from "./TeamAppsUiContext"; +import {UiComponent} from "./UiComponent"; +import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; +import {UiPopupCommandHandler, UiPopupConfig} from "../generated/UiPopupConfig"; +import {parseHtml} from "./Common"; +import {executeWhenFirstDisplayed} from "./util/ExecuteWhenFirstDisplayed"; + +export class UiPopup extends AbstractUiComponent implements UiPopupCommandHandler { + + private contentComponent: UiComponent; + private $main: HTMLElement; + private $componentWrapper: HTMLElement; + + constructor(config: UiPopupConfig, context: TeamAppsUiContext) { + super(config, context); + + this.$main = parseHtml(`
+
+
`); + + this.$componentWrapper = this.$main.querySelector(":scope .component-wrapper"); + + this.contentComponent = config.contentComponent as UiComponent; + this.$componentWrapper.appendChild(this.contentComponent.getMainElement()); + + this.setBackgroundColor(config.backgroundColor); + this.setDimmingColor(config.dimmingColor); + this.setDimensions(config.width, config.height); + } + + doGetMainElement(): HTMLElement { + return this.$main; + } + + setBackgroundColor(backgroundColor: string): void { + this._config.backgroundColor = backgroundColor; + this.$componentWrapper.style.backgroundColor = backgroundColor; + } + + setDimmingColor(dimmingColor: string): void { + this._config.dimmingColor = dimmingColor; + this.$main.style.backgroundColor = dimmingColor; + } + + setDimensions(width: number, height: number): void { + this._config.width = width; + this._config.height = height; + this.updatePosition(); + } + + setPosition(x: number, y: number) { + this._config.x = x; + this._config.y = y; + this.updatePosition(); + } + + @executeWhenFirstDisplayed() + private updatePosition() { + let containerWidth = this.$main.parentElement && this.$main.parentElement.offsetWidth; + let containerHeight = this.$main.parentElement && this.$main.parentElement.offsetHeight; + // width + if (this._config.width === -1) { + this.$main.style.width = null; + this.$main.style.maxWidth = containerWidth + "px"; + } else if (this._config.width === 0) { + this.$main.style.width = containerWidth + "px"; + this.$main.style.maxWidth = null; + } else { + this.$main.style.width = this._config.width + "px"; + this.$main.style.maxWidth = containerWidth + "px"; + } + // height + if (this._config.height === -1) { + this.$main.style.height = null; + this.$main.style.maxHeight = containerHeight + "px"; + } else if (this._config.height === 0) { + this.$main.style.height = containerHeight + "px"; + this.$main.style.maxHeight = null; + } else { + this.$main.style.height = this._config.height + "px"; + this.$main.style.maxHeight = containerHeight + "px"; + } + + this.$componentWrapper.style.left = this._config.x + "px"; + this.$componentWrapper.style.top = this._config.y + "px"; + } + + + onResize(): void { + this.updatePosition(); + } + + close(): void { + this.getMainElement().remove(); + } +} + +TeamAppsUiComponentRegistry.registerComponentClass("UiPopup", UiPopup); diff --git a/teamapps-client/ts/modules/UiProgressDisplay.ts b/teamapps-client/ts/modules/UiProgressDisplay.ts new file mode 100644 index 000000000..0321ec323 --- /dev/null +++ b/teamapps-client/ts/modules/UiProgressDisplay.ts @@ -0,0 +1,98 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ + +import {AbstractUiComponent} from "./AbstractUiComponent"; +import {TeamAppsUiContext} from "./TeamAppsUiContext"; +import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; +import {TeamAppsEvent} from "./util/TeamAppsEvent"; +import {parseHtml, removeClassesByFunction} from "./Common"; +import { + UiProgressDisplay_CancelButtonClickedEvent, + UiProgressDisplay_ClickedEvent, + UiProgressDisplayCommandHandler, + UiProgressDisplayConfig, + UiProgressDisplayEventSource +} from "../generated/UiProgressDisplayConfig"; +import {ProgressBar} from "./micro-components/ProgressBar"; +import {UiProgressStatus} from "../generated/UiProgressStatus"; + + +export class UiProgressDisplay extends AbstractUiComponent implements UiProgressDisplayCommandHandler, UiProgressDisplayEventSource { + onCancelButtonClicked: TeamAppsEvent = new TeamAppsEvent(); + onClicked: TeamAppsEvent = new TeamAppsEvent(); + + private $main: HTMLElement; + private $icon: HTMLElement; + private $taskName: HTMLElement; + private $statusMessage: HTMLElement; + private $progress: HTMLElement; + private $cancelButton: HTMLElement; + private progressBar: ProgressBar; + + constructor(config: UiProgressDisplayConfig, context: TeamAppsUiContext) { + super(config, context); + + this.$main = parseHtml(`
+
+
+
+
+
+
+
+
+
+
`); + + this.$icon = this.$main.querySelector(":scope .icon"); + this.$taskName = this.$main.querySelector(":scope .task-name"); + this.$statusMessage = this.$main.querySelector(":scope .status-string"); + this.$progress = this.$main.querySelector(":scope .progress-bar-wrapper"); + this.progressBar = new ProgressBar(0, {height: 4, transitionTime: 400}); + this.$progress.appendChild(this.progressBar.getMainDomElement()); + this.$cancelButton = this.$main.querySelector(":scope .cancel-button"); + + this.$cancelButton.addEventListener("click", ev => this.onCancelButtonClicked.fire({})); + + this.update(config); + } + + doGetMainElement(): HTMLElement { + return this.$main; + } + + update(config: UiProgressDisplayConfig): void { + this.$icon.style.backgroundImage = `url('${config.icon}')`; + this.$taskName.textContent = config.taskName; + this.$statusMessage.textContent = config.statusMessage; + this.progressBar.setProgress(config.progress); + + this.$main.classList.toggle("unknown-progress", config.progress < 0); + + removeClassesByFunction(this.$main.classList, className => className.startsWith("status-")); + let statusClass = `status-${config.status}`; + this.$main.classList.add(statusClass); + + this.$main.classList.toggle(`cancelable`, config.cancelable); + } + +} + +TeamAppsUiComponentRegistry.registerComponentClass("UiProgressDisplay", UiProgressDisplay); diff --git a/teamapps-client/ts/modules/UiQrCodeScanner.ts b/teamapps-client/ts/modules/UiQrCodeScanner.ts new file mode 100644 index 000000000..47c78b789 --- /dev/null +++ b/teamapps-client/ts/modules/UiQrCodeScanner.ts @@ -0,0 +1,117 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +import {AbstractUiComponent} from "./AbstractUiComponent"; +import {UiQrCodeScanner_QrCodeDetectedEvent, UiQrCodeScannerCommandHandler, UiQrCodeScannerConfig, UiQrCodeScannerEventSource} from "../generated/UiQrCodeScannerConfig"; +import {TeamAppsEvent} from "./util/TeamAppsEvent"; +import {TeamAppsUiContext} from "./TeamAppsUiContext"; +import {calculateDisplayModeInnerSize, parseHtml} from "./Common"; +import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; +import {QrScanner} from './qr-code-scanner/qr-scanner'; +import {executeWhenFirstDisplayed} from "./util/ExecuteWhenFirstDisplayed"; +import {UiPageDisplayMode} from "../generated/UiPageDisplayMode"; + +export class UiQrCodeScanner extends AbstractUiComponent implements UiQrCodeScannerCommandHandler, UiQrCodeScannerEventSource { + + public readonly onQrCodeDetected: TeamAppsEvent = new TeamAppsEvent(); + + private $main: HTMLElement; + private $video: HTMLVideoElement; + private $crosshair: HTMLElement; + private qrScanner: QrScanner = null; + + private selectedCameraIndex: number = 0; + + constructor(config: UiQrCodeScannerConfig, context: TeamAppsUiContext) { + super(config, context); + + this.$main = parseHtml(`
+ +
+
+
+
`); + this.$video = this.$main.querySelector(':scope video'); + this.$crosshair = this.$main.querySelector(':scope .crosshair'); + + this.$video.addEventListener("playing", () => this.onResize()); + + if (config.scanning) { + this.startScanning(config.stopsScanningAtFirstResult); + } + } + + doGetMainElement(): HTMLElement { + return this.$main; + } + + @executeWhenFirstDisplayed(true) + async startScanning(stopScanningAtFirstResult: boolean = true): Promise { + if (this.qrScanner != null) { + this.stopScanning(); + } + + this._config.stopsScanningAtFirstResult = stopScanningAtFirstResult; + this.qrScanner = new QrScanner(this.$video, (result: string) => { + this.onQrCodeDetected.fire({code: result}); + if (this._config.stopsScanningAtFirstResult) { + this.stopScanning(); + } + }); + let availableCameras = await this.getAvailableCameras(); + if (availableCameras.length > 0) { + let deviceId = availableCameras[this.selectedCameraIndex % availableCameras.length].deviceId; + this.qrScanner.start(deviceId); + } + } + + stopScanning(): void { + if (this.qrScanner != null) { + this.qrScanner.stop(); + this.qrScanner.destroy(); + } + this.qrScanner = null; + } + + switchCamera(): void { + this.selectedCameraIndex ++; + this.startScanning(); + } + + private async getAvailableCameras() { + return await navigator.mediaDevices.enumerateDevices() + .then(devices => devices.filter(device => device.kind === 'videoinput')); + } + + onResize(): void { + if (this.$video.videoWidth > 0 && this.$video.videoHeight > 0) { + let crosshairSize = calculateDisplayModeInnerSize(this.$main.getBoundingClientRect(), {width: this.$video.videoWidth, height: this.$video.videoHeight}, UiPageDisplayMode.FIT_SIZE); + this.$crosshair.style.width = crosshairSize.width + "px"; + this.$crosshair.style.height = crosshairSize.height + "px"; + } + } + + + destroy() { + super.destroy(); + this.qrScanner.destroy(); + } +} + +TeamAppsUiComponentRegistry.registerComponentClass("UiQrCodeScanner", UiQrCodeScanner); diff --git a/teamapps-client/ts/modules/UiResponsiveGridLayout.ts b/teamapps-client/ts/modules/UiResponsiveGridLayout.ts index 52c430e4e..b04ae3a9f 100644 --- a/teamapps-client/ts/modules/UiResponsiveGridLayout.ts +++ b/teamapps-client/ts/modules/UiResponsiveGridLayout.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,17 +17,18 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import {UiComponent} from "./UiComponent"; +import {AbstractUiComponent} from "./AbstractUiComponent"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; import {UiGridLayout} from "./micro-components/UiGridLayout"; import {UiResponsiveGridLayoutCommandHandler, UiResponsiveGridLayoutConfig} from "../generated/UiResponsiveGridLayoutConfig"; import {UiResponsiveGridLayoutPolicyConfig} from "../generated/UiResponsiveGridLayoutPolicyConfig"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; +import {parseHtml} from "./Common"; -export class UiResponsiveGridLayout extends UiComponent implements UiResponsiveGridLayoutCommandHandler { +export class UiResponsiveGridLayout extends AbstractUiComponent implements UiResponsiveGridLayoutCommandHandler { - private $main: JQuery; - private $gridLayout: JQuery; + private $main: HTMLElement; + private $gridLayout: HTMLElement; private layoutsFromSmallToLargeMinApplicableWidth: { minApplicableWidth: number, layout: UiGridLayout }[]; private currentLayout: UiGridLayout; @@ -35,15 +36,15 @@ export class UiResponsiveGridLayout extends UiComponent + this.$main = parseHtml(`
`); - this.$gridLayout = this.$main.find(">.UiGridLayout"); + this.$gridLayout = this.$main.querySelector(":scope >.UiGridLayout"); this.setFillHeight(config.fillHeight); this.updateLayoutPolicies(config.layoutPolicies); } - getMainDomElement(): JQuery { + doGetMainElement(): HTMLElement { return this.$main; } @@ -56,27 +57,20 @@ export class UiResponsiveGridLayout extends UiComponent c.attachedToDom = this.attachedToDom); layout.applyTo(this.$gridLayout); } } - private resizeChildren() { - this.currentLayout.getAllComponents().forEach(c => c.reLayout()); - } - private getApplicableLayout(): UiGridLayout { const availableWidth = this.getWidth(); let firstTooLargePolicyIndex = this.layoutsFromSmallToLargeMinApplicableWidth.findIndex(p => p.minApplicableWidth > availableWidth); @@ -87,13 +81,9 @@ export class UiResponsiveGridLayout extends UiComponent c.attachedToDom = true); - } } TeamAppsUiComponentRegistry.registerComponentClass("UiResponsiveGridLayout", UiResponsiveGridLayout); diff --git a/teamapps-client/ts/modules/UiRootPanel.ts b/teamapps-client/ts/modules/UiRootPanel.ts index 76cc1d605..e6a97b7f9 100644 --- a/teamapps-client/ts/modules/UiRootPanel.ts +++ b/teamapps-client/ts/modules/UiRootPanel.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,35 +18,39 @@ * =========================LICENSE_END================================== */ import * as moment from "moment-timezone"; -import * as $ from "jquery"; + import {UiComponentConfig} from "../generated/UiComponentConfig"; -import {UiWindow, UiWindowListener} from "./UiWindow"; +import {UiWindow} from "./UiWindow"; import {UiConfigurationConfig} from "../generated/UiConfigurationConfig"; -import {UiWindowConfig} from "../generated/UiWindowConfig"; -import {UiNotificationConfig} from "../generated/UiNotificationConfig"; -import {UiComponent} from "./UiComponent"; +import {AbstractUiComponent} from "./AbstractUiComponent"; import {TeamAppsUiContext, TeamAppsUiContextInternalApi} from "./TeamAppsUiContext"; -import {convertJavaDateTimeFormatToMomentDateTimeFormat, exitFullScreen, insertAtIndex, showNotification} from "./Common"; -import {UiRootPanelCommandHandler, UiRootPanelConfig} from "../generated/UiRootPanelConfig"; -import {UiComponentRevealAnimation} from "../generated/UiComponentRevealAnimation"; +import {createUiLocation, exitFullScreen, getLastPointerCoordinates, pageTransition, parseHtml} from "./Common"; +import { + UiRootPanel_GlobalKeyEventOccurredEvent, UiRootPanel_NavigationStateChangeEvent, + UiRootPanelCommandHandler, + UiRootPanelConfig, + UiRootPanelEventSource +} from "../generated/UiRootPanelConfig"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; import {UiTemplateConfig} from "../generated/UiTemplateConfig"; import * as log from "loglevel"; import {ElementUiComponentAdapter} from "./micro-components/ElementUiComponentAdapter"; import {UiGenericErrorMessageOption} from "../generated/UiGenericErrorMessageOption"; -import {TeamAppsEvent, TeamAppsEventListener} from "./util/TeamAppsEvent"; -import {UiColorConfig} from "../generated/UiColorConfig"; -import {createUiColorCssString} from "./util/CssFormatUtil"; - -require("moment-jdateformatparser"); - +import {UiComponent} from "./UiComponent"; +import {UiPageTransition} from "../generated/UiPageTransition"; +import {UiPopup} from "./UiPopup"; +import {showNotification, UiNotification} from "./UiNotification"; +import {UiNotificationPosition} from "../generated/UiNotificationPosition"; +import {UiEntranceAnimation} from "../generated/UiEntranceAnimation"; +import {UiExitAnimation} from "../generated/UiExitAnimation"; +import {releaseWakeLock, requestWakeLock} from "./util/WakeLock"; +import {EventSubscription, TeamAppsEvent, TeamAppsEventListener} from "./util/TeamAppsEvent"; +import {KeyEventType} from "../generated/KeyEventType"; -interface ChildComponent { - $wrapper: JQuery, - component: UiComponent -} +export class UiRootPanel extends AbstractUiComponent implements UiRootPanelCommandHandler, UiRootPanelEventSource { -export class UiRootPanel extends UiComponent implements UiRootPanelCommandHandler { + public static readonly onGlobalKeyEventOccurred: TeamAppsEvent = new TeamAppsEvent(); + public static readonly onNavigationStateChange: TeamAppsEvent = new TeamAppsEvent(); private static LOGGER: log.Logger = log.getLogger("UiRootPanel"); private static ALL_ROOT_PANELS_BY_ID: { [id: string]: UiRootPanel } = {}; @@ -58,99 +62,76 @@ export class UiRootPanel extends UiComponent implements UiRoo } = {}; private static WINDOWS_BY_ID: { [windowId: string]: UiWindow } = {}; - private $root: JQuery; - private childComponentsById: {[id: string]: ChildComponent} = {}; - private visibleChildComponent: ChildComponent; - private $backgroundTransitionStyle: JQuery; - private $backgroundStyle: JQuery; - private $imagePreloadDiv: JQuery; + private $root: HTMLElement; + private content: UiComponent; + private $contentWrapper: HTMLElement; + private $backgroundTransitionStyle: HTMLElement; + private $backgroundStyle: HTMLElement; + private $imagePreloadDiv: HTMLElement; private backgroundImage: string; private blurredBackgroundImage: string; private backgroundColor: string; - static __initialize() { - $(window).resize((e) => { - this.ALL_ROOT_PANELS.forEach(rootPanel => { - rootPanel.reLayout(); - }); - Object.keys(this.WINDOWS_BY_ID).map(id => this.WINDOWS_BY_ID[id]).forEach(window => window.reLayout()); - }); - } - constructor(config: UiRootPanelConfig, context: TeamAppsUiContext) { super(config, context); UiRootPanel.ALL_ROOT_PANELS_BY_ID[config.id] = this; - this.$root = $(`
+ this.$root = parseHtml(`
`); - this.$imagePreloadDiv = this.$root.find(".image-preload-div"); - this.$backgroundTransitionStyle = this.$root.find("[data-style-type='backgroundTransitionStyle']"); - this.$backgroundStyle = this.$root.find("[data-style-type='backgroundStyle']"); + this.$imagePreloadDiv = this.$root.querySelector(":scope .image-preload-div"); + this.$backgroundTransitionStyle = this.$root.querySelector(":scope [data-style-type='backgroundTransitionStyle']"); + this.$backgroundStyle = this.$root.querySelector(":scope [data-style-type='backgroundStyle']"); - if (config.childComponents != null && config.childComponents.length > 0) { - config.childComponents.forEach(c => this.addChildComponent(c, false)); - this.setVisibleChildComponent(config.visibleChildComponentId || config.childComponents[0].id, null, 0); - } + this.setContent(config.content as UiComponent); this.setOptimizedForTouch(context.config.optimizedForTouch); } - public getMainDomElement(): JQuery { - return this.$root; + public static setGlobalKeyEventsEnabled(unmodified: boolean, modifiedWithAltKey: boolean, modifiedWithCtrlKey: boolean, modifiedWithMetaKey: boolean, includeRepeats: boolean, keyDown: boolean, keyUp: boolean) { + setGlobalKeyEventsEnabled.call(this, unmodified, modifiedWithAltKey, modifiedWithCtrlKey, modifiedWithMetaKey, includeRepeats, keyDown, keyUp); } - public addChildComponent(component: UiComponent, show: boolean) { - let $childComponentContainer = $(`
`) - .appendTo(this.$root); - component.getMainDomElement().appendTo($childComponentContainer); - this.childComponentsById[component.getId()] = { - component: component, - $wrapper: $childComponentContainer - }; - component.attachedToDom = this.attachedToDom; - if (show) { - this.setVisibleChildComponent(component.getId(), null, 0); - } + public doGetMainElement(): HTMLElement { + return this.$root; } - public setVisibleChildComponent(childComponentId: string, animation: UiComponentRevealAnimation | null, animationDuration: number): void { - if (childComponentId == null) { - this.visibleChildComponent = null; - this.childComponents.forEach(c => c.$wrapper.removeClass('active')); - } else { - let childComponent = this.childComponentsById[childComponentId]; - if (childComponent) { - this.visibleChildComponent = childComponent; - this.childComponents.forEach(c => c.$wrapper.removeClass('active')); - childComponent.$wrapper.addClass('active'); - this.visibleChildComponent.component.reLayout(); - } + public setContent(content: UiComponent, transition: UiPageTransition | null = null, animationDuration: number = 0): void { + if (content == this.content) { + return; } - } - public removeChildComponent(childComponentId: string): void { - let childComponent = this.childComponentsById[childComponentId]; - delete this.childComponentsById[childComponentId]; - if (childComponent) { - childComponent.$wrapper.detach(); + let oldContent = this.content; + let $oldContentWrapper = this.$contentWrapper; + + this.content = content; + + this.$contentWrapper = parseHtml(`
`); + if (content != null) { + this.$contentWrapper.appendChild(content.getMainElement()); } - } + this.$root.appendChild(this.$contentWrapper); - private get childComponents() { - return Object.keys(this.childComponentsById).map(id => this.childComponentsById[id]); + if (transition != null && animationDuration > 0) { + pageTransition($oldContentWrapper, this.$contentWrapper, transition, animationDuration, () => { + $oldContentWrapper && $oldContentWrapper.remove(); + }); + } else { + $oldContentWrapper && $oldContentWrapper.remove(); + } } public static createComponent(config: UiComponentConfig, context: TeamAppsUiContextInternalApi) { - context.createAndRegisterComponent(config); + let o = context.createClientObject(config); + context.registerClientObject(o, config.id, config._type); } - public static destroyComponent(component: UiComponent, context: TeamAppsUiContextInternalApi) { - context.destroyComponent(component); + public static destroyComponent(componentId: string, context: TeamAppsUiContextInternalApi) { + context.destroyClientObject(componentId); } public static refreshComponent(config: UiComponentConfig, context: TeamAppsUiContextInternalApi) { @@ -159,23 +140,25 @@ export class UiRootPanel extends UiComponent implements UiRoo public static setConfig(config: UiConfigurationConfig, context: TeamAppsUiContext) { let oldConfig = context.config; - if (!oldConfig || oldConfig.isoLanguage !== config.isoLanguage) { - $.getScript("runtime-resources/moment-locales/" + config.isoLanguage + ".js"); - $.getScript("runtime-resources/fullcalendar-locales/" + config.isoLanguage + ".js"); + if ((!oldConfig || oldConfig.locale !== config.locale) && config.locale !== 'en') { + $.getScript("runtime-resources/moment-locales/" + config.locale + ".js"); + $.getScript("runtime-resources/fullcalendar-locales/" + config.locale + ".js"); } - moment.locale(config.isoLanguage); - config.dateFormat = convertJavaDateTimeFormatToMomentDateTimeFormat(config.dateFormat); - config.timeFormat = convertJavaDateTimeFormatToMomentDateTimeFormat(config.timeFormat); + moment.locale(config.locale); this.setThemeClassName(config.themeClassName); this.ALL_ROOT_PANELS.forEach(uiRootPanel => { uiRootPanel.setOptimizedForTouch(config.optimizedForTouch); }); - this.LOGGER.warn("TODO Setting configuration on context. This should be implemented using an event instead!"); + // this.LOGGER.warn("TODO Setting configuration on context. This should be implemented using an event instead!"); (context as any).config = config; // TODO change this to firing an event to the context!!!! } + public static setSessionMessageWindows(expiredMessageWindow: UiWindow, errorMessageWindow: UiWindow, terminatedMessageWindow: UiWindow, context: TeamAppsUiContext) { + (context as any).setSessionMessageWindows(expiredMessageWindow, errorMessageWindow, terminatedMessageWindow); + } + public static setPageTitle(pageTitle: string) { document.title = pageTitle; } @@ -221,83 +204,36 @@ export class UiRootPanel extends UiComponent implements UiRoo }); } - public static setBackgroundColor(backgroundColor: UiColorConfig, animationDuration: number) { + public static setBackgroundColor(backgroundColor: string, animationDuration: number) { this.ALL_ROOT_PANELS.forEach(uiRootPanel => { - uiRootPanel.backgroundColor = backgroundColor && createUiColorCssString(backgroundColor); + uiRootPanel.backgroundColor = backgroundColor; uiRootPanel.updateBackground(animationDuration); }) } private updateBackground(animationDuration: number) { - this.$backgroundTransitionStyle.text(` + this.$backgroundTransitionStyle.textContent = ` /*[data-background-container-id='${this.getId()}']*/ .teamapps-backgroundImage, /*[data-background-container-id='${this.getId()}']*/ .teamapps-blurredBackgroundImage { transition: background-image ${animationDuration}ms ease-in-out, background-color ${animationDuration}ms ease-in-out; } - `); - this.$root[0].clientWidth; // ensure the css is applied! - this.$backgroundStyle.text(` + `; + this.$root.clientWidth; // ensure the css is applied! + this.$backgroundStyle.textContent = ` /*[data-background-container-id='${this.getId()}']*/.teamapps-backgroundImage, /*[data-background-container-id='${this.getId()}']*/ .teamapps-backgroundImage { background-color: ${this.backgroundColor || ''}; - background-image: ${this.backgroundImage ? `url(${this.backgroundImage})` : 'none'}; + background-image: ${this.backgroundImage ? `url('${this.backgroundImage}')` : 'none'}; } /*[data-background-container-id='${this.getId()}']*/.teamapps-blurredBackgroundImage, /*[data-background-container-id='${this.getId()}']*/ .teamapps-blurredBackgroundImage { - background-image: ${this.blurredBackgroundImage ? `url(${this.blurredBackgroundImage})` : 'none'}; + background-image: ${this.blurredBackgroundImage ? `url('${this.blurredBackgroundImage}')` : 'none'}; } - `); - } - - public static showWindow(uiWindow: UiWindow, animationDuration = 1000, context?: TeamAppsUiContext) { - this.ALL_ROOT_PANELS.forEach(rootPanel => { - rootPanel.childComponents.forEach(childComponent => { - rootPanel.visibleChildComponent.$wrapper.css({ - transition: `opacity ${animationDuration}ms, filter ${animationDuration}ms` - }); - }); - - }); - - uiWindow.getMainDomElement().appendTo(document.body); - uiWindow.getMainDomElement().attr("data-background-container-id", this.ALL_ROOT_PANELS[0].getId()); - uiWindow.attachedToDom = true; - uiWindow.setListener({ - onWindowClosed: (window, animationDuration) => this.closeWindow(window.getId(), animationDuration) - }); - this.WINDOWS_BY_ID[uiWindow.getId()] = uiWindow; - uiWindow.show(animationDuration); - - this.ALL_ROOT_PANELS.forEach(rootPanel => { - rootPanel.getMainDomElement().toggleClass("modal-window-mode", uiWindow.isModal()); - }); - } - - public static closeWindow(windowId: string, animationDuration: number) { - this.ALL_ROOT_PANELS.forEach(rootPanel => { - rootPanel.getMainDomElement().removeClass('modal-window-mode'); - }); - - let uiWindow = this.WINDOWS_BY_ID[windowId]; - uiWindow.hide(animationDuration); - delete this.WINDOWS_BY_ID[windowId]; - - setTimeout(() => { - uiWindow.getMainDomElement().detach(); - }, animationDuration); - }; - - onResize() { - if (this.visibleChildComponent) { - this.visibleChildComponent.component.reLayout(); - } - } - - protected onAttachedToDom() { - this.childComponents.forEach(c => c.component.attachedToDom = true); + `; } public destroy(): void { + super.destroy(); delete UiRootPanel.ALL_ROOT_PANELS_BY_ID[this.getId()]; if (Object.keys(UiRootPanel.ALL_ROOT_PANELS_BY_ID).length === 0) { Object.keys(UiRootPanel.WINDOWS_BY_ID).forEach(windowId => UiRootPanel.WINDOWS_BY_ID[windowId].close(0)); @@ -305,9 +241,8 @@ export class UiRootPanel extends UiComponent implements UiRoo } public static buildRootPanel(containerElementId: string, uiRootPanel: UiRootPanel, context?: TeamAppsUiContext): void { - const $container = $(containerElementId ? "#" + containerElementId : document.body); - uiRootPanel.getMainDomElement().appendTo($container); - uiRootPanel.attachedToDom = true; + const $container = containerElementId ? document.querySelector(containerElementId) : document.body; + $container.appendChild(uiRootPanel.getMainElement()); } public static setThemeClassName(theme: string) { @@ -319,14 +254,17 @@ export class UiRootPanel extends UiComponent implements UiRoo } } - public static showNotification(notification: UiNotificationConfig, context: TeamAppsUiContext) { - showNotification(context.templateRegistry.createTemplateRenderer(notification.template).render(notification.data), notification); + public static showNotification(notification: UiNotification, position: UiNotificationPosition, entranceAnimation: UiEntranceAnimation, exitAnimation: UiExitAnimation, context: TeamAppsUiContext) { + showNotification(notification, position, entranceAnimation, exitAnimation); } public static downloadFile(fileUrl: string, fileName: string) { const link = document.createElement('a'); - link.href = fileUrl + (fileUrl.indexOf('?') === -1 ? '?' : '&') + 'teamapps-download-filename=' + fileName; - (link).download = fileName; + link.href = fileUrl; + if (fileName != null) { + link.href += (fileUrl.indexOf('?') === -1 ? '?' : '&') + 'teamapps-download-filename=' + fileName; + link.setAttribute("download", fileName); + } if (document.createEvent) { const e = document.createEvent('MouseEvents'); e.initEvent('click', true, true); @@ -382,39 +320,163 @@ export class UiRootPanel extends UiComponent implements UiRoo exitFullScreen(); } - public static showGenericErrorMessage(title: string, message: string, options: UiGenericErrorMessageOption[], context: TeamAppsUiContext): void { + public static createGenericErrorMessageWindow(title: string, message: string, showErrorIcon: boolean, options: UiGenericErrorMessageOption[], context: TeamAppsUiContext): UiWindow { let uiWindow = new UiWindow({ id: null, title: title, - width: 330, - height: 150, - modalBackgroundDimmingColor: {red: 0, green: 0, blue: 0, alpha: .5}, + width: 370, + height: 200, + modalBackgroundDimmingColor: "rgba(0, 0, 0, .5)", modal: true, content: null }, context); - let $contentElement = $(`
-
-
${message}
+ let $contentElement = parseHtml(`
+
+
${message}
${options.map(o => `
${UiGenericErrorMessageOption[o]}
`).join("")}
`); uiWindow.setContent(new ElementUiComponentAdapter($contentElement)); - $contentElement.find('.ok').on('click', () => { + $contentElement.querySelector(':scope .ok').addEventListener('click', () => { uiWindow.close(500); }); - $contentElement.find('.reload').on('click', () => { - window.location.reload(true); + $contentElement.querySelector(':scope .reload').addEventListener('click', () => { + window.location.reload(); }); - UiRootPanel.showWindow(uiWindow, 500); + return uiWindow; } setOptimizedForTouch(optimizedForTouch: boolean) { - this.$root.toggleClass("optimized-for-touch", optimizedForTouch); + this.$root.classList.toggle("optimized-for-touch", optimizedForTouch); document.body.classList.toggle("optimized-for-touch", optimizedForTouch); // needed for popups and maximized panels... TODO either only use this or change implementation } -} -UiRootPanel.__initialize(); + public static showPopupAtCurrentMousePosition(popup: UiPopup) { + popup.setPosition(...getLastPointerCoordinates()); + document.body.appendChild(popup.getMainElement()); + } + public static showPopup(popup: UiPopup) { + document.body.appendChild(popup.getMainElement()); + } + + public static async requestWakeLock(uuid: string): Promise { + return await requestWakeLock(uuid); + } + + public static async releaseWakeLock(uuid: string) { + return releaseWakeLock(uuid); + } + + public static async goToUrl(url: string, blankPage: boolean) { + this.LOGGER.info(`goToUrl(${url}, ${blankPage})`); + if (blankPage) { + window.open(url, '_blank'); + } else { + location.href = url; + } + } + + public static async changeNavigationHistoryState(relativeUrl: string, fireEvent: boolean, push: boolean) { + if (window.location.pathname + window.location.search === relativeUrl) { + return; // nothing to do here... + } + if (push) { + window.history.pushState({}, "", relativeUrl); + } else { + window.history.replaceState({}, "", relativeUrl); + } + if (fireEvent) { + UiRootPanel.onNavigationStateChange.fire({location: createUiLocation(), triggeredBrowserNavigation: false}); + } + } + + public static async navigateForward(steps: number) { + window.history.go(steps); + } + + public static setFavicon(url: string) { + let link:HTMLLinkElement = document.querySelector("link[rel~='icon']"); + if (!link) { + link = document.createElement('link'); + link.rel = 'icon'; + document.getElementsByTagName('head')[0].appendChild(link); + } + link.href = url; + } + + public static setTitle(title: string) { + document.title = title; + } +} TeamAppsUiComponentRegistry.registerComponentClass("UiRootPanel", UiRootPanel); + +// GLOBAL: + +let keyboardEventSettings: { unmodified: boolean, modifiedWithAltKey: boolean, modifiedWithCtrlKey: boolean, modifiedWithMetaKey: boolean, includeRepeats: boolean } = { + unmodified: false, + modifiedWithAltKey: false, + modifiedWithCtrlKey: false, + modifiedWithMetaKey: false, + includeRepeats: false, +}; + +let keyboardEventListener = (e: KeyboardEvent) => { + let settings = keyboardEventSettings; + if (e.repeat && !settings.includeRepeats) { + return; + } + let modified = e.ctrlKey || e.altKey || e.metaKey; + if (!modified && settings.unmodified + || e.ctrlKey && settings.modifiedWithCtrlKey + || e.altKey && settings.modifiedWithAltKey + || e.metaKey && settings.modifiedWithMetaKey) { + + let el = e.target as Element; + let componentId: string | null; + while (el != null) { + el = el.parentElement; + componentId = el?.getAttribute("data-teamapps-id"); + if (componentId != null) { + break; + } + } + + UiRootPanel.onGlobalKeyEventOccurred.fire({ + eventType: e.type == "keydown" ? KeyEventType.KEY_DOWN : KeyEventType.KEY_UP, + sourceComponentId: componentId, + code: e.code, + isComposing: e.isComposing, + key: e.key, + charCode: e.charCode, + keyCode: e.keyCode, + locale: (e as any).locale, + location: e.location, + repeat: e.repeat, + altKey: e.altKey, + ctrlKey: e.ctrlKey, + shiftKey: e.shiftKey, + metaKey: e.metaKey + }) + } +} + +function setGlobalKeyEventsEnabled(unmodified: boolean, modifiedWithAltKey: boolean, modifiedWithCtrlKey: boolean, modifiedWithMetaKey: boolean, includeRepeats: boolean, keyDown: boolean, keyUp: boolean) { + document.removeEventListener("keydown", keyboardEventListener, {capture: true}); + document.removeEventListener("keyup", keyboardEventListener, {capture: true}); + keyboardEventSettings = {unmodified, modifiedWithAltKey, modifiedWithCtrlKey, modifiedWithMetaKey, includeRepeats}; + if (keyDown) { + document.addEventListener("keydown", keyboardEventListener, {capture: true, passive: true}) + } + if (keyUp) { + document.addEventListener("keyup", keyboardEventListener, {capture: true, passive: true}) + } +} + +window.addEventListener('popstate', (event) => { + UiRootPanel.onNavigationStateChange.fire({ + location: createUiLocation(), + triggeredBrowserNavigation: true + }); +}); diff --git a/teamapps-client/ts/modules/UiScript.ts b/teamapps-client/ts/modules/UiScript.ts new file mode 100644 index 000000000..bcd89a4a2 --- /dev/null +++ b/teamapps-client/ts/modules/UiScript.ts @@ -0,0 +1,62 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ + +import {AbstractUiComponent} from "./AbstractUiComponent"; +import {TeamAppsUiContext} from "./TeamAppsUiContext"; +import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; +import {UiScriptCommandHandler, UiScriptConfig} from "../generated/UiScriptConfig"; + +export class UiScript extends AbstractUiComponent implements UiScriptCommandHandler { + private scriptElement: HTMLScriptElement; + + private modulePromise: Promise; + + constructor(config: UiScriptConfig, context: TeamAppsUiContext) { + super(config, context); + + this.scriptElement = document.createElement('script'); + this.scriptElement.type = 'module'; + this.scriptElement.innerText = config.script; + const someExistingScriptElement = document.getElementsByTagName('script')[0]; + someExistingScriptElement.parentNode.insertBefore(this.scriptElement, someExistingScriptElement); + + this.modulePromise = loadModuleFromString(config.script); + } + + protected doGetMainElement(): HTMLElement { + throw new Error("Method not implemented."); + } + + async callFunction(name: string, parameters: any[]) { + ((await this.modulePromise)[name] as Function).apply(null, parameters) + } + +} + +async function loadModuleFromString(moduleCode: string) { + const url = URL.createObjectURL(new Blob([moduleCode], {type: 'application/javascript'})); + try { + return await eval(`import("${url}")`); // needed so webpack does not try to be intelligent... + } finally { + URL.revokeObjectURL(url); + } +} + +TeamAppsUiComponentRegistry.registerComponentClass("UiScript", UiScript); diff --git a/teamapps-client/ts/modules/UiSplitPane.ts b/teamapps-client/ts/modules/UiSplitPane.ts index f6b62ac59..479ea0ba6 100644 --- a/teamapps-client/ts/modules/UiSplitPane.ts +++ b/teamapps-client/ts/modules/UiSplitPane.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,32 +17,32 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; + import {bind} from "./util/Bind"; import {TeamAppsEvent} from "./util/TeamAppsEvent"; import {UiComponentConfig} from "../generated/UiComponentConfig"; import {Emptyable, isEmptyable} from "./util/Emptyable"; -import {UiComponent} from "./UiComponent"; +import {AbstractUiComponent} from "./AbstractUiComponent"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; -import {capitalizeFirstLetter} from "./Common"; +import {capitalizeFirstLetter, css, parseHtml} from "./Common"; import {UiSplitPane_SplitResizedEvent, UiSplitPaneCommandHandler, UiSplitPaneConfig, UiSplitPaneEventSource} from "../generated/UiSplitPaneConfig"; import {UiSplitSizePolicy} from "../generated/UiSplitSizePolicy"; import {UiSplitDirection} from "../generated/UiSplitDirection"; -import {EventFactory} from "../generated/EventFactory"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; +import {UiComponent} from "./UiComponent"; -export class UiSplitPane extends UiComponent implements Emptyable, UiSplitPaneCommandHandler, UiSplitPaneEventSource { - public readonly onSplitResized: TeamAppsEvent = new TeamAppsEvent(this); +export class UiSplitPane extends AbstractUiComponent implements Emptyable, UiSplitPaneCommandHandler, UiSplitPaneEventSource { + public readonly onSplitResized: TeamAppsEvent = new TeamAppsEvent(); private _firstChildComponent: UiComponent; private _lastChildComponent: UiComponent; - private _$splitPane: JQuery; - private _$firstChildContainerWrapper: JQuery; - private _$lastChildContainerWrapper: JQuery; - private _$firstChildContainer: JQuery; - private _$lastChildContainer: JQuery; - private _$dividerWrapper: JQuery; - private _$divider: JQuery; + private _$splitPane: HTMLElement; + private _$firstChildContainerWrapper: HTMLElement; + private _$lastChildContainerWrapper: HTMLElement; + private _$firstChildContainer: HTMLElement; + private _$lastChildContainer: HTMLElement; + private _$dividerWrapper: HTMLElement; + private _$divider: HTMLElement; private _sizeAttribute: 'height' | 'width'; private _minSizeAttribute: 'minWidth' | 'minHeight'; @@ -55,7 +55,7 @@ export class UiSplitPane extends UiComponent implements Empty private firstChildMinSize: number; private lastChildMinSize: number; - public readonly onEmptyStateChanged: TeamAppsEvent = new TeamAppsEvent(this); + public readonly onEmptyStateChanged: TeamAppsEvent = new TeamAppsEvent(); constructor(config: UiSplitPaneConfig, context: TeamAppsUiContext) { @@ -64,7 +64,7 @@ export class UiSplitPane extends UiComponent implements Empty this.sizePolicy = config.sizePolicy; const firstChildContainerId = config.id + '_firstChildContainer'; const lastChildContainerId = config.id + '_lastChildContainer'; - this._$splitPane = $(`
+ this._$splitPane = parseHtml(`
@@ -75,13 +75,13 @@ export class UiSplitPane extends UiComponent implements Empty
`); - const $componentWrappers = this._$splitPane.find(".splitpane-component-wrapper"); - this._$firstChildContainerWrapper = $componentWrappers.first(); - this._$lastChildContainerWrapper = $componentWrappers.last(); - this._$firstChildContainer = this._$firstChildContainerWrapper.find(".splitpane-component"); - this._$lastChildContainer = this._$lastChildContainerWrapper.find(".splitpane-component"); - this._$dividerWrapper = this._$splitPane.find(".splitpane-divider-wrapper"); - this._$divider = this._$splitPane.find(".splitpane-divider"); + const $componentWrappers = this._$splitPane.querySelectorAll(":scope .splitpane-component-wrapper"); + this._$firstChildContainerWrapper = $componentWrappers.item(0); + this._$lastChildContainerWrapper = $componentWrappers.item(1); + this._$firstChildContainer = this._$firstChildContainerWrapper.querySelector(":scope .splitpane-component"); + this._$lastChildContainer = this._$lastChildContainerWrapper.querySelector(":scope .splitpane-component"); + this._$dividerWrapper = this._$splitPane.querySelector(":scope .splitpane-divider-wrapper"); + this._$divider = this._$splitPane.querySelector(":scope .splitpane-divider"); this._sizeAttribute = config.splitDirection === UiSplitDirection.HORIZONTAL ? 'height' : 'width'; this._minSizeAttribute = "min" + capitalizeFirstLetter(this._sizeAttribute) as 'minWidth' | 'minHeight'; @@ -92,11 +92,11 @@ export class UiSplitPane extends UiComponent implements Empty this.firstChildMinSize = config.firstChildMinSize; this.lastChildMinSize = config.lastChildMinSize; - this._$divider.bind('mousedown touchstart', this.mousedownHandler.bind(this)); + ['mousedown', 'touchstart'].forEach((eventName) => this._$divider.addEventListener(eventName, (e: MouseEvent) => this.mousedownHandler(e))); this._updatePositions(); - this.setFirstChild(config.firstChild); - this.setLastChild(config.lastChild); + this.setFirstChild(config.firstChild as UiComponent); + this.setLastChild(config.lastChild as UiComponent); this._updateChildContainerClasses(); } @@ -105,49 +105,47 @@ export class UiSplitPane extends UiComponent implements Empty return this._config.splitDirection } - public getMainDomElement(): JQuery { + public doGetMainElement(): HTMLElement { return this._$splitPane; } - protected onAttachedToDom() { - if (this.firstChildComponent) this.firstChildComponent.attachedToDom = true; - if (this.lastChildComponent) this.lastChildComponent.attachedToDom = true; - } - private mousedownHandler(event: MouseEvent) { - //let blurredBackgroundImageContainers = $('.teamapps-blurredBackgroundImage').removeClass('teamapps-blurredBackgroundImage'); event.preventDefault(); const isTouchEvent = event.type.match(/^touch/), moveEvent = isTouchEvent ? 'touchmove' : 'mousemove', endEvent = isTouchEvent ? 'touchend' : 'mouseup'; - this._$divider.addClass('dragged'); + this._$divider.classList.add('dragged'); if (isTouchEvent) { - this._$divider.addClass('touch'); + this._$divider.classList.add('touch'); } - $(document).on(moveEvent, this.createDragHandler(this.pageXof(event), this.pageYof(event))); - $(document).on(endEvent, (event) => { - $(document).unbind(moveEvent); - $(document).unbind(endEvent); - this._$divider.removeClass('dragged touch'); + let dragHandler = this.createDragHandler(this.pageXof(event), this.pageYof(event)); + let dropHandler = (event: Event) => { + document.removeEventListener(moveEvent, dragHandler); + document.removeEventListener(endEvent, dropHandler); + this._$divider.classList.remove('dragged', 'touch'); let referenceChildSize; if (this.sizePolicy === UiSplitSizePolicy.RELATIVE) { - referenceChildSize = this._$firstChildContainer.get(0)[this._offsetSizeAttribute] / this._$splitPane.get(0)[this._offsetSizeAttribute]; + referenceChildSize = this._$firstChildContainer[this._offsetSizeAttribute] / this._$splitPane[this._offsetSizeAttribute]; } else if (this.sizePolicy === UiSplitSizePolicy.FIRST_FIXED) { - referenceChildSize = this._$firstChildContainer.get(0)[this._offsetSizeAttribute]; + referenceChildSize = this._$firstChildContainer[this._offsetSizeAttribute]; } else { - referenceChildSize = this._$lastChildContainer.get(0)[this._offsetSizeAttribute]; + referenceChildSize = this._$lastChildContainer[this._offsetSizeAttribute]; } - this.onSplitResized.fire(EventFactory.createUiSplitPane_SplitResizedEvent(this._config.id, referenceChildSize)); + this.onSplitResized.fire({ + referenceChildSize: referenceChildSize + }); this.onResize(); - //blurredBackgroundImageContainers.addClass('teamapps-blurredBackgroundImage'); - }); + //blurredBackgroundImageContainers.classList.add('teamapps-blurredBackgroundImage'); + }; + document.addEventListener(moveEvent, dragHandler); + document.addEventListener(endEvent, dropHandler); } - private createDragHandler(dragStartX: number, dragStartY: number): JQuery.EventHandlerBase { - const initialFirstContainerWidth = this._$firstChildContainer[0][this._offsetSizeAttribute]; - const splitPaneSize = this._$splitPane.get(0)[this._offsetSizeAttribute]; + private createDragHandler(dragStartX: number, dragStartY: number): (e: MouseEvent) => void { + const initialFirstContainerWidth = this._$firstChildContainer[this._offsetSizeAttribute]; + const splitPaneSize = this._$splitPane[this._offsetSizeAttribute]; return (event: MouseEvent) => { const diff = (this._config.splitDirection === UiSplitDirection.HORIZONTAL) ? this.pageYof(event) - dragStartY : this.pageXof(event) - dragStartX; const newFirstChildSize = initialFirstContainerWidth + diff; @@ -167,20 +165,19 @@ export class UiSplitPane extends UiComponent implements Empty } private pageXof(event: MouseEvent) { - return event.pageX || (event as any).originalEvent.pageX || (event as any).touches[0].pageX; + return event.pageX ?? (event as any).touches[0].pageX; } private pageYof(event: MouseEvent) { - return event.pageY || (event as any).originalEvent.pageY || (event as any).touches[0].pageY; + return event.pageY ?? (event as any).touches[0].pageY; } public setFirstChild(firstChild: UiComponent) { - this._$firstChildContainer[0].innerHTML = ''; + this._$firstChildContainer.innerHTML = ''; this._firstChildComponent = firstChild; if (firstChild) { - firstChild.getMainDomElement().appendTo(this._$firstChildContainer); - firstChild.attachedToDom = this.attachedToDom; + this._$firstChildContainer.appendChild(firstChild.getMainElement()); if (this._firstChildComponent && isEmptyable(this._firstChildComponent)) { this._firstChildComponent.onEmptyStateChanged.addListener(this.onChildEmptyStateChanged); } @@ -188,19 +185,15 @@ export class UiSplitPane extends UiComponent implements Empty this._updateChildContainerClasses(); this._updatePositions(); - this.firstChildComponent && (this.firstChildComponent.attachedToDom = this.attachedToDom); - this.lastChildComponent && this.lastChildComponent.reLayout(); - this.updateEmptyState(); } public setLastChild(lastChild: UiComponent) { - this._$lastChildContainer[0].innerHTML = ''; + this._$lastChildContainer.innerHTML = ''; this._lastChildComponent = lastChild; if (lastChild) { - lastChild.getMainDomElement().appendTo(this._$lastChildContainer); - lastChild.attachedToDom = this.attachedToDom; + this._$lastChildContainer.appendChild(lastChild.getMainElement()); if (this._lastChildComponent && isEmptyable(this._lastChildComponent)) { this._lastChildComponent.onEmptyStateChanged.addListener(this.onChildEmptyStateChanged); } @@ -208,9 +201,6 @@ export class UiSplitPane extends UiComponent implements Empty this._updateChildContainerClasses(); this._updatePositions(); - this.lastChildComponent && (this.lastChildComponent.attachedToDom = this.attachedToDom); - this.lastChildComponent && this.lastChildComponent.reLayout(); - this.updateEmptyState(); } @@ -219,20 +209,24 @@ export class UiSplitPane extends UiComponent implements Empty const lastEmpty = this.isLastEmtpy(); if (firstEmpty && lastEmpty) { - this._$firstChildContainerWrapper.addClass("empty-child").removeClass("single-child"); - this._$lastChildContainerWrapper.addClass("empty-child").removeClass("single-child"); + this._$firstChildContainerWrapper.classList.add("empty-child"); + this._$firstChildContainerWrapper.classList.remove("single-child"); + this._$lastChildContainerWrapper.classList.add("empty-child"); + this._$lastChildContainerWrapper.classList.remove("single-child"); } else if (firstEmpty && this._config.fillIfSingleChild) { - this._$firstChildContainerWrapper.addClass("empty-child"); - this._$lastChildContainerWrapper.addClass("single-child").removeClass("empty-child"); + this._$firstChildContainerWrapper.classList.add("empty-child"); + this._$lastChildContainerWrapper.classList.add("single-child"); + this._$lastChildContainerWrapper.classList.remove("empty-child"); } else if (lastEmpty && this._config.fillIfSingleChild) { - this._$firstChildContainerWrapper.addClass("single-child").removeClass("empty-child"); - this._$lastChildContainerWrapper.addClass("empty-child"); + this._$firstChildContainerWrapper.classList.add("single-child"); + this._$firstChildContainerWrapper.classList.remove("empty-child"); + this._$lastChildContainerWrapper.classList.add("empty-child"); } else { - this._$firstChildContainerWrapper.removeClass("single-child empty-child"); - this._$lastChildContainerWrapper.removeClass("single-child empty-child"); + this._$firstChildContainerWrapper.classList.remove("single-child", "empty-child"); + this._$lastChildContainerWrapper.classList.remove("single-child", "empty-child"); } - this._$dividerWrapper.toggleClass("hidden", firstEmpty || lastEmpty); - this._$divider.toggleClass("hidden", !this._config.resizable); + this._$dividerWrapper.classList.toggle("hidden", firstEmpty || lastEmpty); + this._$divider.classList.toggle("hidden", !this._config.resizable); this.onResize(); // yes!! Maybe this splitpane does not need it for itself, but we want the children to relayout if necessary! } @@ -240,39 +234,39 @@ export class UiSplitPane extends UiComponent implements Empty const referenceChildSize = this.referenceChildSize; if (this.sizePolicy === UiSplitSizePolicy.RELATIVE) { - this._$firstChildContainerWrapper.css({ + css(this._$firstChildContainerWrapper, { "flex-grow": "" + referenceChildSize, "flex-shrink": "" + referenceChildSize, "flex-basis": "1px", [this._minSizeAttribute]: this.firstChildMinSize }); - this._$lastChildContainerWrapper.css({ + css(this._$lastChildContainerWrapper, { "flex-grow": "" + (1 - referenceChildSize), "flex-shrink": "" + (1 - referenceChildSize), "flex-basis": "1px", [this._minSizeAttribute]: this.lastChildMinSize }); } else if (this.sizePolicy === UiSplitSizePolicy.FIRST_FIXED) { - this._$firstChildContainerWrapper.css({ + css(this._$firstChildContainerWrapper, { "flex-grow": "0", "flex-shrink": "1", "flex-basis": referenceChildSize + 'px', [this._minSizeAttribute]: this.firstChildMinSize }); - this._$lastChildContainerWrapper.css({ + css(this._$lastChildContainerWrapper, { "flex-grow": "1", "flex-shrink": "1", "flex-basis": '1px', [this._minSizeAttribute]: this.lastChildMinSize }); } else if (this.sizePolicy === UiSplitSizePolicy.LAST_FIXED) { - this._$firstChildContainerWrapper.css({ + css(this._$firstChildContainerWrapper, { "flex-grow": "1", "flex-shrink": "1", "flex-basis": '1px', [this._minSizeAttribute]: this.firstChildMinSize }); - this._$lastChildContainerWrapper.css({ + css(this._$lastChildContainerWrapper, { "flex-grow": "0", "flex-shrink": "1", "flex-basis": referenceChildSize + 'px', @@ -305,15 +299,10 @@ export class UiSplitPane extends UiComponent implements Empty return this.isFirstEmpty() && this.isLastEmtpy(); } - public onResize(): void { - this._firstChildComponent && this._firstChildComponent.reLayout(); - this._lastChildComponent && this._lastChildComponent.reLayout(); - } - public setSize(referenceChildSize: number, sizePolicy: UiSplitSizePolicy) { this.referenceChildSize = referenceChildSize; this.sizePolicy = sizePolicy; - this._$divider.addClass('splitpane-' + UiSplitSizePolicy[this.sizePolicy].toLowerCase()); + this._$divider.classList.add('splitpane-' + UiSplitSizePolicy[this.sizePolicy].toLowerCase()); this._updatePositions(); } @@ -337,8 +326,6 @@ export class UiSplitPane extends UiComponent implements Empty this.onEmptyStateChanged.fireIfChanged(this.empty); } - public destroy(): void { - } } TeamAppsUiComponentRegistry.registerComponentClass("UiSplitPane", UiSplitPane); diff --git a/teamapps-client/ts/modules/UiStaticGridLayout.ts b/teamapps-client/ts/modules/UiStaticGridLayout.ts index 0651a6f1b..da3479a62 100644 --- a/teamapps-client/ts/modules/UiStaticGridLayout.ts +++ b/teamapps-client/ts/modules/UiStaticGridLayout.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,27 +17,28 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import {UiComponent} from "./UiComponent"; +import {AbstractUiComponent} from "./AbstractUiComponent"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; import {UiStaticGridLayoutCommandHandler, UiStaticGridLayoutConfig} from "../generated/UiStaticGridLayoutConfig"; import {UiGridLayout} from "./micro-components/UiGridLayout"; import {UiGridLayoutConfig} from "../generated/UiGridLayoutConfig"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; import {UiResponsiveGridLayout} from "./UiResponsiveGridLayout"; +import {parseHtml} from "./Common"; -export class UiStaticGridLayout extends UiComponent implements UiStaticGridLayoutCommandHandler{ +export class UiStaticGridLayout extends AbstractUiComponent implements UiStaticGridLayoutCommandHandler{ - private $main: JQuery; + private $main: HTMLElement; private layout: UiGridLayout; constructor(config: UiStaticGridLayoutConfig, context: TeamAppsUiContext) { super(config, context); - this.$main = $(`
`); + this.$main = parseHtml(`
`); this.updateLayout(config.descriptor); } - getMainDomElement(): JQuery { + doGetMainElement(): HTMLElement { return this.$main; } @@ -46,10 +47,6 @@ export class UiStaticGridLayout extends UiComponent im this.layout.applyTo(this.$main); } - onResize(): void { - this.layout.getAllComponents().forEach(c => c.reLayout()); - } - } TeamAppsUiComponentRegistry.registerComponentClass("UiStaticGridLayout", UiStaticGridLayout); diff --git a/teamapps-client/ts/modules/UiTabPanel.ts b/teamapps-client/ts/modules/UiTabPanel.ts index f86b56bd4..3d743c735 100644 --- a/teamapps-client/ts/modules/UiTabPanel.ts +++ b/teamapps-client/ts/modules/UiTabPanel.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,7 +17,7 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; + import {UiToolbar} from "./tool-container/toolbar/UiToolbar"; import {UiComponentConfig} from "../generated/UiComponentConfig"; import {TeamAppsEvent} from "./util/TeamAppsEvent"; @@ -25,10 +25,10 @@ import {UiTabConfig} from "../generated/UiTabConfig"; import {bind} from "./util/Bind"; import {Emptyable, isEmptyable} from "./util/Emptyable"; import {UiToolButton} from "./micro-components/UiToolButton"; -import {UiComponent} from "./UiComponent"; +import {AbstractUiComponent} from "./AbstractUiComponent"; import {UiDropDown} from "./micro-components/UiDropDown"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; -import {executeWhenAttached} from "./util/ExecuteWhenAttached"; +import {executeWhenFirstDisplayed} from "./util/ExecuteWhenFirstDisplayed"; import { UiTabPanel_TabClosedEvent, UiTabPanel_TabNeedsRefreshEvent, @@ -40,40 +40,40 @@ import { } from "../generated/UiTabPanelConfig"; import {createUiToolButtonConfig} from "../generated/UiToolButtonConfig"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; -import {EventFactory} from "../generated/EventFactory"; import {UiTabPanelTabStyle} from "../generated/UiTabPanelTabStyle"; -import {insertAtIndex, maximizeComponent} from "./Common"; +import {insertAtIndex, insertBefore, maximizeComponent, parseHtml, prependChild} from "./Common"; import {UiWindowButtonType} from "../generated/UiWindowButtonType"; import {StaticIcons} from "./util/StaticIcons"; +import {UiComponent} from "./UiComponent"; interface Tab { config: UiTabConfig; - $button: JQuery; - $wrapper: JQuery; - $dropDownTabButton: JQuery; - $toolbarContainer: JQuery; - $contentContainer: JQuery; + $button: HTMLElement; + $wrapper: HTMLElement; + $dropDownTabButton: HTMLElement; + $toolbarContainer: HTMLElement; + $contentContainer: HTMLElement; toolbar: UiToolbar; contentComponent: UiComponent; buttonWidth?: number; visible: boolean; } -export class UiTabPanel extends UiComponent implements UiTabPanelCommandHandler, UiTabPanelEventSource, Emptyable { +export class UiTabPanel extends AbstractUiComponent implements UiTabPanelCommandHandler, UiTabPanelEventSource, Emptyable { - public readonly onTabSelected: TeamAppsEvent = new TeamAppsEvent(this); + public readonly onTabSelected: TeamAppsEvent = new TeamAppsEvent(); - public readonly onTabNeedsRefresh: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onTabClosed: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onEmptyStateChanged: TeamAppsEvent = new TeamAppsEvent(this); + public readonly onTabNeedsRefresh: TeamAppsEvent = new TeamAppsEvent(); + public readonly onTabClosed: TeamAppsEvent = new TeamAppsEvent(); + public readonly onEmptyStateChanged: TeamAppsEvent = new TeamAppsEvent(); - public readonly onWindowButtonClicked: TeamAppsEvent = new TeamAppsEvent(this); + public readonly onWindowButtonClicked: TeamAppsEvent = new TeamAppsEvent(); private readonly defaultToolButtons = { - [UiWindowButtonType.MINIMIZE]: new UiToolButton(createUiToolButtonConfig("MINIMIZE", StaticIcons.MINIMIZE, "Minimize"), this._context), - [UiWindowButtonType.MAXIMIZE_RESTORE]: new UiToolButton(createUiToolButtonConfig("MAXIMIZE_RESTORE", StaticIcons.MAXIMIZE, "Maximize/Restore"), this._context), - [UiWindowButtonType.CLOSE]: new UiToolButton(createUiToolButtonConfig("CLOSE", StaticIcons.CLOSE, "Close"), this._context), + [UiWindowButtonType.MINIMIZE]: new UiToolButton(createUiToolButtonConfig(StaticIcons.MINIMIZE, "Minimize", {debuggingId: "window-button-minimize"}), this._context), + [UiWindowButtonType.MAXIMIZE_RESTORE]: new UiToolButton(createUiToolButtonConfig(StaticIcons.MAXIMIZE, "Maximize/Restore", {debuggingId: "window-button-maximize"}), this._context), + [UiWindowButtonType.CLOSE]: new UiToolButton(createUiToolButtonConfig(StaticIcons.CLOSE, "Close", {debuggingId: "window-button-close"}), this._context), }; private readonly orderedDefaultToolButtonTypes = [ UiWindowButtonType.MINIMIZE, @@ -81,42 +81,41 @@ export class UiTabPanel extends UiComponent implements UiTabPa UiWindowButtonType.CLOSE ]; - private $tabPanel: JQuery; - private $leftButtonsWrapper: JQuery; - private $rightButtonsWrapper: JQuery; - private $dropDownButton: JQuery; - private $dropDown: JQuery; - private $dropButtonContainerLeft: JQuery; - private $dropButtonContainerRight: JQuery; - private $toolTabButton: JQuery; - private $toolButtonContainer: JQuery; - private $windowButtonContainer: JQuery; - private $contentWrapper: JQuery; - private $tabBar: JQuery; - private $tabsContainer: JQuery; + private $tabPanel: HTMLElement; + private $leftButtonsWrapper: HTMLElement; + private $rightButtonsWrapper: HTMLElement; + private $dropDownButton: HTMLElement; + private $dropDown: HTMLElement; + private $dropButtonContainerLeft: HTMLElement; + private $dropButtonContainerRight: HTMLElement; + private $toolsContainer: HTMLElement; + private $toolButtonContainer: HTMLElement; + private $windowButtonContainer: HTMLElement; + private $contentWrapper: HTMLElement; + private $tabBar: HTMLElement; + private $tabsContainer: HTMLElement; private leftTabs: Tab[] = []; private rightTabs: Tab[] = []; private selectedTab: Tab; private hideTabBarIfSingleTab: boolean; - private toolButtons: { [id: string]: UiToolButton } = {}; - private toolButtonDropDown: UiDropDown; - private windowButtons: UiWindowButtonType[]; + private toolButtons: UiToolButton[] = []; + private windowButtons: UiWindowButtonType[] = []; - private restoreFunction: (animationCallback: () => void) => void; + private restoreFunction: (animationCallback?: () => void) => void; constructor(config: UiTabPanelConfig, context: TeamAppsUiContext) { super(config, context); - this.$tabPanel = $(`
+ this.$tabPanel = parseHtml(`
-
+
@@ -130,18 +129,18 @@ export class UiTabPanel extends UiComponent implements UiTabPa
`); - this.$tabBar = this.$tabPanel.find('>.tab-panel-header'); - this.$tabsContainer = this.$tabBar.find('>.background-color-div'); - this.$leftButtonsWrapper = this.$tabsContainer.find('>.tab-button-container.left'); - this.$rightButtonsWrapper = this.$tabsContainer.find('>.tab-button-container.right'); - this.$toolTabButton = this.$tabsContainer.find('>.tool-tab-button'); - this.$toolButtonContainer = this.$toolTabButton.find('>.tool-button-container'); - this.$windowButtonContainer = this.$toolTabButton.find('>.window-button-container'); - this.$dropDownButton = this.$tabsContainer.find('>.dropdown-button'); - this.$dropDown = this.$tabsContainer.find('>.tab-panel-dropdown'); - this.$dropButtonContainerLeft = this.$dropDown.find('>.dropdown-button-container.left'); - this.$dropButtonContainerRight = this.$dropDown.find('>.dropdown-button-container.right'); - this.$contentWrapper = this.$tabPanel.find('.tabpanel-content-wrapper'); + this.$tabBar = this.$tabPanel.querySelector(':scope >.tab-panel-header'); + this.$tabsContainer = this.$tabBar.querySelector(':scope >.background-color-div'); + this.$leftButtonsWrapper = this.$tabsContainer.querySelector(':scope >.tab-button-container.left'); + this.$rightButtonsWrapper = this.$tabsContainer.querySelector(':scope >.tab-button-container.right'); + this.$toolsContainer = this.$tabsContainer.querySelector(':scope >.tools-container'); + this.$toolButtonContainer = this.$toolsContainer.querySelector(':scope >.tool-button-container'); + this.$windowButtonContainer = this.$toolsContainer.querySelector(':scope >.window-button-container'); + this.$dropDownButton = this.$tabsContainer.querySelector(':scope >.dropdown-button'); + this.$dropDown = this.$tabsContainer.querySelector(':scope >.tab-panel-dropdown'); + this.$dropButtonContainerLeft = this.$dropDown.querySelector(':scope >.dropdown-button-container.left'); + this.$dropButtonContainerRight = this.$dropDown.querySelector(':scope >.dropdown-button-container.right'); + this.$contentWrapper = this.$tabPanel.querySelector(':scope .tabpanel-content-wrapper'); this.setHideTabBarIfSingleTab(!!config.hideTabBarIfSingleTab); this.setTabStyle(config.tabStyle); @@ -155,26 +154,25 @@ export class UiTabPanel extends UiComponent implements UiTabPa this.selectFirstVisibleTab(); } - this.$dropDownButton.click(() => { - if (this.$dropDown.is(':visible')) { - this.$dropDown.addClass("hidden"); + this.$dropDownButton.addEventListener("click", () => { + if ($(this.$dropDown).is(':visible')) { + this.$dropDown.classList.add("hidden"); } else { - this.$dropDown.removeClass("hidden"); - this.$dropDown.position({ + this.$dropDown.classList.remove("hidden"); + $(this.$dropDown).position({ my: "right top", at: "right bottom", of: this.$dropDownButton }); } - }).blur(() => { - this.$dropDown.addClass("hidden"); + }); + this.$dropDownButton.addEventListener("blur", () => { + this.$dropDown.classList.add("hidden"); }); if (config.toolButtons != null) { - this.setToolButtons(config.toolButtons); + this.setToolButtons(config.toolButtons as UiToolButton[]); } - this.toolButtonDropDown = new UiDropDown(); - this.defaultToolButtons[UiWindowButtonType.MAXIMIZE_RESTORE].onClicked.addListener(() => { if (this.restoreFunction == null) { this.maximize(); @@ -184,10 +182,14 @@ export class UiTabPanel extends UiComponent implements UiTabPa }); this.orderedDefaultToolButtonTypes.forEach(windowButtonType => { this.defaultToolButtons[windowButtonType].onClicked.addListener(() => { - this.onWindowButtonClicked.fire(EventFactory.createUiTabPanel_WindowButtonClickedEvent(this.getId(), windowButtonType)); + this.onWindowButtonClicked.fire({ + windowButton: windowButtonType + }); }); }); this.setWindowButtons(config.windowButtons); + this.setFillTabBarWidth(config.fillTabBarWidth ?? false); + this.setTabBarHeight(config.tabBarHeight); } public setMaximized(maximized: boolean) { @@ -200,17 +202,33 @@ export class UiTabPanel extends UiComponent implements UiTabPa public maximize(): void { this.defaultToolButtons[UiWindowButtonType.MAXIMIZE_RESTORE].setIcon(StaticIcons.RESTORE); - this.restoreFunction = maximizeComponent(this, () => this.reLayout(true)); + this.restoreFunction = maximizeComponent(this); } public restore(): void { this.defaultToolButtons[UiWindowButtonType.MAXIMIZE_RESTORE].setIcon(StaticIcons.MAXIMIZE); if (this.restoreFunction != null) { - this.restoreFunction(() => this.reLayout(true)); + this.restoreFunction(); } this.restoreFunction = null; } + setFillTabBarWidth(fillTabBarWidth: boolean): any { + this._config.fillTabBarWidth = fillTabBarWidth; + this.$tabPanel.classList.toggle("fill-tab-bar-width", fillTabBarWidth); + this.relayoutButtons(); + } + + setTabBarHeight(tabBarHeight: string): any { + this._config.tabBarHeight = tabBarHeight; + if (tabBarHeight) { + this.$tabPanel.style.setProperty("--ta-tab-button-height", tabBarHeight); + } else { + this.$tabPanel.style.removeProperty("--ta-tab-button-height"); + } + this.relayoutButtons(); + } + public setHideTabBarIfSingleTab(hideTabBarIfSingleTab: boolean) { this.hideTabBarIfSingleTab = hideTabBarIfSingleTab; this.updateTabBarVisibility(); @@ -220,30 +238,29 @@ export class UiTabPanel extends UiComponent implements UiTabPa return this.leftTabs.concat(this.rightTabs); } - public getMainDomElement(): JQuery { + public doGetMainElement(): HTMLElement { return this.$tabPanel; } private _createTab(tabConfig: UiTabConfig, index: number = Number.MAX_SAFE_INTEGER): Tab { const $tabButton = this.createTabButton(tabConfig.tabId, tabConfig.icon, tabConfig.caption, tabConfig.closeable); const $dropDownTabButton = this.createTabButton(tabConfig.tabId, tabConfig.icon, tabConfig.caption, tabConfig.closeable); - $tabButton.add($dropDownTabButton).mousedown(() => { - this.selectTab(tabConfig.tabId, true); - }); + $tabButton.addEventListener("mousedown", () => this.selectTab(tabConfig.tabId, true)); + $dropDownTabButton.addEventListener("mousedown", () => this.selectTab(tabConfig.tabId, true)); - const $tabContent = $(`
-
-
-
`) - .appendTo(this.$contentWrapper); + const $tabContent = parseHtml(`
+
+
+
`); + this.$contentWrapper.appendChild($tabContent); let tab: Tab = { config: tabConfig, $button: $tabButton, $dropDownTabButton: $dropDownTabButton, $wrapper: $tabContent, - $toolbarContainer: $tabContent.find(".tab-toolbar-container"), - $contentContainer: $tabContent.find(".tab-component-container"), + $toolbarContainer: $tabContent.querySelector(":scope .tab-toolbar-container"), + $contentContainer: $tabContent.querySelector(":scope .tab-component-container"), toolbar: null, contentComponent: null, visible: tabConfig.visible @@ -251,7 +268,7 @@ export class UiTabPanel extends UiComponent implements UiTabPa this.putTabButtonsToIndex(tab, index); if (tabConfig.toolbar) { - this.setTabToolbarInternal(tab, tabConfig.toolbar); + this.setTabToolbarInternal(tab, tabConfig.toolbar as UiToolbar); } return tab; @@ -267,22 +284,23 @@ export class UiTabPanel extends UiComponent implements UiTabPa } } - private createTabButton(tabId: string, iconName: string, caption: string, closeable: boolean) { - const $tabButton = $(`
- ${iconName ? `
` : ''} -
${caption}
-
+ private createTabButton(tabId: string, icon: string, caption: string, closeable: boolean) { + const $tabButton = parseHtml(`
+ ${icon ? `
` : ''} +
${caption}
`); if (closeable) { const closeIconPath = "/resources/window-close-grey.png"; let closeButtonHtml = `
-
+
`; - const $closeButton1 = $(closeButtonHtml).appendTo($tabButton); - $closeButton1.mousedown(() => { + const $closeButton1 = $tabButton.appendChild(parseHtml(closeButtonHtml)); + $closeButton1.addEventListener("mousedown", () => { this.removeTab(tabId); - this.onTabClosed.fire(EventFactory.createUiTabPanel_TabClosedEvent(this._config.id, tabId)); + this.onTabClosed.fire({ + tabId: tabId + }); }); } return $tabButton; @@ -295,59 +313,46 @@ export class UiTabPanel extends UiComponent implements UiTabPa return; } this.selectedTab = tab; - this.$leftButtonsWrapper - .add(this.$rightButtonsWrapper) - .add(this.$dropButtonContainerLeft) - .add(this.$dropButtonContainerRight) - .find('>.tab-button').removeClass('selected'); - tab.$button.addClass('selected'); - tab.$dropDownTabButton.addClass('selected'); - - this.$contentWrapper.find('>.tab-content-wrapper').removeClass('selected'); - tab.$wrapper.addClass('selected'); + [this.$leftButtonsWrapper, this.$rightButtonsWrapper, this.$dropButtonContainerLeft, this.$dropButtonContainerRight] + .forEach(el => el.querySelectorAll(':scope >.tab-button').forEach(el => el.classList.remove('selected'))); + tab.$button.classList.add('selected'); + tab.$dropDownTabButton.classList.add('selected'); + + this.$contentWrapper.querySelectorAll(':scope >.tab-content-wrapper').forEach(el => el.classList.remove('selected')); + tab.$wrapper.classList.add('selected'); if (sendSelectionEvent) { - this.onTabSelected.fire(EventFactory.createUiTabPanel_TabSelectedEvent(this.getId(), tabId)); + this.onTabSelected.fire({ + tabId: tabId + }); } if (this.selectedTab.contentComponent == null) { - this.onTabNeedsRefresh.fire(EventFactory.createUiTabPanel_TabNeedsRefreshEvent(this.getId(), tabId)); - } - if (tab.toolbar) { - if (!tab.toolbar.attachedToDom) { - tab.toolbar.attachedToDom = this.attachedToDom; - } else { - tab.toolbar.reLayout(); - } - } - if (tab.contentComponent) { - if (!tab.contentComponent.attachedToDom) { - tab.contentComponent.attachedToDom = this.attachedToDom; - } else { - tab.contentComponent.reLayout(); - } + this.onTabNeedsRefresh.fire({ + tabId: tabId + }); } } - public setTabContent(tabId: string, content: UiComponent) { + public setTabContent(tabId: string, content: UiComponent, fireLazyLoadEventIfNeeded = false) { const tab = this.getTabById(tabId); const $tabContentContainer = tab.$contentContainer; if (tab.contentComponent) { isEmptyable(tab.contentComponent) && tab.contentComponent.onEmptyStateChanged.removeListener(this.onChildEmptyStateChanged.bind(this)); - $tabContentContainer[0].innerHTML = ''; + $tabContentContainer.innerHTML = ''; } tab.contentComponent = content; if (tab.contentComponent) { isEmptyable(tab.contentComponent) && tab.contentComponent.onEmptyStateChanged.addListener(this.onChildEmptyStateChanged.bind(this)); - content.getMainDomElement().appendTo($tabContentContainer); + $tabContentContainer.appendChild(content.getMainElement()); } this.onChildEmptyStateChanged(); // after (!!) the parent has been informed about the empty state change, we should think about setting attached (and resizing!!!) if (this.selectedTab && this.selectedTab.config.tabId === tabId) { - if (tab.contentComponent) { - tab.contentComponent.attachedToDom = this.attachedToDom; - } else if (tab.config.lazyLoading) { - this.onTabNeedsRefresh.fire(EventFactory.createUiTabPanel_TabNeedsRefreshEvent(this.getId(), tabId)); + if (!tab.contentComponent && tab.config.lazyLoading && fireLazyLoadEventIfNeeded) { + this.onTabNeedsRefresh.fire({ + tabId: tabId + }); } } @@ -356,16 +361,16 @@ export class UiTabPanel extends UiComponent implements UiTabPa setTabConfiguration(tabId: string, icon: string, caption: string, closeable: boolean, visible: boolean, rightSide: boolean): void { let tab = this.getTabById(tabId); - tab.$button[0].innerHTML = ''; - tab.$button.append(this.createTabButton(tabId, icon, caption, closeable).find(">*")); - tab.$dropDownTabButton[0].innerHTML = ''; - tab.$dropDownTabButton.append(this.createTabButton(tabId, icon, caption, closeable).find(">*")); + tab.$button.innerHTML = ''; + tab.$button.append(...Array.from(this.createTabButton(tabId, icon, caption, closeable).querySelectorAll(":scope >*"))); + tab.$dropDownTabButton.innerHTML = ''; + tab.$dropDownTabButton.append(...Array.from(this.createTabButton(tabId, icon, caption, closeable).querySelectorAll(":scope >*"))); if (rightSide && !tab.config.rightSide) { - this.$rightButtonsWrapper.append(tab.$button); - this.$dropButtonContainerRight.append(tab.$dropDownTabButton); + this.$rightButtonsWrapper.appendChild(tab.$button); + this.$dropButtonContainerRight.appendChild(tab.$dropDownTabButton); } else if (!rightSide && tab.config.rightSide) { - this.$leftButtonsWrapper.append(tab.$button); - this.$dropButtonContainerLeft.append(tab.$dropDownTabButton); + this.$leftButtonsWrapper.appendChild(tab.$button); + this.$dropButtonContainerLeft.appendChild(tab.$dropDownTabButton); } tab.config.tabId = tabId; tab.config.icon = icon; @@ -387,13 +392,12 @@ export class UiTabPanel extends UiComponent implements UiTabPa } else { this.leftTabs.push(tab); } - this.setTabContent(tabConfig.tabId, tabConfig.content); + this.setTabContent(tabConfig.tabId, tabConfig.content as UiComponent, true); if (select || this.getAllTabs().length === 1) { this.selectTab(tabConfig.tabId, false); } let tabBarVisibilityChanged = this.updateTabBarVisibility(); if (tabBarVisibilityChanged) { - this.reLayout(true); } else { this.relayoutButtons(); } @@ -405,15 +409,6 @@ export class UiTabPanel extends UiComponent implements UiTabPa this.putTabButtonsToIndex(tab, index); } - protected onAttachedToDom() { - Object.values(this.toolButtons).forEach(b => b.attachedToDom = true); - this.getAllTabs().forEach(tab => { - if (tab.toolbar) tab.toolbar.attachedToDom = true; - if (tab.contentComponent) tab.contentComponent.attachedToDom = true; - }); - this.reLayout(); - } - public setTabToolbar(tabId: string, toolbar: UiToolbar) { let tab = this.getTabById(tabId); if (tab) { @@ -423,14 +418,11 @@ export class UiTabPanel extends UiComponent implements UiTabPa private setTabToolbarInternal(tab: Tab, toolbar: UiToolbar) { if (tab.toolbar != null) { - tab.toolbar.getMainDomElement().detach(); + tab.toolbar.getMainElement().remove(); } tab.toolbar = toolbar; if (toolbar) { - tab.$toolbarContainer.append(toolbar.getMainDomElement()); - } - if (this.selectedTab && this.selectedTab.config.tabId === tab.config.tabId && tab.toolbar) { - tab.toolbar.attachedToDom = this.attachedToDom; + tab.$toolbarContainer.append(toolbar.getMainElement()); } } @@ -440,9 +432,9 @@ export class UiTabPanel extends UiComponent implements UiTabPa if (!tab) return; tab.contentComponent != null && isEmptyable(tab.contentComponent) && tab.contentComponent.onEmptyStateChanged.removeListener(this.onChildEmptyStateChanged.bind(this)); - tab.$button.detach(); - tab.$dropDownTabButton.detach(); - tab.$wrapper.detach(); + tab.$button.remove(); + tab.$dropDownTabButton.remove(); + tab.$wrapper.remove(); this.leftTabs = this.leftTabs.filter(tab => tab.config.tabId !== tabId); this.rightTabs = this.rightTabs.filter(tab => tab.config.tabId !== tabId); @@ -453,7 +445,6 @@ export class UiTabPanel extends UiComponent implements UiTabPa let tabBarVisibilityChanged = this.updateTabBarVisibility(); if (tabBarVisibilityChanged) { - this.reLayout(true); } else { this.relayoutButtons(); } @@ -465,7 +456,9 @@ export class UiTabPanel extends UiComponent implements UiTabPa this.selectTab(this.getVisibleTabs()[0].config.tabId, true); // was !this._context.executingCommand } else if (this.selectedTab != null) { this.selectedTab = null; - this.onTabSelected.fire(EventFactory.createUiTabPanel_TabSelectedEvent(this.getId(), null)); + this.onTabSelected.fire({ + tabId: null + }); } } @@ -492,10 +485,10 @@ export class UiTabPanel extends UiComponent implements UiTabPa } private updateTabBarVisibility(): boolean { - let wasHidden = this.$tabBar.is('.hidden'); + let wasHidden = this.$tabBar.classList.contains('hidden'); let isHidden = this.hideTabBarIfSingleTab && this.getVisibleTabs().length <= 1; - this.$tabBar.toggleClass("hidden", isHidden); - this.$tabBar[0].offsetHeight; // trigger reflow! + this.$tabBar.classList.toggle("hidden", isHidden); + this.$tabBar.offsetHeight; // trigger reflow! return wasHidden != isHidden; } @@ -525,13 +518,11 @@ export class UiTabPanel extends UiComponent implements UiTabPa } public setToolButtons(toolButtons: UiToolButton[]) { - this.$toolButtonContainer[0].innerHTML = ''; - this.$toolButtonContainer.toggleClass("hidden", !toolButtons || toolButtons.length === 0); - this.toolButtons = {}; + this.$toolButtonContainer.innerHTML = ''; + this.$toolButtonContainer.classList.toggle("hidden", !toolButtons || toolButtons.length === 0); + this.toolButtons = toolButtons ?? []; toolButtons.forEach(toolButton => { - toolButton.getMainDomElement().appendTo(this.$toolButtonContainer); - toolButton.attachedToDom = this.attachedToDom; - this.toolButtons[toolButton.getId()] = toolButton; + this.$toolButtonContainer.appendChild(toolButton.getMainElement()); }); this.relayoutButtons(); } @@ -540,97 +531,90 @@ export class UiTabPanel extends UiComponent implements UiTabPa return Object.values(this.toolButtons); } - public setWindowButtons(buttonTypes:UiWindowButtonType[]):void{ + public setWindowButtons(buttonTypes: UiWindowButtonType[]): void { this.windowButtons = []; - this.$windowButtonContainer[0].innerHTML = ''; - if (buttonTypes && buttonTypes.length > 0) { - buttonTypes.forEach(toolButton => { - this.addWindowButton(toolButton); - }); - } else { - this.windowButtons.slice().forEach(button => this.removeWindowButton(button)); - } + this.$windowButtonContainer.innerHTML = ''; + buttonTypes?.forEach(toolButton => { + this.addWindowButton(toolButton); + }); } private addWindowButton(toolButtonType: UiWindowButtonType) { - if (this.windowButtons.filter(tb => tb === toolButtonType).length > 0){ + if (this.windowButtons.filter(tb => tb === toolButtonType).length > 0) { this.removeWindowButton(toolButtonType); } - this.$windowButtonContainer.removeClass("hidden"); + this.$windowButtonContainer.classList.remove("hidden"); this.windowButtons.push(toolButtonType); const button = this.defaultToolButtons[toolButtonType]; - if (this.$windowButtonContainer[0].children.length === 0) { - button.getMainDomElement().prependTo(this.$windowButtonContainer); + if (this.$windowButtonContainer.children.length === 0) { + prependChild(this.$windowButtonContainer, button.getMainElement()); } else { let index = this.windowButtons - .sort((a, b) =>this.orderedDefaultToolButtonTypes.indexOf(a) - this.orderedDefaultToolButtonTypes.indexOf(b)) + .sort((a, b) => this.orderedDefaultToolButtonTypes.indexOf(a) - this.orderedDefaultToolButtonTypes.indexOf(b)) .indexOf(toolButtonType); - if (index >= this.$windowButtonContainer[0].childNodes.length) { - button.getMainDomElement().appendTo(this.$windowButtonContainer); + if (index >= this.$windowButtonContainer.childNodes.length) { + this.$windowButtonContainer.appendChild(button.getMainElement()); } else { - button.getMainDomElement().insertBefore(this.$windowButtonContainer[0].children[index]); + insertBefore(button.getMainElement(), this.$windowButtonContainer.children[index]); } } this.relayoutButtons(); } public removeWindowButton(uiToolButton: UiWindowButtonType) { - this.defaultToolButtons[uiToolButton].getMainDomElement().detach(); + this.defaultToolButtons[uiToolButton].getMainElement().remove(); this.windowButtons = this.windowButtons.filter(tb => tb !== uiToolButton); if (this.windowButtons.length === 0) { - this.$windowButtonContainer.addClass("hidden"); + this.$windowButtonContainer.classList.add("hidden"); } } public setTabStyle(tabStyle: UiTabPanelTabStyle) { - this.$tabPanel.removeClass('tab-style-ears tab-style-blocks') - .addClass(tabStyle === UiTabPanelTabStyle.EARS ? 'tab-style-ears' : 'tab-style-blocks'); - this.$tabBar.toggleClass("teamapps-blurredBackgroundImage", tabStyle === UiTabPanelTabStyle.BLOCKS); - this.reLayout(true); + this.$tabPanel.classList.remove('tab-style-ears', 'tab-style-blocks'); + this.$tabPanel.classList.add(tabStyle === UiTabPanelTabStyle.EARS ? 'tab-style-ears' : 'tab-style-blocks'); + this.$tabBar.classList.toggle("teamapps-blurredBackgroundImage", tabStyle === UiTabPanelTabStyle.BLOCKS); + this.onResize(); } - @executeWhenAttached(true) + @executeWhenFirstDisplayed(true) public onResize(): void { - if (!this.attachedToDom || this.getMainDomElement()[0].offsetWidth <= 0) return; - if (this.selectedTab) { - this.selectedTab.toolbar && this.selectedTab.toolbar.reLayout(); - this.selectedTab.contentComponent && this.selectedTab.contentComponent.reLayout(); - } this.relayoutButtons(); } - @executeWhenAttached(true) + @executeWhenFirstDisplayed(true) private relayoutButtons() { - if (this.$tabBar.is('.hidden')) { + if (this.$tabBar.classList.contains('hidden')) { return; } - let availableWidth = this.$tabsContainer.width() - this.$dropDownButton[0].offsetWidth - this.$toolTabButton[0].offsetWidth; + this.$toolsContainer.classList.toggle("hidden", this.toolButtons.length === 0 && this.windowButtons.length === 0); + let availableWidth = $(this.$tabsContainer).width() - this.$dropDownButton.offsetWidth - this.$toolsContainer.offsetWidth; let sumOfButtonWidths = 0; this.getAllTabs().forEach(tab => { let tabFilled = !this.tabIsDeFactoEmpty(tab); if (tabFilled) { if (!tab.buttonWidth) { - tab.buttonWidth = tab.$button[0].offsetWidth + tab.$button.style.flex = "0 0 auto"; + tab.buttonWidth = tab.$button.offsetWidth; + tab.$button.style.removeProperty("flex"); } sumOfButtonWidths += tab.buttonWidth; if (sumOfButtonWidths < availableWidth) { - tab.$button.removeClass("hidden"); - tab.$dropDownTabButton.addClass("hidden"); + tab.$button.classList.remove("hidden"); + tab.$dropDownTabButton.classList.add("hidden"); } else { - tab.$button.addClass("hidden"); - tab.$dropDownTabButton.removeClass("hidden"); + tab.$button.classList.add("hidden"); + tab.$dropDownTabButton.classList.remove("hidden"); } } else { - tab.$button.add(tab.$dropDownTabButton).toggleClass("hidden", true); + tab.$button.classList.add("hidden"); + tab.$dropDownTabButton.classList.add("hidden"); } }); - this.$dropDownButton.toggleClass("hidden", sumOfButtonWidths <= availableWidth); + this.$dropDownButton.classList.toggle("hidden", sumOfButtonWidths <= availableWidth); } - public destroy(): void { - } } TeamAppsUiComponentRegistry.registerComponentClass("UiTabPanel", UiTabPanel); diff --git a/teamapps-client/ts/modules/UiTemplateTestContainer.ts b/teamapps-client/ts/modules/UiTemplateTestContainer.ts deleted file mode 100644 index b3cf3434a..000000000 --- a/teamapps-client/ts/modules/UiTemplateTestContainer.ts +++ /dev/null @@ -1,54 +0,0 @@ -/*- - * ========================LICENSE_START================================= - * TeamApps - * --- - * Copyright (C) 2014 - 2019 TeamApps.org - * --- - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * =========================LICENSE_END================================== - */ -import * as $ from "jquery"; -import {UiComponent} from "./UiComponent"; -import {TeamAppsUiContext} from "./TeamAppsUiContext"; -import {UiTemplateTestContainerConfig} from "../generated/UiTemplateTestContainerConfig"; -import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; - - -export class UiTemplateTestContainer extends UiComponent { - - private $panel: JQuery; - - constructor(config: UiTemplateTestContainerConfig, context: TeamAppsUiContext) { - super(config, context); - this.$panel = $(`
-
${config.description}
-
- ${context.templateRegistry.createTemplateRenderer(config.template).render(config.data)} -
-
`); - } - - - public getMainDomElement(): JQuery { - return this.$panel; - } - - public destroy(): void { - } - - protected onAttachedToDom(): void { - } - -} - -TeamAppsUiComponentRegistry.registerComponentClass("UiTemplateTestContainer", UiTemplateTestContainer); diff --git a/teamapps-client/ts/modules/UiTestHarness.ts b/teamapps-client/ts/modules/UiTestHarness.ts new file mode 100644 index 000000000..9419871f4 --- /dev/null +++ b/teamapps-client/ts/modules/UiTestHarness.ts @@ -0,0 +1,73 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +import {UiConfigurationConfig} from "../generated/UiConfigurationConfig"; +import {TemplateRegistry} from "./TemplateRegistry"; +import {UiComponentConfig} from "../generated/UiComponentConfig"; +import {TeamAppsUiContext} from "./TeamAppsUiContext"; +import {UiPictureChooser} from "./formfield/file/UiPictureChooser"; +import {UiFieldGroup} from "./formfield/UiFieldGroup"; +import {UiTextField} from "./formfield/UiTextField"; +import {UiButton} from "./formfield/UiButton"; +import {UiComponent} from "./UiComponent"; + + +export class UiTestHarness { + + constructor() { + let context = new TestTeamAppsUiContext(); + + // let component = this.createRadioGroup(); + // let component = this.createRadioGroup(); + // let component = this.createUiPictureChooser(); + + let component = new UiFieldGroup({ + id: "asdf", + fields: [ + new UiTextField({stylesBySelector: {"": {flex: "1 1 auto"}}}, context), + new UiButton({template: null, templateRecord: null}, context) + ] + }, context); + + (window as any).c = component; + document.body.appendChild(component.getMainElement()); + } + + private createUiPictureChooser() { + let uiPictureChooser = new UiPictureChooser({ + id: "asdf", + value: null, + uploadUrl: "/upload" + }, new TestTeamAppsUiContext()); + uiPictureChooser.onValueChanged.addListener(eventObject => console.log("Value changed: " + eventObject.value)); + return uiPictureChooser; + } +} + +class TestTeamAppsUiContext implements TeamAppsUiContext { + readonly sessionId: string = "1234567890"; + readonly isHighDensityScreen: boolean = false; + readonly executingCommand: boolean = false; + readonly config: UiConfigurationConfig = {}; + readonly templateRegistry: TemplateRegistry = new TemplateRegistry(this); + + getClientObjectById(id: string): UiComponent { + return null; + } +} diff --git a/teamapps-client/ts/modules/UiTimeGraph.ts b/teamapps-client/ts/modules/UiTimeGraph.ts deleted file mode 100644 index bef4a190a..000000000 --- a/teamapps-client/ts/modules/UiTimeGraph.ts +++ /dev/null @@ -1,867 +0,0 @@ -/*- - * ========================LICENSE_START================================= - * TeamApps - * --- - * Copyright (C) 2014 - 2019 TeamApps.org - * --- - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * =========================LICENSE_END================================== - */ -import * as $ from "jquery"; -import {UiComponent} from "./UiComponent"; -import {TeamAppsEvent} from "./util/TeamAppsEvent"; -import {TeamAppsUiContext} from "./TeamAppsUiContext"; -import {executeWhenAttached} from "./util/ExecuteWhenAttached"; -import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; -import * as d3 from "d3"; -import {BaseType, NamespaceLocalObject} from "d3"; -import {Selection} from "d3-selection"; -import {ScaleContinuousNumeric, ScaleLinear, ScaleTime} from "d3-scale"; -import {UiScaleType} from "../generated/UiScaleType"; -import {bind} from "./util/Bind"; -import {ZoomBehavior, ZoomedElementBaseType} from "d3-zoom"; -import {Area, Line} from "d3-shape"; -import {Axis} from "d3-axis"; -import {Interval, IntervalManager} from "./util/IntervalManager"; -import {UiTimeGraphDataPointConfig} from "../generated/UiTimeGraphDataPointConfig"; -import {generateUUID} from "./Common"; -import { - UiTimeGraph_DataNeededEvent, - UiTimeGraph_IntervalSelectedEvent, - UiTimeGraph_ZoomedEvent, - UiTimeGraphCommandHandler, - UiTimeGraphConfig, - UiTimeGraphEventSource -} from "../generated/UiTimeGraphConfig"; -import {createUiLongIntervalConfig, UiLongIntervalConfig} from "../generated/UiLongIntervalConfig"; -import {EventFactory} from "../generated/EventFactory"; -import {BrushBehavior} from "d3-brush"; -import {UiLineChartCurveType} from "../generated/UiLineChartCurveType"; -import {UiLineChartLineFormatConfig} from "../generated/UiLineChartLineFormatConfig"; -import {createUiColorCssString} from "./util/CssFormatUtil"; -import {debouncedMethod, DebounceMode} from "./util/debounce"; -import {UiLineChartYScaleZoomMode} from "../generated/UiLineChartYScaleZoomMode"; -import {UiLineChartMouseScrollZoomPanMode} from "../generated/UiLineChartMouseScrollZoomPanMode"; -import {UiTimeChartZoomLevelConfig} from "../generated/UiTimeChartZoomLevelConfig"; - -type SVGGSelection = Selection; - -interface DataPoint { - x: number, - y: number -} - -function fakeZeroIfLogScale(y: number, scaleType: UiScaleType) { - if (scaleType === UiScaleType.LOG10 && y === 0) { - return UiTimeGraph.LOGSCALE_MIN_Y; - } else { - return y; - } -} - -const yTickFormat = d3.format("-,.2s"); - -export class UiTimeGraph extends UiComponent implements UiTimeGraphCommandHandler, UiTimeGraphEventSource { - - public readonly onDataNeeded: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onIntervalSelected: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onZoomed: TeamAppsEvent = new TeamAppsEvent(this); - - public static readonly LOGSCALE_MIN_Y = 0.5; - public static readonly DROP_SHADOW_ID = "drop-shadow"; - - private mouseScrollZoomPanMode: UiLineChartMouseScrollZoomPanMode; - private intervalX: UiLongIntervalConfig; - private maxPixelsBetweenDataPoints: number; - - private seriesById: { [id: string]: Series } = {}; - - private $main: JQuery; - - private $svg: d3.Selection; - private $rootG: SVGGSelection; - private $dropShadowFilter: Selection; - private $clipPath: d3.Selection; - - private scaleX: ScaleTime; - - private dropShadowFilterId: string; - - private margin = {top: 20, right: 15, bottom: 25}; - private zoom: ZoomBehavior; - private xAxis: Axis; - private $xAxis: SVGGSelection; - private $horizontalPanRect: d3.Selection; - private zoomLevelIntervalManagers: IntervalManager[]; - private brush: BrushBehavior; - private $brush: SVGGSelection; - private $graphClipContainer: Selection; - private xSelection: UiLongIntervalConfig; - private $yZeroLine: Selection; - private zoomLevels: UiTimeChartZoomLevelConfig[]; - private $yAxisContainer: Selection; - - private lastDrawableWidth: number = 0; - - private get drawableWidth() { - return this.getWidth() - this.marginLeft - this.margin.right - } - - private get drawableHeight() { - return this.getHeight() - this.margin.top - this.margin.bottom - } - - private get marginLeft() { - return this.getAllSeries().reduce((sum, s) => sum + s.getYScaleWidth(), 0); - } - - constructor(config: UiTimeGraphConfig, context: TeamAppsUiContext) { - super(config, context); - - this.zoomLevels = config.zoomLevels; - this.maxPixelsBetweenDataPoints = config.maxPixelsBetweenDataPoints; - - this.$main = $('
'); - - this.scaleX = d3.scaleTime() - .range([0, this.drawableWidth]); - - this.$svg = d3.select(this.$main[0]) - .append("svg"); - this.$rootG = this.$svg - .append("g"); - - this.setIntervalX(config.intervalX); - - this.$xAxis = this.$rootG.append("g") - .classed("x-axis", true); - this.$horizontalPanRect = this.$xAxis.append("rect") - .classed("horizontal-pan-rect", true) - .attr("width", "100%") - .attr("height", 20); - let x = 0; - const panZoom = d3.zoom() - .scaleExtent([1, 1]) - .on("zoom", () => { - const zoomTransform = d3.zoomTransform(this.$xAxis.node()); - const zoomTransform2 = d3.zoomTransform(this.$graphClipContainer.node()); - this.zoom.translateBy(this.$graphClipContainer, (zoomTransform.x - x) / zoomTransform2.k, 0); - x = zoomTransform.x; - this.redraw(); - }); - this.$xAxis.call(panZoom); - this.xAxis = d3.axisBottom(this.scaleX); - - this.$yAxisContainer = this.$rootG.append("g") - .classed("y-axis-container", true); - this.dropShadowFilterId = `${UiTimeGraph.DROP_SHADOW_ID}-${this.getId()}`; - let $defs = this.$svg.append("defs") - .html(navigator.vendor.indexOf("Apple") === -1 ? ` - - - - - - - - - - ` : ''); - this.$dropShadowFilter = $defs.select(`#${this.dropShadowFilterId}`); - let clipPathId = this.getId() + "-clipping-path-" + generateUUID(); - this.$clipPath = $defs.append("clipPath") - .attr("id", clipPathId) - .append("rect") - .attr("y", -50); - - this.$graphClipContainer = this.$rootG.append("g") - .classed("graph-clipping-container", true) - .attr("clip-path", `url(#${clipPathId})`); - - this.$yZeroLine = this.$graphClipContainer.append("line") - .classed("y-zero-line", true); - - this.zoom = d3.zoom() - .scaleExtent([1, this.calculateMaxZoomFactor()]) - .on("zoom", () => { - this.redraw(); - this.getAllSeries().forEach(s => s.handleXBoundsChange()); - this.fireUiZoomEvent(); - }); - this.setMouseScrollZoomPanMode(config.mouseScrollZoomPanMode); - - Object.keys(config.lineFormats).forEach(seriesId => { - let lineFormat = config.lineFormats[seriesId]; - this.createAndAddSeries(seriesId, lineFormat); - }); - - this.initZoomLevelIntervalManagers(); - - this.brush = d3.brushX() - .extent([[0, 0], [100, 100] /*provisional values!*/]) - .on("end", this.handleBrushSelection); - - this.$brush = this.$graphClipContainer.append("g") - .classed("brush", true) as SVGGSelection; - this.$brush - .call(this.brush); - - this.resetAllData(this.zoomLevels); - } - - private createAndAddSeries(seriesId: string, lineFormat: UiLineChartLineFormatConfig) { - let series = new Series(this, seriesId, lineFormat, this.$graphClipContainer, this.zoomLevels.length, this.dropShadowFilterId); - series.scaleY.range([this.drawableHeight, 0]); - this.$yAxisContainer.node().appendChild(series.$yAxis.node()); - this.seriesById[seriesId] = series; - } - - getZoomBoundsX() { - let transformedScaleX = this.getTransformedScaleX(); - return [+transformedScaleX.domain()[0], +transformedScaleX.domain()[1]]; - } - - getTransformedScaleX(): ScaleTime { - let zoomTransform = d3.zoomTransform(this.$graphClipContainer.node()); - return zoomTransform.rescaleX(this.scaleX as any); - } - - private initZoomLevelIntervalManagers() { - this.zoomLevelIntervalManagers = []; - for (let i = 0; i < this.zoomLevels.length; i++) { - this.zoomLevelIntervalManagers[i] = new IntervalManager(); - } - } - - @executeWhenAttached() - private redraw() { - if (this.getWidth() === 0 || this.getHeight() === 0) { - return; - } - this.lastDrawableWidth = this.drawableWidth; - - this.$rootG.attr("transform", `translate(${this.marginLeft},${this.margin.top})`); - - let left = 0; - this.getAllSeries().forEach(s => { - s.$yAxis.attr("transform", `translate(${-left},0)`); - left += s.getYScaleWidth(); - }); - - // TODO still needed? if yes, move to series! - // this.$yZeroLine - // .attr("x1", 0) - // .attr("y1", this.scaleY(0)) - // .attr("x2", this.getWidth()) - // .attr("y2", this.scaleY(0)) - // .attr("visibility", this.scaleY.domain()[0] === 0 ? "hidden" : "visible"); - - let transformedScaleX = this.getTransformedScaleX(); - - this.$xAxis.call(this.xAxis.scale(transformedScaleX)); - - let domain = this.restrictDomainXToConfiguredInterval([+transformedScaleX.domain()[0], +transformedScaleX.domain()[1]]); - - const zoomLevel = this.getCurrentZoomLevel(); - // this.logger.debug("millisecondsPerPixel: " + millisecondsPerPixel + " zoomLevel: " + zoomLevel + " at scale: " + zoomTransform.k); - - let uncoveredIntervals = this.zoomLevelIntervalManagers[zoomLevel].getUncoveredIntervals(new Interval(domain[0], domain[1])); - if (uncoveredIntervals.length > 0) { - for (let uncoveredInterval of uncoveredIntervals) { - if (uncoveredInterval.start >= uncoveredInterval.end) { - continue; // do not request empty intervals. may happen if the domainX is [0, 0] - } - this.logger.debug("firing onDataNeeded: " + uncoveredInterval); - if (uncoveredInterval.start == null || isNaN(uncoveredInterval.start) || uncoveredInterval.end == null || isNaN(uncoveredInterval.end)) { - this.logger.error("Uncovered interval is corrupt. Will not retrieve data!"); - } else { - this.onDataNeeded.fire(EventFactory.createUiTimeGraph_DataNeededEvent(this.getId(), zoomLevel, createUiLongIntervalConfig(uncoveredInterval.start, uncoveredInterval.end))); - this.zoomLevelIntervalManagers[zoomLevel].addInterval(new Interval(uncoveredInterval.start, uncoveredInterval.end)); - } - } - } - - this.getAllSeries().forEach(series => { - series.draw(); - }); - - if (this.xSelection) { - let brushX1 = Math.max(-10, transformedScaleX(this.xSelection.min)); - let brushX2 = Math.min(this.drawableWidth + 10, transformedScaleX(this.xSelection.max)); - if (brushX2 < 0 || brushX1 > this.drawableWidth) { - this.brush.move(this.$brush, null); - } else { - this.brush.move(this.$brush, [brushX1, brushX2]); - } - } else { - this.brush.move(this.$brush, null); - } - } - - private restrictDomainXToConfiguredInterval(domain: [number, number]) { - return [Math.max(this.intervalX.min, +(domain[0])), Math.min(this.intervalX.max, +(domain[1]))]; - } - - getCurrentZoomLevel() { - let transformedScaleX = this.getTransformedScaleX(); - let domain = this.restrictDomainXToConfiguredInterval([+transformedScaleX.domain()[0], +transformedScaleX.domain()[1]]); - - const millisecondsPerPixel = (domain[1] - domain[0]) / this.drawableWidth; - let sortedZoomLevels = this.getSortedZoomLevels(); - let zoomLevelToApply = sortedZoomLevels - .filter(indexedZoomLevel => { - let minMillisecondsPerPixels = indexedZoomLevel.zoomLevel.approximateMillisecondsPerDataPoint / this.maxPixelsBetweenDataPoints; - return minMillisecondsPerPixels <= millisecondsPerPixel; - })[0] || sortedZoomLevels[sortedZoomLevels.length - 1]; - return zoomLevelToApply.index; - } - - private getSortedZoomLevels() { - return this.zoomLevels - .map((zoomLevel, index) => { - return {index, zoomLevel} - }) - .sort((z1, z2) => z2.zoomLevel.approximateMillisecondsPerDataPoint - z1.zoomLevel.approximateMillisecondsPerDataPoint); - } - - @bind - private handleBrushSelection() { - let transformedScaleX = this.getTransformedScaleX(); - - if (d3.event.sourceEvent == null || d3.event.sourceEvent.type === "zoom") { - return; // handle only user-triggered brush changes! - } - let brushSelection = d3.brushSelection(this.$brush.node()); - if (brushSelection != null) { - this.xSelection = createUiLongIntervalConfig(+transformedScaleX.invert(brushSelection[0] as number), +transformedScaleX.invert(brushSelection[1] as number)); - this.onIntervalSelected.fire(EventFactory.createUiTimeGraph_IntervalSelectedEvent(this.getId(), this.xSelection)); - } else { - this.xSelection = null; - this.onIntervalSelected.fire(EventFactory.createUiTimeGraph_IntervalSelectedEvent(this.getId(), null)); - } - } - - public onResize(): void { - if (this.getWidth() === 0 && this.getHeight() === 0) { - return; - } - - this.scaleX.range([0, this.drawableWidth]); - - // adjust the translation value of the zoom transformation, so the displayed interval stays the same despite the resize! - if (this.lastDrawableWidth != null && this.lastDrawableWidth != this.drawableWidth) { - const zoomTransform = d3.zoomTransform(this.$graphClipContainer.node()); - const newTranslateX = zoomTransform.x * this.drawableWidth / this.lastDrawableWidth; - if (!isNaN(newTranslateX)) { - this.zoom.transform(this.$graphClipContainer, d3.zoomIdentity.translate(newTranslateX, 0).scale(zoomTransform.k)); - } - } - - this.getAllSeries().forEach(s => s.scaleY.range([this.drawableHeight, 0])); - this.updateZoomExtents(); - - this.$xAxis.attr("transform", "translate(0," + this.drawableHeight + ")"); - - this.getAllSeries().forEach(s => s.updateYAxisTickFormat(this.getHeight())); - - this.$dropShadowFilter.attr("width", this.drawableWidth) - .attr("height", this.getHeight() + 100); - this.$clipPath.attr("width", this.drawableWidth) - .attr("height", this.getHeight() + 100); - - this.brush.extent([[0, -5], [this.drawableWidth, this.drawableHeight]]); - this.$brush.call(this.brush); // update size... - - this.redraw(); - } - - private updateZoomExtents() { - if (this.zoom == null) { // not yet initialized - return; - } - this.zoom - .translateExtent([[0, -Infinity], [this.getWidth() /*zoom is attached to $svg, remember!*/, Infinity]]) - .scaleExtent([1, this.calculateMaxZoomFactor()]); - } - - private getAllSeries() { - return Object.keys(this.seriesById).map(id => this.seriesById[id]); - } - - addData(zoomLevel: number, intervalX: UiLongIntervalConfig, data: { [seriesId: string]: UiTimeGraphDataPointConfig[] }): void { - this.zoomLevelIntervalManagers[zoomLevel].addInterval(new Interval(intervalX.min, intervalX.max)); - Object.keys(data).forEach(seriesId => { - let seriesData = data[seriesId]; - this.seriesById[seriesId] && this.seriesById[seriesId].addData(zoomLevel, intervalX, seriesData); - }); - this.redraw(); - this.getAllSeries().forEach(s => s.handleXBoundsChange()) - } - - resetAllData(newZoomLevels: UiTimeChartZoomLevelConfig[]): void { - this.zoomLevels = newZoomLevels; - this.initZoomLevelIntervalManagers(); - this.getAllSeries().forEach(d => d.resetData(newZoomLevels.length)); - this.redraw(); - } - - @debouncedMethod(500, DebounceMode.BOTH) - replaceAllData(newZoomLevels: UiTimeChartZoomLevelConfig[], zoomLevel: number, intervalX: UiLongIntervalConfig, data: { [seriesId: string]: UiTimeGraphDataPointConfig[] }): void { - this.zoomLevels = newZoomLevels; - this.initZoomLevelIntervalManagers(); - this.getAllSeries().forEach(d => d.resetData(newZoomLevels.length)); - this.addData(zoomLevel, intervalX, data); - } - - setIntervalX(intervalX: UiLongIntervalConfig): void { - this.intervalX = intervalX; - this.reLayout(false); - this.updateZoomExtents(); - this.scaleX.domain([this.intervalX.min, this.intervalX.max]); - } - - setIntervalY(lineId: string, intervalY: UiLongIntervalConfig): void { - this.seriesById[lineId].setIntervalY(intervalY); - } - - public setYScaleZoomMode(lineId: string, yScaleZoomMode: UiLineChartYScaleZoomMode): void { - this.seriesById[lineId].setYScaleZoomMode(yScaleZoomMode); - } - - setMouseScrollZoomPanMode(mouseScrollZoomPanMode: UiLineChartMouseScrollZoomPanMode): void { - this.mouseScrollZoomPanMode = mouseScrollZoomPanMode; - - this.$graphClipContainer.on('.zoom', null); - this.$graphClipContainer.call(this.zoom); - - let originalZoomWeelHandler = this.$graphClipContainer.on("wheel.zoom"); - let me = this; - this.$graphClipContainer.on("wheel.zoom", function () { - if (me.mouseScrollZoomPanMode === UiLineChartMouseScrollZoomPanMode.DISABLED) { - return; - } else if (me.mouseScrollZoomPanMode === UiLineChartMouseScrollZoomPanMode.WITH_MODIFIER_KEY && !(d3.event).ctrlKey && !(d3.event).altKey && !(d3.event).shiftKey && !(d3.event).metaKey) { - return; - } - originalZoomWeelHandler.apply(this, arguments); - me.zoom.translateBy(me.$graphClipContainer, -1 * d3.event.deltaX / d3.zoomTransform(me.$graphClipContainer.node()).k / 2, 0); - d3.event.preventDefault(); // prevent MacOS' swipe pages back and forward - }); - } - - @debouncedMethod(500, DebounceMode.LATER) - private fireUiZoomEvent() { - let transformedScaleX = this.getTransformedScaleX(); - let domain = transformedScaleX.domain(); - let currentZoomLevel = this.getCurrentZoomLevel(); - this.onZoomed.fire(EventFactory.createUiTimeGraph_ZoomedEvent(this.getId(), createUiLongIntervalConfig(+domain[0], +domain[1]), currentZoomLevel)); - } - - @executeWhenAttached() - setYScaleType(lineId: string, yScaleType: UiScaleType): void { - this.seriesById[lineId].setYScaleType(yScaleType); - this.reLayout(true); - } - - setSelectedInterval(intervalX: UiLongIntervalConfig): void { - this.xSelection = intervalX; - this.redraw(); - } - - setMaxPixelsBetweenDataPoints(maxPixelsBetweenDataPoints: number): void { - this.maxPixelsBetweenDataPoints = maxPixelsBetweenDataPoints; - this.updateZoomExtents(); - this.redraw(); - } - - destroy(): void { - // nothing to do! - } - - getMainDomElement(): JQuery { - return this.$main; - } - - private calculateMaxZoomFactor(): number { - if (this.getWidth() === 0 || this.getHeight() === 0) { - return 1; - } - - let displayedMilliseconds = this.intervalX.max - this.intervalX.min; - let sortedZoomLevels = this.getSortedZoomLevels(); - let highestZoomLevelApproximateMillisPerDataPoint = sortedZoomLevels[sortedZoomLevels.length - 1].zoomLevel.approximateMillisecondsPerDataPoint; - - return this.maxPixelsBetweenDataPoints * displayedMilliseconds / (this.drawableWidth * highestZoomLevelApproximateMillisPerDataPoint); - } - - setLineFormats(lineFormats: { [lineId: string]: UiLineChartLineFormatConfig }): void { - Object.keys(lineFormats).forEach(lineId => { - let lineFormat = lineFormats[lineId]; - let existingSeries = this.seriesById[lineId]; - if (existingSeries) { - existingSeries.setLineFormat(lineFormat); - } else { - this.createAndAddSeries(lineId, lineFormat); - } - }); - Object.keys(this.seriesById).forEach(lineId => { - if (!lineFormats[lineId]) { - this.seriesById[lineId].destroy(); - delete this.seriesById[lineId]; - } - }); - this.reLayout(true); - } - - -} - -var CurveTypeToCurveFactory = { - [UiLineChartCurveType.LINEAR]: d3.curveLinear, - [UiLineChartCurveType.STEP]: d3.curveStep, - [UiLineChartCurveType.STEPBEFORE]: d3.curveStepBefore, - [UiLineChartCurveType.STEPAFTER]: d3.curveStepAfter, - [UiLineChartCurveType.BASIS]: d3.curveBasis, - [UiLineChartCurveType.CARDINAL]: d3.curveCardinal, - [UiLineChartCurveType.MONOTONE]: d3.curveMonotoneX, - [UiLineChartCurveType.CATMULLROM]: d3.curveCatmullRom, -}; - -class Series { - private zoomLevelData: UiTimeGraphDataPointConfig[][] = []; - private line: Line; - private $line: Selection; - private area: Area; - private $area: Selection; - private $dots: d3.Selection; - private $defs: Selection; - private colorScale: ScaleLinear; - private $main: Selection; - private numberOfZoomLevels: number; - - public $yAxis: SVGGSelection; - private yAxis: Axis; - - private intervalY: UiLongIntervalConfig; - private yScaleType: UiScaleType; - scaleY: ScaleContinuousNumeric; - private yScaleZoomMode: any; - - constructor( - private timeGraph: UiTimeGraph, - private id: string, - private lineFormat: UiLineChartLineFormatConfig, - $container: SVGGSelection, - numberOfZoomLevels: number, - private dropShadowFilterId: string, - ) { - this.intervalY = lineFormat.intervalY; - this.yScaleType = lineFormat.yScaleType; - this.yScaleZoomMode = lineFormat.yScaleZoomMode; - this.$yAxis = d3.select(document.createElementNS((d3.namespace("svg:text") as NamespaceLocalObject).space, "g") as SVGGElement); - this.$yAxis.style("color", createUiColorCssString(this.lineFormat.yAxisColor)); - this.updateYScaleType(); - this.yAxis = d3.axisLeft(this.scaleY); - - this.resetData(numberOfZoomLevels); - this.$main = $container - .append("g") - .attr("data-series-id", `${this.timeGraph.getId()}-${id}`); - this.initDomNodes(); - } - - updateYScaleType(): ScaleContinuousNumeric { - if (this.yScaleType === UiScaleType.LOG10) { - this.scaleY = d3.scaleLog(); - } else { - this.scaleY = d3.scaleLinear(); - } - let domainMin = fakeZeroIfLogScale(this.intervalY.min, this.yScaleType); - this.scaleY.domain([domainMin, this.intervalY.max]); - return this.scaleY; - } - - private initDomNodes() { - this.area = d3.area() - .curve(CurveTypeToCurveFactory[this.lineFormat.graphType]); - this.$area = this.$main.append("path") - .classed("area", true) - .attr("fill", `url(#area-gradient-${this.timeGraph.getId()}-${this.id})`); - this.line = d3.line() - .curve(CurveTypeToCurveFactory[this.lineFormat.graphType]); - this.$line = this.$main.append("path") - .classed("line", true) - .attr("stroke", `url(#line-gradient-${this.timeGraph.getId()}-${this.id})`); - // .style("filter", `url("#${this.dropShadowFilterId}")`); - this.$dots = this.$main.append("g") - .classed("dots", true); - - this.colorScale = d3.scaleLinear() - .range([createUiColorCssString(this.lineFormat.lineColorScaleMin), createUiColorCssString(this.lineFormat.lineColorScaleMax)]); - this.$defs = this.$main.append("defs") - .html(` - - - - - - - `); - } - - public resetData(numberOfZoomLevels: number): any { - this.numberOfZoomLevels = numberOfZoomLevels; - this.zoomLevelData = []; - for (let i = 0; i < this.numberOfZoomLevels; i++) { - this.zoomLevelData[i] = []; - } - } - - public getDisplayedData(zoomLevel: number, xStart: number, xEnd: number, scale: UiScaleType): DataPoint[] { - let i = 0; - for (; i < this.zoomLevelData[zoomLevel].length; i++) { - if (this.zoomLevelData[zoomLevel][i].x > xStart) { - break; - } - } - let startIndex = i === 0 ? 0 : i - 1; - for (; i < this.zoomLevelData[zoomLevel].length; i++) { - if (this.zoomLevelData[zoomLevel][i].x >= xEnd) { - break; - } - } - let endIndex = i === this.zoomLevelData[zoomLevel].length ? i : i + 1; - - - let data = this.zoomLevelData[zoomLevel].slice(startIndex, endIndex); - // if (data.length > 200) { - // data.forEach(d => (d as any).date = new Date(d.x).toString()); - // console.log(`zoomLevel: ${zoomLevel}; interval: ${new Date(xStart).toString()}-${new Date(xEnd).toString()}; layer: ${this.zoomLevelData[zoomLevel].length}; returned: ${data.length}`, data); - // } - return data; - } - - public getDisplayedDataYBounds(): [number, number] { - let minY = Number.MAX_VALUE; - let maxY = Number.MIN_VALUE; - const zoomBoundsX = this.timeGraph.getZoomBoundsX(); - let displayedData = this.getDisplayedData(this.timeGraph.getCurrentZoomLevel(), zoomBoundsX[0], zoomBoundsX[1], this.yScaleType); - displayedData.forEach(d => { - if (d.y < minY) { - minY = d.y; - } - if (d.y > maxY) { - maxY = d.y; - } - }); - if (minY === Number.MAX_VALUE && maxY === Number.MIN_VALUE) { - minY = 0; - maxY = 1; - } - return [minY, maxY]; - } - - public addData(zoomLevel: number, intervalX: UiLongIntervalConfig, data: UiTimeGraphDataPointConfig[]) { - - let zoomLevelData = this.zoomLevelData[zoomLevel]; - let minOverlappingIndex:number = null; - let maxOverlappingIndex:number = null; - for (let i = 0; i < zoomLevelData.length; i++) { - if (zoomLevelData[i].x >= intervalX.min && zoomLevelData[i].x <= intervalX.max) { - if (minOverlappingIndex == null) { - minOverlappingIndex = i; - } - maxOverlappingIndex = i; - } - } - - if (minOverlappingIndex != null && maxOverlappingIndex != null) { - zoomLevelData.splice(minOverlappingIndex, maxOverlappingIndex - minOverlappingIndex + 1, ...data); - } else { - this.zoomLevelData[zoomLevel] = zoomLevelData.concat(data); - } - this.zoomLevelData[zoomLevel].sort((a, b) => a.x - b.x); - } - - public draw() { - this.colorScale.domain(this.scaleY.domain()); - this.$defs.select(".line-gradient").attr("y2", this.scaleY.range()[0]); - this.$defs.select(".area-gradient").attr("y2", this.scaleY.range()[0]); - - let data = this.getDisplayedData(this.timeGraph.getCurrentZoomLevel(), this.timeGraph.getZoomBoundsX()[0], this.timeGraph.getZoomBoundsX()[1], this.yScaleType); - const scaleX = this.timeGraph.getTransformedScaleX(); - this.line - .x(d => scaleX(d.x)) - .y(d => this.scaleY(fakeZeroIfLogScale(d.y, this.yScaleType))); - this.$line.attr("d", this.line(data)); - - if (this.lineFormat.areaColorScaleMin != null && this.lineFormat.areaColorScaleMin.alpha > 0 - || this.lineFormat.areaColorScaleMax != null && this.lineFormat.areaColorScaleMax.alpha > 0) { // do not render transparent area! - this.area - .x(d => scaleX(d.x)) - .y0(this.scaleY.range()[0]) - .y1(d => this.scaleY(fakeZeroIfLogScale(d.y, this.yScaleType))); - this.$area.attr("d", this.area(data)); - } - - let $dotsDataSelection = this.$dots.selectAll(".dot") - .data(this.lineFormat.dataDotRadius > 0 ? data : []) - // .attr("fill", d => this.colorScale(d.y)) - .attr("cx", d => scaleX(d.x)) - .attr("cy", d => this.scaleY(fakeZeroIfLogScale(d.y, this.yScaleType))); - $dotsDataSelection - .enter().append("circle") // Uses the enter().append() method - .attr("class", "dot") // Assign a class for styling - // .attr("fill", d => this.colorScale(d.y)) - .attr("cx", d => scaleX(d.x)) - .attr("cy", d => this.scaleY(fakeZeroIfLogScale(d.y, this.yScaleType))) - .attr("r", this.lineFormat.dataDotRadius); - $dotsDataSelection.exit().remove(); - - - this.$yAxis.call(this.yAxis); - let $ticks = this.$yAxis.node().querySelectorAll('.tick'); - for (let i = 0; i < $ticks.length; i++) { - let $text: SVGTextElement = $ticks[i].querySelector("text"); - let querySelector: any = $ticks[i].querySelector('line'); - if ($text.innerHTML === '') { - querySelector.setAttribute("visibility", 'hidden'); - } else { - querySelector.setAttribute("visibility", 'visible'); - } - } - } - - setLineFormat(lineFormat: UiLineChartLineFormatConfig) { - this.lineFormat = lineFormat; - this.$main.html(""); - this.initDomNodes(); - } - - handleXBoundsChange() { - if (this.isDynamicYScaleZooming()) { - this.animatedZoomYScale(); - } - } - - private animatedZoomYScale() { - let minY: number, maxY: number; - if (this.yScaleZoomMode === UiLineChartYScaleZoomMode.DYNAMIC) { - let displayedDataYBounds = this.getDisplayedDataYBounds(); - let delta = displayedDataYBounds[1] - displayedDataYBounds[0]; - minY = displayedDataYBounds[0] - delta * .05; - maxY = displayedDataYBounds[1] + delta * .05; - } else if (this.yScaleZoomMode === UiLineChartYScaleZoomMode.DYNAMIC_INCLUDING_ZERO) { - [minY, maxY] = this.getDisplayedDataYBounds(); - if (minY > 0) { - minY = 0; - } - if (maxY < 0) { - maxY = 0; - } - } else { - minY = this.intervalY.min; - maxY = this.intervalY.max; - } - minY = fakeZeroIfLogScale(minY, this.yScaleType); - - if (Math.abs(this.scaleY.domain()[0]) > 1e15 || Math.abs(this.scaleY.domain()[1]) > 1e15) { // see https://github.com/d3/d3-interpolate/pull/63 - this.scaleY.domain([0, 1]) - } - - d3.transition(`${this.timeGraph.getId()}-${this.id}-zoomYToDisplayedDomain`) - .ease(d3.easeLinear) - .duration(300) - .tween(`${this.timeGraph.getId()}-${this.id}-zoomYToDisplayedDomain`, () => { - // create interpolator and do not show nasty floating numbers - let intervalInterpolator = d3.interpolateArray(this.scaleY.domain().map(date => +date), [minY, maxY]); - return (t: number) => { - this.scaleY.domain(intervalInterpolator(t)); - this.draw(); - } - }); - } - - setIntervalY(intervalY: UiLongIntervalConfig) { - this.intervalY = intervalY; - this.handleXBoundsChange() - } - - public getYScaleWidth(): number { - return (this.intervalY.max > 10000 || this.intervalY.min < 10) ? 37 - : (this.intervalY.max > 100) ? 30 - : 25; - } - - setYScaleZoomMode(yScaleZoomMode: UiLineChartYScaleZoomMode) { - this.yScaleZoomMode = yScaleZoomMode; - this.animatedZoomYScale(); - } - - private isDynamicYScaleZooming() { - return this.yScaleZoomMode === UiLineChartYScaleZoomMode.DYNAMIC || this.yScaleZoomMode === UiLineChartYScaleZoomMode.DYNAMIC_INCLUDING_ZERO; - } - - setYScaleType(yScaleType: UiScaleType) { - if (yScaleType === UiScaleType.LOG10) { - this.scaleY = d3.scaleLog(); - } else { - this.scaleY = d3.scaleLinear(); - } - let domainMin = fakeZeroIfLogScale(this.intervalY.min, this.yScaleType); - this.scaleY.domain([domainMin, this.intervalY.max]); - } - - public updateYAxisTickFormat(availableHeight: number) { - let minY = this.scaleY.domain()[0]; - let maxY = this.scaleY.domain()[1]; - let delta = maxY - minY; - let numberOfYTickGroups = Math.log10(delta) + 1; - let heightPerYTickGroup = availableHeight / numberOfYTickGroups; - if (this.yScaleType === UiScaleType.LOG10) { - this.yAxis.tickFormat((value: number, i: number) => { - if (value < 1) { - return ""; - } else { - if (heightPerYTickGroup >= 150) { - return yTickFormat(value); - } else if (heightPerYTickGroup >= 80) { - let firstDigitOfValue = Number(("" + value)[0]); - return firstDigitOfValue <= 5 ? yTickFormat(value) : ""; - } else if (heightPerYTickGroup >= 30) { - let firstDigitOfValue = Number(("" + value)[0]); - return firstDigitOfValue === 1 || firstDigitOfValue === 5 ? yTickFormat(value) : ""; - } else { - let firstDigitOfValue = Number(("" + value)[0]); - return firstDigitOfValue === 1 ? yTickFormat(value) : ""; - } - } - }); - } else { - this.yAxis.tickFormat((domainValue: number) => { - if (delta < 2) { - return d3.format("-,.4r")(domainValue) - } else { - return yTickFormat(domainValue) - } - }) - .ticks(availableHeight / 20); - } - } - - public destroy() { - this.$main.remove(); - this.$yAxis.remove(); - } -} - -TeamAppsUiComponentRegistry.registerComponentClass("UiTimeGraph", UiTimeGraph); diff --git a/teamapps-client/ts/modules/UiTree.ts b/teamapps-client/ts/modules/UiTree.ts index 8b95f7823..2b6f06a16 100644 --- a/teamapps-client/ts/modules/UiTree.ts +++ b/teamapps-client/ts/modules/UiTree.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,84 +17,96 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; -import {UiComboBox} from "./formfield/UiComboBox"; -import {UiTree_NodeSelectedEvent, UiTree_RequestTreeDataEvent, UiTree_TextInputEvent, UiTreeCommandHandler, UiTreeConfig, UiTreeEventSource} from "../generated/UiTreeConfig"; +import { + UiTree_NodeExpansionChangedEvent, + UiTree_NodeSelectedEvent, + UiTree_RequestTreeDataEvent, + UiTree_TextInputEvent, + UiTreeCommandHandler, + UiTreeConfig, + UiTreeEventSource +} from "../generated/UiTreeConfig"; import {TeamAppsEvent} from "./util/TeamAppsEvent"; -import {defaultTreeQueryFunctionFactory, ResultCallback, trivialMatch, TrivialTree} from "trivial-components"; -import {UiComponent} from "./UiComponent"; -import {buildObjectTree, buildTreeEntryHierarchy, matchingModesMapping, NodeWithChildren, Renderer} from "./Common"; +import {TrivialTree} from "./trivial-components/TrivialTree"; +import {AbstractUiComponent} from "./AbstractUiComponent"; +import {buildObjectTree, NodeWithChildren, parseHtml, Renderer} from "./Common"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; -import {EventFactory} from "../generated/EventFactory"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; import {UiTreeRecordConfig} from "../generated/UiTreeRecordConfig"; import {UiComboBoxTreeRecordConfig} from "../generated/UiComboBoxTreeRecordConfig"; import {UiTemplateConfig} from "../generated/UiTemplateConfig"; +import {loadSensitiveThrottling} from "./util/throttle"; +import {UiInfiniteItemView2_ContextMenuRequestedEvent} from "../generated/UiInfiniteItemView2Config"; +import {ContextMenu} from "./micro-components/ContextMenu"; +import {UiComponent} from "./UiComponent"; -export class UiTree extends UiComponent implements UiTreeCommandHandler, UiTreeEventSource { +export class UiTree extends AbstractUiComponent implements UiTreeCommandHandler, UiTreeEventSource { - public readonly onTextInput: TeamAppsEvent = new TeamAppsEvent(this, 250); - public readonly onNodeSelected: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onRequestTreeData: TeamAppsEvent = new TeamAppsEvent(this); + public readonly onTextInput: TeamAppsEvent = new TeamAppsEvent({throttlingMode: "debounce", delay: 500}); + public readonly onNodeSelected: TeamAppsEvent = new TeamAppsEvent(); + public readonly onRequestTreeData: TeamAppsEvent = new TeamAppsEvent(); + public readonly onNodeExpansionChanged: TeamAppsEvent = new TeamAppsEvent(); + public readonly onContextMenuRequested: TeamAppsEvent = new TeamAppsEvent(); - private $panel: JQuery; + private $panel: HTMLElement; private trivialTree: TrivialTree; - private lastResultCallback: (result: NodeWithChildren[]) => void; private nodes: UiTreeRecordConfig[]; private templateRenderers: { [name: string]: Renderer }; - + private contextMenu: ContextMenu; constructor(config: UiTreeConfig, context: TeamAppsUiContext) { super(config, context); - this.$panel = $('
'); - const $input = $('').appendTo(this.$panel); + this.$panel = parseHtml('
'); this.templateRenderers = context.templateRegistry.createTemplateRenderers(config.templates); this.nodes = config.initialData; - let localQueryFunction: Function; - let queryFunction = (queryString: string, resultCallback: ResultCallback>) => { - this.lastResultCallback = resultCallback; - this.onTextInput.fire(EventFactory.createUiTree_TextInputEvent(this.getId(), queryString)); - if (localQueryFunction != null) { - localQueryFunction(queryString, resultCallback); - } - }; - - this.trivialTree = new TrivialTree($input, { + this.trivialTree = new TrivialTree({ entries: buildObjectTree(config.initialData, "id", "parentId"), selectedEntryId: config.selectedNodeId, childrenProperty: "__children", expandedProperty: "expanded", entryRenderingFunction: entry => this.renderRecord(entry), - searchBarMode: 'show-if-filled', lazyChildrenFlag: 'lazyChildren', - lazyChildrenQueryFunction: (node, resultCallback) => { - this.onRequestTreeData.fire(EventFactory.createUiTree_RequestTreeDataEvent(config.id, node && node.id)) + lazyChildrenQueryFunction: async (node) => { + this.onRequestTreeData.fire({ + parentNodeId: node && node.id + }); + return []; // TODO this will not show a spinner... }, spinnerTemplate: `
`, - queryFunction: queryFunction, showExpanders: config.showExpanders, - expandOnSelection: config.openOnSelection, + selectableDecider: entry => entry.selectable, + toggleExpansionOnClick: config.openOnSelection, enforceSingleExpandedPath: config.enforceSingleExpandedPath, - inputValueFunction: entry => entry && entry.id, idFunction: entry => entry && entry.id, indentation: config.indentation, - animationDuration: config.animate ? 70 : 0, - matchingOptions: { - matchingMode: matchingModesMapping[config.textMatchingMode] - } + animationDuration: config.animate ? 120 : 0, + directSelectionViaArrowKeys: true }); this.trivialTree.onSelectedEntryChanged.addListener((entry) => { - this.onNodeSelected.fire(EventFactory.createUiTree_NodeSelectedEvent(config.id, entry.id)); + this.onNodeSelected.fire({ + nodeId: entry.id + }); }); + this.trivialTree.onNodeExpansionStateChanged.addListener(({node, expanded}) => this.onNodeExpansionChanged.fire({nodeId: node.id, expanded})) + this.$panel.appendChild(this.trivialTree.getMainDomElement()); if (config.selectedNodeId != null) { this.trivialTree.getTreeBox().revealSelectedEntry(false); } + this.contextMenu = new ContextMenu(); + this.trivialTree.onContextMenu.addListener(contextMenuEvent => { + if (this._config.contextMenuEnabled) { + this.contextMenu.open(contextMenuEvent.event, requestId => this.onContextMenuRequested.fire({ + recordId: (contextMenuEvent.entry as UiTreeRecordConfig).id, + requestId + })); + } + }) } private renderRecord(record: NodeWithChildren): string { @@ -106,10 +118,11 @@ export class UiTree extends UiComponent implements UiTreeCommandHa } } - public getMainDomElement(): JQuery { + public doGetMainElement(): HTMLElement { return this.$panel; } + @loadSensitiveThrottling(100, 10, 3000) replaceData(nodes: UiTreeRecordConfig[]): void { this.nodes = nodes; this.trivialTree.updateEntries(buildObjectTree(nodes, "id", "parentId")); @@ -119,7 +132,7 @@ export class UiTree extends UiComponent implements UiTreeCommandHa this.nodes = this.nodes.filter(node => nodesToBeRemoved.indexOf(node.id) === -1); this.nodes.push(...nodesToBeAdded); nodesToBeRemoved.forEach(nodeId => this.trivialTree.removeNode(nodeId)); - nodesToBeAdded.forEach(node => this.trivialTree.addOrUpdateNode(node.parentId, node)); + nodesToBeAdded.forEach(node => this.trivialTree.addOrUpdateNode(node.parentId, node, false)); } public setSelectedNode(recordId: number | null): void { @@ -130,9 +143,14 @@ export class UiTree extends UiComponent implements UiTreeCommandHa this.templateRenderers[id] = this._context.templateRegistry.createTemplateRenderer(template); } - public destroy(): void { - // nothing to do + setContextMenuContent(requestId: number, component: UiComponent): void { + this.contextMenu.setContent(component, requestId); } + + closeContextMenu(requestId: number): void { + this.contextMenu.close(requestId); + } + } TeamAppsUiComponentRegistry.registerComponentClass("UiTree", UiTree); diff --git a/teamapps-client/ts/modules/UiTreeGraph.ts b/teamapps-client/ts/modules/UiTreeGraph.ts new file mode 100644 index 000000000..19b238b61 --- /dev/null +++ b/teamapps-client/ts/modules/UiTreeGraph.ts @@ -0,0 +1,1214 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ + +import * as d3 from "d3"; +import {BaseType, HierarchyNode, HierarchyPointLink, HierarchyPointNode, Selection, ZoomBehavior} from "d3"; +import {AbstractUiComponent} from "./AbstractUiComponent"; +import { + UiTreeGraph_NodeClickedEvent, + UiTreeGraph_NodeExpandedOrCollapsedEvent, + UiTreeGraph_ParentExpandedOrCollapsedEvent, + UiTreeGraph_SideListExpandedOrCollapsedEvent, + UiTreeGraphCommandHandler, + UiTreeGraphConfig, + UiTreeGraphEventSource +} from "../generated/UiTreeGraphConfig"; +import {TeamAppsEvent} from "./util/TeamAppsEvent"; +import {UiTreeGraphNodeConfig} from "../generated/UiTreeGraphNodeConfig"; +import {TeamAppsUiContext} from "./TeamAppsUiContext"; +import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; +import {UiTreeGraphNodeImage_CornerShape} from "../generated/UiTreeGraphNodeImageConfig"; +import {parseHtml} from "./Common"; +import {flextree, FlexTreeLayout} from "d3-flextree"; +import {executeWhenFirstDisplayed} from "./util/ExecuteWhenFirstDisplayed"; + +export class UiTreeGraph extends AbstractUiComponent implements UiTreeGraphCommandHandler, UiTreeGraphEventSource { + + public readonly onNodeClicked: TeamAppsEvent = new TeamAppsEvent(); + public readonly onNodeExpandedOrCollapsed: TeamAppsEvent = new TeamAppsEvent(); + public readonly onParentExpandedOrCollapsed: TeamAppsEvent = new TeamAppsEvent(); + public readonly onSideListExpandedOrCollapsed: TeamAppsEvent = new TeamAppsEvent(); + + private chart: TreeChart; + private $main: HTMLElement; + + constructor(config: UiTreeGraphConfig, context: TeamAppsUiContext) { + super(config, context); + + this.$main = parseHtml(`
`); + this.chart = new TreeChart(context) + .container(this.$main); + this.update(config); + this.moveToRootNode(); + (window as any).moveToRootNode = () => this.moveToRootNode(); + } + + public update(config: UiTreeGraphConfig) { + this.chart + .backgroundColor(config.backgroundColor) + .initialZoom(config.zoomFactor) + .data(config.nodes) + .compact(config.compact) + .verticalLayerGap(config.verticalLayerGap) + .horizontalSiblingGap(config.horizontalSiblingGap) + .horizontalNonSignlingGap(config.horizontalNonSignlingGap) + .sideListIndent(config.sideListIndent) + .sideListVerticalGap(config.sideListVerticalGap) + .onNodeClick((nodeId: string) => { + this.onNodeClicked.fire({nodeId: nodeId}); + }) + .onNodeExpandedOrCollapsed((nodeId: string, expanded: boolean, lazyLoad: boolean) => { + this.onNodeExpandedOrCollapsed.fire({nodeId, expanded, lazyLoad}); + }) + .onParentExpandedOrCollapsed((nodeId: string, expanded: boolean, lazyLoad: boolean) => { + this.onParentExpandedOrCollapsed.fire({nodeId, expanded, lazyLoad}); + }) + .onSideListExpandedOrCollapsed((nodeId: string, expanded: boolean) => { + this.onSideListExpandedOrCollapsed.fire({nodeId, expanded}); + }); + this.chart.render(); + } + + @executeWhenFirstDisplayed(true) + public moveToRootNode() { + this.onResize(); + this.chart.moveToRootNode(400); + } + + @executeWhenFirstDisplayed(true) + public moveToNode(nodeId: string) { + this.onResize(); + this.chart.moveToNode(nodeId); + } + + setNodes(nodes: UiTreeGraphNodeConfig[]): void { + this.chart.data(nodes) + .render(); + } + + addNode(node: UiTreeGraphNodeConfig): void { + this.chart.data().push(node); + this.chart.render(); + } + + removeNode(nodeId: string): void { + this.chart.data(this.chart.data().filter(n => n.id !== nodeId)); + this.chart.render(); + } + + setNodeExpanded(nodeId: string, expanded: boolean): void { + this.chart.data().filter(n => n.id === nodeId)[0].expanded = expanded; + this.chart.render(); + } + + updateNode(node: UiTreeGraphNodeConfig): void { + let index = this.chart.data().findIndex(n => n.id === node.id); + if (index !== -1) { + this.chart.data()[index] = node; + } else { + this.chart.data().push(node); + } + this.chart.render(); + } + + setZoomFactor(zoomFactor: number): void { + this.chart.setZoomFactor(zoomFactor); + } + + doGetMainElement(): HTMLElement { + return this.$main; + } + + onResize(): void { + this.chart + .svgWidth(this.getWidth()) + .svgHeight(this.getHeight()) + .render(); + } + +} + +TeamAppsUiComponentRegistry.registerComponentClass("UiTreeGraph", UiTreeGraph); + +interface TreeChart { + getChartState: () => TreeChartAttributes; + + svgWidth(): number; + + svgWidth(svgWidth: number): TreeChart; + + svgHeight(): number; + + svgHeight(marginTop: number): TreeChart; + + marginTop(): number; + + marginTop(marginTop: number): TreeChart; + + marginBottom(): number; + + marginBottom(marginBottom: number): TreeChart; + + marginRight(): number; + + marginRight(marginRight: number): TreeChart; + + marginLeft(): number; + + marginLeft(marginLeft: number): TreeChart; + + container(): string | HTMLElement; + + container(container: string | HTMLElement): TreeChart; + + defaultTextFill(): string; + + defaultTextFill(defaultTextFill: string): TreeChart; + + nodeTextFill(): string; + + nodeTextFill(nodeTextFill: string): TreeChart; + + defaultFont(): string; + + defaultFont(defaultFont: string): TreeChart; + + backgroundColor(): string; + + backgroundColor(backgroundColor: string): TreeChart; + + data(): UiTreeGraphNodeConfig[]; + + data(data: UiTreeGraphNodeConfig[]): TreeChart; + + depth(): number; + + depth(depth: number): TreeChart; + + duration(): number; + + duration(duration: number): TreeChart; + + strokeWidth(): number; + + strokeWidth(strokeWidth: number): TreeChart; + + initialZoom(): number; + + initialZoom(initialZoom: number): TreeChart; + + compact(compact: boolean): this; + + verticalLayerGap(): number, + + verticalLayerGap(verticalLayerGap: number): this, + + horizontalSiblingGap(): number, + + horizontalSiblingGap(horizontalSiblingGap: number): this, + + horizontalNonSignlingGap(): number, + + horizontalNonSignlingGap(horizontalNonSignlingGap: number): this, + + sideListIndent(): number, + + sideListIndent(sideListIndent: number): this, + + sideListVerticalGap(): number, + + sideListVerticalGap(sideListVerticalGap: number): this, + + onNodeClick(): (nodeId: string) => void; + + onNodeClick(onNodeClick: (nodeId: string) => void): TreeChart; + + onNodeExpandedOrCollapsed(): (nodeId: string, expanded: boolean, lazyLoad: boolean) => void; + + onNodeExpandedOrCollapsed(onNodeExpandedOrCollapsed: (nodeId: string, expanded: boolean, lazyLoad: boolean) => void): TreeChart; + + onParentExpandedOrCollapsed(): (nodeId: string, expanded: boolean, lazyLoad: boolean) => void; + + onParentExpandedOrCollapsed(onParentExpandedOrCollapsed: (nodeId: string, expanded: boolean, lazyLoad: boolean) => void): TreeChart; + + onSideListExpandedOrCollapsed(): (nodeId: string, expanded: boolean) => void; + + onSideListExpandedOrCollapsed(onSideListExpandedOrCollapsed: (nodeId: string, expanded: boolean) => void): TreeChart; + +} + +interface TreeNode extends HierarchyPointNode { + hasChildren?: boolean, + sideListNodes: TreeNodeLike[] +} + +interface TreeNodeLike { + data: UiTreeGraphNodeConfig, + id?: string, + hasChildren?: boolean, + parent?: TreeNode | null, + x: number, + y: number +} + +export interface Layouts { + treemap: FlexTreeLayout +} + +export interface TreeChartAttributes { + [key: string]: any, + + id: string, + data?: UiTreeGraphNodeConfig[], + root: TreeNode, + svg: Selection, + svgWidth?: number, + svgHeight?: number, + marginTop?: number, + marginBottom?: number, + marginRight?: number, + marginLeft?: number, + container?: any, + defaultTextFill?: string, + defaultFont?: string, + backgroundColor: string, + duration: number, + strokeWidth: number, + dropShadowId: string, + initialZoom: number, + onNodeClick?: (name: string) => void, + onNodeExpandedOrCollapsed?: (nodeId: string, expanded: boolean, lazyLoad: boolean) => void, + onParentExpandedOrCollapsed?: (nodeId: string, expanded: boolean, lazyLoad: boolean) => void, + onSideListExpandedOrCollapsed?: (nodeId: string, expanded: boolean) => void, + verticalLayerGap: number, + horizontalSiblingGap: number, + horizontalNonSignlingGap: number, + sideListIndent: number, + sideListVerticalGap: number, + defs: Selection, + compact: boolean, +} + +export interface PatternifyParameter { + selector: string, + tag: string, + data?: any +} + +class TreeChart { + private chart: Selection; + private zoomBehavior: ZoomBehavior; + + constructor(private context: TeamAppsUiContext) { + // Exposed variables + const attrs: TreeChartAttributes = { + id: `ID${Math.floor(Math.random() * 1000000)}`, // Id for event handlings + svgWidth: 800, + svgHeight: 600, + marginTop: 0, + marginBottom: 0, + marginRight: 0, + marginLeft: 0, + container: 'body', + defaultTextFill: '#2C3E50', + nodeTextFill: 'white', + defaultFont: 'Helvetica', + backgroundColor: 'transparent', + data: null, + duration: 400, + strokeWidth: 3, + dropShadowId: null, + initialZoom: 1, + verticalLayerGap: 36, + horizontalSiblingGap: 20, + horizontalNonSignlingGap: 36, + sideListIndent: 20, + sideListVerticalGap: 20, + compact: false, + onNodeClick: (): void => { + }, + onNodeExpandedOrCollapsed: () => { + }, + onParentExpandedOrCollapsed: () => { + }, + onSideListExpandedOrCollapsed: () => { + } + } as any; + + this.getChartState = () => attrs; + + // Dynamically set getter and setter functions for Chart class + Object.keys(attrs).forEach((key) => { + //@ts-ignore + this[key] = function (_) { + if (!arguments.length) { + return attrs[key]; + } + attrs[key] = _; + return this; + }; + }); + } + + // This method can be invoked via chart.setZoomFactor API, it zooms to particulat scale + setZoomFactor(zoomLevel: number) { + const attrs = this.getChartState(); + + // Store passed zoom level + attrs.initialZoom = zoomLevel; + } + + render() { + //InnerFunctions which will update visuals + const attrs = this.getChartState(); + + //Drawing containers + const container: Selection = d3.select(attrs.container); + const containerRect = container.node().getBoundingClientRect(); + if (containerRect.width > 0) attrs.svgWidth = containerRect.width; + if (containerRect.height > 0) attrs.svgHeight = containerRect.height; + + //Attach drop shadow id to attrs object + this.setDropShadowId(attrs); + + //Calculated properties + const calc: any = { + id: null, + chartTopMargin: null, + chartLeftMargin: null, + chartWidth: null, + chartHeight: null + }; + calc.id = `ID${Math.floor(Math.random() * 1000000)}`; // id for event handlings + calc.chartLeftMargin = attrs.marginLeft; + calc.chartTopMargin = attrs.marginTop; + calc.chartWidth = attrs.svgWidth - attrs.marginRight - calc.chartLeftMargin; + calc.chartHeight = attrs.svgHeight - attrs.marginBottom - calc.chartTopMargin; + attrs.calc = calc; + + // Get maximum node width and height + calc.nodeMaxWidth = d3.max(attrs.data, (d) => d.width); + + // Calculate max node depth (it's needed for layout heights calculation) + calc.centerX = calc.chartWidth / 2; + + // ******************* BEHAVIORS . ********************** + this.zoomBehavior = d3.zoom().on("zoom", () => this.zoomed()); + + // ************************* DRAWING ************************** + //Add svg + + const svg = patternify(container, { + tag: 'svg', + selector: 'svg-chart-container' + }) + .attr('width', attrs.svgWidth) + .attr('height', attrs.svgHeight) + .attr('font-family', attrs.defaultFont) + .call(this.zoomBehavior) + .style('background-color', attrs.backgroundColor); + attrs.svg = svg; + + //Add container g element + this.chart = patternify(svg, { + tag: 'g', + selector: 'chart' + }); + // .attr('transform', `translate(${calc.chartLeftMargin},${calc.chartTopMargin}) scale(${d3.zoomTransform(svg.node()).k})`); + + attrs.chart = this.chart; + + // ************************** ROUNDED AND SHADOW IMAGE WORK USING SVG FILTERS ********************** + + //Adding defs element for rounded image + attrs.defs = patternify(svg, { + tag: 'defs', + selector: 'image-defs' + }); + + // Adding defs element for image's shadow + const filterDefs = patternify(svg, { + tag: 'defs', + selector: 'filter-defs' + }); + + // Adding shadow element - (play with svg filter here - https://bit.ly/2HwnfyL) + const filter = patternify(filterDefs, { + tag: 'filter', + selector: 'shadow-filter-element' + }) + .attr('id', attrs.dropShadowId) + .attr('y', `${-50}%`) + .attr('x', `${-50}%`) + .attr('height', `${200}%`) + .attr('width', `${200}%`); + + // Add gaussian blur element for shadows - we can control shadow length with this + patternify(filter, { + tag: 'feGaussianBlur', + selector: 'feGaussianBlur-element' + }) + .attr('in', 'SourceAlpha') + .attr('stdDeviation', 3.1) + .attr('result', 'blur'); + + // Add fe-offset element for shadows - we can control shadow positions with it + patternify(filter, { + tag: 'feOffset', + selector: 'feOffset-element' + }) + .attr('in', 'blur') + .attr('result', 'offsetBlur') + .attr("dx", 4.28) + .attr("dy", 4.48) + .attr("x", 8) + .attr("y", 8); + + // Add fe-flood element for shadows - we can control shadow color and opacity with this element + patternify(filter, { + tag: 'feFlood', + selector: 'feFlood-element' + }) + .attr("in", "offsetBlur") + .attr("flood-color", 'black') + .attr("flood-opacity", 0.3) + .attr("result", "offsetColor"); + + // Add feComposite element for shadows + patternify(filter, { + tag: 'feComposite', + selector: 'feComposite-element' + }) + .attr("in", "offsetColor") + .attr("in2", "offsetBlur") + .attr("operator", "in") + .attr("result", "offsetBlur"); + + // Add feMerge element for shadows + const feMerge = patternify(filter, { + tag: 'feMerge', + selector: 'feMerge-element' + }); + + // Add feMergeNode element for shadows + patternify(feMerge, { + tag: 'feMergeNode', + selector: 'feMergeNode-blur' + }) + .attr('in', 'offsetBlur'); + + // Add another feMergeNode element for shadows + patternify(feMerge, { + tag: 'feMergeNode', + selector: 'feMergeNode-graphic' + }) + .attr('in', 'SourceGraphic'); + + // Display tree contenrs + this.update(); + + + //######################################### UTIL FUNCS ################################## + // This function restyles foreign object elements () + + + d3.select(window).on(`resize.${attrs.id}`, () => { + const containerRect = container.node().getBoundingClientRect(); + // if (containerRect.width > 0) attrs.svgWidth = containerRect.width; + // main(); + }); + + + return this; + } + + // This function sets drop shadow ID to the passed object + setDropShadowId(d: { id: string, dropShadowId: string }) { + + // If it's already set, then return + if (d.dropShadowId) return; + + // Generate drop shadow ID + let id = `${d.id}-drop-shadow`; + + // If DOM object is available, then use UID method to generated shadow id + //@ts-ignore + if (typeof DOM != 'undefined') { + //@ts-ignore + id = DOM.uid(d.id).id; + } + + // Extend passed object with drop shadow ID + Object.assign(d, { + dropShadowId: id + }) + } + + + // This function basically redraws visible graph, based on nodes state + update() { + const attrs = this.getChartState(); + + let visibleNodeRecords = this.getVisibleNodeRecords(attrs.data); + let visibleNodeRecordsInOrder = attrs.data.filter(r => visibleNodeRecords[r.id] != null); + + // Store new root by converting flat data to hierarchy + let hierarchy = d3.stratify() + .id((d: UiTreeGraphNodeConfig) => d.id) + .parentId((d: UiTreeGraphNodeConfig) => visibleNodeRecords[d.parentId] != null ? d.parentId : null) + (visibleNodeRecordsInOrder) as TreeNode; + attrs.root = hierarchy; + + let recordsByParentId = getById(attrs.data.filter(r => r.id !== attrs.root.id), n => n.parentId); + attrs.root.eachBefore(n => { + n.hasChildren = recordsByParentId[n.id] != null || (n.data as any).hasLazyChildren; + }); + + let layerHeightByDepth: number[]; + if (!attrs.compact) { + layerHeightByDepth = hierarchy.descendants().reduce((heightsByDepth: number[], d: TreeNode) => { + let nodeHeight = this.calculateNodeSize(d).height; + if (heightsByDepth[d.depth] == null || heightsByDepth[d.depth] < nodeHeight) { + heightsByDepth[d.depth] = nodeHeight; + } + return heightsByDepth; + }, []); + } + + // Assigns the x and y position for the nodes + // Generate tree layout function + let treeLayout = flextree() + .nodeSize(node => { + let width = node.data.width; + if (node.data.sideListExpanded && node.data.sideListNodes && node.data.sideListNodes.length > 0) { + let sideListOverlapping = attrs.sideListIndent + this.calculateSideListSize(node).width - (1/5) * width; + width = width + Math.max(0, sideListOverlapping * 2); + } + return [width, attrs.compact ? this.calculateNodeSize(node).height : layerHeightByDepth[node.depth]]; + }) + .spacing((node1, node2) => { + // Calculates only the horizontal spacing! The layout algorithm only honors the node's height for vertical spacing! + if (node1.depth !== node2.depth || node1.path(node2).length > 3) { + return attrs.horizontalNonSignlingGap; + } else { // siblings + return attrs.horizontalSiblingGap; + } + }); + const treeData = treeLayout(attrs.root); + + treeData.each((d: TreeNode) => { + if (d.data.sideListNodes != null && d.data.sideListNodes.length > 0 && d.data.sideListExpanded) { + let currentY = 0; + d.sideListNodes = d.data.sideListNodes.map(r => { + const treeNodeLike = { + data: r, + id: r.id, + x: attrs.sideListIndent, + y: currentY + attrs.sideListVerticalGap + }; + currentY += r.height + attrs.sideListVerticalGap; + return treeNodeLike; + }); + } + }); + + // Get tree nodes and links and attach some properties + const nodes = treeData.descendants() as TreeNode[]; + + // ------------------- FILTERS --------------------- + + this.drawLinks(this.chart, attrs.root.links()); + const nodesSelection = this.drawNodes(this.chart, () => nodes); + + let sideListG = nodesSelection.selectAll(':scope > g.side-list-g') + .data(d => [d]) + .join(enter => enter + .append('g') + .classed('side-list-g', true) + ) + .attr('transform', d => `translate(${d.data.width * 4 / 5},${d.data.height})`); + const sideListLinkSelection = sideListG.selectAll(':scope > .side-list-line') + .data((d: TreeNode) => { + return (d.data.sideListExpanded && d.sideListNodes) || []; + }) + .join( + enter => enter + .insert('path', "path") + .attr("class", "side-list-line") + .attr('d', d => this.hookLine({x: 0, y: 0}, {x: 0, y: 0})), + update => update, + exit => exit.transition() + .duration(attrs.duration) + .attr('d', d => this.hookLine({x: 0, y: 0}, {x: 0, y: 0})) + .remove() + ) + .attr("fill", "none") + .attr("stroke-width", d => d.data.connectorLineWidth || 2) + .attr('stroke', d => d.data.connectorLineColor ? d.data.connectorLineColor : 'white') + .attr('stroke-dasharray', d => d.data.dashArray ? d.data.dashArray : '') + .transition() + .duration(attrs.duration) + .attr('d', (d, i, groups) => { + return this.hookLine({x: 0, y: 0}, {x: d.x, y: d.y + d.data.height / 2}) + }); + this.drawNodes(sideListG, (d: TreeNode) => d.sideListNodes || []); + + this.drawExpanderButton( + nodesSelection, + 'node-button-g', + (d: TreeNodeLike) => d.hasChildren, + d => ({x: d.data.width / 2, y: d.data.height}), + (d: TreeNodeLike) => d.data.expanded, + (d: TreeNode) => { + d.data.expanded = !d.data.expanded; + attrs.onNodeExpandedOrCollapsed(d.data.id, d.data.expanded, d.data.expanded && d.data.hasLazyChildren && attrs.data.filter(c => c.parentId === d.id).length == 0); + this.update(); + } + ); + + this.drawExpanderButton( + nodesSelection, + 'node-side-list-expander-g', + (d: TreeNodeLike) => d.data.sideListNodes && d.data.sideListNodes.length > 0, + (d: TreeNodeLike) => ({x: d.data.width * 4 / 5, y: d.data.height}), + (d: TreeNodeLike) => d.data.sideListExpanded, + (d: TreeNode) => { + d.data.sideListExpanded = !d.data.sideListExpanded; + attrs.onSideListExpandedOrCollapsed(d.data.id, d.data.sideListExpanded); + this.update(); + } + ); + + this.drawExpanderButton( + nodesSelection, + 'lazy-parent-expander-g', + (d: TreeNodeLike) => { + return d.data.parentExpandable; + }, + (d: TreeNodeLike) => ({x: d.data.width / 2, y: 0}), + (d: TreeNodeLike) => d.parent != null, + (d: TreeNode) => { + d.data.parentExpanded = !d.data.parentExpanded; + attrs.onParentExpandedOrCollapsed(d.data.id, d.data.parentExpanded, attrs.data.filter(c => c.id === d.data.parentId).length == 0); + this.update(); + } + ); + } + + drawExpanderButton( + parentSelection: Selection, + cssClassName: string, + visible: (d: TreeNodeLike) => boolean, + position: (d: TreeNodeLike) => { x: number; y: any }, + expanded: (d: TreeNodeLike) => boolean, + onMouseDown: (d: TreeNode) => void + ) { + const attrs = this.getChartState(); +// Add Node button circle's group (expand-collapse button) + const childrenExpanderButtonG = parentSelection.selectAll(':scope > g.' + cssClassName) + .data(d => visible(d) ? [d] : []) + .join(enter => enter + .append("g") + .classed(cssClassName, true) + .on('mousedown', onMouseDown) + ) + .attr('transform', d => { + let pos = position(d); + return `translate(${pos.x},${pos.y})`; + }); + + // Add expand collapse button circle + childrenExpanderButtonG.selectAll(':scope > circle.node-button-circle') + .data(d => [d]) + .join(enter => enter + .append("circle") + .classed("node-button-circle", true) + ) + .attr('r', 10) + .attr('stroke-width', d => d.data.borderWidth || attrs.strokeWidth) + .attr('fill', "white") + .attr('stroke', d => d.data.borderColor); + +// Add button text + childrenExpanderButtonG.selectAll(':scope > text.node-button-text') + .data(d => [d]) + .join(enter => enter + .append("text") + .classed("node-button-text", true) + ) + .attr('text-anchor', 'middle') + .attr('alignment-baseline', 'middle') + .attr('fill', attrs.defaultTextFill) + .attr('font-size', d => expanded(d) ? 30 : 20) + .text(d => expanded(d) ? '-' : '+') + .attr('y', this.isEdge() ? 10 : 0); + + return childrenExpanderButtonG; + } + + calculateNodeSize(d: HierarchyNode) { + let attrs = this.getChartState(); + let nodeWidth = d.data.width; + let nodeHeight = d.data.height; + if (d.data.sideListNodes != null && d.data.sideListNodes.length > 0 && d.data.sideListExpanded) { + let sideListSize = this.calculateSideListSize(d); + nodeWidth = nodeWidth * (4 / 5) + sideListSize.width; + nodeHeight += sideListSize.height; + } + nodeHeight += attrs.verticalLayerGap; + return {width: nodeWidth, height: nodeHeight}; + } + + calculateSideListSize(d: HierarchyNode) { + return d.data.sideListNodes.reduce((size, node) => { + size.width = Math.max(size.width, node.width); + size.height += node.height + this.getChartState().sideListVerticalGap; + return size; + }, {width: 0, height: 0}); + } + + private drawNodes(parentSelection: Selection, dataFunction: (d: any) => TreeNodeLike[]) { + const attrs = this.getChartState(); + + // Add patterns for each node (it's needed for rounded image implementation) + let allNormalNodes = attrs.root.descendants(); + let allSideListNodes = attrs.root.descendants().flatMap(n => n.sideListNodes || []); + const patternsSelection = attrs.defs.selectAll('.pattern') + .data([...allNormalNodes, ...allSideListNodes].filter(n => n.data.image != null), (d: TreeNode) => d.id) + .join(enter => enter.append('pattern')) + .attr('class', 'pattern') + .attr('height', 1) + .attr('width', 1) + .attr('id', d => d.id); + + + // Add images to patterns + const patternImages = patternify(patternsSelection, { + tag: 'image', + selector: 'pattern-image', + data: (d: TreeNode) => [d].filter(d => d.data.image != null) + }) + .attr('x', 0) + .attr('y', 0) + .attr('height', (d: TreeNode) => d.data.image.width) + .attr('width', (d: TreeNode) => d.data.image.height) + .attr('xlink:href', (d: TreeNode) => d.data.image && d.data.image.url) + .attr('viewbox', (d: TreeNode) => `0 0 ${d.data.image.width * 2} ${d.data.image.height}`) + .attr('preserveAspectRatio', 'xMidYMin slice'); + + // Remove patterns exit selection after animation + patternsSelection.exit().transition().duration(attrs.duration).remove(); + + // -------------------------- NODES ---------------------- + // Get nodes selection + const nodesSelection = parentSelection.selectAll(':scope > .node') + .data(dataFunction, d => (d as any).id) + .join( + enter => enter + .append('g') + .attr('class', 'node') + .attr("transform", d => { + let transitionOrigin = this.getParentExpanderPosition(d); + return `translate(${transitionOrigin.x - d.data.width / 2},${transitionOrigin.y})`; + }) + .on('click', d => { + if (d3.event.srcElement.classList.contains('node-button-circle')) { + return; + } + attrs.onNodeClick(d.data.id); + }), + update => update, + exit => exit.attr('opacity', 1) + .transition() + .duration(attrs.duration) + .attr("transform", (d: TreeNode) => { + let transitionTarget = this.getParentExpanderPosition(d); + return `translate(${transitionTarget.x - d.data.width / 2},${transitionTarget.y})`; + }) + .on('end', function () { + d3.select(this).remove(); + }) + .attr('opacity', 0) + .remove() + ); + + nodesSelection.transition() + .attr('opacity', 0) + .duration(attrs.duration) + .attr("transform", d => { + return `translate(${d.x},${d.y})`; + }) + .attr('opacity', 1); + + + // Style node rectangles + nodesSelection.selectAll(':scope > .node-rect') + .data(d => [d]) + .join(enter => enter + .append('rect') + .classed("node-rect", true) + ) + .attr('width', d => d.data.width) + .attr('height', d => d.data.height) + .attr('x', 0) + .attr('y', 0) + .attr('rx', d => d.data.borderRadius || 0) + .attr('stroke-width', d => d.data.borderWidth) + .attr('stroke', d => d.data.borderColor) + .style("fill", d => d.data.backgroundColor); + + // Add node icon image inside node + nodesSelection.selectAll(':scope > image.node-icon-image') + .data(d => d.data.icon ? [d] : []) + .join(enter => enter + .append('image') + .classed('node-icon-image', true) + ) + .attr('width', d => d.data.icon.size) + .attr('height', d => d.data.icon.size) + .attr("xlink:href", d => d.data.icon.icon) + .attr('x', d => -d.data.icon.size / 2) + .attr('y', d => -d.data.icon.size / 2); + + // Defined node images wrapper group + const imageGroups = nodesSelection.selectAll(':scope > g.node-image-group') + .data(d => [d].filter(d => d.data.image != null)) + .join(enter => enter + .append("g") + .classed("node-image-group", true) + ) + .attr('transform', (d: TreeNode) => { + let x = -d.data.image.width / 2; + let y = -d.data.image.height / 2; + return `translate(${x},${y})` + }); + + imageGroups.selectAll(':scope > rect.node-image-rect') + .data(d => [d].filter(d => d.data.image != null)) + .join(enter => enter + .append("rect") + .classed("node-image-rect", true) + ) + .attr('fill', d => `url('#${d.id}')`) + .attr('width', d => d.data.image.width) + .attr('height', d => d.data.image.height) + .attr('stroke', d => d.data.image.borderColor) + .attr('stroke-width', d => d.data.image.borderWidth) + .attr('rx', d => d.data.image.cornerShape == UiTreeGraphNodeImage_CornerShape.CIRCLE ? Math.max(d.data.image.width, d.data.image.height) + : d.data.image.cornerShape == UiTreeGraphNodeImage_CornerShape.ROUNDED ? Math.min(d.data.image.width, d.data.image.height) / 10 + : 0) + .attr('y', d => d.data.image.centerTopDistance) + .attr('x', d => d.data.image.centerLeftDistance) + .attr('filter', d => d.data.image.shadow ? `url('#${attrs.dropShadowId}')` : 'none'); + + // Add foreignObject element inside rectangle + const fo = nodesSelection.selectAll(':scope > foreignObject.node-foreign-object') + .data(d => [d]) + .join(enter => enter + .append("foreignObject") + .classed("node-foreign-object", true) + ); + + fo.selectAll(':scope > .node-foreign-object-div') + .data(d => [d]) + .join(enter => enter + .append("xhtml:div") + .classed("node-foreign-object-div", true) + ); + + this.restyleForeignObjectElements(); + return nodesSelection; + } + + private getParentExpanderPosition(d: TreeNodeLike) { + if (d.parent == null) { + return {x: d.data.width / 2, y: 0}; + } + return {x: d.parent.x + d.parent.data.width / 2, y: d.parent.y + d.parent.data.height}; + } + + private drawLinks(parentSelection: Selection, links: HierarchyPointLink[]) { + const attrs = this.getChartState(); + // -------------------------- LINKS ---------------------- + // Get links selection + const linkSelection = parentSelection.selectAll(':scope > path.link') + .data(links, (d: HierarchyPointLink) => d.target.id) + .join( + enter => enter + .insert('path', "g") + .attr("class", "link") + .attr('d', (d: HierarchyPointLink) => { + let transitionOrigin = this.getParentExpanderPosition(d.target as TreeNode); + return this.diagonalLine(transitionOrigin, transitionOrigin); + }), + update => update, + exit => exit.transition() + .duration(attrs.duration) + .attr('d', (d: HierarchyPointLink) => { + let transitionOrigin = this.getParentExpanderPosition(d.target as TreeNode); + return this.diagonalLine(transitionOrigin, transitionOrigin); + }) + .remove() + ) + .attr("fill", "none") + .attr("stroke-width", d => d.target.data.connectorLineWidth || 2) + .attr('stroke', d => d.target.data.connectorLineColor ? d.target.data.connectorLineColor : 'white') + .attr('stroke-dasharray', d => d.target.data.dashArray ? d.target.data.dashArray : '') + .transition() + .duration(attrs.duration) + .attr('d', d => this.diagonalLine({x: d.source.x + d.source.data.width / 2, y: d.source.y + d.source.data.height}, {x: d.target.x + d.target.data.width / 2, y: d.target.y})); + } + + private isEdge() { + return window.navigator.userAgent.includes("Edge"); + } + + diagonalLine(s: { x: number, y: number }, t: { x: number, y: number }) { + // Calculate some variables based on source and target (s,t) coordinates + let deltaX = t.x - s.x; + let directionX = deltaX < 0 ? -1 : 1; + let deltaY = t.y - s.y; + let directionY = deltaY < 0 ? -1 : 1; + let defaultRadius = this.getChartState().verticalLayerGap / 3; + let r = Math.min(Math.abs(deltaX) / 2, Math.abs(deltaY) / 2, defaultRadius); + let h = Math.abs(deltaY) - r - this.getChartState().verticalLayerGap / 2; + let w = Math.abs(deltaX) - r * 2; + + // Build the path + return ` + M ${(s.x)} ${(s.y)} + L ${(s.x)} ${s.y + h * directionY} + C ${(s.x)} ${s.y + h * directionY + r * directionY} ${(s.x)} ${s.y + h * directionY + r * directionY} ${s.x + r * directionX} ${s.y + h * directionY + r * directionY} + L ${s.x + w * directionX + r * directionX} ${s.y + h * directionY + r * directionY} + C ${(t.x)} ${s.y + h * directionY + r * directionY} ${(t.x)} ${s.y + h * directionY + r * directionY} ${(t.x)} ${t.y - (this.getChartState().verticalLayerGap / 2 - r) * directionY} + L ${(t.x)} ${(t.y)} + `; + } + + hookLine(s: { x: number, y: number }, t: { x: number, y: number }) { + // Calculate some variables based on source and target (s,t) coordinates + let deltaX = t.x - s.x; + let directionX = deltaX < 0 ? -1 : 1; + let deltaY = t.y - s.y; + let directionY = deltaY < 0 ? -1 : 1; + let defaultRadius = 35; + let r = Math.min(Math.abs(deltaX), Math.abs(deltaY), defaultRadius); + let h = Math.abs(deltaY) - r; + let w = Math.abs(deltaX) - r; + + // Build the path + return ` + M ${(s.x)} ${(s.y)} + L ${(s.x)} ${s.y + h * directionY} + C ${(s.x)} ${s.y + h * directionY + r * directionY} ${(s.x)} ${s.y + h * directionY + r * directionY} ${s.x + r * directionX} ${s.y + h * directionY + r * directionY} + L ${(t.x)} ${(t.y)} + `; + } + + restyleForeignObjectElements() { + const attrs = this.getChartState(); + + attrs.svg.selectAll('.node-foreign-object') + .attr('width', (n: TreeNode) => n.data.width) + .attr('height', (n: TreeNode) => n.data.height); + attrs.svg.selectAll('.node-foreign-object-div') + .style('width', (n: TreeNode) => `${n.data.width}px`) + .style('height', (n: TreeNode) => `${n.data.height}px`) + .html((n: TreeNode) => this.context.templateRegistry.createTemplateRenderer(n.data.template).render(n.data.record.values)) + .select('*') + .style('display', 'inline-grid') + } + + // Toggle children on click. + private getVisibleNodeRecords(records: UiTreeGraphNodeConfig[]) { + const recordsById = getById(records); + + function depth(r: UiTreeGraphNodeConfig) { + let depth = 0; + while (r.parentId != null && recordsById[r.parentId] != null) { + depth++; + r = recordsById[r.parentId]; + } + return depth; + } + + // find the lowest root (without parent node or with parentExpanded == false) + const visibleRoot = records + .filter(r => r.parentId == null || recordsById[r.parentId] == null || r.parentExpanded == false) + .sort((r1, r2) => depth(r2) - depth(r1)) + [0]; + + const visibleNodeRecordsById: { [id: string]: UiTreeGraphNodeConfig } = {[visibleRoot.id]: visibleRoot}; + const invisibleNodeRecordsById: { [id: string]: UiTreeGraphNodeConfig } = {}; + + // everything above the visible root is invisible (by definition) + let invisibleParent = visibleRoot; // only for initialization. the visible root remains visible + do { + invisibleParent = invisibleParent.parentId != null ? recordsById[invisibleParent.parentId] : null; + if (invisibleParent != null) { + invisibleNodeRecordsById[invisibleParent.id] = invisibleParent; + } + } while (invisibleParent != null); + + // propagate visibility/invisibility knowledge downwards + let remainingRecords: UiTreeGraphNodeConfig[] = records.filter(r => visibleNodeRecordsById[r.id] == null && invisibleNodeRecordsById[r.id] == null); + let visibleNodesChanged: boolean; + do { + visibleNodesChanged = false; + remainingRecords = remainingRecords.filter(r => { + let visibleParent = visibleNodeRecordsById[r.parentId]; + let invisibleParent = invisibleNodeRecordsById[r.parentId]; + if (visibleParent != null) { + if (visibleParent.expanded) { + visibleNodeRecordsById[r.id] = r; + visibleNodesChanged = true; + return false; + } else { + invisibleNodeRecordsById[r.id] = r; + return false; + } + } else if (invisibleParent != null) { + invisibleNodeRecordsById[r.id] = r; + return false; + } else { + return true; // we don't know yet for this node... + } + }); + } while (visibleNodesChanged); + return visibleNodeRecordsById; + } + + private getAllDescendants(node: UiTreeGraphNodeConfig, includeSelf: boolean) { + // O(h^2) where h is the height of the tree. + // So the worst case performance is O(n * n/2) for a totally linear tree. + // Average case: O(n * log(n)) + // If used for a root node (see usages!!): O(n) due to optimization! + + const descendantsById: { [id: string]: UiTreeGraphNodeConfig } = {}; + descendantsById[node.id] = node; + const nonDescendantsById: { [id: string]: UiTreeGraphNodeConfig } = {}; // common case optimization! + + let untaggedNodes = this.getChartState().data.filter(n => n.id != node.id); + + let descendantsChanged: boolean; + let nonDescendantsChanged: boolean; + do { + descendantsChanged = false; + nonDescendantsChanged = false; + untaggedNodes = untaggedNodes.filter(n => { + if (descendantsById[n.parentId] != null) { + descendantsById[n.id] = n; + descendantsChanged = true; + return false; + } else if (nonDescendantsById[n.parentId] != null) { + nonDescendantsById[n.id] = n; + nonDescendantsChanged = true; + return false; + } else { + return true; + } + }); + } while (descendantsChanged && nonDescendantsChanged); + + for (let untaggedNode of untaggedNodes) { + descendantsById[untaggedNode.id] = untaggedNode; // if nonDescendantsChanged[0] == false, all remaining nodes must be descendants! + } + + if (!includeSelf) { + delete descendantsById[node.id]; + } + + return descendantsById; + } + + // Zoom handler function + zoomed() { + const attrs = this.getChartState(); + const chart = attrs.chart; + + // Get d3 event's transform object + const transform = d3.event.transform; + + // Store it + attrs.lastTransform = transform; + + // Reposition and rescale chart accordingly + chart.attr('transform', transform); + + // Apply new styles to the foreign object element + if (this.isEdge()) { + this.restyleForeignObjectElements(); + } + } + + public moveToRootNode(animationDuration: number = 400) { + let attrs: TreeChartAttributes = this.getChartState(); + if (attrs.root) { + this.moveToHierarchyNode(attrs.root, animationDuration); + } + } + + public moveToNode(nodeId: string, animationDuration: number = 400) { + let attrs: TreeChartAttributes = this.getChartState(); + let treeNode = attrs.root.descendants().filter(d => d.id === nodeId)[0]; + if (treeNode != null) { + this.moveToHierarchyNode(treeNode, animationDuration); + } + } + + private moveToHierarchyNode(node: HierarchyPointNode, animationDuration: number = 400) { + let attrs: TreeChartAttributes = this.getChartState(); + let zoomTransform = d3.zoomTransform(attrs.svg.node()); + + let nodeSize = this.calculateNodeSize(node); + + attrs.svg.transition().duration(animationDuration).call( + this.zoomBehavior.transform, + d3.zoomIdentity.scale(zoomTransform.k).translate(-node.x - nodeSize.width / 2 + attrs.svgWidth / (2 * zoomTransform.k), -node.y + (attrs.verticalLayerGap)) + ); + } + +} + +function getById(recs: R[], idExtractor: (r: R) => string = r => (r as any).id) { + return recs.reduce((recordsById, record) => { + recordsById[idExtractor(record)] = record; + return recordsById; + }, {} as { [id: string]: R }) +} + +export function patternify(container: Selection, params: PatternifyParameter) { + var selector = params.selector; + var elementTag = params.tag; + var data = params.data || [selector]; + + // Pattern in action + var selection = container.selectAll(':scope > .' + selector) + .data(data, (d: any, i: number) => { + if (typeof d === 'object' && d.id) { + return d.id; + } + return i; + }) as Selection; + selection.exit().remove(); + selection = selection.enter().append(elementTag).merge(selection); + selection.attr('class', selector); + return selection; +} diff --git a/teamapps-client/ts/modules/UiVerticalLayout.ts b/teamapps-client/ts/modules/UiVerticalLayout.ts index 92ec58de0..9f1ae597d 100644 --- a/teamapps-client/ts/modules/UiVerticalLayout.ts +++ b/teamapps-client/ts/modules/UiVerticalLayout.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,56 +17,47 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; -import {UiComponent} from "./UiComponent"; + +import {AbstractUiComponent} from "./AbstractUiComponent"; import {UiComponentConfig} from "../generated/UiComponentConfig"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; import {UiVerticalLayoutCommandHandler, UiVerticalLayoutConfig} from "../generated/UiVerticalLayoutConfig"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; +import {parseHtml} from "./Common"; +import {UiComponent} from "./UiComponent"; -export class UiVerticalLayout extends UiComponent implements UiVerticalLayoutCommandHandler { - private $verticalLayout: JQuery; +export class UiVerticalLayout extends AbstractUiComponent implements UiVerticalLayoutCommandHandler { + private $verticalLayout: HTMLElement; private children: UiComponent[] = []; constructor(config: UiVerticalLayoutConfig, context: TeamAppsUiContext) { super(config, context); - this.$verticalLayout = $('
'); + this.$verticalLayout = parseHtml('
'); if (config.components) { for (let i = 0; i < config.components.length; i++) { - this.addComponent(config.components[i]); + this.addComponent(config.components[i] as UiComponent); } } } - public getMainDomElement(): JQuery { + public doGetMainElement(): HTMLElement { return this.$verticalLayout; } - protected onAttachedToDom() { - this.children.forEach(c => c.attachedToDom = true); - } - - public onResize(): void { - this.children.forEach(c => c.reLayout()); - } - - public destroy(): void { - } - public addComponent(childComponent: UiComponent) { - const $childWrapper = $('
').appendTo(this.$verticalLayout); - childComponent.getMainDomElement().appendTo($childWrapper); + const $childWrapper = parseHtml('
'); + this.$verticalLayout.appendChild($childWrapper); + $childWrapper.appendChild(childComponent.getMainElement()); this.children.push(childComponent); - childComponent.attachedToDom = this.attachedToDom; } public removeComponent(childComponent: UiComponent) { this.children = this.children.filter(c => c !== childComponent); - let $childWrapper = childComponent.getMainDomElement().closest(".vertical-layout-child-wrapper"); - $childWrapper.detach(); + let $childWrapper = childComponent.getMainElement().closest(".vertical-layout-child-wrapper"); + $childWrapper.remove(); } } diff --git a/teamapps-client/ts/modules/UiVideoPlayer.ts b/teamapps-client/ts/modules/UiVideoPlayer.ts index fcc18d1a2..2de9555c5 100644 --- a/teamapps-client/ts/modules/UiVideoPlayer.ts +++ b/teamapps-client/ts/modules/UiVideoPlayer.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,35 +18,36 @@ * =========================LICENSE_END================================== */ /// -import * as $ from "jquery"; + import "mediaelement/full"; -import {UiComponent} from "./UiComponent"; +import {AbstractUiComponent} from "./AbstractUiComponent"; import {TeamAppsEvent} from "./util/TeamAppsEvent"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; import { + UiVideoPlayer_EndedEvent, UiVideoPlayer_ErrorLoadingEvent, UiVideoPlayer_PlayerProgressEvent, - UiVideoPlayer_PosterImageSize, UiVideoPlayerCommandHandler, UiVideoPlayerConfig, UiVideoPlayerEventSource } from "../generated/UiVideoPlayerConfig"; -import {EventFactory} from "../generated/EventFactory"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; -import {createUiColorCssString} from "./util/CssFormatUtil"; import {UiMediaPreloadMode} from "../generated/UiMediaPreloadMode"; +import {parseHtml} from "./Common"; +import {UiPosterImageSize} from "../generated/UiPosterImageSize"; -export class UiVideoPlayer extends UiComponent implements UiVideoPlayerCommandHandler, UiVideoPlayerEventSource { +export class UiVideoPlayer extends AbstractUiComponent implements UiVideoPlayerCommandHandler, UiVideoPlayerEventSource { - public readonly onPlayerProgress: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onErrorLoading: TeamAppsEvent = new TeamAppsEvent(this); + public readonly onPlayerProgress: TeamAppsEvent = new TeamAppsEvent(); + public readonly onEnded: TeamAppsEvent = new TeamAppsEvent(); + public readonly onErrorLoading: TeamAppsEvent = new TeamAppsEvent(); - private $componentWrapper: JQuery; - private $video: JQuery; + private $componentWrapper: HTMLElement; + private $video: HTMLElement; private mediaPlayer: any; - private contentReady: boolean = false; + private playerInitialized: boolean = false; private jumpToPositionWhenReady: number = 0; private destroyed: boolean; @@ -56,17 +57,18 @@ export class UiVideoPlayer extends UiComponent implements U constructor(config: UiVideoPlayerConfig, context: TeamAppsUiContext) { super(config, context); - const posterImageSizeCssClass = `poster-${UiVideoPlayer_PosterImageSize[config.posterImageSize].toLowerCase()}`; - this.$componentWrapper = $( - `
- + const posterImageSizeCssClass = `poster-${UiPosterImageSize[config.posterImageSize].toLowerCase()}`; + let preload = `${config.preloadMode === UiMediaPreloadMode.AUTO ? 'auto' : config.preloadMode === UiMediaPreloadMode.METADATA ? 'metadata' : 'none'}`; + this.$componentWrapper = parseHtml( + `
+
`); - this.$componentWrapper.toggleClass("hide-controls", !config.showControls); - this.$video = this.$componentWrapper.find("video"); + this.$componentWrapper.classList.toggle("hide-controls", !config.showControls); + this.$video = this.$componentWrapper.querySelector(":scope video"); // TODO this is the point where the element was inserted to the DOM - this.mediaPlayer = new mejs.MediaElementPlayer(this.$componentWrapper.find('video')[0], { + this.mediaPlayer = new mejs.MediaElementPlayer(this.$componentWrapper.querySelector(':scope video'), { enablePluginDebug: false, plugins: ['fasterslower'], type: '', @@ -74,48 +76,52 @@ export class UiVideoPlayer extends UiComponent implements U features: ['playpause', 'current', 'progress', 'duration', 'tracks', 'volume', 'fullscreen'], timerRate: 250, success: (mediaElement: HTMLMediaElement) => { - this.onContentReady(); - + this.onPlayerInitialized(); mediaElement.addEventListener('play', (e) => { - this.onPlayerProgress.fire(EventFactory.createUiVideoPlayer_PlayerProgressEvent(config.id, 0)); + this.onPlayerProgress.fire({ + positionInSeconds: 0 + }); }, false); - let lastPlayTime = 0; mediaElement.addEventListener('timeupdate', (e) => { let currentPlayTime = mediaElement.currentTime; if (lastPlayTime < currentPlayTime && lastPlayTime % config.sendPlayerProgressEventsEachXSeconds > currentPlayTime % config.sendPlayerProgressEventsEachXSeconds) { - this.onPlayerProgress.fire(EventFactory.createUiVideoPlayer_PlayerProgressEvent(config.id, Math.floor(currentPlayTime))); + this.onPlayerProgress.fire({ + positionInSeconds: Math.floor(currentPlayTime) + }); } lastPlayTime = currentPlayTime; }, false); + mediaElement.addEventListener('ended', (e) => { + this.onEnded.fire({}); + }); }, error: () => { if (!this.destroyed) { - console.log(); - this.onErrorLoading.fire(EventFactory.createUiVideoPlayer_ErrorLoadingEvent(config.id)) + this.onErrorLoading.fire({}) } } }); - this.$componentWrapper.add(this.$componentWrapper.find('.mejs__container')) - .css('background-color', createUiColorCssString(config.backgroundColor)); + this.$componentWrapper.style.backgroundColor = config.backgroundColor; + this.$componentWrapper.querySelector(':scope .mejs__container').style.backgroundColor = config.backgroundColor; this.setPreloadMode(config.preloadMode); this.setAutoplay(config.autoplay); - } - public getMainDomElement(): JQuery { - return this.$componentWrapper; + this.displayedDeferredExecutor.invokeWhenReady(() => { + if (this.autoplay) { + this.play(); + } + }); } - protected onAttachedToDom(): void { - if (this.autoplay) { - this.play(); - } + public doGetMainElement(): HTMLElement { + return this.$componentWrapper; } - private onContentReady() { - this.contentReady = true; + private onPlayerInitialized() { + this.playerInitialized = true; if (this.playState === "playing" || (this.autoplay && this.playState !== "paused")) { this.play(); } @@ -123,7 +129,7 @@ export class UiVideoPlayer extends UiComponent implements U public play() { this.playState = "playing"; - if (this.contentReady) { + if (this.playerInitialized) { this.mediaPlayer.play(); } } @@ -134,7 +140,7 @@ export class UiVideoPlayer extends UiComponent implements U } public jumpTo(seconds: number) { - if (this.contentReady) { + if (this.playerInitialized) { this.mediaPlayer.setCurrentTime(seconds); } else { this.jumpToPositionWhenReady = seconds; @@ -142,11 +148,14 @@ export class UiVideoPlayer extends UiComponent implements U } public onResize(): void { + // console.log(this.getWidth(), this.getHeight(), Math.min(this.getHeight(), this.mediaPlayer.height), this.mediaPlayer.width, this.mediaPlayer.height) + // this.mediaPlayer.setPlayerSize(this.getWidth(), Math.min(this.getHeight(), this.mediaPlayer.height)); // CAUTION: maybe we will have to handle fullscreen mode this.mediaPlayer.setPlayerSize(this.mediaPlayer.width, this.mediaPlayer.height); // CAUTION: maybe we will have to handle fullscreen mode this.mediaPlayer.setControlsSize(); } public destroy(): void { + super.destroy(); this.destroyed = true; try { this.mediaPlayer.pause(); @@ -160,33 +169,32 @@ export class UiVideoPlayer extends UiComponent implements U setAutoplay(autoplay: boolean): void { this.autoplay = autoplay; this.playState = "initial"; - + if (autoplay) { - if (this.contentReady) { + if (this.playerInitialized) { this.mediaPlayer.play(); } - this.$video.attr("autoplay", "autoplay") + this.$video.setAttribute("autoplay", "autoplay") } else { - this.$video.removeAttr("autoplay"); + this.$video.removeAttribute("autoplay"); } } setPreloadMode(preloadMode: UiMediaPreloadMode): void { - this.$video.attr("preload", `${preloadMode === UiMediaPreloadMode.AUTO ? 'auto' : preloadMode === UiMediaPreloadMode.METADATA ? 'metadata' : 'none'}`); + this.$video.setAttribute("preload", `${preloadMode === UiMediaPreloadMode.AUTO ? 'auto' : preloadMode === UiMediaPreloadMode.METADATA ? 'metadata' : 'none'}`); } setUrl(url: string): void { - this.getMainDomElement().find(".mejs__overlay-error").parent().hide(); - this.getMainDomElement().find(".mejs__poster").show(); - this.getMainDomElement().find(".mejs__overlay-play").css("display", "flex"); + this.getMainElement().querySelector(":scope .mejs__overlay-error").parentElement.classList.add("hidden"); + this.getMainElement().querySelector(":scope .mejs__poster").classList.remove("hidden"); + this.getMainElement().querySelector(":scope .mejs__overlay-play").style.display = "flex"; this.mediaPlayer.pause(); - this.contentReady = false; if (url == null) { this.pause(); } else { this.mediaPlayer.setSrc(url); } - this.$componentWrapper.toggleClass("not-playable", url == null); + this.$componentWrapper.classList.toggle("not-playable", url == null); } } diff --git a/teamapps-client/ts/modules/UiWebRtcPlayer.ts b/teamapps-client/ts/modules/UiWebRtcPlayer.ts index f1c287272..3b96b4723 100644 --- a/teamapps-client/ts/modules/UiWebRtcPlayer.ts +++ b/teamapps-client/ts/modules/UiWebRtcPlayer.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,12 +17,12 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; + import {UiWebRtcPlayerCommandHandler, UiWebRtcPlayerConfig} from "../generated/UiWebRtcPlayerConfig"; import {UiSpinner} from "./micro-components/UiSpinner"; -import {UiComponent} from "./UiComponent"; +import {AbstractUiComponent} from "./AbstractUiComponent"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; -import {applyDisplayMode} from "./Common"; +import {applyDisplayMode, parseHtml} from "./Common"; import {UiPageDisplayMode} from "../generated/UiPageDisplayMode"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; import {UiWebRtcPlayingSettingsConfig} from "../generated/UiWebRtcPlayingSettingsConfig"; @@ -31,14 +31,14 @@ import {UiVideoCodec} from "../generated/UiVideoCodec"; type WebRtcState = 'new' | 'checking' | 'connected' | 'completed' | 'failed' | 'disconnected' | 'closed'; -export class UiWebRtcPlayer extends UiComponent implements UiWebRtcPlayerCommandHandler { +export class UiWebRtcPlayer extends AbstractUiComponent implements UiWebRtcPlayerCommandHandler { private static readonly PEER_CONNECTION_CONFIG: any = {'iceServers': []}; - private $main: JQuery; - private $videoContainer: JQuery; - private $audioActivityDisplayContainer: JQuery; - private $spinnerContainer: JQuery; + private $main: HTMLElement; + private $videoContainer: HTMLElement; + private $audioActivityDisplayContainer: HTMLElement; + private $spinnerContainer: HTMLElement; private remoteVideo: HTMLVideoElement; private peerConnection: RTCPeerConnection = null; @@ -51,21 +51,21 @@ export class UiWebRtcPlayer extends UiComponent implements constructor(config: UiWebRtcPlayerConfig, context: TeamAppsUiContext) { super(config, context); - this.$main = $(` + this.$main = parseHtml(`
`); - this.remoteVideo = this.$main.find('video')[0]; + this.remoteVideo = this.$main.querySelector(':scope video') as HTMLVideoElement; this.remoteVideo.addEventListener("abort", () => { // this event will be triggered when stopPlaying() is invoked. this.remoteVideo.load(); // show poster again. We cannot do this directly in stopPlaying()... }); this.remoteVideo.onloadedmetadata = this.onResize.bind(this); - this.$videoContainer = this.$main.find('.video-container'); - this.$audioActivityDisplayContainer = this.$main.find('.audio-activity-display-container'); - this.$spinnerContainer = this.$main.find('.spinner-container'); + this.$videoContainer = this.$main.querySelector(':scope .video-container'); + this.$audioActivityDisplayContainer = this.$main.querySelector(':scope .audio-activity-display-container'); + this.$spinnerContainer = this.$main.querySelector(':scope .spinner-container'); this.$spinnerContainer.append(new UiSpinner().getMainDomElement()); this.setBackgroundImageUrl(config.backgroundImageUrl); @@ -102,7 +102,7 @@ export class UiWebRtcPlayer extends UiComponent implements this.peerConnection = new RTCPeerConnection(UiWebRtcPlayer.PEER_CONNECTION_CONFIG); this.peerConnection.onicecandidate = this.gotIceCandidate.bind(this); this.peerConnection.oniceconnectionstatechange = this.onPlayingIceConnectionStateChange.bind(this); - this.peerConnection.onaddstream = this.gotRemoteStream.bind(this); + (this.peerConnection as any).onaddstream = this.gotRemoteStream.bind(this); (this.peerConnection as any).ontrack = this.gotRemoteTrack.bind(this); this.logger.debug("wsURL: " + this.settings.signalingUrl); this.sendPlayGetOffer(this.settings.wowzaApplicationName, this.settings.streamName); @@ -207,7 +207,7 @@ export class UiWebRtcPlayer extends UiComponent implements private updateUi() { const nonLoadingIceStates: WebRtcState[] = ['connected', 'completed', 'closed']; let loading: boolean = (this.signalingWsConnection != null || nonLoadingIceStates.indexOf(this.iceConnectionState) === -1); - this.$spinnerContainer.toggleClass("hidden", !loading); + this.$spinnerContainer.classList.toggle("hidden", !loading); } private gotDescriptionForPlaying(description: RTCSessionDescription) { @@ -233,7 +233,7 @@ export class UiWebRtcPlayer extends UiComponent implements } } - private gotRemoteStream(event: MediaStreamEvent) { + private gotRemoteStream(event: any) { // this.logger.debug('gotRemoteStream: ' + event.stream); // this.remoteVideo.muted = false; // try { @@ -253,23 +253,19 @@ export class UiWebRtcPlayer extends UiComponent implements public onResize(): void { applyDisplayMode(this.$main, this.$videoContainer, UiPageDisplayMode.FIT_SIZE, { - innerPreferedDimensions: { + innerPreferredDimensions: { width: this.remoteVideo.videoWidth, height: this.remoteVideo.videoHeight } }); } - destroy(): void { - } - - getMainDomElement(): JQuery { + doGetMainElement(): HTMLElement { return this.$main; } } -navigator.getUserMedia = navigator.getUserMedia || (navigator as any).mozGetUserMedia || (navigator as any).webkitGetUserMedia; (window as any).RTCPeerConnection = (window as any).RTCPeerConnection || (window as any).mozRTCPeerConnection || (window as any).webkitRTCPeerConnection; (window as any).RTCIceCandidate = (window as any).RTCIceCandidate || (window as any).mozRTCIceCandidate || (window as any).webkitRTCIceCandidate; (window as any).RTCSessionDescription = (window as any).RTCSessionDescription || (window as any).mozRTCSessionDescription || (window as any).webkitRTCSessionDescription; diff --git a/teamapps-client/ts/modules/UiWebRtcPublisher.ts b/teamapps-client/ts/modules/UiWebRtcPublisher.ts deleted file mode 100644 index 871ac9849..000000000 --- a/teamapps-client/ts/modules/UiWebRtcPublisher.ts +++ /dev/null @@ -1,641 +0,0 @@ -/*- - * ========================LICENSE_START================================= - * TeamApps - * --- - * Copyright (C) 2014 - 2019 TeamApps.org - * --- - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * =========================LICENSE_END================================== - */ -import * as $ from "jquery"; -import {UiAudioActivityDisplay} from "./micro-components/UiAudioActivityDisplay"; -import {UiWebRtcPublisher_PublishingFailedEvent, UiWebRtcPublisherCommandHandler, UiWebRtcPublisherConfig, UiWebRtcPublisherEventSource} from "../generated/UiWebRtcPublisherConfig"; -import {UiSpinner} from "./micro-components/UiSpinner"; -import {UiComponent} from "./UiComponent"; -import {TeamAppsUiContext} from "./TeamAppsUiContext"; -import {UiPageDisplayMode} from "../generated/UiPageDisplayMode"; -import {UiAudioCodec} from "../generated/UiAudioCodec"; -import {UiVideoCodec} from "../generated/UiVideoCodec"; -import {applyDisplayMode, parseHtml} from "./Common"; -import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; -import {UiWebRtcPublishingSettingsConfig} from "../generated/UiWebRtcPublishingSettingsConfig"; -import {checkChromeExtensionAvailable, getScreenConstraints, isChrome} from "./util/ScreenCapturing"; -import {TeamAppsEvent} from "./util/TeamAppsEvent"; -import {EventFactory} from "../generated/EventFactory"; -import {UiWebRtcPublishingErrorReason} from "../generated/UiWebRtcPublishingErrorReason"; -import {MediaStreamWithMixiSizingInfo, MultiStreamsMixer} from "./util/MultiStreamsMixer"; -import {UiWebRtcPublishingMediaSettingsConfig} from "../generated/UiWebRtcPublishingMediaSettingsConfig"; - -type WebRtcState = 'new' | 'checking' | 'connected' | 'completed' | 'failed' | 'disconnected' | 'closed'; - -interface SdpDescriptorEnhancingData { - audioBitrate: number, - videoBitrate: number, - videoFrameRate: number -} - -export class UiWebRtcPublisher extends UiComponent implements UiWebRtcPublisherCommandHandler, UiWebRtcPublisherEventSource { - - private static readonly PEER_CONNECTION_CONFIG: any = {'iceServers': []}; - - public readonly onPublishingFailed: TeamAppsEvent = new TeamAppsEvent(this); - - private $main: JQuery; - private $videoContainer: JQuery; - private video: HTMLVideoElement; - private $audioActivityDisplayContainer: JQuery; - private $spinnerContainer: JQuery; - - private publishingSettings: UiWebRtcPublishingSettingsConfig; - private publishingSignalingWsConnection: WebSocket = null; - private publishingPeerConnection: RTCPeerConnection = null; - private publishingUserData: { param1: string }; - private publishingIceConnectionState: WebRtcState; - - private audioActivityDisplay: UiAudioActivityDisplay; - private multiStreamsMixer: MultiStreamsMixer; - private microphoneMuted: boolean; - - constructor(config: UiWebRtcPublisherConfig, context: TeamAppsUiContext) { - super(config, context); - - this.$main = $(` -
-
- -
- - -
-
`); - this.video = this.$main.find('video')[0]; - this.video.addEventListener("abort", () => { // this event will be triggered when stopPlaying() is invoked. - this.video.load(); // show poster again. We cannot do this directly in stopPlaying()... - }); - this.video.onloadedmetadata = this.onResize.bind(this); - this.$videoContainer = this.$main.find('.video-container'); - this.$audioActivityDisplayContainer = this.$main.find('.audio-activity-display-container'); - this.audioActivityDisplay = new UiAudioActivityDisplay(); - this.$audioActivityDisplayContainer.append(this.audioActivityDisplay.getMainDomElement()); - this.$spinnerContainer = this.$main.find('.spinner-container'); - this.$spinnerContainer.append(new UiSpinner().getMainDomElement()); - - if (config.publishingSettings != null) { - this.publish(config.publishingSettings); - } - - this.setMicrophoneMuted(config.microphoneMuted); - - this.setBackgroundImageUrl(config.backgroundImageUrl); - } - - public publish(settings: UiWebRtcPublishingSettingsConfig) { - this.unPublish(); - - this.publishingSettings = settings; - - this.getMediaStreamMixer(settings.mediaSettings) - .then((multiStreamsMixer: MultiStreamsMixer) => { - this.multiStreamsMixer = multiStreamsMixer; - this.setMicrophoneMuted(this.microphoneMuted); - if (this.multiStreamsMixer.getMixedStream().getTracks().some(f => f.kind === "audio")) { - this.audioActivityDisplay.bindToStream(this.multiStreamsMixer.getMixedStream()); - } - this.video.muted = true; - try { - this.video.srcObject = multiStreamsMixer.getMixedStream(); - } catch (error) { - this.video.src = window.URL.createObjectURL(multiStreamsMixer); - } - - this.reconnectForPublishing(); - }) - .catch((reason: any) => { - if (reason !== UiWebRtcPublishingErrorReason.CHROME_SCREEN_SHARING_EXTENSION_NOT_INSTALLED) { - reason = UiWebRtcPublishingErrorReason.OTHER; - } - this.onPublishingFailed.fire(EventFactory.createUiWebRtcPublisher_PublishingFailedEvent(this.getId(), reason)); - console.error("Could not create screen sharing MediaStream. Reason:", UiWebRtcPublishingErrorReason[reason]); - }); - } - - setMicrophoneMuted(microphoneMuted: boolean): void { - this.microphoneMuted = microphoneMuted; - if (this.multiStreamsMixer) { - this.multiStreamsMixer.getInputMediaStreams().forEach(inputStream => inputStream.getAudioTracks().forEach(track => track.enabled = !this.microphoneMuted)); - } - this.audioActivityDisplay.getMainDomElement()[0].classList.toggle("hidden", microphoneMuted); - } - - private async getMediaStreamMixer(publishingSettings: UiWebRtcPublishingMediaSettingsConfig) { - - let screenStream: MediaStream = null; - let camMicStream: MediaStream = null; - - if (publishingSettings.screen) { - const chromeExtensionInstalled = await checkChromeExtensionAvailable(); - this.logger.info("Chrome extension available: " + chromeExtensionInstalled); - const canProbablyPublishScreen = !isChrome || chromeExtensionInstalled; - if (canProbablyPublishScreen) { - const screenConstraints = await getScreenConstraints(); - try { - screenStream = await navigator.mediaDevices.getUserMedia({video: screenConstraints}); - } catch (e) { - throw UiWebRtcPublishingErrorReason.CANNOT_GET_SCREEN_MEDIA_STREAM; - } - } else { - throw UiWebRtcPublishingErrorReason.CHROME_SCREEN_SHARING_EXTENSION_NOT_INSTALLED; - } - } - - if (publishingSettings.video || publishingSettings.audio) { - let constraints = { - video: publishingSettings.video ? { - width: publishingSettings.videoWidth, - height: publishingSettings.videoHeight - } : false, - audio: publishingSettings.audio - }; - camMicStream = await navigator.mediaDevices.getUserMedia(constraints); - // setTimeout(() => { - // normalStream.getTracks().forEach(t => t.stop()) - // }, 5000) - } - - if (screenStream != null && camMicStream != null) { - const screenStreamDimensions = await UiWebRtcPublisher.determineVideoSize(screenStream); - const screenStreamWithSizingInfo = screenStream as MediaStreamWithMixiSizingInfo; - screenStreamWithSizingInfo.fullcanvas = true; - screenStreamWithSizingInfo.width = screenStreamDimensions.width; - screenStreamWithSizingInfo.height = screenStreamDimensions.height; - - if (camMicStream.getTracks().some(t => t.kind === "video")) { - const screenStreamShortDimension = Math.min(screenStreamDimensions.width, screenStreamDimensions.height); - const cameraAspectRatio = camMicStream.getTracks().filter(t => t.kind === "video")[0].getSettings().aspectRatio; - const pictureInPictureHeight = Math.round((25 / 100) * screenStreamShortDimension); - const pictureInPictureWidth = Math.round(pictureInPictureHeight * cameraAspectRatio); - - const camMicStreamWithSizingInfo = camMicStream as MediaStreamWithMixiSizingInfo; - camMicStreamWithSizingInfo.width = pictureInPictureWidth; - camMicStreamWithSizingInfo.height = pictureInPictureHeight; - camMicStreamWithSizingInfo.left = screenStreamDimensions.width - pictureInPictureWidth; - camMicStreamWithSizingInfo.top = 0; // screenStreamDimensions.height - pictureInPictureHeight; - } - - let multiStreamsMixer = new MultiStreamsMixer([screenStreamWithSizingInfo, camMicStream]); - multiStreamsMixer.frameInterval = 1000 / screenStream.getTracks()[0].getSettings().frameRate; - return multiStreamsMixer; - } else if (camMicStream != null) { - return new MultiStreamsMixer([camMicStream]); - } else if (screenStream != null) { - let multiStreamsMixer = new MultiStreamsMixer([screenStream]); - multiStreamsMixer.frameInterval = 1000 / screenStream.getTracks()[0].getSettings().frameRate; - return multiStreamsMixer; - } - - } - - private static determineVideoSize(mediaStream: MediaStream, timeout = 1000): Promise<{ width: number, height: number }> { - return new Promise((resolve, reject) => { - var video: HTMLVideoElement = parseHtml(''); - document.body.appendChild(video); - video.srcObject = mediaStream; - video.muted = true; - video.onloadedmetadata = (e) => { - resolve({width: video.videoWidth, height: video.videoHeight}); - video.remove(); - }; - setTimeout(() => { - reject(); - video.remove(); - }, timeout); - }); - } - - private reconnectForPublishing() { - if (!this.publishingSettings) { - return; - } - - this.publishingSignalingWsConnection = new WebSocket(this.publishingSettings.signalingUrl); - this.publishingSignalingWsConnection.binaryType = 'arraybuffer'; - this.publishingSignalingWsConnection.onopen = () => { - this.logger.debug("this.playingSignalingWsConnection.onopen"); - this.publishingPeerConnection = new RTCPeerConnection(UiWebRtcPublisher.PEER_CONNECTION_CONFIG); - this.publishingPeerConnection.onicecandidate = this.gotIceCandidate.bind(this); - this.publishingPeerConnection.oniceconnectionstatechange = this.onPublishingIceConnectionStateChange.bind(this); - this.publishingPeerConnection.addStream(this.multiStreamsMixer.getMixedStream()); - this.publishingPeerConnection.createOffer() - .then(sdpDescriptionInit => { - const enhancingData: SdpDescriptorEnhancingData = { - audioBitrate: Number(this.publishingSettings.mediaSettings.audioKiloBitsPerSecond), - videoBitrate: Number(this.publishingSettings.mediaSettings.videoKiloBitsPerSecond), - videoFrameRate: Number(this.publishingSettings.mediaSettings.videoFps) - }; - - this.logger.debug('offer created: ' + JSON.stringify({'sdp': sdpDescriptionInit})); - // console.log(sdpDescriptionInit.sdp); - sdpDescriptionInit.sdp = this.enhanceSDP(sdpDescriptionInit.sdp, enhancingData); - // console.log("=========================="); - // console.log(sdpDescriptionInit.sdp); - this.logger.debug('offer enhanced: ' + JSON.stringify(sdpDescriptionInit.sdp)); - - this.publishingPeerConnection.setLocalDescription(sdpDescriptionInit) - .then(() => { - let streamInfo = { - applicationName: this.publishingSettings.wowzaApplicationName, - streamName: this.publishingSettings.streamName, - sessionId: "[empty]" - }; - this.publishingUserData = {param1: "value1"}; - this.publishingSignalingWsConnection.send('{"direction":"publish", "command":"sendOffer", "streamInfo":' + JSON.stringify(streamInfo) + ', "sdp":' + JSON.stringify(sdpDescriptionInit) + ', "userData":' + JSON.stringify(this.publishingUserData) + '}'); - }, (error) => { - this.logger.error('set description error', error) - }); - }, this.errorHandler.bind(this)); - }; - this.publishingSignalingWsConnection.onmessage = (evt) => { - this.logger.debug("this.playingSignalingWsConnection.onmessage: " + evt.data); - const msgJSON = JSON.parse(evt.data); - const msgStatus = Number(msgJSON['status']); - this.logger.info(`Status: ${msgStatus} - ${msgJSON['statusDescription']}`); - if (msgStatus == 200) { - if (msgJSON.sdp !== undefined) { - this.logger.debug('Received sdp: ' + JSON.stringify(msgJSON['sdp'])); - this.publishingPeerConnection.setRemoteDescription(new RTCSessionDescription(msgJSON.sdp)) - .then(() => { - //peerConnection.createAnswer(gotDescription, errorHandler); - }, this.errorHandler.bind(this)); - } - const iceCandidates = msgJSON['iceCandidates']; - if (iceCandidates !== undefined) { - for (let index in iceCandidates) { - this.logger.debug('iceCandidates: ' + JSON.stringify(iceCandidates[index])); - this.publishingPeerConnection.addIceCandidate(new RTCIceCandidate(iceCandidates[index])); - } - } - } else { - this.logger.error(msgJSON.statusDescription); - setTimeout(() => this.reconnectForPublishing(), 1000); - } - if (this.publishingSignalingWsConnection != null) { - this.publishingSignalingWsConnection.close(); - } - this.publishingSignalingWsConnection = null; - }; - this.publishingSignalingWsConnection.onclose = () => { - this.logger.debug("this.playingSignalingWsConnection.onclose"); - }; - this.publishingSignalingWsConnection.onerror = (evt) => { - this.logger.error('WebSocket connection failed: ' + this.publishingSettings.signalingUrl); - this.logger.debug("this.playingSignalingWsConnection.onerror: " + JSON.stringify(evt)); - setTimeout(() => this.reconnectForPublishing(), 1000); - }; - this.updateUi(); - } - - public unPublish() { - this.publishingSettings = null; - if (this.publishingPeerConnection != null) { - this.publishingPeerConnection.close(); - } - this.publishingPeerConnection = null; - - if (this.publishingSignalingWsConnection != null) { - this.publishingSignalingWsConnection.close(); - } - this.publishingSignalingWsConnection = null; - this.video.src = ""; - this.video.srcObject = null; - this.video.muted = false; - if (this.multiStreamsMixer != null) { - this.multiStreamsMixer.close(); - } - } - - private gotIceCandidate(event: RTCPeerConnectionIceEvent) { - if (event.candidate != null) { - this.logger.debug('gotIceCandidate: ' + JSON.stringify({'ice': event.candidate})); - } - } - - private onPublishingIceConnectionStateChange(event: Event) { - let peerConnection = event.currentTarget as RTCPeerConnection; - let state = peerConnection.iceConnectionState; - this.publishingIceConnectionState = state; - this.logger.debug("publishingIceConnectionState: " + state); - - if (state === "closed") { - this.audioActivityDisplay.unbind(); - } - if (state === "failed") { - setTimeout(() => this.reconnectForPublishing(), 1000); - } - - this.updateUi(); - } - - private updateUi() { - const nonLoadingIceStates: WebRtcState[] = ['connected', 'completed', 'closed']; - let loading: boolean = this.publishingSignalingWsConnection != null || nonLoadingIceStates.indexOf(this.publishingIceConnectionState) === -1; - - this.$spinnerContainer.toggleClass("hidden", !loading); - this.$audioActivityDisplayContainer.toggleClass("hidden", this.publishingIceConnectionState !== "completed"); - } - - private addAudio(sdpStr: string, audioLine: string) { - var sdpLines = sdpStr.split(/\r\n/); - var sdpStrRet = ''; - var done = false; - - for (var sdpIndex in sdpLines) { - var sdpLine = sdpLines[sdpIndex]; - - if (sdpLine.length <= 0) - continue; - - - sdpStrRet += sdpLine; - sdpStrRet += '\r\n'; - - if ('a=rtcp-mux'.localeCompare(sdpLine) == 0 && done == false) { - sdpStrRet += audioLine; - done = true; - } - - - } - return sdpStrRet; - } - - private addVideo(sdpStr: string, videoLine: string) { - var sdpLines = sdpStr.split(/\r\n/); - var sdpSection = 'header'; - var hitMID = false; - var sdpStrRet = ''; - var done = false; - - var rtcpSize = false; - var rtcpMux = false; - - for (var sdpIndex in sdpLines) { - var sdpLine = sdpLines[sdpIndex]; - - if (sdpLine.length <= 0) - continue; - - if (sdpLine.includes("a=rtcp-rsize")) { - rtcpSize = true; - } - - if (sdpLine.includes("a=rtcp-mux")) { - rtcpMux = true; - } - - } - - for (var sdpIndex in sdpLines) { - var sdpLine = sdpLines[sdpIndex]; - - sdpStrRet += sdpLine; - sdpStrRet += '\r\n'; - - if (('a=rtcp-rsize'.localeCompare(sdpLine) == 0) && done == false && rtcpSize == true) { - sdpStrRet += videoLine; - done = true; - } - - if ('a=rtcp-mux'.localeCompare(sdpLine) == 0 && done == true && rtcpSize == false) { - sdpStrRet += videoLine; - done = true; - } - - if ('a=rtcp-mux'.localeCompare(sdpLine) == 0 && done == false && rtcpSize == false) { - done = true; - } - - } - return sdpStrRet; - } - - private enhanceSDP(sdpStr: string, enhanceData: SdpDescriptorEnhancingData) { - let sdpLines = sdpStr.split(/\r\n/); - let sdpSection = 'header'; - let hitMID = false; - let sdpStrRet = ''; - - //console.log("Original SDP: "+sdpStr); - - // Firefox provides a reasonable SDP, Chrome is just odd - // so we have to doing a little mundging to make it all work - let audioIndex: number; - let videoIndex: number; - if (!sdpStr.includes("THIS_IS_SDPARTA") || this.publishingSettings.mediaSettings.videoCodec === UiVideoCodec.VP9) { - let sdpOutput = {}; - for (let sdpIndex in sdpLines) { - const sdpLine = sdpLines[sdpIndex]; - - if (sdpLine.length <= 0) - continue; - - var doneCheck = this.checkLine(sdpLine, sdpOutput); - if (!doneCheck) - continue; - - sdpStrRet += sdpLine; - sdpStrRet += '\r\n'; - - } - let audio: string; - ({outputString: audio, line: audioIndex} = this.deliverCheckLine(UiAudioCodec[this.publishingSettings.mediaSettings.audioCodec], "audio", sdpOutput)); - sdpStrRet = this.addAudio(sdpStrRet, audio); - let video: string; - ({outputString: video, line: videoIndex} = this.deliverCheckLine(UiVideoCodec[this.publishingSettings.mediaSettings.videoCodec], "video", sdpOutput)); - sdpStrRet = this.addVideo(sdpStrRet, video); - sdpStr = sdpStrRet; - sdpLines = sdpStr.split(/\r\n/); - sdpStrRet = ''; - } - - for (var sdpIndex in sdpLines) { - var sdpLine = sdpLines[sdpIndex]; - - if (sdpLine.length <= 0) - continue; - - if (sdpLine.indexOf("m=audio") == 0 && audioIndex != -1) { - let audioMLines = sdpLine.split(" "); - sdpStrRet += audioMLines[0] + " " + audioMLines[1] + " " + audioMLines[2] + " " + audioIndex + "\r\n"; - continue; - } - - if (sdpLine.indexOf("m=video") == 0 && videoIndex != -1) { - let videoMLines = sdpLine.split(" "); - sdpStrRet += videoMLines[0] + " " + videoMLines[1] + " " + videoMLines[2] + " " + videoIndex + "\r\n"; - continue; - } - sdpStrRet += sdpLine; - - if (sdpLine.indexOf("m=audio") === 0) { - sdpSection = 'audio'; - hitMID = false; - } else if (sdpLine.indexOf("m=video") === 0) { - sdpSection = 'video'; - hitMID = false; - } else if (sdpLine.indexOf("a=rtpmap") == 0) { - sdpSection = 'bandwidth'; - hitMID = false; - } - - if (sdpLine.indexOf("a=mid:") === 0 || sdpLine.indexOf("a=rtpmap") == 0) { - if (!hitMID) { - if ('audio'.localeCompare(sdpSection) == 0) { - if (enhanceData.audioBitrate !== undefined) { - sdpStrRet += '\r\nb=CT:' + (enhanceData.audioBitrate); - sdpStrRet += '\r\nb=AS:' + (enhanceData.audioBitrate); - } - hitMID = true; - } else if ('video'.localeCompare(sdpSection) == 0) { - if (enhanceData.videoBitrate !== undefined) { - sdpStrRet += '\r\nb=CT:' + (enhanceData.videoBitrate); - sdpStrRet += '\r\nb=AS:' + (enhanceData.videoBitrate); - if (enhanceData.videoFrameRate !== undefined) { - sdpStrRet += '\r\na=framerate:' + enhanceData.videoFrameRate; - } - } - hitMID = true; - } else if ('bandwidth'.localeCompare(sdpSection) == 0) { - let rtpmapID; - rtpmapID = this.getRtpMapID(sdpLine); - if (rtpmapID !== null) { - const match = rtpmapID[2].toLowerCase(); - if (('vp9'.localeCompare(match) == 0) || ('vp8'.localeCompare(match) == 0) || ('h264'.localeCompare(match) == 0) || - ('red'.localeCompare(match) == 0) || ('ulpfec'.localeCompare(match) == 0) || ('rtx'.localeCompare(match) == 0)) { - if (enhanceData.videoBitrate !== undefined) { - sdpStrRet += '\r\na=fmtp:' + rtpmapID[1] + ' x-google-min-bitrate=' + (enhanceData.videoBitrate) + ';x-google-max-bitrate=' + (enhanceData.videoBitrate); - } - } - - if (('opus'.localeCompare(match) == 0) || ('isac'.localeCompare(match) == 0) || ('g722'.localeCompare(match) == 0) || ('pcmu'.localeCompare(match) == 0) || - ('pcma'.localeCompare(match) == 0) || ('cn'.localeCompare(match) == 0)) { - if (enhanceData.audioBitrate !== undefined) { - sdpStrRet += '\r\na=fmtp:' + rtpmapID[1] + ' x-google-min-bitrate=' + (enhanceData.audioBitrate) + ';x-google-max-bitrate=' + (enhanceData.audioBitrate); - } - } - } - } - } - } - sdpStrRet += '\r\n'; - } - // this.logger.debug("Resulting SDP (offer): "+sdpStrRet); - return sdpStrRet; - } - - private deliverCheckLine(profile: string, type: "audio" | "video", SDPOutput: { [x: number]: string }): { - outputString: string, - line: number - } { - - if (profile === "H264") { - profile = "42e01f"; - } - - var outputString = ""; - for (var line in SDPOutput) { - var lineInUse = SDPOutput[line]; - outputString += lineInUse; - if (type === "audio" && lineInUse.toLowerCase().includes((profile.toLowerCase())) - || type === "video" && lineInUse.toLowerCase().includes(profile.toLowerCase())) { - if (profile === "VP9" || profile === "VP8") { - var output = ""; - var outputs = lineInUse.split(/\r\n/); - for (var position in outputs) { - var transport = outputs[position]; - if (transport.indexOf("transport-cc") !== -1 || transport.indexOf("goog-remb") !== -1 || transport.indexOf("nack") !== -1) { - continue; - } - output += transport; - output += "\r\n"; - } - return {outputString: output, line: parseInt(line)}; - } - return {outputString: lineInUse, line: parseInt(line)}; - } - } - return {outputString: "", line: -1}; - } - - private checkLine(line: string, SDPOutput: { [x: number]: string }) { - if (line.startsWith("a=rtpmap") || line.startsWith("a=rtcp-fb") || line.startsWith("a=fmtp")) { - var res = line.split(":"); - - if (res.length > 1) { - var number = res[1].split(" "); - if (!isNaN(number[0] as any)) // TODO use parseInt!! - { - if (!number[1].startsWith("http") && !number[1].startsWith("ur")) { - var currentString = SDPOutput[number[0] as any as number]; - if (!currentString) { - currentString = ""; - } - currentString += line + "\r\n"; - SDPOutput[number[0] as any as number] = currentString; - return false; - } - } - } - } - - return true; - } - - private getRtpMapID(line: string) { - const findid = new RegExp('a=rtpmap:(\\d+) (\\w+)/(\\d+)'); - const found = line.match(findid); - return (found && found.length >= 3) ? found : null; - } - - private errorHandler(error: any) { - this.logger.error(error); - } - - public setBackgroundImageUrl(backgroundImageUrl: string) { - this.video.style.backgroundImage = `url(${backgroundImageUrl})`; - } - - public onResize(): void { - applyDisplayMode(this.$main, this.$videoContainer, UiPageDisplayMode.FIT_SIZE, { - innerPreferedDimensions: { - width: this.video.videoWidth, - height: this.video.videoHeight - } - }); - } - - destroy(): void { - this.unPublish(); - } - - getMainDomElement(): JQuery { - return this.$main; - } - -} - -navigator.getUserMedia = navigator.getUserMedia || (navigator as any).mozGetUserMedia || (navigator as any).webkitGetUserMedia; -(window as any).RTCPeerConnection = (window as any).RTCPeerConnection || (window as any).mozRTCPeerConnection || (window as any).webkitRTCPeerConnection; -(window as any).RTCIceCandidate = (window as any).RTCIceCandidate || (window as any).mozRTCIceCandidate || (window as any).webkitRTCIceCandidate; -(window as any).RTCSessionDescription = (window as any).RTCSessionDescription || (window as any).mozRTCSessionDescription || (window as any).webkitRTCSessionDescription; - -TeamAppsUiComponentRegistry.registerComponentClass("UiWebRtcPublisher", UiWebRtcPublisher); diff --git a/teamapps-client/ts/modules/UiWindow.ts b/teamapps-client/ts/modules/UiWindow.ts index b31ff7de8..40f0403f1 100644 --- a/teamapps-client/ts/modules/UiWindow.ts +++ b/teamapps-client/ts/modules/UiWindow.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,61 +17,69 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; + import {UiPanel} from "./UiPanel"; import {UiPanelHeaderFieldConfig} from "../generated/UiPanelHeaderFieldConfig"; -import {UiComponent} from "./UiComponent"; +import {AbstractUiComponent} from "./AbstractUiComponent"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; -import {UiWindowCommandHandler, UiWindowConfig, UiWindowEventSource} from "../generated/UiWindowConfig"; +import {UiWindow_ClosedEvent, UiWindowCommandHandler, UiWindowConfig, UiWindowEventSource} from "../generated/UiWindowConfig"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; -import {keyCodes} from "trivial-components"; -import {UiColorConfig} from "../generated/UiColorConfig"; -import {createUiColorCssString} from "./util/CssFormatUtil"; +import {keyCodes} from "./trivial-components/TrivialCore"; import {UiToolbar} from "./tool-container/toolbar/UiToolbar"; import {UiToolButton} from "./micro-components/UiToolButton"; import {TeamAppsEvent} from "./util/TeamAppsEvent"; import {UiPanel_WindowButtonClickedEvent} from "../generated/UiPanelConfig"; -import {EventFactory} from "../generated/EventFactory"; import {UiWindowButtonType} from "../generated/UiWindowButtonType"; +import {animateCSS, Constants, css, parseHtml} from "./Common"; +import {UiComponent} from "./UiComponent"; +import {UiExitAnimation} from "../generated/UiExitAnimation"; +import {UiEntranceAnimation} from "../generated/UiEntranceAnimation"; +import {viewport} from "@popperjs/core"; -export interface UiWindowListener { - onWindowClosed: (window: UiWindow, animationDuration: number) => void; -} - -export class UiWindow extends UiComponent implements UiWindowCommandHandler, UiWindowEventSource { +export class UiWindow extends AbstractUiComponent implements UiWindowCommandHandler, UiWindowEventSource { - public readonly onWindowButtonClicked: TeamAppsEvent = new TeamAppsEvent(this); + public readonly onWindowButtonClicked: TeamAppsEvent = new TeamAppsEvent(); + public readonly onClosed: TeamAppsEvent = new TeamAppsEvent(); - private $main: JQuery; - private $panelWrapper: JQuery; - private listener: UiWindowListener; + private $main: HTMLElement; + private $panelWrapper: HTMLElement; + private $resizers: HTMLElement[]; private panel: UiPanel; private escapeKeyListener: (e: KeyboardEvent) => void; private clickOutsideListener: (e: MouseEvent) => void; - private closeable: boolean; - private closeOnEscape: boolean; - private closeOnClickOutside: boolean; - private modal: boolean; - private modalBackgroundDimmingColor: UiColorConfig; + private windowResizeListener: (e: Event) => void; constructor(config: UiWindowConfig, context: TeamAppsUiContext) { super(config, context); - this.$main = $(`
+ this.$main = parseHtml(`
+
+
+
+
+
+
+
+
`); - this.$panelWrapper = this.$main.find(">.panel-wrapper"); + this.$panelWrapper = this.$main.querySelector(":scope >.panel-wrapper"); + this.$resizers = Array.from(this.$panelWrapper.querySelectorAll(":scope > .resizer")); this.panel = new UiPanel(config, context); - this.panel.getMainDomElement().appendTo(this.$panelWrapper); + this.$panelWrapper.appendChild(this.panel.getMainElement()); if (config.closeable) { this.panel.addWindowButton(UiWindowButtonType.CLOSE); } - this.panel.getWindowButton(UiWindowButtonType.CLOSE).onClicked.addListener(() => this.close(500)); - this.panel.onWindowButtonClicked.addListener(eventObject => this.onWindowButtonClicked.fire(EventFactory.createUiPanel_WindowButtonClickedEvent(this.getId(), eventObject.windowButton))); + this.panel.getWindowButton(UiWindowButtonType.CLOSE).onClicked.addListener(() => this.close(500, true)); + this.panel.onWindowButtonClicked.addListener(eventObject => { + return this.onWindowButtonClicked.fire({ + windowButton: eventObject.windowButton + }); + }); this.setSize(config.width, config.height); this.setModal(config.modal); @@ -79,69 +87,69 @@ export class UiWindow extends UiComponent implements UiWindowCom this.setCloseable(config.closeable); this.setCloseOnEscape(config.closeOnEscape); this.setCloseOnClickOutside(config.closeOnClickOutside); - } - protected onAttachedToDom() { - if (this.panel) this.panel.attachedToDom = true; + const $panelHeading = this.panel.getMainElement().querySelector(":scope > .panel-heading") + $panelHeading.addEventListener("mousedown", (e: MouseEvent) => this.handleTitlebarMousedown(e)) + + for (let resizer of this.$resizers) { + resizer.addEventListener("mousedown", (e: MouseEvent) => this.handleResizerMousedown(resizer, e)) + } + + this.windowResizeListener = () => this.assureInViewPort(); + window.addEventListener("resize", this.windowResizeListener); + + this.setResizable(this._config.resizable); } + + public show(animationDuration: number) { - this.$main.css({ - transition: `opacity ${animationDuration}ms` - }); - this.$panelWrapper.css({ - transition: `box-shadow ${animationDuration}ms, transform ${animationDuration}ms` - }); - this.$main.removeClass("offscreen"); + document.body.appendChild(this.getMainElement()); + + this.$main.classList.remove("hidden"); + this.$main.classList.add("open"); + animateCSS(this.$panelWrapper, Constants.ENTRANCE_ANIMATION_CSS_CLASSES[UiEntranceAnimation.ZOOM_IN], animationDuration); this.escapeKeyListener = (e) => { - if (this.closeOnEscape && e.keyCode === keyCodes.escape) { - this.close(500); + if (this._config.closeOnEscape && e.keyCode === keyCodes.escape) { + this.close(animationDuration, true); } }; document.body.addEventListener("keydown", this.escapeKeyListener, {capture: true}); this.clickOutsideListener = (e) => { - if (this.closeOnClickOutside && e.target === this.$main[0]) { - this.close(500); + if (this._config.closeOnClickOutside && e.target === this.$main) { + this.close(animationDuration, true); } }; - this.$main[0].addEventListener("click", this.clickOutsideListener) - } - - public hide(animationDuration: number) { - this.$main.css({ - transition: `opacity ${animationDuration}ms` - }); - this.$panelWrapper.css({ - transition: `box-shadow ${animationDuration}ms, transform ${animationDuration}ms` - }); - this.$main.addClass("offscreen"); - this.$main.css({ - "pointer-events": "none" - }); - this.removeCloseEventListeners(); + this.$main.addEventListener("click", this.clickOutsideListener) } - private removeCloseEventListeners() { + private removeEventListeners() { if (this.escapeKeyListener) { - document.body.removeEventListener("keydown", this.escapeKeyListener, {capture: true}) + document.body.removeEventListener("keydown", this.escapeKeyListener, {capture: true}); } if (this.clickOutsideListener) { - this.$main[0].removeEventListener("click", this.clickOutsideListener) + this.$main.removeEventListener("click", this.clickOutsideListener); } + window.removeEventListener("resize", this.windowResizeListener); } - public getMainDomElement(): JQuery { + public doGetMainElement(): HTMLElement { return this.$main; } - public setListener(listener: UiWindowListener) { - this.listener = listener; - } - - public close(animationDuration: number) { - this.listener.onWindowClosed(this, animationDuration); + public close(animationDuration: number, fireEvent: boolean = false) { + this.setMaximized(false); + this.$main.classList.remove("open"); + animateCSS(this.$panelWrapper, Constants.EXIT_ANIMATION_CSS_CLASSES[UiExitAnimation.ZOOM_OUT], animationDuration, () => { + this.$main.classList.add("hidden"); + this.getMainElement().remove(); + }); + this.removeEventListeners(); + if (fireEvent) { + this.onClosed.fire({}); + } } public setContent(content: UiComponent) { @@ -176,16 +184,20 @@ export class UiWindow extends UiComponent implements UiWindowCom this.panel.setTitle(title); } + public setBadge(badge: string) { + this.panel.setBadge(badge); + } + public setCloseOnClickOutside(closeOnClickOutside: boolean): void { - this.closeOnClickOutside = closeOnClickOutside; + this._config.closeOnClickOutside = closeOnClickOutside; } public setCloseOnEscape(closeOnEscape: boolean): void { - this.closeOnEscape = closeOnEscape; + this._config.closeOnEscape = closeOnEscape; } public setCloseable(closeable: boolean): void { - this.closeable = closeable; + this._config.closeable = closeable; if (closeable) { this.panel.addWindowButton(UiWindowButtonType.CLOSE); } else { @@ -193,24 +205,20 @@ export class UiWindow extends UiComponent implements UiWindowCom } } - public setModalBackgroundDimmingColor(modalBackgroundDimmingColor: UiColorConfig): void { - this.modalBackgroundDimmingColor = modalBackgroundDimmingColor; - this.$main.css("background-color", createUiColorCssString(modalBackgroundDimmingColor)); + public setModalBackgroundDimmingColor(modalBackgroundDimmingColor: string): void { + this._config.modalBackgroundDimmingColor = modalBackgroundDimmingColor; + this.$main.style.backgroundColor = modalBackgroundDimmingColor; } public setModal(modal: boolean) { - this.modal = modal; - this.$main.toggleClass('modal', this.modal); - } - - public isModal() { - return this.modal; + this._config.modal = modal; + this.$main.classList.toggle('modal', modal); } public setSize(width: number, height: number): void { - this.$panelWrapper.css({ + css(this.$panelWrapper, { width: width ? width + "px" : "100%", - height: height ? height + "px" : "100%" + height: height === 0 ? "100%" : height < 0 ? "auto" : height + "px" }); } @@ -218,18 +226,152 @@ export class UiWindow extends UiComponent implements UiWindowCom this.panel.setStretchContent(stretch); } - public onResize(): void { - this.panel.reLayout(); + public destroy(): void { + super.destroy(); + this.removeEventListeners(); } - public destroy(): void { - this.removeCloseEventListeners(); + + setWindowButtons(windowButtons: UiWindowButtonType[]): void { } + private handleTitlebarMousedown(e: MouseEvent) { + if (!this._config.movable) { + return; + } + const initialRect = getBoundingPageRect(this.$panelWrapper); + const initialMouseX = e.pageX + const initialMouseY = e.pageY + + const mousemove = (e: MouseEvent) => { + const totalDeltaX = e.pageX - initialMouseX; + const totalDeltaY = e.pageY - initialMouseY; + const viewPortRect = getViewportRect(); - setWindowButtons(windowButtons: UiWindowButtonType[]): void { + const minX = this._config.keepInViewport ? viewPortRect.left : Number.NEGATIVE_INFINITY; + const maxX = this._config.keepInViewport ? viewPortRect.right - initialRect.width : Number.POSITIVE_INFINITY; + const minY = this._config.keepInViewport ? viewPortRect.top : Number.NEGATIVE_INFINITY; + const maxY = this._config.keepInViewport ? viewPortRect.bottom - initialRect.height : Number.POSITIVE_INFINITY; + + this.$panelWrapper.style.left = Math.min(maxX, Math.max(minX, initialRect.x + totalDeltaX)) + "px" + this.$panelWrapper.style.top = Math.min(maxY, Math.max(minY, initialRect.y + totalDeltaY)) + "px" + } + + const mouseup = () => { + window.removeEventListener("mousemove", mousemove) + window.removeEventListener("mouseup", mouseup) + } + + window.addEventListener("mousemove", mousemove) + window.addEventListener("mouseup", mouseup) + } + + private handleResizerMousedown(resizer: HTMLElement, e: MouseEvent) { + if (!this._config.resizable) { + return; + } + const initialRect = getBoundingPageRect(this.$panelWrapper); + const initialMouseX = e.pageX + const initialMouseY = e.pageY + + const mousemove = (e: MouseEvent) => { + const totalDeltaX = e.pageX - initialMouseX; + const totalDeltaY = e.pageY - initialMouseY; + + const viewPortRect = getViewportRect(); + + if (resizer.classList.contains("t")) { + const minYDueToViewPort = this._config.keepInViewport ? viewPortRect.top : Number.NEGATIVE_INFINITY; + const maxYDueToMinHeight = initialRect.bottom - this._config.minHeight; + + const newTop = Math.max(minYDueToViewPort, Math.min(maxYDueToMinHeight, initialRect.y + totalDeltaY)); + const newHeight = Math.max(this._config.minHeight, initialRect.bottom - newTop); + + this.$panelWrapper.style.top = newTop + "px" + this.$panelWrapper.style.height = newHeight + "px" + } + if (resizer.classList.contains("b")) { + const maxHeight = this._config.keepInViewport ? viewPortRect.bottom - initialRect.top : Number.POSITIVE_INFINITY; + const newHeight = Math.min(maxHeight, Math.max(this._config.minHeight, initialRect.height + totalDeltaY)); + this.$panelWrapper.style.height = newHeight + "px"; + } + if (resizer.classList.contains("l")) { + const minXDueToViewPort = this._config.keepInViewport ? viewPortRect.left : Number.NEGATIVE_INFINITY; + const maxXDueToMinWidth = initialRect.right - this._config.minWidth; + + const newLeft = Math.max(minXDueToViewPort, Math.min(maxXDueToMinWidth, initialRect.x + totalDeltaX)); + const newWidth = Math.max(this._config.minWidth, initialRect.right - newLeft); + + this.$panelWrapper.style.left = newLeft + "px" + this.$panelWrapper.style.width = newWidth + "px" + } + if (resizer.classList.contains("r")) { + const maxWidth = this._config.keepInViewport ? viewPortRect.right - initialRect.left : Number.POSITIVE_INFINITY; + const newWidth = Math.min(maxWidth, Math.max(this._config.minWidth, initialRect.width + totalDeltaX)); + this.$panelWrapper.style.width = newWidth + "px"; + } + } + + const mouseup = () => { + window.removeEventListener("mousemove", mousemove) + window.removeEventListener("mouseup", mouseup) + } + + window.addEventListener("mousemove", mousemove) + window.addEventListener("mouseup", mouseup) + } + + private assureInViewPort() { + const windowRect = getBoundingPageRect(this.$panelWrapper); + const viewPortRect = getViewportRect(); + + if (windowRect.right > viewPortRect.right) { + this.$panelWrapper.style.left = viewPortRect.right - windowRect.width + "px"; + } + if (windowRect.bottom > viewPortRect.bottom) { + this.$panelWrapper.style.top = viewPortRect.bottom - windowRect.height + "px"; + } + } + + public setResizable(resizable: boolean) { + this._config.resizable = resizable; + this.$panelWrapper.classList.toggle("resizable", resizable); } } TeamAppsUiComponentRegistry.registerComponentClass("UiWindow", UiWindow); + + +function getBoundingPageRect(element: HTMLElement) { + const rect = element.getBoundingClientRect(); + const scrollLeft = window.scrollX || document.documentElement.scrollLeft; + const scrollTop = window.scrollY || document.documentElement.scrollTop; + + return { + left: rect.left + scrollLeft, + top: rect.top + scrollTop, + x: rect.left + scrollLeft, + y: rect.top + scrollTop, + right: rect.right + scrollLeft, + bottom: rect.bottom + scrollTop, + width: rect.width, + height: rect.height + }; +} + +function getViewportRect() { + const scrollLeft = window.scrollX || document.documentElement.scrollLeft; + const scrollTop = window.scrollY || document.documentElement.scrollTop; + return { + left: scrollLeft, + top: scrollTop, + x: scrollLeft, + y: scrollTop, + right: scrollLeft + window.innerWidth, + bottom: scrollTop + window.innerHeight, + width: window.innerWidth, + height: window.innerHeight + }; +} + diff --git a/teamapps-client/ts/modules/WebWorkerTeamAppsConnection.ts b/teamapps-client/ts/modules/WebWorkerTeamAppsConnection.ts deleted file mode 100644 index 4e2611b93..000000000 --- a/teamapps-client/ts/modules/WebWorkerTeamAppsConnection.ts +++ /dev/null @@ -1,55 +0,0 @@ -/*- - * ========================LICENSE_START================================= - * TeamApps - * --- - * Copyright (C) 2014 - 2019 TeamApps.org - * --- - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * =========================LICENSE_END================================== - */ -import {UiClientInfoConfig} from "../generated/UiClientInfoConfig"; -import {TeamAppsConnection, TeamAppsConnectionListener} from "../shared/TeamAppsConnection"; - -/// - -export class WebWorkerTeamAppsConnection implements TeamAppsConnection { - - private worker: Worker; - - constructor(webSocketUrl: string, sessionId: string, clientInfo: UiClientInfoConfig, commandHandler: TeamAppsConnectionListener) { - // let x = WebpackWorker; - // console.log(x); - // this.worker = new (WebpackWorker as any)() as Worker; // TODO use webpack to set this path! - this.worker = new Worker("/js/communication-worker.js"); - this.worker.postMessage({webSocketUrl, sessionId, clientInfo}); - - this.worker.onmessage = (e: MessageEvent): any => { - if (e.data._type === 'onCommand') { - commandHandler.executeCommand(e.data.uiCommand); - } else if (e.data._type === 'onCommands') { - commandHandler.executeCommands(e.data.uiCommands); - } else if (e.data._type === 'onConnectionBroken') { - commandHandler.onConnectionErrorOrBroken(e.data.reason, e.data.message); - } else if (e.data._type === 'onConnectionInitialized') { - commandHandler.onConnectionInitialized(); - } else { - console.error("Unknown type of message received from web worker: " + JSON.stringify(e.data)); - } - }; - } - - sendEvent(event: any): void { - this.worker.postMessage({event}); - } - -} diff --git a/teamapps-client/ts/modules/charting/AbstractUiGraph.ts b/teamapps-client/ts/modules/charting/AbstractUiGraph.ts new file mode 100644 index 000000000..611ab4a0f --- /dev/null +++ b/teamapps-client/ts/modules/charting/AbstractUiGraph.ts @@ -0,0 +1,200 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +import {ScaleContinuousNumeric, ScaleTime} from "d3-scale"; +import {SVGSelection} from "./Charting"; +import {UiLineChartYScaleZoomMode} from "../../generated/UiLineChartYScaleZoomMode"; +import * as d3 from "d3"; +import {NamespaceLocalObject} from "d3"; +import {UiScaleType} from "../../generated/UiScaleType"; +import {UiGraph} from "./UiGraph"; +import {maxTickIntegerPartLength, YAxis} from "./YAxis"; +import {UiGraphConfig} from "../../generated/UiGraphConfig"; +import {UiGraphDataConfig} from "../../generated/UiGraphDataConfig"; +import * as ulp from "ulp"; + +export abstract class AbstractUiGraph + implements UiGraph { + + protected config: C; + + protected scaleY: ScaleContinuousNumeric; + private yAxis: YAxis; + + protected zoomLevelIndex: number; + protected scaleX: ScaleTime; + protected $main: SVGSelection; + + constructor( + config: C, + protected timeGraphId: string + ) { + this.yAxis = new YAxis({ + color: config.yAxisColor, + label: config.yAxisLabel, + maxTickDigits: config.maxTickDigits + }); + this.setConfig(config) + this.$main = d3.select(document.createElementNS((d3.namespace("svg:text") as NamespaceLocalObject).space, "g") as SVGGElement) + .attr("data-series-id", `${this.timeGraphId}-${this.config.id}`); + } + + protected abstract doRedraw(): void; + + public abstract getUncoveredIntervals(zoomLevel: number, interval: [number, number]): [number, number][]; + + public abstract markIntervalAsCovered(zoomLevel: number, interval: [number, number]): void; + + public abstract addData(zoomLevel: number, data: D): void; + + public abstract resetData(): void; + + public abstract getYDataBounds(xInterval: [number, number]): [number, number]; + + getMainSelection() { + return this.$main; + } + + public updateZoomX(zoomLevelIndex: number, scaleX: ScaleTime) { + this.zoomLevelIndex = zoomLevelIndex; + this.scaleX = scaleX; + } + + public redraw() { + let scaleYDomain = this.getScaleYDomain(); + + if (scaleYDomain != null && (scaleYDomain.minY !== this.scaleY.domain()[0] || scaleYDomain.maxY !== this.scaleY.domain()[1])) { + d3.transition(`${this.timeGraphId}-${this.config.id}-zoomYToDisplayedDomain`) + .ease(d3.easeLinear) + .duration(300) + .tween(`${this.timeGraphId}-${this.config.id}-zoomYToDisplayedDomain`, () => { + // create interpolator and do not show nasty floating numbers + let intervalInterpolator = d3.interpolateArray(this.scaleY.domain(), [scaleYDomain.minY, scaleYDomain.maxY]); + return (t: number) => { + this.scaleY.domain(intervalInterpolator(t)); + this.yAxis.draw(); + this.doRedraw(); + } + }); + } else { + this.yAxis.draw(); + this.doRedraw(); + } + } + + private getScaleYDomain() { + let minY: number, maxY: number; + + function crossesZero(bound: number, margin: number) { + return Math.sign(bound + margin) !== Math.sign(bound); + } + + let displayedDataYBounds = this.getYDataBounds(this.getDisplayedIntervalX()); + displayedDataYBounds = displayedDataYBounds[0] === undefined || displayedDataYBounds[1] === undefined ? [0, 1] : displayedDataYBounds; + if (this.config.yScaleZoomMode === UiLineChartYScaleZoomMode.DYNAMIC) { + let margin = (displayedDataYBounds[1] - displayedDataYBounds[0]) * .05; + minY = crossesZero(displayedDataYBounds[0], -margin) ? displayedDataYBounds[0] : displayedDataYBounds[0] - margin; + maxY = crossesZero(displayedDataYBounds[1], margin) ? displayedDataYBounds[1] : displayedDataYBounds[1] + margin; + } else if (this.config.yScaleZoomMode === UiLineChartYScaleZoomMode.DYNAMIC_INCLUDING_ZERO) { + let margin = (displayedDataYBounds[1] - displayedDataYBounds[0]) * .05; + minY = displayedDataYBounds[0] >= 0 ? 0 : displayedDataYBounds[0] - margin; + maxY = displayedDataYBounds[1] <= 0 ? 0 : displayedDataYBounds[1] + margin; + } else { + minY = this.config.intervalY.min; + maxY = this.config.intervalY.max; + } + + // if we use an auto-scaling zoom mode, we want at least two ticks to have a significant value. + if (minY != maxY && (this.config.yScaleZoomMode === UiLineChartYScaleZoomMode.DYNAMIC + || this.config.yScaleZoomMode === UiLineChartYScaleZoomMode.DYNAMIC_INCLUDING_ZERO)) { + let numberOfTicks = 3; + while (true) { + const integerPartLength = maxTickIntegerPartLength(minY, maxY, numberOfTicks); + const inc: number = d3.tickIncrement(minY, maxY, numberOfTicks); + const numberOfDigitsAddedByTickIncrements = inc < 0 ? Math.ceil(Math.log10(-inc)) : 0; + const numberOfSignificantDigits = integerPartLength + numberOfDigitsAddedByTickIncrements; + if (integerPartLength >= this.config.maxTickDigits) { + break; + } else if (numberOfSignificantDigits > this.config.maxTickDigits) { + const delta = maxY - minY; + let boundaryExpansion = Math.max(delta / 4, 1e-14); + const nextMinY = Math.min(minY - boundaryExpansion, ulp.nextDown(minY)) + const nextMaxY = Math.max(maxY + boundaryExpansion, ulp.nextUp(maxY)) + console.debug(`Increasing dy from (${minY}:${maxY}) to (${nextMinY}:${nextMaxY})`) + minY = nextMinY; + maxY = nextMaxY; + } else { + break; + } + } + } + + return {minY, maxY}; + } + + private updateYScale(): void { + const oldScaleY = this.scaleY; + if (this.config.yScaleType === UiScaleType.SYMLOG) { + this.scaleY = d3.scaleSymlog(); + } else if (this.config.yScaleType === UiScaleType.LOG10) { + this.scaleY = d3.scaleLog(); + } else { + this.scaleY = d3.scaleLinear(); + } + if (oldScaleY != null) { + this.scaleY.range(oldScaleY.range()); + } + this.yAxis.setScale(this.scaleY); + } + + setYRange(range: [number, number]) { + this.scaleY.range(range); + this.yAxis.setScale(this.scaleY); + } + + public setConfig(config: C) { + this.config = config; + this.yAxis.setConfig({ + color: config.yAxisColor, + label: config.yAxisLabel, + maxTickDigits: config.maxTickDigits + }) + this.updateYScale(); + } + + protected getDisplayedIntervalX(): [number, number] { + return [+(this.scaleX.domain()[0]), +(this.scaleX.domain()[1])]; + } + + + public destroy(): void { + this.yAxis.getSelection().remove(); + this.$main.remove(); + } + + getConfig() { + return this.config; + } + + getYAxis(): YAxis { + return this.yAxis; + } + +} diff --git a/teamapps-client/ts/modules/charting/Charting.ts b/teamapps-client/ts/modules/charting/Charting.ts new file mode 100644 index 000000000..6c6f3f6db --- /dev/null +++ b/teamapps-client/ts/modules/charting/Charting.ts @@ -0,0 +1,43 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +import {UiScaleType} from "../../generated/UiScaleType"; +import {UiTimeGraph} from "./UiTimeGraph"; +import {UiLineChartCurveType} from "../../generated/UiLineChartCurveType"; +import * as d3 from "d3"; +import {BaseType, Selection} from "d3"; + +export const CurveTypeToCurveFactory = { + [UiLineChartCurveType.LINEAR]: d3.curveLinear, + [UiLineChartCurveType.STEP]: d3.curveStep, + [UiLineChartCurveType.STEPBEFORE]: d3.curveStepBefore, + [UiLineChartCurveType.STEPAFTER]: d3.curveStepAfter, + [UiLineChartCurveType.BASIS]: d3.curveBasis, + [UiLineChartCurveType.CARDINAL]: d3.curveCardinal, + [UiLineChartCurveType.MONOTONE]: d3.curveMonotoneX, + [UiLineChartCurveType.CATMULLROM]: d3.curveCatmullRom, +}; + +export interface DataPoint { + x: number, + y: number +} + +export type SVGSelection = Selection; +export type SVGGSelection = Selection; diff --git a/teamapps-client/ts/modules/charting/D3Area2.ts b/teamapps-client/ts/modules/charting/D3Area2.ts new file mode 100644 index 000000000..f6e817555 --- /dev/null +++ b/teamapps-client/ts/modules/charting/D3Area2.ts @@ -0,0 +1,121 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +import {CurveFactory, CurveGenerator, curveLinear, line} from "d3-shape"; +import {path, Path} from "d3-path"; + +type DatumToValue = (d: D, i: number, dArr: D[]) => V; + +function constant(x: T) { + return () => x; +} + +export class D3Area2 { + private _x0: DatumToValue = (_: D) => (_ as any)[0]; + private _x1: DatumToValue = null; + private _y0: DatumToValue = constant(0); + private _y1: DatumToValue = (_: D) => (_ as any)[1]; + private _context: any = null; + private _curve: CurveFactory = curveLinear; + private _output: CurveGenerator = null; + + public writePath(dataMin: any[], dataMax: any[]) { + let buffer: Path; + if (this._context == null) { + this._output = this._curve(buffer = path()); + } + + this._output.areaStart(); + this._output.lineStart(); + for (let i = 0; i < dataMax.length; i++) { + this._output.point(+this._x0(dataMax[i], i, dataMax), +this._y0(dataMax[i], i, dataMax)); + } + this._output.lineEnd(); + this._output.lineStart(); + for (let i = dataMin.length - 1; i >= 0; i--) { + this._output.point(+this._x1(dataMin[i], i, dataMin), +this._y1(dataMin[i], i, dataMin)); + } + this._output.lineEnd(); + this._output.areaEnd(); + + if (buffer) { + return this._output = null, buffer + "" || null; + } + } + + private arealine() { + return line().curve(this._curve).context(this._context); + } + + public x(_: DatumToValue) { + this._x0 = this._x1 = typeof _ === "function" ? _ : constant(+_); + return this; + }; + + public x0(_: DatumToValue) { + this._x0 = typeof _ === "function" ? _ : constant(+_); + return this; + }; + + public x1(_: DatumToValue) { + this._x1 = _ == null ? null : typeof _ === "function" ? _ : constant(+_); + return this; + }; + + public y(_: DatumToValue) { + this._y0 = this._y1 = typeof _ === "function" ? _ : constant(+_); + return this; + }; + + public y0(_: DatumToValue) { + this._y0 = typeof _ === "function" ? _ : constant(+_); + return this; + }; + + public y1(_: DatumToValue) { + this._y1 = _ == null ? null : typeof _ === "function" ? _ : constant(+_); + return this; + }; + + public lineX0() { + return this.arealine().x(this._x0).y(this._y0); + }; + + public lineY0 = this.lineX0; + + public lineY1() { + return this.arealine().x(this._x0).y(this._y1); + }; + + public lineX1() { + return this.arealine().x(this._x1).y(this._y0); + }; + + public curve(_: CurveFactory) { + this._curve = _; + this._context != null && (this._output = this._curve(this._context)); + return this; + }; + + public context(_: any) { + _ == null ? this._context = this._output = null : this._output = this._curve(this._context = _); + return this; + }; + +} diff --git a/teamapps-client/ts/modules/charting/DataStore.ts b/teamapps-client/ts/modules/charting/DataStore.ts new file mode 100644 index 000000000..ed8b7b2a6 --- /dev/null +++ b/teamapps-client/ts/modules/charting/DataStore.ts @@ -0,0 +1,161 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +import {UiLongIntervalConfig} from "../../generated/UiLongIntervalConfig"; +import {DataPoint} from "./Charting"; +import {UiLineGraphDataPointConfig} from "../../generated/UiLineGraphDataPointConfig"; +import {UiLineGraphDataConfig} from "../../generated/UiLineGraphDataConfig"; +import {UiGraphDataConfig} from "../../generated/UiGraphDataConfig"; +import {UiIncidentGraphDataConfig} from "../../generated/UiIncidentGraphDataConfig"; +import {UiIncidentGraphDataPointConfig} from "../../generated/UiIncidentGraphDataPointConfig"; +import {Interval, IntervalManager} from "../util/IntervalManager"; + + +abstract class AbstractDataStore { + + private intervalManagerByZoomLevel: Map = new Map(); + private dataObsolete = false; + + public reset(): void { + this.dataObsolete = true; // do not reset store's data directly but wait until the new data has been set! + this.intervalManagerByZoomLevel.clear(); + } + + public getUncoveredIntervals(zoomLevel: number, interval: [number, number]): [number, number][] { + return this.getIntervalManager(zoomLevel).getUncoveredIntervals(interval); + } + + public markIntervalAsCovered(zoomLevel: number, interval: [number, number]): void { + this.getIntervalManager(zoomLevel).addInterval(interval); + } + + public addData(zoomLevel: number, data: D) { + if (this.dataObsolete) { + this.doResetData(); + this.dataObsolete = false; + } + this.getIntervalManager(zoomLevel).addInterval([data.interval.min, data.interval.max]); + this.doAddData(zoomLevel, data); + } + + protected abstract doResetData(): void; + + protected abstract doAddData(zoomLevel: number, data: D): void; + + public abstract getData(zoomLevelIndex: number, intervalX: [number, number]): Omit; + + private getIntervalManager(zoomLevel: number) { + if (!this.intervalManagerByZoomLevel.has(zoomLevel)) { + this.intervalManagerByZoomLevel.set(zoomLevel, new IntervalManager()) + } + return this.intervalManagerByZoomLevel.get(zoomLevel); + } +} + +export class LineGraphDataStore extends AbstractDataStore { + + private zoomLevelData: UiLineGraphDataPointConfig[][] = []; + + protected doResetData() { + this.zoomLevelData = []; + } + + protected doAddData(zoomLevel: number, data: UiLineGraphDataConfig) { + this.assureZoomLevelArrayExists(zoomLevel); + + let interval = [data.interval.min, data.interval.max]; + let zoomLevelData = this.zoomLevelData[zoomLevel]; + let minOverlappingIndex: number = null; + let maxOverlappingIndex: number = null; + for (let i = 0; i < zoomLevelData.length; i++) { + if (zoomLevelData[i].x >= interval[0] && zoomLevelData[i].x <= interval[1]) { + if (minOverlappingIndex == null) { + minOverlappingIndex = i; + } + maxOverlappingIndex = i; + } + } + if (minOverlappingIndex != null && maxOverlappingIndex != null) { + zoomLevelData.splice(minOverlappingIndex, maxOverlappingIndex - minOverlappingIndex + 1, ...data.dataPoints); + } else { + this.zoomLevelData[zoomLevel] = zoomLevelData.concat(data.dataPoints); + } + this.zoomLevelData[zoomLevel].sort((a, b) => a.x - b.x); + } + + public getData(zoomLevelIndex: number, intervalX: [number, number]) { + this.assureZoomLevelArrayExists(zoomLevelIndex); + + let i = 0; + for (; i < this.zoomLevelData[zoomLevelIndex].length; i++) { + if (this.zoomLevelData[zoomLevelIndex][i].x >= intervalX[0]) { + break; + } + } + let startIndex = i === 0 ? 0 : i - 1; + for (; i < this.zoomLevelData[zoomLevelIndex].length; i++) { + if (this.zoomLevelData[zoomLevelIndex][i].x >= intervalX[1]) { + break; + } + } + let endIndex = i === this.zoomLevelData[zoomLevelIndex].length ? i : i + 1; + return {dataPoints: this.zoomLevelData[zoomLevelIndex].slice(startIndex, endIndex)}; + } + + private assureZoomLevelArrayExists(zoomLevel: number) { + if (this.zoomLevelData.length <= zoomLevel) { + let numberOfZoomLevelsToAdd = zoomLevel - this.zoomLevelData.length + 1; + for (let i = 0; i < numberOfZoomLevelsToAdd; i++) { + this.zoomLevelData.push([]) + } + } + } +} + +export class IncidentGraphDataStore extends AbstractDataStore { + + private zoomLevelData: UiIncidentGraphDataPointConfig[][] = []; + + protected doResetData() { + this.zoomLevelData = []; + } + + protected doAddData(zoomLevel: number, data: UiIncidentGraphDataConfig) { + let interval = [data.interval.min, data.interval.max]; + this.assureZoomLevelArrayExists(zoomLevel); + this.zoomLevelData[zoomLevel] = this.zoomLevelData[zoomLevel].filter(dp => { + return !(dp.x2 >= interval[0] && dp.x1 < interval[1]) + }); + this.zoomLevelData[zoomLevel].push(...data.dataPoints); + } + + public getData(zoomLevelIndex: number, intervalX: [number, number]) { + this.assureZoomLevelArrayExists(zoomLevelIndex); + return {dataPoints: this.zoomLevelData[zoomLevelIndex].filter(dp => dp.x2 >= intervalX[0] && dp.x1 < intervalX[1])}; + } + + private assureZoomLevelArrayExists(zoomLevel: number) { + if (this.zoomLevelData.length <= zoomLevel) { + let numberOfZoomLevelsToAdd = zoomLevel - this.zoomLevelData.length + 1; + for (let i = 0; i < numberOfZoomLevelsToAdd; i++) { + this.zoomLevelData.push([]) + } + } + } +} diff --git a/teamapps-client/ts/modules/charting/GraphContext.ts b/teamapps-client/ts/modules/charting/GraphContext.ts new file mode 100644 index 000000000..d5222fc7d --- /dev/null +++ b/teamapps-client/ts/modules/charting/GraphContext.ts @@ -0,0 +1,28 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +export interface GraphContext { + getPopperHandle(): PopperHandle; +} + +export interface PopperHandle { + update(referenceElement: Element, content: Element | string): void; + hide(): void; + destroy(): void; +} diff --git a/teamapps-client/ts/modules/charting/TimeGraphPopper.ts b/teamapps-client/ts/modules/charting/TimeGraphPopper.ts new file mode 100644 index 000000000..c2df3e262 --- /dev/null +++ b/teamapps-client/ts/modules/charting/TimeGraphPopper.ts @@ -0,0 +1,116 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +import { createPopper, Instance as Popper } from '@popperjs/core'; +import {animateCSS, Constants, parseHtml} from "../Common"; +import {UiEntranceAnimation} from "../../generated/UiEntranceAnimation"; + +export class TimeGraphPopper { + + private referenceElement: Element; + private popper: Popper; + private $popperElement: HTMLElement; + private visibilityTimeout: number; + private $contentContainer: HTMLElement; + + constructor() { + this.$popperElement = parseHtml(``); + document.body.appendChild(this.$popperElement); + this.$contentContainer = this.$popperElement.querySelector(":scope .ta-tooltip-inner"); + this.popper = createPopper(document.body, this.$popperElement, { + placement: 'top', + modifiers: [ + { + name: "flip", + options: { + fallbackPlacements: ['bottom', 'right', 'left'] + } + }, + { + name: "preventOverflow" + } , + { + name: "offset", + options: { + offset: [0, 8] + } + }, + { + name: "arrow", + options: { + element: ".ta-tooltip-arrow", // "[data-popper-arrow]" + padding: 10, // 0 + } + } + ] + }); + + this.$popperElement.addEventListener("pointerenter", ev => this.setVisible(false, false)); + } + + public update(referenceElement: Element, content: Element|string) { + let oldReferenceElement = this.referenceElement; + (this.popper as any).state.elements.reference = referenceElement; + this.$contentContainer.innerHTML = ""; + if (typeof content === "string") { + content = parseHtml(content); + } + this.$contentContainer.append(content); + this.setVisible(referenceElement != null && content != null, oldReferenceElement != null && referenceElement != null); + } + + private setVisible(visible: boolean, animate = true) { + window.clearTimeout(this.visibilityTimeout); + + if (visible) { + if (animate) { + this.visibilityTimeout = window.setTimeout(() => { + let wasHidden = this.$popperElement.classList.contains('hidden'); + this.$popperElement.classList.remove("hidden"); + this.popper.update(); + if (wasHidden) { + animateCSS(this.$popperElement, Constants.EXIT_ANIMATION_CSS_CLASSES[UiEntranceAnimation.FADE_IN], 200) + } + }, 200); + } else { + this.$popperElement.classList.remove("hidden"); + this.popper.update(); + } + } else { + if (animate) { + this.visibilityTimeout = window.setTimeout(() => { + this.$popperElement.classList.add("hidden"); + }, 200); + } else { + this.$popperElement.classList.add("hidden"); + } + } + } + + public destroy() { + this.popper.destroy(); + this.$popperElement.remove(); + } +} + + diff --git a/teamapps-client/ts/modules/charting/UiGraph.ts b/teamapps-client/ts/modules/charting/UiGraph.ts new file mode 100644 index 000000000..3e9d24e8c --- /dev/null +++ b/teamapps-client/ts/modules/charting/UiGraph.ts @@ -0,0 +1,51 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +import {ScaleTime} from "d3-scale"; +import {SVGSelection} from "./Charting"; +import {YAxis} from "./YAxis"; +import {UiGraphDataConfig} from "../../generated/UiGraphDataConfig"; +import {UiLongIntervalConfig} from "../../generated/UiLongIntervalConfig"; +import {UiGraphConfig} from "../../generated/UiGraphConfig"; + +export interface UiGraph { + + setConfig(config: C): void; + + getMainSelection(): SVGSelection; + + updateZoomX(zoomLevelIndex: number, scaleX: ScaleTime): void; + + getYAxis(): YAxis | null; + + setYRange(range: [number, number]): void; + + redraw(): void; + + getUncoveredIntervals(zoomLevel: number, interval: [number, number]): [number, number][]; + + markIntervalAsCovered(zoomLevel: number, interval: [number, number]): void; + + addData(zoomLevel: number, data: D): void; + + resetData(): void; + + destroy(): void; + +} diff --git a/teamapps-client/ts/modules/charting/UiGraphGroup.ts b/teamapps-client/ts/modules/charting/UiGraphGroup.ts new file mode 100644 index 000000000..2bdccf1cb --- /dev/null +++ b/teamapps-client/ts/modules/charting/UiGraphGroup.ts @@ -0,0 +1,117 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +import {SVGSelection} from "./Charting"; +import {AbstractUiGraph} from "./AbstractUiGraph"; +import {UiTimeGraph} from "./UiTimeGraph"; +import {ScaleTime} from 'd3'; +import {UiLineChartYScaleZoomMode} from "../../generated/UiLineChartYScaleZoomMode"; +import {UiGraphGroupConfig} from "../../generated/UiGraphGroupConfig"; +import {UiGraphGroupDataConfig} from "../../generated/UiGraphGroupDataConfig"; +import {UiLongIntervalConfig} from "../../generated/UiLongIntervalConfig"; +import * as d3 from "d3"; +import {GraphContext} from "./GraphContext"; +import {IntervalManager} from "../util/IntervalManager"; + +export class UiGraphGroup extends AbstractUiGraph { + + private $yZeroLine: SVGSelection; + private graphs = new Map(); + + constructor( + timeGraphId: string, + config: UiGraphGroupConfig, + private dropShadowFilterId: string, + graphContext: GraphContext + ) { + super(config, timeGraphId); + + this.$main.classed("graph-group", true) + .attr("data-series-ids", `${this.timeGraphId}-${this.config.id}`); + this.initDomNodes(); + + this.config.graphs.forEach(graphConfig => { + graphConfig.yScaleZoomMode = UiLineChartYScaleZoomMode.FIXED; + this.graphs.set(graphConfig.id, UiTimeGraph.createDataDisplay(timeGraphId, graphConfig, dropShadowFilterId, graphContext)); + }) + + this.graphs.forEach(dd => this.$main.node().append(dd.getMainSelection().node())); + } + + getUncoveredIntervals(zoomLevel: number, interval: [number, number]): [number, number][] { + let uncoveredIntervalManager = new IntervalManager(); + this.graphs.forEach(graph => uncoveredIntervalManager.addIntervals(graph.getUncoveredIntervals(zoomLevel, interval))); + return uncoveredIntervalManager.getCoveredIntervals(); // !! + } + + markIntervalAsCovered(zoomLevel: number, interval: [number, number]): void { + this.graphs.forEach(graph => graph.markIntervalAsCovered(zoomLevel, interval)); + } + + addData(zoomLevel: number, data: UiGraphGroupDataConfig): void { + for(let [graphId, graphData] of Object.entries(data.graphDataByGraphId)) { + this.graphs.get(graphId).addData(zoomLevel, graphData); + } + } + + public getYDataBounds(xInterval: [number, number]): [number, number] { + return d3.extent(Array.of(...this.graphs.values()) + .flatMap(graph => graph.getYDataBounds(xInterval))); + } + + resetData(): void { + this.graphs.forEach(g => g.resetData()); + } + + updateZoomX(zoomLevelIndex: number, scaleX: ScaleTime) { + this.graphs.forEach(dd => dd.updateZoomX(zoomLevelIndex, scaleX)); + super.updateZoomX(zoomLevelIndex, scaleX); + } + + private initDomNodes() { + this.$yZeroLine = this.$main.append("line") + .classed("y-zero-line", true); + } + + protected doRedraw() { + this.$yZeroLine + .attr("x1", 0) + .attr("y1", this.scaleY(0)) + .attr("x2", this.scaleX.range()[1]) + .attr("y2", this.scaleY(0)) + .attr("stroke", this.config.yAxisColor) + .attr("visibility", (this.config.yZeroLineVisible && this.scaleY.domain()[0] !== 0) ? "visible" : "hidden"); + + this.graphs.forEach(dd => { + dd.getConfig().intervalY = {min: this.scaleY.domain()[0], max: this.scaleY.domain()[1]}; + dd.redraw() + }); + } + + setYRange(range: [number, number]) { + super.setYRange(range); + this.graphs.forEach(dd => { + dd.setYRange(range); + }); + } + +} + + + diff --git a/teamapps-client/ts/modules/charting/UiHoseGraph.ts b/teamapps-client/ts/modules/charting/UiHoseGraph.ts new file mode 100644 index 000000000..082f6c8d6 --- /dev/null +++ b/teamapps-client/ts/modules/charting/UiHoseGraph.ts @@ -0,0 +1,223 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +import {Line} from "d3-shape"; +import * as d3 from "d3"; +import {CurveTypeToCurveFactory, DataPoint, SVGSelection} from "./Charting"; +import {AbstractUiGraph} from "./AbstractUiGraph"; +import {LineGraphDataStore} from "./DataStore"; +import {D3Area2} from "./D3Area2"; +import {isVisibleColor} from "../Common"; +import {UiHoseGraphConfig} from "../../generated/UiHoseGraphConfig"; +import {UiHoseGraphDataConfig} from "../../generated/UiHoseGraphDataConfig"; +import {UiLongIntervalConfig} from "../../generated/UiLongIntervalConfig"; +import {IntervalManager} from "../util/IntervalManager"; +import {UiLineGraphDataPointConfig} from "../../generated/UiLineGraphDataPointConfig"; +import * as log from "loglevel"; + +export class UiHoseGraph extends AbstractUiGraph { + + private static logger: log.Logger = log.getLogger("UiHoseGraph"); + + private middleLine: Line; + private $middleLine: SVGSelection; + private lowerLine: Line; + private $lowerLine: SVGSelection; + private upperLine: Line; + private $upperLine: SVGSelection; + private area: D3Area2; + private $area: SVGSelection; + private $defs: SVGSelection; + private $dots: SVGSelection; + + private $yZeroLine: SVGSelection; + + private upperLineDataStore = new LineGraphDataStore(); + private middleLineDataStore = new LineGraphDataStore(); + private lowerLineDataStore = new LineGraphDataStore(); + + private get dataStores() { + return [this.upperLineDataStore, this.middleLineDataStore, this.lowerLineDataStore]; + } + + constructor( + timeGraphId: string, + config: UiHoseGraphConfig, + private dropShadowFilterId: string + ) { + super(config, timeGraphId); + this.$main.classed("hose-graph", true); + this.initLinesAndColorScale(); + this.initDomNodes(); + } + + getUncoveredIntervals(zoomLevel: number, interval: [number, number]): [number, number][] { + let uncoveredIntervalManager = new IntervalManager(); + this.dataStores.forEach(ds => uncoveredIntervalManager.addIntervals(ds.getUncoveredIntervals(zoomLevel, interval))); + return uncoveredIntervalManager.getCoveredIntervals(); // !! + } + + markIntervalAsCovered(zoomLevel: number, interval: [number, number]): void { + this.dataStores.forEach(ds => ds.markIntervalAsCovered(zoomLevel, interval)); + } + + public addData(zoomLevel: number, data: UiHoseGraphDataConfig): void { + if (data.upperLineData != null) { + this.upperLineDataStore.addData(zoomLevel, data.upperLineData); + } + if (data.middleLineData != null) { + this.middleLineDataStore.addData(zoomLevel, data.middleLineData); + } + if (data.lowerLineData != null) { + this.lowerLineDataStore.addData(zoomLevel, data.lowerLineData); + } + } + + public resetData(): void { + this.dataStores.forEach(ds => ds.reset()); + } + + public getYDataBounds(xInterval: [number, number]): [number, number] { + return d3.extent(this.dataStores + .map(ds => ds.getData(this.zoomLevelIndex, xInterval).dataPoints) + .flat() + .map(dataPoint => dataPoint.y)); + } + + private initLinesAndColorScale() { + this.area = new D3Area2() + .curve(CurveTypeToCurveFactory[this.config.graphType]); + this.middleLine = d3.line() + .curve(CurveTypeToCurveFactory[this.config.graphType]); + this.lowerLine = d3.line() + .curve(CurveTypeToCurveFactory[this.config.graphType]); + this.upperLine = d3.line() + .curve(CurveTypeToCurveFactory[this.config.graphType]); + + } + + private initDomNodes() { + this.$area = this.$main.append("path") + .classed("area", true); + this.$middleLine = this.$main.append("path") + .classed("line", true); + this.$lowerLine = this.$main.append("path") + .classed("line", true); + this.$upperLine = this.$main.append("path") + .classed("line", true); + + this.$yZeroLine = this.$main.append("line") + .classed("y-zero-line", true); + + // .style("filter", `url("#${this.dropShadowFilterId}")`); + this.$dots = this.$main.append("g") + .classed("dots", true); + + this.$defs = this.$main.append("defs") + .html(` + + `); + } + + public doRedraw() { + this.$defs.select(".area-stripe-fill path") + .attr("stroke", this.config.areaColor ?? "#00000000"); + + let areaDataMax: UiLineGraphDataPointConfig[]; + if (isVisibleColor(this.config.upperLineColor)) { + areaDataMax = this.upperLineDataStore.getData(this.zoomLevelIndex, this.getDisplayedIntervalX()).dataPoints; + } else { + UiHoseGraph.logger.info("NOT drawing upperLine, since line color is invisible!") + areaDataMax = []; + } + let lineData: UiLineGraphDataPointConfig[]; + if (isVisibleColor(this.config.middleLineColor)) { + lineData = this.middleLineDataStore.getData(this.zoomLevelIndex, this.getDisplayedIntervalX()).dataPoints; + } else { + UiHoseGraph.logger.info("NOT drawing middleLine, since line color is invisible!") + lineData = []; + } + let areaDataMin: UiLineGraphDataPointConfig[]; + if (isVisibleColor(this.config.lowerLineColor)) { + areaDataMin = this.lowerLineDataStore.getData(this.zoomLevelIndex, this.getDisplayedIntervalX()).dataPoints; + } else { + UiHoseGraph.logger.info("NOT drawing lowerLine, since line color is invisible!") + areaDataMin = []; + } + + this.middleLine + .x(d => this.scaleX(d.x)) + .y(d => this.scaleY(d.y)); + this.$middleLine + .attr("d", this.middleLine(lineData)) + .attr("stroke", this.config.middleLineColor); + + this.lowerLine + .x(d => this.scaleX(d.x)) + .y(d => this.scaleY(d.y)); + this.$lowerLine + .attr("d", this.lowerLine(areaDataMin)) + .attr("stroke", this.config.lowerLineColor); + + this.upperLine + .x(d => this.scaleX(d.x)) + .y(d => this.scaleY(d.y)); + this.$upperLine + .attr("d", this.upperLine(areaDataMax)) + .attr("stroke", this.config.upperLineColor); + + + this.area + .x(d => { + return this.scaleX(d.x) + }) + .y((d, index) => { + return this.scaleY(d.y) + }); + this.$area + .attr("d", this.area.writePath(areaDataMin, areaDataMax)) + .attr("fill", this.config.stripedArea ? `url(#area-stripe-fill-${this.timeGraphId}-${this.config.id})`: this.config.areaColor ?? "#00000000"); + + let $dotsDataSelection = this.$dots.selectAll("circle.dot") + .data(this.config.dataDotRadius > 0 ? lineData : []) + .join("circle") + .classed("dot", true) + .attr("cx", d => this.scaleX(d.x)) + .attr("cy", d => this.scaleY(d.y)) + .attr("r", this.config.dataDotRadius); + $dotsDataSelection.exit().remove(); + + this.$yZeroLine + .attr("x1", 0) + .attr("y1", this.scaleY(0)) + .attr("x2", this.scaleX.range()[1]) + .attr("y2", this.scaleY(0)) + .attr("stroke", this.config.yAxisColor) + .attr("visibility", (this.config.yZeroLineVisible && this.scaleY.domain()[0] !== 0) ? "visible" : "hidden"); + } + + setConfig(lineFormat: UiHoseGraphConfig) { + super.setConfig(lineFormat); + this.initLinesAndColorScale(); + } + +} + + + diff --git a/teamapps-client/ts/modules/charting/UiIncidentGraph.ts b/teamapps-client/ts/modules/charting/UiIncidentGraph.ts new file mode 100644 index 000000000..131566b5d --- /dev/null +++ b/teamapps-client/ts/modules/charting/UiIncidentGraph.ts @@ -0,0 +1,141 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +import * as d3 from "d3"; +import {GraphContext, PopperHandle} from "./GraphContext"; +import {AbstractUiGraph} from "./AbstractUiGraph"; +import {UiIncidentGraphConfig} from "../../generated/UiIncidentGraphConfig"; +import {UiIncidentGraphDataConfig} from "../../generated/UiIncidentGraphDataConfig"; +import {UiLongIntervalConfig} from "../../generated/UiLongIntervalConfig"; +import {IncidentGraphDataStore} from "./DataStore"; +import {IntervalManager} from "../util/IntervalManager"; + +export class UiIncidentGraph extends AbstractUiGraph { + + private dataStore = new IncidentGraphDataStore(); + private popperHandle: PopperHandle; + + constructor( + timeGraphId: string, + config: UiIncidentGraphConfig, + private graphContext: GraphContext + ) { + super(config, timeGraphId); + this.$main.classed("incident-graph", true); + this.popperHandle = graphContext.getPopperHandle(); + } + + getUncoveredIntervals(zoomLevel: number, interval: [number, number]): [number, number][] { + return this.dataStore.getUncoveredIntervals(zoomLevel, interval); + } + + markIntervalAsCovered(zoomLevel: number, interval: [number, number]): void { + this.dataStore.markIntervalAsCovered(zoomLevel, interval); + } + + public addData(zoomLevel: number, data: UiIncidentGraphDataConfig): void { + this.dataStore.addData(zoomLevel, data) + } + + public resetData(): void { + this.dataStore.reset(); + } + + public getYDataBounds(intervalX: [number, number]): [number, number] { + return d3.extent(this.dataStore.getData(this.zoomLevelIndex, intervalX).dataPoints.map(dp => dp.y)); + } + + doRedraw() { + // Three function that change the tooltip when user hover / move / leave a cell + const mouseenter = (d: any, i: number, nodes: Element[]) => { + d3.select(nodes[i]) + .style("stroke-width", "2"); + this.popperHandle.update(nodes[i], d.tooltipHtml); + }; + const mouseleave = (d: any, i: number, nodes: Element[]) => { + d3.select(nodes[i]) + .style("stroke-width", "1"); + this.popperHandle.hide(); + }; + + const thickness = 10; + + let data = this.dataStore.getData(this.zoomLevelIndex, this.getDisplayedIntervalX()); + + this.$main + .selectAll("rect.incident-rect") + .data(data.dataPoints) + .join("rect") + .classed("incident-rect", true) + .attr("stroke", d => d.color) + .attr("stroke-width", 1) + .attr("fill", d => { + let color = d3.color(d.color); + color.opacity = color.opacity / 6; + return color.toString(); + }) + // .attr("stroke-linecap", "round") + .attr("x", d => Math.max(-thickness, this.scaleX(d.x1) - (thickness / 2))) + .attr("width", d => { + // this whole min max mess is necessary to make the rectangles not get overly large and thereby control where the tooltip + // will appear (should be at the middle of the _visible_ portion of the rectangle) + const x1 = Math.max(-thickness, this.scaleX(d.x1) - (thickness / 2)); + const x2 = Math.min(this.scaleX.range()[1] + thickness, this.scaleX(d.x2) - (thickness / 2)); + return Math.max(0, x2 - x1 + thickness); + }) + .attr("y", d => this.scaleY(d.y) - (thickness / 2)) + .attr("rx", (thickness / 2)) + .attr("ry", (thickness / 2)) + .attr("height", d => thickness) + .on("mouseenter", mouseenter) + .on("mouseleave", mouseleave); + + this.$main + .selectAll("line.incident-line") + .data(data.dataPoints) + .join("line") + .classed("incident-line", true) + .attr("stroke", d => d.color) + .attr("stroke-width", 1) + .attr("stroke-linecap", "round") + .attr("x1", d => this.scaleX(d.x1)) + .attr("x2", d => this.scaleX(d.x2)) + .attr("y1", d => this.scaleY(d.y)) + .attr("y2", d => this.scaleY(d.y)); + + this.$main + .selectAll("circle.incident-dot") + .data(data.dataPoints.flatMap(d => [{x: d.x1, y: d.y, color: d.color}, {x: d.x2, y: d.y, color: d.color}])) + .join("circle") + .classed("incident-dot", true) + .attr("fill", d => d.color) + .attr("cx", d => this.scaleX(d.x)) + .attr("cy", d => this.scaleY(d.y)) + .attr("r", "2"); + } + + + destroy() { + super.destroy(); + this.popperHandle.destroy(); + } +} + + + diff --git a/teamapps-client/ts/modules/charting/UiLineGraph.ts b/teamapps-client/ts/modules/charting/UiLineGraph.ts new file mode 100644 index 000000000..5a61dccb5 --- /dev/null +++ b/teamapps-client/ts/modules/charting/UiLineGraph.ts @@ -0,0 +1,162 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +import {Area, Line} from "d3-shape"; +import * as d3 from "d3"; +import {ScaleLinear} from "d3"; +import {CurveTypeToCurveFactory, DataPoint, SVGSelection} from "./Charting"; +import {AbstractUiGraph} from "./AbstractUiGraph"; +import {isVisibleColor} from "../Common"; +import {UiLineGraphDataConfig} from "../../generated/UiLineGraphDataConfig"; +import {UiLineGraphConfig} from "../../generated/UiLineGraphConfig"; +import {UiLongIntervalConfig} from "../../generated/UiLongIntervalConfig"; +import {LineGraphDataStore} from "./DataStore"; +import {UiLineGraphDataPointConfig} from "../../generated/UiLineGraphDataPointConfig"; + +export class UiLineGraph extends AbstractUiGraph { + + private line: Line; + private $line: SVGSelection; + private area: Area; + private $area: SVGSelection; + private $dots: SVGSelection; + private $defs: SVGSelection; + private colorScale: ScaleLinear; + + private $yZeroLine: SVGSelection; + + private dataStore = new LineGraphDataStore(); + + constructor( + timeGraphId: string, + config: UiLineGraphConfig, + private dropShadowFilterId: string + ) { + super(config, timeGraphId); + this.$main.classed("line-graph", true); + this.initLinesAndColorScale(); + this.initDomNodes(); + } + + getUncoveredIntervals(zoomLevel: number, interval: [number, number]): [number, number][] { + return this.dataStore.getUncoveredIntervals(zoomLevel, interval); + } + + markIntervalAsCovered(zoomLevel: number, interval: [number, number]): void { + this.dataStore.markIntervalAsCovered(zoomLevel, interval); + } + + public addData(zoomLevel: number, data: UiLineGraphDataConfig): void { + this.dataStore.addData(zoomLevel, data); + } + + public resetData(): void { + this.dataStore.reset(); + } + + public getYDataBounds(xInterval: [number, number]): [number, number] { + return d3.extent(this.dataStore.getData(this.zoomLevelIndex, xInterval).dataPoints + .map(dataPoint => dataPoint.y)); + } + + private initDomNodes() { + this.$area = this.$main.append("path") + .classed("area", true) + .attr("fill", `url('#area-gradient-${this.timeGraphId}-${this.config.id}')`); + this.$line = this.$main.append("path") + .classed("line", true) + .attr("stroke", `url('#line-gradient-${this.timeGraphId}-${this.config.id}')`); + this.$yZeroLine = this.$main.append("line") + .classed("y-zero-line", true); + + // .style("filter", `url("#${this.dropShadowFilterId}")`); + this.$dots = this.$main.append("g") + .classed("dots", true); + + this.$defs = this.$main.append("defs") + .html(` + + + `); + } + + public doRedraw() { + this.colorScale.domain(this.scaleY.domain()); + this.$defs.select(".line-gradient") + .attr("y2", this.scaleY.range()[0]) + .selectAll("stop") + .data([this.config.lineColorScaleMax, this.config.lineColorScaleMin]) + .join("stop") + .attr("stop-color", d => d) + .attr("offset", (datum, index) => index); + this.$defs.select(".area-gradient") + .attr("y2", this.scaleY.range()[0]) + .selectAll("stop") + .data([this.config.areaColorScaleMax, this.config.areaColorScaleMin]) + .join("stop") + .attr("stop-color", d => d) + .attr("offset", (datum, index) => index); + + let dataPoints = this.dataStore.getData(this.zoomLevelIndex, this.getDisplayedIntervalX()).dataPoints; + this.line + .x(d => this.scaleX(d.x)) + .y(d => this.scaleY(d.y)); + this.$line.attr("d", this.line(dataPoints)); + + if (isVisibleColor(this.config.areaColorScaleMin) || isVisibleColor(this.config.areaColorScaleMax)) { + this.area + .x(d => this.scaleX(d.x)) + .y0(this.scaleY.range()[0]) + .y1(d => this.scaleY(d.y)); + this.$area.attr("d", this.area(dataPoints)); + } + + let $dotsDataSelection = this.$dots.selectAll("circle.dot") + .data(this.config.dataDotRadius > 0 ? dataPoints : []) + .join("circle") + .classed("dot", true) + .attr("cx", d => this.scaleX(d.x)) + .attr("cy", d => this.scaleY(d.y)) + .attr("r", this.config.dataDotRadius); + $dotsDataSelection.exit().remove(); + + this.$yZeroLine + .attr("x1", 0) + .attr("y1", this.scaleY(0)) + .attr("x2", this.scaleX.range()[1]) + .attr("y2", this.scaleY(0)) + .attr("stroke", this.config.yAxisColor) + .attr("visibility", (this.config.yZeroLineVisible && this.scaleY.domain()[0] !== 0) ? "visible" : "hidden"); + } + + setConfig(graphConfig: UiLineGraphConfig) { + super.setConfig(graphConfig); + this.initLinesAndColorScale(); + } + + private initLinesAndColorScale() { + this.area = d3.area() + .curve(CurveTypeToCurveFactory[this.config.graphType]); + this.line = d3.line() + .curve(CurveTypeToCurveFactory[this.config.graphType]); + this.colorScale = d3.scaleLinear() + .range([this.config.lineColorScaleMin, this.config.lineColorScaleMax]); + } + +} diff --git a/teamapps-client/ts/modules/charting/UiTimeGraph.ts b/teamapps-client/ts/modules/charting/UiTimeGraph.ts new file mode 100644 index 000000000..a6233551f --- /dev/null +++ b/teamapps-client/ts/modules/charting/UiTimeGraph.ts @@ -0,0 +1,566 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ + +import {AbstractUiComponent} from "../AbstractUiComponent"; +import {TeamAppsEvent} from "../util/TeamAppsEvent"; +import {TeamAppsUiContext} from "../TeamAppsUiContext"; +import {executeWhenFirstDisplayed} from "../util/ExecuteWhenFirstDisplayed"; +import {TeamAppsUiComponentRegistry} from "../TeamAppsUiComponentRegistry"; +import * as d3 from "d3"; +import {Selection} from "d3-selection"; +import {ScaleTime} from "d3-scale"; +import {bind} from "../util/Bind"; +import {ZoomBehavior, ZoomedElementBaseType} from "d3-zoom"; +import {Axis} from "d3-axis"; +import {generateUUID, parseHtml} from "../Common"; +import { + UiTimeGraph_IntervalSelectedEvent, + UiTimeGraph_ZoomedEvent, + UiTimeGraphCommandHandler, + UiTimeGraphConfig, + UiTimeGraphEventSource +} from "../../generated/UiTimeGraphConfig"; +import {createUiLongIntervalConfig, UiLongIntervalConfig} from "../../generated/UiLongIntervalConfig"; +import {BrushBehavior} from "d3-brush"; +import {debouncedMethod, DebounceMode} from "../util/debounce"; +import {UiLineChartMouseScrollZoomPanMode} from "../../generated/UiLineChartMouseScrollZoomPanMode"; +import {UiTimeChartZoomLevelConfig} from "../../generated/UiTimeChartZoomLevelConfig"; +import {UiLineGraph} from "./UiLineGraph"; +import {UiHoseGraph} from "./UiHoseGraph"; +import {UiGraphGroup} from "./UiGraphGroup"; +import {DateTime} from 'luxon'; +import {scaleZoned} from "d3-luxon"; +import {SVGGSelection, SVGSelection} from "./Charting"; +import {UiGraph} from "./UiGraph"; +import {UiIncidentGraph} from "./UiIncidentGraph"; +import {TimeGraphPopper} from "./TimeGraphPopper"; +import {UiGraphDataConfig} from "../../generated/UiGraphDataConfig"; +import {UiGraphConfig} from "../../generated/UiGraphConfig"; +import {AbstractUiGraph} from "./AbstractUiGraph"; +import {GraphContext, PopperHandle} from "./GraphContext"; +import {throttledMethod} from "../util/throttle"; + +export class UiTimeGraph extends AbstractUiComponent implements UiTimeGraphCommandHandler, UiTimeGraphEventSource { + + public readonly onIntervalSelected: TeamAppsEvent = new TeamAppsEvent(); + public readonly onZoomed: TeamAppsEvent = new TeamAppsEvent(); + + public static readonly LOGSCALE_MIN_Y = 0.5; + public static readonly DROP_SHADOW_ID = "drop-shadow"; + + private mouseScrollZoomPanMode: UiLineChartMouseScrollZoomPanMode; + private intervalX: UiLongIntervalConfig; + private maxPixelsBetweenDataPoints: number; + + private graphById: { [id: string]: UiGraph } = {}; + + private $main: HTMLElement; + + private $svg: Selection; + private $rootG: SVGSelection; + private $dropShadowFilter: SVGSelection; + private $clipPath: SVGSelection; + + private scaleX: ScaleTime; + + private dropShadowFilterId: string; + + private margin = {top: 20, right: 15, bottom: 25}; + private zoom: ZoomBehavior; + private xAxis: Axis; + private $xAxis: SVGSelection; + private $horizontalPanRect: SVGSelection; + private brush: BrushBehavior; + private $brush: SVGGSelection; + private $graphClipContainer: SVGGSelection; + private xSelection: UiLongIntervalConfig; + private zoomLevels: UiTimeChartZoomLevelConfig[]; + private $yAxisContainer: SVGSelection; + + private lastDrawableWidth: number = 0; + + private eventsPopper: TimeGraphPopper = new TimeGraphPopper(); + private graphContext: GraphContext; + + get drawableWidth() { + return this.getWidth() - this.marginLeft - this.margin.right + } + + private get drawableHeight() { + return this.getHeight() - this.margin.top - this.margin.bottom + } + + private get marginLeft() { + let marginLeft = this.getAllSeries().reduce((sum, s) => sum + (s.getYAxis()?.getWidth() ?? 0), 0);return marginLeft; + } + + constructor(config: UiTimeGraphConfig, context: TeamAppsUiContext) { + super(config, context); + + const me = this; + this.graphContext = { + getPopperHandle() { + return { + update(referenceElement: Element, content: (Element | string)): void { + me.eventsPopper.update(referenceElement, content); + }, + hide(): void { + me.eventsPopper.update(null, null); + }, + destroy(): void { + me.eventsPopper.update(null, null); + } + } as PopperHandle; + } + } + + this.zoomLevels = config.zoomLevels; + this.maxPixelsBetweenDataPoints = config.maxPixelsBetweenDataPoints; + + this.$main = parseHtml('
'); + + this.scaleX = scaleZoned(config.timeZoneId, 1); // TODO first day of week... + + this.$svg = d3.select(this.$main) + .append("svg"); + this.$rootG = this.$svg + .append("g"); + + this.setIntervalX(config.intervalX); + + this.$xAxis = this.$rootG.append("g") + .classed("axis", true) + .classed("x-axis", true); + this.$horizontalPanRect = this.$xAxis.append("rect") + .classed("horizontal-pan-rect", true) + .attr("width", "100%") + .attr("height", 20); + let x = 0; + const panZoom = d3.zoom() + .scaleExtent([1, 1]) + .on("zoom", () => { + const zoomTransform = d3.zoomTransform(this.$xAxis.node()); + const zoomTransform2 = d3.zoomTransform(this.$graphClipContainer.node()); + this.zoom.translateBy(this.$graphClipContainer, (zoomTransform.x - x) / zoomTransform2.k, 0); + x = zoomTransform.x; + this.redraw(); + }); + this.$xAxis.call(panZoom); + this.xAxis = d3.axisBottom(this.scaleX); + + const formatMillisecond = (dateTime: DateTime) => dateTime.toFormat(".SSS"), + formatSecond = (dateTime: DateTime) => dateTime.toFormat(":ss"), + formatMinute = (dateTime: DateTime) => dateTime.toLocaleString({hour: "2-digit", minute: "2-digit"}), + formatHour = (dateTime: DateTime) => dateTime.toLocaleString({hour: "2-digit", minute: "2-digit"}), + formatDay = (dateTime: DateTime) => dateTime.toLocaleString({day: "2-digit", month: "2-digit"}), + formatMonth = (dateTime: DateTime) => dateTime.toFormat("LLL"), + formatYear = (dateTime: DateTime) => dateTime.toFormat("yyyy"); + + const multiFormat = (date: Date) => { + let dateTime = DateTime.fromJSDate(date).setZone(this._config.timeZoneId); + let formatter = dateTime.startOf("second") < dateTime ? formatMillisecond + : dateTime.startOf("minute") < dateTime ? formatSecond + : dateTime.startOf("hour") < dateTime ? formatMinute + : dateTime.startOf("day") < dateTime ? formatHour + : dateTime.startOf("month") < dateTime ? formatDay + : dateTime.startOf("year") < dateTime ? formatMonth + : formatYear; + return formatter(dateTime.setLocale(this._config.locale)); + } + + this.xAxis.tickFormat(multiFormat); + + this.$yAxisContainer = this.$rootG.append("g") + .classed("y-axis-container", true); + this.dropShadowFilterId = `${UiTimeGraph.DROP_SHADOW_ID}-${this.getId()}`; + let $defs = this.$svg.append("defs") + .html(navigator.vendor.indexOf("Apple") === -1 ? ` + + + + + + + + + + ` : ''); + this.$dropShadowFilter = $defs.select(`#${this.dropShadowFilterId}`); + let clipPathId = this.getId() + "-clipping-path-" + generateUUID(); + this.$clipPath = $defs.append("clipPath") + .attr("id", clipPathId) + .append("rect") + .attr("y", -50); + + this.$graphClipContainer = this.$rootG.append("g") + .classed("graph-clipping-container", true) + .attr("clip-path", `url('#${clipPathId}')`); + + this.brush = d3.brushX() + .extent([[0, 0], [100, 100] /*provisional values!*/]) + .on("end", this.handleBrushSelection); + this.$brush = this.$graphClipContainer.append("g") + .classed("brush", true) + .call(this.brush); + + this.zoom = d3.zoom() + .scaleExtent([1, this.calculateMaxZoomFactor()]) + .on("zoom", () => { + this.redraw(); + this.fireUiZoomEvent(); + }); + this.setMouseScrollZoomPanMode(config.mouseScrollZoomPanMode); + + config.graphs.forEach(line => { + this.graphById[line.id] = this.createGraph(line); + }); + + } + + zoomTo(intervalX: UiLongIntervalConfig): void { + if (intervalX.min < this.intervalX.min) { + intervalX.min = this.intervalX.min; + } + if (intervalX.min > this.intervalX.max) { + intervalX.min = this.intervalX.max - 1; + } + if (intervalX.max < this.intervalX.min) { + intervalX.max = this.intervalX.min + 1; + } + if (intervalX.max > this.intervalX.max) { + intervalX.max = this.intervalX.max; + } + let k = (this.intervalX.max - this.intervalX.min) / (intervalX.max - intervalX.min); + this.zoom.transform(this.$graphClipContainer, d3.zoomIdentity.scale(k).translate(-this.scaleX(intervalX.min), 0)); + } + + private createGraph(lineFormat: UiGraphConfig): AbstractUiGraph { + let display = UiTimeGraph.createDataDisplay(this._config.id, lineFormat, this.dropShadowFilterId, this.graphContext); + this.$graphClipContainer.node().appendChild(display.getMainSelection().node()); + display.setYRange([this.drawableHeight, 0]); + if (display.getYAxis() != null) { + this.$yAxisContainer.node().appendChild(display.getYAxis().getSelection().node()); + } + return display; + } + + public static createDataDisplay(timeGraphId: string, graphConfig: UiGraphConfig, dropShadowFilterId: string, graphContext: GraphContext) { + let display: AbstractUiGraph; + if (graphConfig._type === 'UiLineGraph') { + display = new UiLineGraph(timeGraphId, graphConfig, dropShadowFilterId); + } else if (graphConfig._type === 'UiHoseGraph') { + display = new UiHoseGraph(timeGraphId, graphConfig, dropShadowFilterId); + } else if (graphConfig._type === 'UiIncidentGraph') { + display = new UiIncidentGraph(timeGraphId, graphConfig, graphContext); + } else if (graphConfig._type === 'UiGraphGroup') { + display = new UiGraphGroup(timeGraphId, graphConfig, dropShadowFilterId, graphContext); + } + return display; + } + + getTransformedScaleX(): ScaleTime { + let zoomTransform = d3.zoomTransform(this.$graphClipContainer.node()); + return zoomTransform.rescaleX(this.scaleX as any); + } + + @executeWhenFirstDisplayed() + @throttledMethod(200) + private redraw() { + if (this.getWidth() === 0 || this.getHeight() === 0) { + return; + } + this.lastDrawableWidth = this.drawableWidth; + + this.$rootG.attr("transform", `translate(${this.marginLeft},${this.margin.top})`); + + let left = 0; + this.getAllSeries().forEach(s => { + s.getYAxis()?.getSelection()?.attr("transform", `translate(${-left},0)`); + left += s.getYAxis()?.getWidth() ?? 0; + }); + + let transformedScaleX = this.getTransformedScaleX(); + + this.$xAxis.call(this.xAxis.scale(transformedScaleX)); + + let domain = this.restrictDomainXToConfiguredInterval([+transformedScaleX.domain()[0], +transformedScaleX.domain()[1]]); + + const zoomLevel = this.getCurrentZoomLevel(); + // this.logger.debug("millisecondsPerPixel: " + millisecondsPerPixel + " zoomLevel: " + zoomLevel + " at scale: " + zoomTransform.k); + + + let uncoveredIntervalsByGraphId: { [graphId: string]: UiLongIntervalConfig[] } = {}; + for (let [id, graph] of Object.entries(this.graphById)) { + let uncoveredIntervals = graph.getUncoveredIntervals(zoomLevel, [domain[0], domain[1]]); + uncoveredIntervals = uncoveredIntervals + .filter(i => (i[0] < i[1])) // remove empty intervals + .filter(i => !(i[0] == null || isNaN(i[0]) || i[1] == null || isNaN(i[1]))); // invalid intervals + + if (uncoveredIntervals.length > 0) { + uncoveredIntervalsByGraphId[id] = uncoveredIntervals.map(i => createUiLongIntervalConfig(i[0], i[1])); + uncoveredIntervals.forEach(i => graph.markIntervalAsCovered(zoomLevel, i)); + } + } + if (Object.keys(uncoveredIntervalsByGraphId).length > 0) { + this.logger.debug("firing onDataNeeded: " + uncoveredIntervalsByGraphId); + this.onZoomed.fire({ + zoomLevelIndex: zoomLevel, + millisecondsPerPixel: this.getMillisecondsPerPixel(), + neededIntervalsByGraphId: uncoveredIntervalsByGraphId, + displayedInterval: createUiLongIntervalConfig(domain[0], domain[1]) + }); + } + + if (this.xSelection) { + let brushX1 = Math.max(-10, transformedScaleX(this.xSelection.min)); + let brushX2 = Math.min(this.drawableWidth + 10, transformedScaleX(this.xSelection.max)); + if (brushX2 < 0 || brushX1 > this.drawableWidth) { + this.brush.move(this.$brush, null); + } else { + this.brush.move(this.$brush, [brushX1, brushX2]); + } + } else { + this.brush.move(this.$brush, null); + } + + this.getAllSeries().forEach(series => { + series.updateZoomX(this.getCurrentZoomLevel(), this.getTransformedScaleX()); + series.redraw(); + }); + } + + private restrictDomainXToConfiguredInterval(domain: [number, number]) { + return [Math.max(this.intervalX.min, +(domain[0])), Math.min(this.intervalX.max, +(domain[1]))]; + } + + getCurrentZoomLevel() { + let transformedScaleX = this.getTransformedScaleX(); + let domain = this.restrictDomainXToConfiguredInterval([+transformedScaleX.domain()[0], +transformedScaleX.domain()[1]]); + + const millisecondsPerPixel = (domain[1] - domain[0]) / this.drawableWidth; + let sortedZoomLevels = this.getSortedZoomLevels(); + let zoomLevelToApply = sortedZoomLevels + .filter(indexedZoomLevel => { + let minMillisecondsPerPixels = indexedZoomLevel.zoomLevel.approximateMillisecondsPerDataPoint / this.maxPixelsBetweenDataPoints; + return minMillisecondsPerPixels <= millisecondsPerPixel; + })[0] || sortedZoomLevels[sortedZoomLevels.length - 1]; + return zoomLevelToApply.index; + } + + private getMillisecondsPerPixel() { + let transformedScaleX = this.getTransformedScaleX(); + let domain = this.restrictDomainXToConfiguredInterval([+transformedScaleX.domain()[0], +transformedScaleX.domain()[1]]); + return (domain[1] - domain[0]) / this.drawableWidth; + } + + private getSortedZoomLevels() { + return this.zoomLevels + .map((zoomLevel, index) => { + return {index, zoomLevel} + }) + .sort((z1, z2) => z2.zoomLevel.approximateMillisecondsPerDataPoint - z1.zoomLevel.approximateMillisecondsPerDataPoint); + } + + @bind + private handleBrushSelection() { + let transformedScaleX = this.getTransformedScaleX(); + + if (d3.event.sourceEvent == null || d3.event.sourceEvent.type === "zoom") { + return; // handle only user-triggered brush changes! + } + let brushSelection = d3.brushSelection(this.$brush.node()); + if (brushSelection != null) { + this.xSelection = createUiLongIntervalConfig(+transformedScaleX.invert(brushSelection[0] as number), +transformedScaleX.invert(brushSelection[1] as number)); + this.onIntervalSelected.fire({ + intervalX: this.xSelection + }); + } else { + this.xSelection = null; + this.onIntervalSelected.fire({ + intervalX: null + }); + } + } + + public onResize(): void { + if (this.getWidth() === 0 && this.getHeight() === 0) { + return; + } + + this.scaleX.range([0, this.drawableWidth]); + + // adjust the translation value of the zoom transformation, so the displayed interval stays the same despite the resize! + if (this.lastDrawableWidth != null && this.lastDrawableWidth != this.drawableWidth) { + const zoomTransform = d3.zoomTransform(this.$graphClipContainer.node()); + const newTranslateX = zoomTransform.x * this.drawableWidth / this.lastDrawableWidth; + if (!isNaN(newTranslateX)) { + this.zoom.transform(this.$graphClipContainer, d3.zoomIdentity.translate(newTranslateX, 0).scale(zoomTransform.k)); + } + } + + this.getAllSeries().forEach(s => s.setYRange([this.drawableHeight, 0])); + this.updateZoomExtents(); + + this.$xAxis.attr("transform", "translate(0," + this.drawableHeight + ")"); + + this.$dropShadowFilter.attr("width", this.drawableWidth) + .attr("height", this.getHeight() + 100); + this.$clipPath.attr("width", this.drawableWidth) + .attr("height", this.getHeight() + 100); + + this.brush.extent([[0, -5], [this.drawableWidth, this.drawableHeight]]); + this.$brush.call(this.brush); // update size... + + this.redraw(); + } + + private updateZoomExtents() { + if (this.zoom == null) { // not yet initialized + return; + } + this.zoom + .translateExtent([[0, -Infinity], [this.getWidth() /*zoom is attached to $svg, remember!*/, Infinity]]) + .scaleExtent([1, this.calculateMaxZoomFactor()]); + } + + private getAllSeries() { + return Object.keys(this.graphById).map(id => this.graphById[id]); + } + + addData(zoomLevel: number, data: { [graphId: string]: UiGraphDataConfig }): void { + for (const [graphId, graphData] of Object.entries(data)) { + let graph = this.graphById[graphId]; + if (graph != null) { + graph.addData(zoomLevel, graphData); + } + } + this.redraw(); + } + + resetAllData(intervalX: UiLongIntervalConfig, newZoomLevels: UiTimeChartZoomLevelConfig[]): void { + this.setIntervalX(intervalX); + this.zoomLevels = newZoomLevels; + Object.values(this.graphById).forEach(g => g.resetData()); + this.redraw(); + } + + resetGraphData(graphId: string): void { + this.graphById[graphId]?.resetData(); + this.redraw(); + } + + setIntervalX(intervalX: UiLongIntervalConfig): void { + this.intervalX = intervalX; + this.updateZoomExtents(); + this.scaleX.domain([this.intervalX.min, this.intervalX.max]); + } + + setMouseScrollZoomPanMode(mouseScrollZoomPanMode: UiLineChartMouseScrollZoomPanMode): void { + this.mouseScrollZoomPanMode = mouseScrollZoomPanMode; + + this.$graphClipContainer.on('.zoom', null); + this.$graphClipContainer.call(this.zoom); + + let originalZoomWeelHandler = this.$graphClipContainer.on("wheel.zoom"); + let me = this; + this.$graphClipContainer.on("wheel.zoom", function () { + if (me.mouseScrollZoomPanMode === UiLineChartMouseScrollZoomPanMode.DISABLED) { + return; + } else if (me.mouseScrollZoomPanMode === UiLineChartMouseScrollZoomPanMode.WITH_MODIFIER_KEY && !(d3.event).ctrlKey && !(d3.event).altKey && !(d3.event).shiftKey && !(d3.event).metaKey) { + return; + } + originalZoomWeelHandler.apply(this, arguments); + me.zoom.translateBy(me.$graphClipContainer, -1 * d3.event.deltaX / d3.zoomTransform(me.$graphClipContainer.node()).k / 2, 0); + d3.event.preventDefault(); // prevent MacOS' swipe pages back and forward + }); + } + + @debouncedMethod(500, DebounceMode.LATER) + private fireUiZoomEvent() { + let transformedScaleX = this.getTransformedScaleX(); + let domain = transformedScaleX.domain(); + let currentZoomLevel = this.getCurrentZoomLevel(); + this.onZoomed.fire({ + millisecondsPerPixel: this.getMillisecondsPerPixel(), + displayedInterval: createUiLongIntervalConfig(+domain[0], +domain[1]), + zoomLevelIndex: currentZoomLevel, + neededIntervalsByGraphId: null + }); + } + + setSelectedInterval(intervalX: UiLongIntervalConfig): void { + this.xSelection = intervalX; + this.redraw(); + } + + setMaxPixelsBetweenDataPoints(maxPixelsBetweenDataPoints: number): void { + this.maxPixelsBetweenDataPoints = maxPixelsBetweenDataPoints; + this.updateZoomExtents(); + this.redraw(); + } + + doGetMainElement(): HTMLElement { + return this.$main; + } + + private calculateMaxZoomFactor(): number { + if (this.getWidth() === 0 || this.getHeight() === 0) { + return 1; + } + + let displayedMilliseconds = this.intervalX.max - this.intervalX.min; + let sortedZoomLevels = this.getSortedZoomLevels(); + let highestZoomLevelApproximateMillisPerDataPoint = sortedZoomLevels[sortedZoomLevels.length - 1].zoomLevel.approximateMillisecondsPerDataPoint; + + return this.maxPixelsBetweenDataPoints * displayedMilliseconds / (this.drawableWidth * highestZoomLevelApproximateMillisPerDataPoint); + } + + setGraphs(graphs: UiGraphConfig[]) { + graphs.forEach(lineConfig => { + let existingSeries = this.graphById[lineConfig.id]; + if (existingSeries) { + existingSeries.setConfig(lineConfig); + } else { + this.graphById[lineConfig.id] = this.createGraph(lineConfig); + } + }); + let lineConfigsById = graphs.reduce((previousValue, currentValue) => (previousValue[currentValue.id] = previousValue) && previousValue, {} as { [id: string]: UiGraphConfig }); + Object.keys(this.graphById).forEach(lineId => { + if (!lineConfigsById[lineId]) { + this.graphById[lineId].destroy(); + delete this.graphById[lineId]; + } + }); + this.onResize(); + } + + addOrUpdateGraph(graph: UiGraphConfig) { + let series = this.graphById[graph.id]; + if (series) { + series.setConfig(graph); + } else { + this.graphById[graph.id] = this.createGraph(graph); + } + this.onResize(); + } +} + + +TeamAppsUiComponentRegistry.registerComponentClass("UiTimeGraph", UiTimeGraph); diff --git a/teamapps-client/ts/modules/charting/YAxis.ts b/teamapps-client/ts/modules/charting/YAxis.ts new file mode 100644 index 000000000..a8bbea6f5 --- /dev/null +++ b/teamapps-client/ts/modules/charting/YAxis.ts @@ -0,0 +1,148 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +import {Axis, NamespaceLocalObject} from "d3"; +import {SVGSelection} from "./Charting"; +import {ScaleContinuousNumeric, ScaleLogarithmic} from "d3-scale"; +import d3 = require("d3"); + +function isLogScale(scale: ScaleContinuousNumeric): scale is ScaleLogarithmic { + return typeof ((scale as any).base) === "function" + || typeof ((scale as any).constant) === "function"; +} + +interface YAxisConfig { + color: string, + label: string, + maxTickDigits: number +} + +export class YAxis { + + private $axis: SVGSelection; + private $label: SVGSelection; + private axis: Axis; + private scale: ScaleContinuousNumeric; + + constructor(private config: YAxisConfig) { + this.$axis = d3.select(document.createElementNS((d3.namespace("svg:text") as NamespaceLocalObject).space, "g") as SVGGElement) + .classed("axis", true) + .classed("y-axis", true); + this.$label = this.$axis.append("text") + .classed("label", true) + .attr("x", 0) + .attr("dy", -5) + .attr("fill", "currentColor"); + this.scale = d3.scaleLinear(); // will be replaced... + this.axis = d3.axisLeft(this.scale); + } + + setConfig(config: YAxisConfig) { + this.config = config; + this.draw(); + } + + getSelection(): SVGSelection { + return this.$axis; + } + + getWidth(): number { + let fontSize = parseFloat(getComputedStyle(this.$axis.node()).fontSize); + return 10 + (this.config.maxTickDigits + 1) * .6 * fontSize; + } + + setScale(scaleY: ScaleContinuousNumeric) { + this.scale = scaleY; + // Note that the scale is dynamic. the reference to scaleY is set, not its values, so changes to scaleY (e.g. for animations) + // have an effect on the axis! + this.axis.scale(scaleY); + this.updateTickFormat(); + } + + public draw() { + this.updateTickFormat(); + + this.$label.text(this.config.label) + this.$axis.call(this.axis); + + let $ticks = this.$axis.node().querySelectorAll('.tick'); + for (let i = 0; i < $ticks.length; i++) { + let $text: SVGTextElement = $ticks[i].querySelector("text"); + let querySelector: any = $ticks[i].querySelector('line'); + if ($text.innerHTML === '') { + querySelector.setAttribute("opacity", '.3'); + } else { + querySelector.setAttribute("opacity", '1'); + } + } + + this.$axis.style("color", this.config.color); + } + + private updateTickFormat() { + const tickFormat = d3.format(`-,.${this.config.maxTickDigits}~r`); + const largeNumberTickFormat = d3.format(`-,.${this.config.maxTickDigits}~s`); + + let availableHeight = Math.abs(this.scale.range()[1] - this.scale.range()[0]); + let minY = this.scale.domain()[0]; + let maxY = this.scale.domain()[1]; + const numberOfDisplayableTicks = Math.ceil(availableHeight / 20); + + // make sure we only display as many ticks that all ticks have different (displayed) values! + let numberOfTicks = numberOfDisplayableTicks; + if (minY != maxY) { + while (numberOfTicks > 0) { + const integerPartLen = maxTickIntegerPartLength(minY, maxY, numberOfTicks); + const inc: number = d3.tickIncrement(minY, maxY, numberOfTicks); + const numberOfDigitsAddedByTickIncrements = inc < 0 ? Math.ceil(Math.log10(-inc)) : 0; + const numberOfSignificantDigits = integerPartLen + numberOfDigitsAddedByTickIncrements; + if (integerPartLen >= this.config.maxTickDigits) { + break; + } else if (numberOfSignificantDigits > this.config.maxTickDigits) { + console.debug(`Decreasing the number of ticks from ${numberOfTicks} to ${numberOfTicks - 1}`) + numberOfTicks--; + } else { + break; + } + } + } + this.axis.ticks(numberOfTicks); + + this.axis.tickFormat((value: number) => { + const log10 = Math.log10(Math.abs(value)); + let isPowerOfTen = value === 0 || Math.abs(Math.round(log10) - log10) < 1e-6; + if (!isLogScale(this.scale) || numberOfTicks <= 4 || isPowerOfTen) { + if (Math.ceil(log10) > this.config.maxTickDigits) { + // If the integer part alone is taking more digits than allowed, fallback to SI notation. No way to make this fit... + return largeNumberTickFormat(value); + } else { + return tickFormat(value) + } + } else { + // do not display all numbers on logarithmic scales, since they tend to be displayed very narrowly... + return ""; + } + }); + } + +} + +export function maxTickIntegerPartLength(minY: number, maxY: number, numberOfTicks: number) { + return Math.max(...d3.ticks(minY, maxY, numberOfTicks).map(t => Math.abs(t) < 1 ? 1 : Math.floor(Math.log10(Math.abs(t))) + 1)); +} diff --git a/teamapps-client/ts/shared/CMD.ts b/teamapps-client/ts/modules/communication/CMD.ts similarity index 89% rename from teamapps-client/ts/shared/CMD.ts rename to teamapps-client/ts/modules/communication/CMD.ts index 35b0f9f96..44256ace5 100644 --- a/teamapps-client/ts/shared/CMD.ts +++ b/teamapps-client/ts/modules/communication/CMD.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,7 +17,7 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import {UiCommand} from "../generated/UiCommand"; +import {UiCommand} from "../../generated/UiCommand"; export interface CMD { id: number diff --git a/teamapps-client/ts/modules/communication/ReconnectingWebSocketConnection.ts b/teamapps-client/ts/modules/communication/ReconnectingWebSocketConnection.ts new file mode 100644 index 000000000..726e4817c --- /dev/null +++ b/teamapps-client/ts/modules/communication/ReconnectingWebSocketConnection.ts @@ -0,0 +1,110 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +import {AbstractServerMessageConfig} from "../../generated/AbstractServerMessageConfig"; +import {AbstractClientMessageConfig} from "../../generated/AbstractClientMessageConfig"; +import log from "loglevel"; +import stringify from "json-stable-stringify"; + +export interface ReconnectingCompressingWebSocketConnectionListener { + onConnected: () => void; + onMessage: (messageObject: AbstractServerMessageConfig) => void; + onConnectionLost: () => void; + onReconnected: () => void; +} + +export class ReconnectingCompressingWebSocketConnection { + + private static logger: log.Logger = log.getLogger("ReconnectingCompressingWebSocketConnection"); + + private url: string; + private connection: WebSocket; + private initialConnection = true; + private closed: any; + + constructor(url: string, private listener: ReconnectingCompressingWebSocketConnectionListener) { + this.url = url; + this.reconnect(); + } + + public isConnected() { + return this.connection != null && this.connection.readyState === WebSocket.OPEN; + }; + + public send(object: AbstractClientMessageConfig) { + let jsonString = stringify(object, { + cmp: (a, b) => { + let aIsUnderscore = a.key[0] === '_'; + let bIsUnderscore = b.key[0] === '_'; + return (aIsUnderscore && !bIsUnderscore) ? -1 : (!aIsUnderscore && bIsUnderscore) ? 1 : 0; + } + }); + this.connection.send(jsonString); + }; + + private reconnect() { + if (this.closed) { + return; + } + + ReconnectingCompressingWebSocketConnection.log(`Connecting to ${this.url}`); + + this.connection = new WebSocket(this.url); + this.connection.binaryType = 'arraybuffer'; + + this.connection.onopen = () => { + if (this.initialConnection) { + ReconnectingCompressingWebSocketConnection.log("Connected."); + this.listener.onConnected(); + this.initialConnection = false; + } else { + ReconnectingCompressingWebSocketConnection.log("Reconnected."); + this.listener.onReconnected(); + } + }; + this.connection.onclose = () => { + console.warn('Websocket connection CLOSED!'); + this.listener.onConnectionLost(); + this.connection = null; + setTimeout(() => this.reconnect(), 2000); + }; + this.connection.onerror = (error) => { + ReconnectingCompressingWebSocketConnection.log('WebSocket error: ' + error); + }; + this.connection.onmessage = (e) => { + let json: string = e.data as string; + if (json) { + try { + this.listener.onMessage(JSON.parse(json)); + } catch (err) { + ReconnectingCompressingWebSocketConnection.log("Error while parsing message JSON: " + json + "\n" + err); + } + } + }; + } + + public stopReconnecting() { + this.closed = true; + } + + + private static log(message: string) { + console.log("Connection: " + message); + } +} diff --git a/teamapps-client/ts/shared/TeamAppsConnection.ts b/teamapps-client/ts/modules/communication/TeamAppsConnection.ts similarity index 66% rename from teamapps-client/ts/shared/TeamAppsConnection.ts rename to teamapps-client/ts/modules/communication/TeamAppsConnection.ts index b871de7db..8319db1e3 100644 --- a/teamapps-client/ts/shared/TeamAppsConnection.ts +++ b/teamapps-client/ts/modules/communication/TeamAppsConnection.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,21 +17,20 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import {UiCommand} from "../generated/UiCommand"; -import {INIT_NOK_Reason} from "../generated/INIT_NOKConfig"; -import {REINIT_NOK_Reason} from "../generated/REINIT_NOKConfig"; -import {SERVER_ERROR_Reason} from "../generated/SERVER_ERRORConfig"; -import {UiEvent} from "../generated/UiEvent"; +import {UiCommand} from "../../generated/UiCommand"; +import {UiEvent} from "../../generated/UiEvent"; +import {UiQuery} from "../../generated/UiQuery"; +import {UiSessionClosingReason} from "../../generated/UiSessionClosingReason"; export const typescriptDeclarationFixConstant = 1; export interface TeamAppsConnection { sendEvent(event: UiEvent): void; + sendQuery(query: UiQuery): Promise; } export interface TeamAppsConnectionListener { onConnectionInitialized(): void; - onConnectionErrorOrBroken(reason: INIT_NOK_Reason | REINIT_NOK_Reason | SERVER_ERROR_Reason, message?: string): void; + onConnectionErrorOrBroken(reason: UiSessionClosingReason, message?: string): void; executeCommand(uiCommand: UiCommand): Promise; - executeCommands(uiCommands: UiCommand[]): Promise[]; } diff --git a/teamapps-client/ts/shared/TeamAppsConnectionImpl.ts b/teamapps-client/ts/modules/communication/TeamAppsConnectionImpl.ts similarity index 59% rename from teamapps-client/ts/shared/TeamAppsConnectionImpl.ts rename to teamapps-client/ts/modules/communication/TeamAppsConnectionImpl.ts index fcfee93a8..7d236607b 100644 --- a/teamapps-client/ts/shared/TeamAppsConnectionImpl.ts +++ b/teamapps-client/ts/modules/communication/TeamAppsConnectionImpl.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,24 +17,28 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import {TeamAppsConnection, TeamAppsConnectionListener} from "../shared/TeamAppsConnection"; +import {TeamAppsConnection, TeamAppsConnectionListener} from "./TeamAppsConnection"; import {ReconnectingCompressingWebSocketConnection} from "./ReconnectingWebSocketConnection"; -import {UiClientInfoConfig} from "../generated/UiClientInfoConfig"; -import {INITConfig} from "../generated/INITConfig"; -import {INIT_NOK_Reason, INIT_NOKConfig} from "../generated/INIT_NOKConfig"; -import {REINIT_NOK_Reason, REINIT_NOKConfig} from "../generated/REINIT_NOKConfig"; -import {REINITConfig} from "../generated/REINITConfig"; -import {CMD_REQUESTConfig} from "../generated/CMD_REQUESTConfig"; -import {INIT_OKConfig} from "../generated/INIT_OKConfig"; -import {REINIT_OKConfig} from "../generated/REINIT_OKConfig"; -import {MULTI_CMDConfig} from "../generated/MULTI_CMDConfig"; -import {EVENTConfig} from "../generated/EVENTConfig"; -import {SERVER_ERROR_Reason, SERVER_ERRORConfig} from "../generated/SERVER_ERRORConfig"; +import {UiClientInfoConfig} from "../../generated/UiClientInfoConfig"; +import {INITConfig} from "../../generated/INITConfig"; +import {INIT_NOKConfig} from "../../generated/INIT_NOKConfig"; +import {REINIT_NOKConfig} from "../../generated/REINIT_NOKConfig"; +import {REINITConfig} from "../../generated/REINITConfig"; +import {CMD_REQUESTConfig} from "../../generated/CMD_REQUESTConfig"; +import {INIT_OKConfig} from "../../generated/INIT_OKConfig"; +import {REINIT_OKConfig} from "../../generated/REINIT_OKConfig"; +import {MULTI_CMDConfig} from "../../generated/MULTI_CMDConfig"; +import {EVENTConfig} from "../../generated/EVENTConfig"; +import {SESSION_CLOSEDConfig} from "../../generated/SESSION_CLOSEDConfig"; import {CMD} from "./CMD"; -import {logException} from "../modules/Common"; -import {AbstractClientPayloadMessageConfig} from "../generated/AbstractClientPayloadMessageConfig"; -import {UiEvent} from "../generated/UiEvent"; -import {CMD_RESULTConfig} from "../generated/CMD_RESULTConfig"; +import {logException} from "../Common"; +import {AbstractClientPayloadMessageConfig} from "../../generated/AbstractClientPayloadMessageConfig"; +import {UiEvent} from "../../generated/UiEvent"; +import {CMD_RESULTConfig} from "../../generated/CMD_RESULTConfig"; +import {UiSessionClosingReason} from "../../generated/UiSessionClosingReason"; +import {UiQuery} from "../../generated/UiQuery"; +import {QUERYConfig} from "../../generated/QUERYConfig"; +import {QUERY_RESULTConfig} from "../../generated/QUERY_RESULTConfig"; enum TeamAppsProtocolStatus { @@ -46,11 +50,9 @@ enum TeamAppsProtocolStatus { export class TeamAppsConnectionImpl implements TeamAppsConnection { - private static readonly SENT_EVENTS_MIN_BUFFER_SIZE = 500; - private static readonly SENT_EVENTS_MAX_BUFFER_SIZE = 1000; - private static readonly KEEPALIVE_INTERVAL = 25000; - private static readonly MIN_REQUESTED_COMMANDS = 5; - private static readonly MAX_REQUESTED_COMMANDS = 20; + private sentEventsMinBufferSize = 500; + private minRequestedCommands = 3; + private maxRequestedCommands = 20; private protocolStatus: TeamAppsProtocolStatus; @@ -59,7 +61,8 @@ export class TeamAppsConnectionImpl implements TeamAppsConnection { private payloadMessagesQueue: AbstractClientPayloadMessageConfig[] = []; private sentEventsBuffer: AbstractClientPayloadMessageConfig[] = []; - private eventIdCounter = 1; + private clientMessageIdCounter = 1; + private queryResultHandlerByMessageId: Map any> = new Map(); private maxRequestedCommandId = 0; private lastReceivedCommandId: number; @@ -75,24 +78,34 @@ export class TeamAppsConnectionImpl implements TeamAppsConnection { _type: "INIT", sessionId, clientInfo, - maxRequestedCommandId: TeamAppsConnectionImpl.MAX_REQUESTED_COMMANDS + maxRequestedCommandId: this.maxRequestedCommands } as INITConfig) }, - onMessage: (message) => { + onMessage: async (message) => { if (TeamAppsConnectionImpl.isMULTI_CMD(message)) { let cmds = message.cmds as CMD[]; - const resultPromises = commandHandler.executeCommands(cmds.map(cmd => cmd.c)); - resultPromises.forEach((promise, i) => { - promise.then(result => { - if (cmds[i].r) { - this.sendResult(cmds[i].id, result); + for (let cmd of cmds) { + this.lastReceivedCommandId = cmd.id; + try { + let result = await commandHandler.executeCommand(cmd.c); + if (cmd.r) { + this.sendResult(cmd.id, result); } - }).catch(reason => logException(reason)) - }); + } catch (reason) { + logException(reason); + } finally { + this.ensureEnoughCommandsRequested(); + } + } this.lastReceivedCommandId = cmds[cmds.length - 1].id; - this.ensureEnoughCommandsRequested(); + } else if (TeamAppsConnectionImpl.isQUERY_RESULT(message)) { + this.queryResultHandlerByMessageId.get(message.queryId)(message.result); } else if (TeamAppsConnectionImpl.isINIT_OK(message)) { this.protocolStatus = TeamAppsProtocolStatus.ESTABLISHED; + this.sentEventsMinBufferSize = message.sentEventsBufferSize; + this.initKeepAlive(message.keepaliveInterval ?? 25_000); + this.minRequestedCommands = message.minRequestedCommands; + this.maxRequestedCommands = message.maxRequestedCommands; this.log("Connection accepted."); this.flushPayloadMessages(); commandHandler.onConnectionInitialized(); @@ -113,19 +126,22 @@ export class TeamAppsConnectionImpl implements TeamAppsConnection { this.flushPayloadMessages(); } else if (TeamAppsConnectionImpl.isINIT_NOK(message)) { this.protocolStatus = TeamAppsProtocolStatus.ERROR; - this.log("Connection refused. Reason: " + INIT_NOK_Reason[message.reason]); + this.log("Connection refused. Reason: " + UiSessionClosingReason[message.reason]); commandHandler.onConnectionErrorOrBroken(message.reason); this.connection.stopReconnecting(); // give the server the chance to send more commands, but if it disconnected, do not attempt to reconnect. } else if (TeamAppsConnectionImpl.isREINIT_NOK(message)) { this.protocolStatus = TeamAppsProtocolStatus.ERROR; - this.log("Reconnect refused. Reason: " + REINIT_NOK_Reason[message.reason]); + this.log("Reconnect refused. Reason: " + UiSessionClosingReason[message.reason]); commandHandler.onConnectionErrorOrBroken(message.reason); this.connection.stopReconnecting(); // give the server the chance to send more commands, but if it disconnected, do not attempt to reconnect. - } else if (TeamAppsConnectionImpl.isSERVER_ERROR(message)) { + } else if (TeamAppsConnectionImpl.isPING(message)) { + this.log("Got PING from server."); + this.connection.send({_type: "KEEPALIVE", sessionId: this.sessionId}); + } else if (TeamAppsConnectionImpl.isSESSION_CLOSED(message)) { this.protocolStatus = TeamAppsProtocolStatus.ERROR; - this.log("Error reported by server: " + SERVER_ERROR_Reason[message.reason] + ": " + message.message); + this.log("Error reported by server: " + UiSessionClosingReason[message.reason] + ": " + message.message); commandHandler.onConnectionErrorOrBroken(message.reason, message.message); - // do NOT close the connection here. The server might handle this gracefully. + this.connection.stopReconnecting(); } else { this.log(`ERROR: unknown message type: ${message._type}`) } @@ -139,24 +155,24 @@ export class TeamAppsConnectionImpl implements TeamAppsConnection { _type: "REINIT", sessionId, lastReceivedCommandId: this.lastReceivedCommandId || -1, - maxRequestedCommandId: this.lastReceivedCommandId + TeamAppsConnectionImpl.MAX_REQUESTED_COMMANDS + maxRequestedCommandId: this.lastReceivedCommandId + this.maxRequestedCommands } as REINITConfig) } }); + } + + private initKeepAlive(keepaliveInterval: number) { self.setInterval(() => { if (this.isConnected()) { - this.connection.send({ - _type: "KEEPALIVE", - sessionId: sessionId - }) + this.connection.send({_type: "KEEPALIVE", sessionId: this.sessionId}); } - }, TeamAppsConnectionImpl.KEEPALIVE_INTERVAL) + }, keepaliveInterval) } private ensureEnoughCommandsRequested() { - if (this.maxRequestedCommandId - this.lastReceivedCommandId <= TeamAppsConnectionImpl.MIN_REQUESTED_COMMANDS) { + if (this.maxRequestedCommandId - this.lastReceivedCommandId <= this.minRequestedCommands) { try { - let maxRequestedCommandId = this.lastReceivedCommandId + TeamAppsConnectionImpl.MAX_REQUESTED_COMMANDS; + let maxRequestedCommandId = this.lastReceivedCommandId + this.maxRequestedCommands; this.connection.send({ _type: "CMD_REQUEST", sessionId: this.sessionId, @@ -186,12 +202,20 @@ export class TeamAppsConnectionImpl implements TeamAppsConnection { return message._type === 'REINIT_NOK'; } + private static isPING(message: any): message is REINIT_NOKConfig { + return message._type === 'PING'; + } + private static isMULTI_CMD(message: any): message is MULTI_CMDConfig { return message._type === 'MULTI_CMD'; } - private static isSERVER_ERROR(message: any): message is SERVER_ERRORConfig { - return message._type === 'SERVER_ERROR'; + private static isQUERY_RESULT(message: any): message is QUERY_RESULTConfig { + return message._type === 'QUERY_RESULT'; + } + + private static isSESSION_CLOSED(message: any): message is SESSION_CLOSEDConfig { + return message._type === 'SESSION_CLOSED'; } private isConnected() { @@ -202,17 +226,29 @@ export class TeamAppsConnectionImpl implements TeamAppsConnection { let protocolEvent: EVENTConfig = { _type: "EVENT", sessionId: this.sessionId, - id: this.eventIdCounter++, + id: this.clientMessageIdCounter++, uiEvent: event }; this.sendClientPayloadMessage(protocolEvent); } + sendQuery(query: UiQuery): Promise { + let clientMessageId = this.clientMessageIdCounter++; + let protocolQuery: QUERYConfig = { + _type: "QUERY", + sessionId: this.sessionId, + id: clientMessageId, + uiQuery: query + }; + this.sendClientPayloadMessage(protocolQuery); + return new Promise(resolve => this.queryResultHandlerByMessageId.set(clientMessageId, resolve)) + } + private sendResult(cmdId: number, result: any) { let cmdResult: CMD_RESULTConfig = { _type: "CMD_RESULT", sessionId: this.sessionId, - id: this.eventIdCounter++, + id: this.clientMessageIdCounter++, cmdId: cmdId, result: result }; @@ -241,8 +277,9 @@ export class TeamAppsConnectionImpl implements TeamAppsConnection { private sendPayloadMessage(payloadMessage: AbstractClientPayloadMessageConfig) { this.sentEventsBuffer.push(payloadMessage); - if (this.sentEventsBuffer.length > TeamAppsConnectionImpl.SENT_EVENTS_MAX_BUFFER_SIZE) { - this.sentEventsBuffer.splice(0, TeamAppsConnectionImpl.SENT_EVENTS_MAX_BUFFER_SIZE - TeamAppsConnectionImpl.SENT_EVENTS_MIN_BUFFER_SIZE); + let sentEventsMaxBufferSize = this.sentEventsMinBufferSize * 2; + if (this.sentEventsBuffer.length > sentEventsMaxBufferSize) { + this.sentEventsBuffer.splice(0, sentEventsMaxBufferSize - this.sentEventsMinBufferSize); } this.connection.send(payloadMessage); } diff --git a/teamapps-client/ts/modules/datetime/LocalDateTime.ts b/teamapps-client/ts/modules/datetime/LocalDateTime.ts new file mode 100644 index 000000000..271f4ec74 --- /dev/null +++ b/teamapps-client/ts/modules/datetime/LocalDateTime.ts @@ -0,0 +1,407 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +import { + DateObjectUnits, + DateTime, + DateTimeFormatOptions, + DiffOptions, + Duration, + DurationObject, + DurationUnit, + Interval, + LocaleOptions, + ToISODateOptions, + ToISOTimeOptions, + ToSQLOptions, + Zone, + ZoneOptions +} from "luxon"; + +export type LocalDateObject = DateObjectUnits & LocaleOptions; + +var LOCAL_ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone; + +export class LocalDateTime implements Omit { + + private dateTime: DateTime; // guaranteed to be "UTC" + + constructor(dateTime: DateTime) { + this.dateTime = dateTime.setZone("UTC", {keepLocalTime: true}); + } + + static fromDateTime(dateTime: DateTime) { + return new LocalDateTime(dateTime) + } + + static fromObject(obj: LocalDateObject) { + return new LocalDateTime(DateTime.fromObject(obj)) + } + + static local() { + return new LocalDateTime(DateTime.local()) + } + + static fromISO(text: string) { + return new LocalDateTime(DateTime.fromISO(text)); + } + + get day(): number { + return this.dateTime.day; + } + + set day(value: number) { + this.dateTime.day = value; + } + + get daysInMonth(): number { + return this.dateTime.daysInMonth; + } + + set daysInMonth(value: number) { + this.dateTime.daysInMonth = value; + } + + get daysInYear(): number { + return this.dateTime.daysInYear; + } + + set daysInYear(value: number) { + this.dateTime.daysInYear = value; + } + + get hour(): number { + return this.dateTime.hour; + } + + set hour(value: number) { + this.dateTime.hour = value; + } + + get invalidReason(): string | null { + return this.dateTime.invalidReason; + } + + set invalidReason(value: string | null) { + this.dateTime.invalidReason = value; + } + + get invalidExplanation(): string | null { + return this.dateTime.invalidExplanation; + } + + set invalidExplanation(value: string | null) { + this.dateTime.invalidExplanation = value; + } + + get isInLeapYear(): boolean { + return this.dateTime.isInLeapYear; + } + + set isInLeapYear(value: boolean) { + this.dateTime.isInLeapYear = value; + } + + get isValid(): boolean { + return this.dateTime.isValid; + } + + set isValid(value: boolean) { + this.dateTime.isValid = value; + } + + get locale(): string { + return this.dateTime.locale; + } + + set locale(value: string) { + this.dateTime.locale = value; + } + + get millisecond(): number { + return this.dateTime.millisecond; + } + + set millisecond(value: number) { + this.dateTime.millisecond = value; + } + + get minute(): number { + return this.dateTime.minute; + } + + set minute(value: number) { + this.dateTime.minute = value; + } + + get month(): number { + return this.dateTime.month; + } + + set month(value: number) { + this.dateTime.month = value; + } + + get monthLong(): string { + return this.dateTime.monthLong; + } + + set monthLong(value: string) { + this.dateTime.monthLong = value; + } + + get monthShort(): string { + return this.dateTime.monthShort; + } + + set monthShort(value: string) { + this.dateTime.monthShort = value; + } + + get numberingSystem(): string { + return this.dateTime.numberingSystem; + } + + set numberingSystem(value: string) { + this.dateTime.numberingSystem = value; + } + + get ordinal(): number { + return this.dateTime.ordinal; + } + + set ordinal(value: number) { + this.dateTime.ordinal = value; + } + + get outputCalendar(): string { + return this.dateTime.outputCalendar; + } + + set outputCalendar(value: string) { + this.dateTime.outputCalendar = value; + } + + get quarter(): number { + return this.dateTime.quarter; + } + + set quarter(value: number) { + this.dateTime.quarter = value; + } + + get second(): number { + return this.dateTime.second; + } + + set second(value: number) { + this.dateTime.second = value; + } + + get weekNumber(): number { + return this.dateTime.weekNumber; + } + + set weekNumber(value: number) { + this.dateTime.weekNumber = value; + } + + get weekYear(): number { + return this.dateTime.weekYear; + } + + set weekYear(value: number) { + this.dateTime.weekYear = value; + } + + get weekday(): number { + return this.dateTime.weekday; + } + + set weekday(value: number) { + this.dateTime.weekday = value; + } + + get weekdayLong(): string { + return this.dateTime.weekdayLong; + } + + set weekdayLong(value: string) { + this.dateTime.weekdayLong = value; + } + + get weekdayShort(): string { + return this.dateTime.weekdayShort; + } + + set weekdayShort(value: string) { + this.dateTime.weekdayShort = value; + } + + get weeksInWeekYear(): number { + return this.dateTime.weeksInWeekYear; + } + + set weeksInWeekYear(value: number) { + this.dateTime.weeksInWeekYear = value; + } + + get year(): number { + return this.dateTime.year; + } + + set year(value: number) { + this.dateTime.year = value; + } + + diff(other: LocalDateTime, unit?: DurationUnit | DurationUnit[], options?: DiffOptions): Duration { + return this.dateTime.diff(other.dateTime, unit, options); + } + + diffNow(unit?: DurationUnit | DurationUnit[], options?: DiffOptions): Duration { + return this.diff(LocalDateTime.local(), unit, options); + } + + endOf(unit: DurationUnit): LocalDateTime { + return new LocalDateTime(this.dateTime.endOf(unit)); + } + + equals(other: LocalDateTime): boolean { + return this.dateTime.equals(other.dateTime); + } + + get(unit: keyof DateTime): number { + return this.dateTime.get(unit); + } + + hasSame(other: LocalDateTime, unit: DurationUnit): boolean { + return this.dateTime.hasSame(other.dateTime, unit); + } + + minus(duration: Duration | number | DurationObject): LocalDateTime { + return new LocalDateTime(this.dateTime.minus(duration)); + } + + plus(duration: Duration | number | DurationObject): LocalDateTime { + return new LocalDateTime(this.dateTime.plus(duration)); + } + + reconfigure(properties: LocaleOptions): LocalDateTime { + return new LocalDateTime(this.dateTime.reconfigure(properties)); + } + + resolvedLocaleOpts(options?: DateTimeFormatOptions): Intl.ResolvedDateTimeFormatOptions { + return this.dateTime.resolvedLocaleOpts(options); + } + + set(values: DateObjectUnits): LocalDateTime { + return new LocalDateTime(this.dateTime.set(values)); + } + + setLocale(locale: string): LocalDateTime { + return new LocalDateTime(this.dateTime.setLocale(locale)); + } + + startOf(unit: DurationUnit): LocalDateTime { + return new LocalDateTime(this.dateTime.startOf(unit)); + } + + toBSON(): Date { + return this.dateTime.toBSON(); + } + + toFormat(format: string, options?: DateTimeFormatOptions): string { + return this.dateTime.toFormat(format, options); + } + + toHTTP(): string { + return this.dateTime.toHTTP(); + } + + toISO(options?: ToISOTimeOptions): string { + return this.dateTime.toISO(options); + } + + toISODate(options?: ToISODateOptions): string { + return this.dateTime.toISODate(options); + } + + toISOTime(options?: ToISOTimeOptions): string { + return this.dateTime.toISOTime(options); + } + + toISOWeekDate(): string { + return this.dateTime.toISOWeekDate(); + } + + toJSON(): string { + return this.dateTime.toJSON(); + } + + toZoned(zone: string | Zone, options?: ZoneOptions): DateTime { + return this.dateTime.setZone(zone, {keepLocalTime: true, ...options}); + } + + toUTC(offset?: number, options?: ZoneOptions): DateTime { + return this.dateTime.toUTC(offset, {...options, keepLocalTime: true}); + } + + toLocal(): DateTime { + return this.toZoned(LOCAL_ZONE); + } + + toLocaleParts(options?: LocaleOptions & DateTimeFormatOptions): (any & { type: string })[] { + return this.dateTime.toLocaleParts(options); + } + + toLocaleString(options?: LocaleOptions & DateTimeFormatOptions): string { + return this.dateTime.toLocaleString(options); + } + + toObject(options?: { includeConfig?: boolean }): LocalDateObject { + let dateObject = this.dateTime.toObject(options); + dateObject = {...dateObject}; + delete dateObject.zone; + return dateObject; + } + + toSQL(options?: ToSQLOptions): string { + return this.dateTime.toSQL({...options, includeOffset: false, includeZone: false}); + } + + toSQLDate(): string { + return this.dateTime.toSQLDate(); + } + + toSQLTime(options?: ToSQLOptions): string { + return this.dateTime.toSQLTime({...options, includeOffset: false, includeZone: false}); + } + + toString(): string { + return this.dateTime.year + "-" + this.dateTime.month + "-" + this.dateTime.day; + } + + until(other: DateTime): Interval { + return this.dateTime.until(other); + } + + valueOf() { + return this.dateTime.valueOf(); + } +} diff --git a/teamapps-client/ts/modules/datetime/LocalTime.ts b/teamapps-client/ts/modules/datetime/LocalTime.ts new file mode 100644 index 000000000..be9cdb2fa --- /dev/null +++ b/teamapps-client/ts/modules/datetime/LocalTime.ts @@ -0,0 +1,24 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +export interface LocalTime { + hour: number, + minute: number, + second?: number +} diff --git a/teamapps-client/ts/modules/formfield/UiButton.ts b/teamapps-client/ts/modules/formfield/UiButton.ts index 7f57b93c7..e28a3c05e 100644 --- a/teamapps-client/ts/modules/formfield/UiButton.ts +++ b/teamapps-client/ts/modules/formfield/UiButton.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,70 +21,79 @@ import {UiItemView} from "../UiItemView"; import {TeamAppsUiContext} from "../TeamAppsUiContext"; import {TeamAppsUiComponentRegistry} from "../TeamAppsUiComponentRegistry"; import {UiDropDown} from "../micro-components/UiDropDown"; -import {UiComponent} from "../UiComponent"; import {UiTemplateConfig} from "../../generated/UiTemplateConfig"; -import {UiButton_DropDownOpenedEvent, UiButtonCommandHandler, UiButtonConfig, UiButtonEventSource} from "../../generated/UiButtonConfig"; -import * as $ from "jquery"; -import {keyCodes} from "trivial-components"; +import { + UiButton_ClickedEvent, + UiButton_DropDownOpenedEvent, + UiButtonCommandHandler, + UiButtonConfig, + UiButtonEventSource +} from "../../generated/UiButtonConfig"; import {UiField} from "./UiField"; -import {UiColorConfig} from "../../generated/UiColorConfig"; -import {createUiColorCssString} from "../util/CssFormatUtil"; import {UiFieldEditingMode} from "../../generated/UiFieldEditingMode"; import {TeamAppsEvent} from "../util/TeamAppsEvent"; -import {EventFactory} from "../../generated/EventFactory"; import {bind} from "../util/Bind"; import {UiFieldMessageConfig} from "../../generated/UiFieldMessageConfig"; -import {getHighestSeverity} from "../micro-components/FieldMessagesPopper"; -import {UiFieldMessageSeverity} from "../../generated/UiFieldMessageSeverity"; +import {parseHtml} from "../Common"; +import {UiComponent} from "../UiComponent"; +import {executeWhenFirstDisplayed} from "../util/ExecuteWhenFirstDisplayed"; -export class UiButton extends UiField implements UiButtonEventSource, UiButtonCommandHandler { +export class UiButton extends UiField implements UiButtonEventSource, UiButtonCommandHandler { - public readonly onDropDownOpened: TeamAppsEvent = new TeamAppsEvent(this); + public readonly onClicked: TeamAppsEvent = new TeamAppsEvent(); + public readonly onDropDownOpened: TeamAppsEvent = new TeamAppsEvent(); private template: UiTemplateConfig; private templateRecord: any; - private _$button: JQuery; + private $main: HTMLElement; private _dropDown: UiDropDown; // lazy-init! private dropDownComponent: UiComponent; private minDropDownWidth: number; private minDropDownHeight: number; private openDropDownIfNotSet: boolean; + private onClickJavaScript: string; protected initialize(config: UiButtonConfig, context: TeamAppsUiContext) { this.template = config.template; this.templateRecord = config.templateRecord; - this._$button = $(this.getReadOnlyHtml(this.templateRecord, -1)); + this.$main = parseHtml(this.getReadOnlyHtml(this.templateRecord, -1)); this.minDropDownWidth = config.minDropDownWidth; this.minDropDownHeight = config.minDropDownHeight; this.openDropDownIfNotSet = config.openDropDownIfNotSet; - this.getMainInnerDomElement() - .on("mousedown keydown", (e) => { - if (e.type === "mousedown" || e.which === keyCodes.enter || e.which === keyCodes.space) { - if (this.dropDownComponent != null || this.openDropDownIfNotSet) { - if (!this.dropDown.isOpen) { - const width = this.getMainInnerDomElement()[0].offsetWidth; - this.dropDown.open({$reference: this.getMainInnerDomElement(), width: Math.max(this.minDropDownWidth, width), minHeight: this.minDropDownHeight}); - this.onDropDownOpened.fire(EventFactory.createUiButton_DropDownOpenedEvent(this.getId())); - this.getMainInnerDomElement().addClass("open"); - } else { - this.dropDown.close(); // not needed for clicks, but for keydown! - } + ['mousedown', 'keydown'].forEach((eventName) => this.getMainInnerDomElement().addEventListener(eventName, (e: Event) => { + if (e.type === "mousedown" || (e as KeyboardEvent).key === "Enter" || (e as KeyboardEvent).key === " ") { + if (this.dropDownComponent != null || this.openDropDownIfNotSet) { + if (!this.dropDown.isOpen) { + const width = this.getMainInnerDomElement().offsetWidth; + this.dropDown.open({$reference: this.getMainInnerDomElement(), width: Math.max(this.minDropDownWidth, width), minHeight: this.minDropDownHeight}); + this.onDropDownOpened.fire({}); + this.getMainInnerDomElement().classList.add("open"); + } else { + this.dropDown.close(); // not needed for clicks, but for keydown! } } - }) - // It is necessary to commit only after a full "click", since fields commit on blur. E.g. a UiTextField should blur-commit first, before the button click-commits. This would not work with "mousedown". - .on("click keypress", (e) => { - if (e.type === "click" || e.which === keyCodes.enter || e.which === keyCodes.space) { - if (this.getEditingMode() === UiFieldEditingMode.EDITABLE || this.getEditingMode() === UiFieldEditingMode.EDITABLE_IF_FOCUSED) { - this.commit(true); + } + })); + + // It is necessary to commit only after a full "click", since fields commit on blur. E.g. a UiTextField should blur-commit first, before the button click-commits. This would not work with "mousedown". + ["click", "keypress"].forEach(eventName => this.getMainInnerDomElement().addEventListener(eventName, (e) => { + if (e.type === "click" || (e as KeyboardEvent).key === "Enter" || (e as KeyboardEvent).key === " ") { + if (this.getEditingMode() === UiFieldEditingMode.EDITABLE || this.getEditingMode() === UiFieldEditingMode.EDITABLE_IF_FOCUSED) { + if (this.onClickJavaScript != null) { + let context = this._context; // make context available in evaluated javascript + eval(this.onClickJavaScript); } + this.onClicked.fire({}); + this.commit(true); } - }); - this.setDropDownComponent(config.dropDownComponent); + } + })); + this.setDropDownComponent(config.dropDownComponent as UiComponent); + this.setOnClickJavaScript(config.onClickJavaScript); } setDropDownSize(minDropDownWidth: number, minDropDownHeight: number): void { @@ -102,7 +111,6 @@ export class UiButton extends UiField implements UiButtonE this.dropDownComponent.onItemClicked.addListener(this.closeDropDown); } this.dropDown.setContentComponent(this.dropDownComponent); - this.dropDownComponent.attachedToDom = true; } else { this.dropDownComponent = null; this.dropDown.setContentComponent(null); @@ -110,7 +118,7 @@ export class UiButton extends UiField implements UiButtonE } @bind - private closeDropDown() { + public closeDropDown() { this.dropDown.close(); } @@ -118,8 +126,8 @@ export class UiButton extends UiField implements UiButtonE // lazy-init! if (this._dropDown == null) { this._dropDown = new UiDropDown(); - this._dropDown.getMainDomElement().addClass("UiButton-dropdown"); - this._dropDown.onClose.addListener(eventObject => this.getMainInnerDomElement().removeClass("open")) + this._dropDown.getMainDomElement().classList.add("UiButton-dropdown"); + this._dropDown.onClose.addListener(eventObject => this.getMainInnerDomElement().classList.remove("open")) } return this._dropDown; } @@ -128,8 +136,8 @@ export class UiButton extends UiField implements UiButtonE this.openDropDownIfNotSet = openDropDownIfNotSet; } - public getMainInnerDomElement(): JQuery { - return this._$button; + public getMainInnerDomElement(): HTMLElement { + return this.$main; } setTemplate(template: UiTemplateConfig, templateRecord: any): void { @@ -140,7 +148,7 @@ export class UiButton extends UiField implements UiButtonE setTemplateRecord(data: any): void { this.templateRecord = data; let innerHtml = this.renderContent(); - this._$button[0].innerHTML = innerHtml; + this.$main.innerHTML = innerHtml; } private renderContent(): string { @@ -151,12 +159,9 @@ export class UiButton extends UiField implements UiButtonE super.setFieldMessages(fieldMessageConfigs); } - public getFocusableElement(): JQuery { - return this._$button; - } - + @executeWhenFirstDisplayed() focus(): void { - this._$button.focus(); + this.$main.focus(); } protected displayCommittedValue(): void { @@ -168,29 +173,30 @@ export class UiButton extends UiField implements UiButtonE } protected onEditingModeChanged(editingMode: UiFieldEditingMode): void { - UiField.defaultOnEditingModeChangedImpl(this); + UiField.defaultOnEditingModeChangedImpl(this, () => this.$main); } - public getReadOnlyHtml(value: boolean, availableWidth: number): string { + public getReadOnlyHtml(value: void, availableWidth: number): string { return `
${this.renderContent()}
`; } - public valuesChanged(v1: boolean, v2: boolean): boolean { + public valuesChanged(v1: void, v2: void): boolean { return false; } - doDestroy(): void { - } - - isValidData(v: boolean): boolean { + isValidData(v: void): boolean { return true; } getDefaultValue(): true { return true; } + + setOnClickJavaScript(onClickJavaScript: string): void { + this.onClickJavaScript = onClickJavaScript; + } } TeamAppsUiComponentRegistry.registerFieldClass("UiButton", UiButton); diff --git a/teamapps-client/ts/modules/formfield/UiCheckBox.ts b/teamapps-client/ts/modules/formfield/UiCheckBox.ts index 42bb71899..3b47aeb8a 100644 --- a/teamapps-client/ts/modules/formfield/UiCheckBox.ts +++ b/teamapps-client/ts/modules/formfield/UiCheckBox.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,58 +17,56 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; import {UiCheckBoxCommandHandler, UiCheckBoxConfig, UiCheckBoxEventSource} from "../../generated/UiCheckBoxConfig"; import {UiFieldEditingMode} from "../../generated/UiFieldEditingMode"; import {UiField} from "./UiField"; import {TeamAppsUiContext} from "../TeamAppsUiContext"; -import {generateUUID} from "../Common"; +import {generateUUID, parseHtml} from "../Common"; import {TeamAppsUiComponentRegistry} from "../TeamAppsUiComponentRegistry"; -import {UiColorConfig} from "../../generated/UiColorConfig"; -import {createUiColorCssString} from "../util/CssFormatUtil"; -import {keyCodes} from "trivial-components"; +import {keyCodes} from "../trivial-components/TrivialCore"; import {UiFieldMessageConfig} from "../../generated/UiFieldMessageConfig"; import {getHighestSeverity} from "../micro-components/FieldMessagesPopper"; import {UiFieldMessageSeverity} from "../../generated/UiFieldMessageSeverity"; +import {executeWhenFirstDisplayed} from "../util/ExecuteWhenFirstDisplayed"; export class UiCheckBox extends UiField implements UiCheckBoxEventSource, UiCheckBoxCommandHandler { - private $main: JQuery; - private $check: JQuery; - private $label: JQuery; - private $style: JQuery; + private $main: HTMLElement; + private $check: HTMLElement; + private $label: HTMLElement; + private $style: HTMLElement; - private backgroundColor: UiColorConfig; - private checkColor: UiColorConfig; - private borderColor: UiColorConfig; + private backgroundColor: string; + private checkColor: string; + private borderColor: string; protected initialize(config: UiCheckBoxConfig, context: TeamAppsUiContext) { const uuid = "cb-" + generateUUID(); - this.$main = $(`
+ this.$main = parseHtml(`
`); - this.$check = this.$main.find(".checkbox-check"); - this.$label = this.$main.find(".checkbox-label"); - this.$style = this.$main.find("style"); + this.$check = this.$main.querySelector(":scope .checkbox-check"); + this.$label = this.$main.querySelector(":scope .checkbox-label"); + this.$style = this.$main.querySelector(":scope style"); this.setCaption(config.caption); this.setBackgroundColor(config.backgroundColor); this.setCheckColor(config.checkColor); this.setBorderColor(config.borderColor); - this.$main.mousedown(() => { + this.$main.addEventListener("mousedown", () => { setTimeout(() => this.focus()); }); - this.$main.click(() => { + this.$main.addEventListener('click', () => { if (this.getEditingMode() === UiFieldEditingMode.DISABLED || this.getEditingMode() === UiFieldEditingMode.READONLY) { return; } this.toggleCommittedValue(); }); - this.$check.on("keydown", (e) => { + this.$check.addEventListener("keydown", (e) => { if (e.keyCode === keyCodes.space) { this.toggleCommittedValue(); e.preventDefault(); // no scroll-down! @@ -85,25 +83,29 @@ export class UiCheckBox extends UiField implements Ui this.commit(true); } - public getMainInnerDomElement(): JQuery { + public getMainInnerDomElement(): HTMLElement { return this.$main; } setCaption(caption: string): void { - this.$label.text(caption); + if (!this._config.htmlEnabled) { + this.$label.textContent = caption || ''; + } else { + this.$label.innerHTML = caption || ''; + } } - setBackgroundColor(backgroundColor: UiColorConfig): void { + setBackgroundColor(backgroundColor: string): void { this.backgroundColor = backgroundColor; this.updateStyles(); } - setCheckColor(checkColor: UiColorConfig): void { + setCheckColor(checkColor: string): void { this.checkColor = checkColor; this.updateStyles(); } - setBorderColor(borderColor: UiColorConfig): void { + setBorderColor(borderColor: string): void { this.borderColor = borderColor; this.updateStyles(); } @@ -116,44 +118,36 @@ export class UiCheckBox extends UiField implements Ui private updateStyles() { const highestMessageSeverity = getHighestSeverity(this.getFieldMessages()); if (highestMessageSeverity > UiFieldMessageSeverity.INFO) { - this.$style.text(''); // styles are defined by message severity styles + this.$style.textContent = ''; // styles are defined by message severity styles } else { - this.$style.text(`[data-teamapps-id=${this._config.id}] > .checkbox-check { - background-color: ${createUiColorCssString(this.backgroundColor)}; - color: ${createUiColorCssString(this.checkColor)}; - border: 1px solid ${createUiColorCssString(this.borderColor)}; - }`); + this.$style.textContent = `[data-teamapps-id=${this._config.id}] > .checkbox-check { + background-color: ${(this.backgroundColor ?? '')}; + color: ${(this.checkColor ?? '')}; + border: 1px solid ${(this.borderColor ?? '')}; + }`; } } - public getFocusableElement(): JQuery { - return this.$check; - } - protected displayCommittedValue(): void { let v = this.getCommittedValue(); - this.$check.text(v ? "\ue013" : ""); + this.$check.textContent = v ? "\ue013" : ""; } + @executeWhenFirstDisplayed() focus(): void { this.$check.focus(); } - doDestroy(): void { - this.$main.detach(); - } - getTransientValue(): boolean { return this.getCommittedValue(); } protected onEditingModeChanged(editingMode: UiFieldEditingMode): void { + UiField.defaultOnEditingModeChangedImpl(this, () => this.$check); if (editingMode === UiFieldEditingMode.DISABLED || editingMode === UiFieldEditingMode.READONLY) { - this.$main.addClass("disabled"); - this.$check.attr("tabindex", ""); + this.$main.classList.add("disabled"); } else { - this.$main.removeClass("disabled"); - this.$check.attr("tabindex", "0"); + this.$main.classList.remove("disabled"); } } diff --git a/teamapps-client/ts/modules/formfield/UiColorPicker.ts b/teamapps-client/ts/modules/formfield/UiColorPicker.ts index ee73b5b54..d9b00bdcc 100644 --- a/teamapps-client/ts/modules/formfield/UiColorPicker.ts +++ b/teamapps-client/ts/modules/formfield/UiColorPicker.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,32 +17,30 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; import {UiField} from "./UiField"; import {UiColorPickerConfig, UiColorPickerEventSource} from "../../generated/UiColorPickerConfig"; import {TeamAppsUiContext} from "../TeamAppsUiContext"; import {TeamAppsUiComponentRegistry} from "../TeamAppsUiComponentRegistry"; import {UiFieldEditingMode} from "../../generated/UiFieldEditingMode"; -import {createUiColorConfig, UiColorConfig} from "../../generated/UiColorConfig"; import {create as createPickr, HSVaColor, Pickr} from "pickr-widget"; -import {createUiColorCssString} from "../util/CssFormatUtil"; -import {executeWhenAttached} from "../util/ExecuteWhenAttached"; -import {keyCodes} from "trivial-components"; +import {executeWhenFirstDisplayed} from "../util/ExecuteWhenFirstDisplayed"; +import {keyCodes} from "../trivial-components/TrivialCore"; +import {parseHtml} from "../Common"; -export class UiColorPicker extends UiField implements UiColorPickerEventSource { - private $main: JQuery; +export class UiColorPicker extends UiField implements UiColorPickerEventSource { + private $main: HTMLElement; private pickr: Pickr; private doNotCommit: boolean; protected initialize(config: UiColorPickerConfig, context: TeamAppsUiContext) { - this.$main = $(`
`); + this.$main = parseHtml(`
`); this.doNotCommit = true; this.pickr = createPickr({ - el: this.$main.find('.pickr')[0], + el: this.$main.querySelector(':scope .pickr'), parent: document.body, position: "middle", - default: createUiColorCssString(config.defaultColor), + default: config.defaultColor, comparison: false, @@ -79,30 +77,27 @@ export class UiColorPicker extends UiField i }); this.doNotCommit = false; - this.$main.on("keydown", (e) => { + this.$main.addEventListener("keydown", (e) => { if (e.keyCode === keyCodes.enter || e.keyCode === keyCodes.space) { this.pickr.show(); } }); - this.$main.find(".pcr-button").addClass("field-border field-border-glow"); + this.$main.querySelector(":scope .pcr-button").classList.add("field-border", "field-border-glow"); } - isValidData(v: UiColorConfig): boolean { - return v == null || (v.red != null && v.green != null && v.blue != null && v.alpha != null); + isValidData(v: string): boolean { + return v == null || typeof v == 'string'; } commit(forceEvenIfNotChanged?: boolean): boolean { return super.commit(forceEvenIfNotChanged); } - @executeWhenAttached() + @executeWhenFirstDisplayed() protected displayCommittedValue(): void { let committedValue = this.getCommittedValue(); - if (committedValue != null && committedValue.alpha === 1) { // TODO https://github.com/Simonwep/pickr/issues/19 - committedValue = {...committedValue, alpha: .999999}; - } - let colorString = committedValue != null ? createUiColorCssString(committedValue) : createUiColorCssString(this.getDefaultValue()); + let colorString = committedValue ?? this.getDefaultValue(); try { this.doNotCommit = true; @@ -112,23 +107,23 @@ export class UiColorPicker extends UiField i } } - getDefaultValue(): UiColorConfig { + getDefaultValue(): string { return this._config.defaultColor; } - getFocusableElement(): JQuery { - return this.$main; + @executeWhenFirstDisplayed() + focus(): void { + return this.$main.focus(); } - getMainInnerDomElement(): JQuery { + getMainInnerDomElement(): HTMLElement { return this.$main; } - getTransientValue(): UiColorConfig { + getTransientValue(): string { const color = this.pickr.getColor(); let rgb = color.toRGBA(); // the alpha value is buggy - const uiColorConfig = createUiColorConfig(rgb[0], rgb[1], rgb[2], {alpha: color.a}); - return uiColorConfig; + return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${color.a})`; } protected onEditingModeChanged(editingMode: UiFieldEditingMode): void { @@ -139,16 +134,13 @@ export class UiColorPicker extends UiField i } } - valuesChanged(v1: UiColorConfig, v2: UiColorConfig): boolean { + valuesChanged(v1: string, v2: string): boolean { return (v1 == null) !== (v2 == null) - || (v1 != null && v2 != null) - && (v1.red !== v2.red - || v1.green !== v2.green - || v1.blue !== v2.blue - || v1.alpha !== v2.alpha); + || (v1 != null && v2 != null && v1 !== v2); } - doDestroy(): void { + destroy(): void { + super.destroy(); this.pickr.destroyAndRemove(); } diff --git a/teamapps-client/ts/modules/formfield/UiComboBox.ts b/teamapps-client/ts/modules/formfield/UiComboBox.ts index 7053d9769..2ebccbfed 100644 --- a/teamapps-client/ts/modules/formfield/UiComboBox.ts +++ b/teamapps-client/ts/modules/formfield/UiComboBox.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,71 +17,44 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; -import {defaultTreeQueryFunctionFactory, keyCodes, ResultCallback, TrivialComboBox, trivialMatch, TrivialTreeBox} from "trivial-components"; +import {TrivialComboBox} from "../trivial-components/TrivialComboBox"; +import {TrivialTreeBox} from "../trivial-components/TrivialTreeBox"; import {UiFieldEditingMode} from "../../generated/UiFieldEditingMode"; -import {UiTextMatchingMode} from "../../generated/UiTextMatchingMode"; import {UiField} from "./UiField"; import {TeamAppsUiContext} from "../TeamAppsUiContext"; -import {UiComboBox_LazyChildDataRequestedEvent, UiComboBoxCommandHandler, UiComboBoxConfig, UiComboBoxEventSource} from "../../generated/UiComboBoxConfig"; +import {UiComboBoxCommandHandler, UiComboBoxConfig, UiComboBoxEventSource} from "../../generated/UiComboBoxConfig"; import {TeamAppsEvent} from "../util/TeamAppsEvent"; import {TeamAppsUiComponentRegistry} from "../TeamAppsUiComponentRegistry"; -import {UiTextInputHandlingField_SpecialKeyPressedEvent, UiTextInputHandlingField_TextInputEvent} from "../../generated/UiTextInputHandlingFieldConfig"; +import { + UiTextInputHandlingField_SpecialKeyPressedEvent, + UiTextInputHandlingField_TextInputEvent +} from "../../generated/UiTextInputHandlingFieldConfig"; import {UiSpecialKey} from "../../generated/UiSpecialKey"; import {UiComboBoxTreeRecordConfig} from "../../generated/UiComboBoxTreeRecordConfig"; import {UiTemplateConfig} from "../../generated/UiTemplateConfig"; import {buildObjectTree, NodeWithChildren, Renderer} from "../Common"; -import {EventFactory} from "../../generated/EventFactory"; +import {TreeBoxDropdown} from "../trivial-components/dropdown/TreeBoxDropdown"; +import {UiToolButton} from "../micro-components/UiToolButton"; +import {executeWhenFirstDisplayed} from "../util/ExecuteWhenFirstDisplayed"; export function isFreeTextEntry(o: UiComboBoxTreeRecordConfig): boolean { return o != null && o.id < 0; } export class UiComboBox extends UiField implements UiComboBoxEventSource, UiComboBoxCommandHandler { - public readonly onTextInput: TeamAppsEvent = new TeamAppsEvent(this, 250); - public readonly onSpecialKeyPressed: TeamAppsEvent = new TeamAppsEvent(this, 250); - public readonly onLazyChildDataRequested: TeamAppsEvent = new TeamAppsEvent(this); + public readonly onTextInput: TeamAppsEvent = new TeamAppsEvent({throttlingMode: "debounce", delay: 250}); + public readonly onSpecialKeyPressed: TeamAppsEvent = new TeamAppsEvent({throttlingMode: "debounce", delay: 250}); - private $originalInput: JQuery; private trivialComboBox: TrivialComboBox>; private templateRenderers: { [name: string]: Renderer }; - private lastResultCallback: (result: NodeWithChildren[]) => void; private freeTextIdEntryCounter = -1; protected initialize(config: UiComboBoxConfig, context: TeamAppsUiContext) { - this.$originalInput = $(``); - this.templateRenderers = config.templates != null ? context.templateRegistry.createTemplateRenderers(config.templates) : {}; - let localQueryFunction: Function; - const objectTree = buildObjectTree(config.staticData, "id", "parentId"); - if (config.staticData != null && config.staticData.length > 0) { - let trivialMatchingOptions = UiComboBox.createTrivialMatchingOptions(config); - localQueryFunction = defaultTreeQueryFunctionFactory(objectTree, (entry: NodeWithChildren, queryString: string) => { - if (config.staticDataMatchPropertyNames) { - return config.staticDataMatchPropertyNames.some(fieldName => entry.values[fieldName] && trivialMatch(entry.values[fieldName], queryString, trivialMatchingOptions).length > 0); - } else { - return trivialMatch(entry.asString, queryString, trivialMatchingOptions).length > 0; - } - }, "__children", "expanded"); - } - let queryFunction = (queryString: string, resultCallback: ResultCallback>) => { - this.lastResultCallback = resultCallback; - this.onTextInput.fire(EventFactory.createUiTextInputHandlingField_TextInputEvent(this.getId(), queryString)); - if (localQueryFunction != null) { - localQueryFunction(queryString, resultCallback); - } - }; - - this.trivialComboBox = new TrivialComboBox>(this.$originalInput, { - allowFreeText: config.allowAnyText, - childrenProperty: "__children", - expandedProperty: "expanded", - showExpanders: config.showExpanders, - queryFunction: queryFunction, - entryRenderingFunction: entry => this.renderRecord(entry, true), + this.trivialComboBox = new TrivialComboBox>({ selectedEntryRenderingFunction: entry => { if (entry == null) { return ""; @@ -94,43 +67,70 @@ export class UiComboBox extends UiField) => { - this.onLazyChildDataRequested.fire(EventFactory.createUiComboBox_LazyChildDataRequestedEvent(this.getId(), node.id)); - }, - lazyChildrenFlag: entry => entry.lazyChildren, - spinnerTemplate: `
`, - textHighlightingEntryLimit: config.textHighlightingEntryLimit, + showDropDownOnResultsOnly: config.showDropDownAfterResultsArrive, showClearButton: config.showClearButton, entryToEditorTextFunction: e => e.asString, - autoCompleteFunction: (editorText, entry) => { - const entryAsString = entry.asString; - if (entryAsString.toLowerCase().indexOf(editorText.toLowerCase()) === 0) { - return entryAsString; + textToEntryFunction: (freeText) => { + if (config.allowAnyText) { + return {id: this.freeTextIdEntryCounter--, values: {}, asString: freeText}; } else { - return ""; + return null; } }, - freeTextEntryFactory: (freeText) => { - return {id: this.freeTextIdEntryCounter--, values: {}, asString: freeText}; + preselectFirstQueryResult: config.highlightFirstResultEntry, + placeholderText: config.placeholderText, + dropDownMaxHeight: config.dropDownMaxHeight, + dropDownMinWidth: config.dropDownMinWidth + }, new TreeBoxDropdown({ + queryFunction: (queryString: string) => { + return config.retrieveDropdownEntries({queryString}) + .then(entries => buildObjectTree(entries, "id", "parentId")); }, - idFunction: entry => entry && entry.id - }); - $(this.trivialComboBox.getMainDomElement()).addClass("UiComboBox"); + textHighlightingEntryLimit: config.textHighlightingEntryLimit, + preselectionMatcher: (query, entry) => entry.asString.toLowerCase().indexOf(query.toLowerCase()) >= 0 + }, new TrivialTreeBox>({ + childrenProperty: "__children", + expandedProperty: "expanded", + showExpanders: config.showExpanders, + entryRenderingFunction: entry => this.renderRecord(entry, true), + idFunction: entry => entry && entry.id, + lazyChildrenQueryFunction: async (node: NodeWithChildren) => buildObjectTree(await config.lazyChildren({parentId: node.id}), "id", "parentId"), + lazyChildrenFlag: entry => entry.lazyChildren, + selectableDecider: entry => entry.selectable, + selectOnHover: true, + animationDuration: this._config.animate ? 120 : 0 + }))); + this.trivialComboBox.getMainDomElement().classList.add("UiComboBox", "default-min-field-width"); this.trivialComboBox.onSelectedEntryChanged.addListener(() => this.commit()); - $(this.trivialComboBox.getEditor()).on("keydown", (e) => { - if (e.keyCode === keyCodes.escape) { - this.onSpecialKeyPressed.fire(EventFactory.createUiTextInputHandlingField_SpecialKeyPressedEvent(this.getId(), UiSpecialKey.ESCAPE)); - } else if (e.keyCode === keyCodes.enter) { - this.onSpecialKeyPressed.fire(EventFactory.createUiTextInputHandlingField_SpecialKeyPressedEvent(this.getId(), UiSpecialKey.ENTER)); + this.trivialComboBox.getEditor().addEventListener("keydown", (e: KeyboardEvent) => { + if (e.key === "Escape") { + this.onSpecialKeyPressed.fire({ + key: UiSpecialKey.ESCAPE + }); + } else if (e.key === "Enter") { + this.onSpecialKeyPressed.fire({ + key: UiSpecialKey.ENTER + }); } }); + this.trivialComboBox.getEditor().addEventListener("input", e => this.onTextInput.fire({enteredString: (e.target as HTMLInputElement).value})); + + this.trivialComboBox.getMainDomElement().classList.add("field-border", "field-border-glow", "field-background"); + this.trivialComboBox.getMainDomElement().querySelector(":scope .tr-editor").classList.add("field-background"); + this.trivialComboBox.getMainDomElement().querySelector(":scope .tr-trigger").classList.add("field-border"); + if (config.toolButtons != null) { + this.trivialComboBox.setToolButtons(config.toolButtons as UiToolButton[]); + } + } + + setToolButtons(toolButtons: unknown[]): any { + this.trivialComboBox.setToolButtons((toolButtons as UiToolButton[]) ?? []); + } - $(this.trivialComboBox.getMainDomElement()).addClass("field-border field-border-glow field-background"); - $(this.trivialComboBox.getMainDomElement()).find(".tr-editor").addClass("field-background"); - $(this.trivialComboBox.getMainDomElement()).find(".tr-trigger").addClass("field-border"); - this.trivialComboBox.onFocus.addListener(() => this.getMainDomElement().addClass("focus")); - this.trivialComboBox.onBlur.addListener(() => this.getMainDomElement().removeClass("focus")); + protected initFocusHandling() { + this.trivialComboBox.onFocus.addListener(() => this.onFocusGained.fire({})); + this.trivialComboBox.onBlur.addListener(() => this.onBlur.fire({})); } private renderRecord(record: NodeWithChildren, dropdown: boolean): string { @@ -147,52 +147,17 @@ export class UiComboBox extends UiField 0 && this.hasFocus()) { - this.trivialComboBox.openDropDown(); - } - } - - setChildNodes(parentId: number, recordList: UiComboBoxTreeRecordConfig[]): void { - const objectTree = buildObjectTree(recordList, "id", "parentId"); - (this.trivialComboBox.getDropDownComponent() as TrivialTreeBox>).updateChildren(parentId as any /*fix trivial-components tsd*/, objectTree); - } - - public getMainInnerDomElement(): JQuery { - return $(this.trivialComboBox.getMainDomElement()); - } - - public getFocusableElement(): JQuery { - return $(this.trivialComboBox.getMainDomElement()); + public getMainInnerDomElement(): HTMLElement { + return this.trivialComboBox.getMainDomElement() as HTMLElement; } protected displayCommittedValue(): void { let uiValue = this.getCommittedValue(); - this.trivialComboBox.setSelectedEntry(uiValue, true); + this.trivialComboBox.setValue(uiValue); } public getTransientValue(): any { - return this.trivialComboBox.getSelectedEntry(); + return this.trivialComboBox.getValue(); } protected convertValueForSendingToServer(value: UiComboBoxTreeRecordConfig): any { @@ -202,18 +167,16 @@ export class UiComboBox extends UiField implements UiComponentFieldEventSource, UiComponentFieldCommandHandler { private component: UiComponent; - private $componentWrapper: JQuery; + private $componentWrapper: HTMLElement; protected initialize(config: UiComponentFieldConfig, context: TeamAppsUiContext) { - this.$componentWrapper = $('
'); - this.setBackgroundColor(config.backgroundColor); - this.setBorder(config.border); - this.setSize(config.width, config.height); - this.setComponent(config.component); + this.$componentWrapper = parseHtml('
'); + this.setHeight(config.height); + this.setComponent(config.component as UiComponent); + this.setBordered(!!config.bordered) } isValidData(v: void): boolean { return true; } - setBackgroundColor(backgroundColor: UiColorConfig): void { - this.$componentWrapper.css({ - "background-color": backgroundColor ? createUiColorCssString(backgroundColor) : '' - }); - } - - setBorder(border: UiBorderConfig): void { - this.$componentWrapper.css(createUiBorderCssObject(border)); + setBordered(bordered: boolean): void { + this.$componentWrapper.classList.toggle("bordered", bordered); + this.$componentWrapper.classList.toggle("field-border", bordered); + this.$componentWrapper.classList.toggle("field-border-glow", bordered) } setComponent(component: UiComponent): void { if (this.component != null) { - this.component.getMainDomElement().detach(); + this.component.getMainElement().remove(); } this.component = component; - this.component.getMainDomElement().appendTo(this.$componentWrapper); - } - - setSize(width: number, height: number): void { - this.$componentWrapper.css({ - height: height ? `${height}px` : '', - width: width ? `${width}px` : '' - }); + this.$componentWrapper.appendChild(this.component.getMainElement()); } - protected onAttachedToDom(): void { - this.component.attachedToDom = true; + setHeight(height: number): void { + this.$componentWrapper.style.height = height ? `${height}px` : ''; } - public getMainInnerDomElement(): JQuery { + public getMainInnerDomElement(): HTMLElement { return this.$componentWrapper; } - public getFocusableElement(): JQuery { - return null; - } - protected displayCommittedValue(): void { // do nothing } + @executeWhenFirstDisplayed() focus(): void { // do nothing } - doDestroy(): void { - this.$componentWrapper.detach(); - } - getTransientValue(): void { } @@ -110,9 +89,6 @@ export class UiComponentField extends UiField impl return false; } - onResize(): void { - this.component && this.component.reLayout(true); - } } TeamAppsUiComponentRegistry.registerFieldClass("UiComponentField", UiComponentField); diff --git a/teamapps-client/ts/modules/formfield/UiCompositeField.ts b/teamapps-client/ts/modules/formfield/UiCompositeField.ts index eaf9aa4ba..4ad461a1a 100644 --- a/teamapps-client/ts/modules/formfield/UiCompositeField.ts +++ b/teamapps-client/ts/modules/formfield/UiCompositeField.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,25 +17,25 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; import {TeamAppsEvent} from "../util/TeamAppsEvent"; import {UiField, ValueChangeEventData} from "./UiField"; import * as log from "loglevel"; -import Logger = log.Logger; import {TeamAppsUiContext} from "../TeamAppsUiContext"; -import {keyCodes} from "trivial-components"; import {UiCompositeSubFieldConfig} from "../../generated/UiCompositeSubFieldConfig"; import {UiCompositeFieldConfig} from "../../generated/UiCompositeFieldConfig"; -import {UiEvent} from "../../generated/UiEvent"; import {UiFieldEditingMode} from "../../generated/UiFieldEditingMode"; import {UiColumnDefinitionConfig} from "../../generated/UiColumnDefinitionConfig"; import {TeamAppsUiComponentRegistry} from "../TeamAppsUiComponentRegistry"; -import {TableDataProviderItem} from "../table/TableDataProvider"; +import Logger = log.Logger; +import {closestAncestor, parseHtml} from "../Common"; +import {TableDataProviderItem} from "../UiInfiniteItemView"; +import {UiTableClientRecordConfig} from "../../generated/UiTableClientRecordConfig"; +import {executeWhenFirstDisplayed} from "../util/ExecuteWhenFirstDisplayed"; export type SubField = { config: UiCompositeSubFieldConfig, field: UiField, - $cell: JQuery, + $cell: HTMLElement, visible?: boolean }; @@ -49,21 +49,21 @@ export class UiCompositeField extends UiField { private static logger: Logger = log.getLogger("UiCompositeField"); public readonly onSubFieldValueChanged: TeamAppsEvent = - new TeamAppsEvent(this); + new TeamAppsEvent(); private subFields: SubField[]; - private $wrapper: JQuery; + private $wrapper: HTMLElement; protected initialize(config: UiCompositeFieldConfig, context: TeamAppsUiContext) { this.logger.debug('initializing'); this.subFields = []; let {$wrapper, subFieldSkeletons} = UiCompositeField.createDomStructure(config); subFieldSkeletons.forEach(subFieldSkeleton => { - const field = subFieldSkeleton.config.field; - field.getMainDomElement().appendTo(subFieldSkeleton.$cell); + const field = subFieldSkeleton.config.field as UiField; + subFieldSkeleton.$cell.appendChild(field.getMainElement()); field.onValueChanged.addListener((eventObject: ValueChangeEventData ) => { this.onSubFieldValueChanged.fire({ - fieldName: subFieldSkeleton.config.field.fieldName, + fieldName: "TODO!", originalEmitter: field, ...eventObject }); @@ -75,12 +75,12 @@ export class UiCompositeField extends UiField { }); UiCompositeField.validateNumberOfRowHeights(config); - $wrapper.on("keydown", (e) => { - if (e.which == keyCodes.tab) { + $wrapper.addEventListener("keydown", (e: KeyboardEvent) => { + if (e.key == "Tab") { let nextFocusableField = this.getNextFocusableField(e.shiftKey ? -1 : 1); if (nextFocusableField != null) { nextFocusableField.focus(); - this.logger.trace("navigated to " + nextFocusableField.getMainInnerDomElement()[0]); + this.logger.trace("navigated to " + nextFocusableField.getMainInnerDomElement()); return false; } else { this.logger.trace("not navigated"); @@ -131,7 +131,6 @@ export class UiCompositeField extends UiField { let allFocusableSubFields = allSubFields .filter(f => f.field.getEditingMode() !== UiFieldEditingMode.READONLY && f.field.getEditingMode() !== UiFieldEditingMode.DISABLED - && f.field.getFocusableElement() != null && f.visible === true); return allFocusableSubFields .sort((sf1: SubField, sf2: SubField) => { @@ -153,21 +152,18 @@ export class UiCompositeField extends UiField { return `[${sf.config.tabIndex}-${sf.config.row}-${sf.config.col}]`; } - public getMainInnerDomElement(): JQuery { + public getMainInnerDomElement(): HTMLElement { return this.$wrapper; } - public getFocusableElement(): JQuery { - return this.$wrapper; // TODO!! - } - protected displayCommittedValue(): void { let committedValue: any = this.getCommittedValue(); this.getAllSubFields().forEach(subField => { if (committedValue == null) { subField.field.setCommittedValue(null); } else { - subField.field.setCommittedValue(committedValue[subField.config.field.fieldName]); + let fieldName = "TODO!"; //(subField.config.field as UiField).fieldName; + subField.field.setCommittedValue(committedValue[fieldName]); } }); UiCompositeField.updateDeclaredSubfieldVisibilities(this.subFields, committedValue); @@ -180,12 +176,14 @@ export class UiCompositeField extends UiField { .forEach(subField => { let coordinates = subField.config.row + ',' + subField.config.col; if (visibleFieldsByCell[coordinates] != null) { - UiCompositeField.logger.warn(`Two or more sub-fields are visible in the same cell in UiCompositeField! Showing the last one only. Conflicting fieldNames: ${visibleFieldsByCell[coordinates].config.field.fieldName}, ${subField.config.field.fieldName}`); + let fieldName = "TODO!"; // visibleFieldsByCell[coordinates].config.field.fieldName; + let otherFieldName = "TODO!"; //subField.config.field.fieldName; + UiCompositeField.logger.warn(`Two or more sub-fields are visible in the same cell in UiCompositeField! Showing the last one only. Conflicting fieldNames: ${fieldName}, ${otherFieldName}`); } visibleFieldsByCell[coordinates] = subField; }); let visibleSubFields = Object.keys(visibleFieldsByCell).map(key => visibleFieldsByCell[key]); - subFields.forEach(subField => subField.$cell.toggleClass("hidden", visibleSubFields.indexOf(subField) === -1)); + subFields.forEach(subField => subField.$cell.classList.toggle("hidden", visibleSubFields.indexOf(subField) === -1)); } private static updateDeclaredSubfieldVisibilities(subFieldSkeletons: SubField[], value: any) { @@ -203,11 +201,12 @@ export class UiCompositeField extends UiField { return this.getCommittedValue(); // we might want to overwrite the values contained in this composite field... } + @executeWhenFirstDisplayed() focus(): void { if (window.event instanceof MouseEvent) { - let $cell = $(window.event.target).closest(".subfield-wrapper"); - if ($cell.length > 0) { - let subField = this.getSubFieldByFieldName($cell.attr("data-field-propertyname")); + let $cell = closestAncestor(window.event.target as HTMLElement, ".subfield-wrapper"); + if ($cell != null) { + let subField = this.getSubFieldByFieldName($cell.getAttribute("data-fieldname")); if (subField) { subField.field.focus(); return; @@ -224,7 +223,9 @@ export class UiCompositeField extends UiField { } private getSubFieldByFieldName(fieldName: string): SubField { - return this.subFields.filter(sf => sf.config.field.fieldName === fieldName)[0]; + console.error("TODO"); + return null; + // return this.subFields.filter(sf => sf.config.field.fieldName === fieldName)[0]; } protected onEditingModeChanged(editingMode: UiFieldEditingMode): void { @@ -239,10 +240,6 @@ export class UiCompositeField extends UiField { return this.subFields; } - doDestroy(): void { - this.$wrapper.detach(); - } - public getReadOnlyHtml(value: any, availableWidth: number): string { UiCompositeField.validateNumberOfRowHeights(this._config); let {$wrapper, subFieldSkeletons} = UiCompositeField.createDomStructure(this._config); @@ -252,26 +249,26 @@ export class UiCompositeField extends UiField { subFieldSkeletons.forEach(subfield => { if (subfield.field.getReadOnlyHtml) { - return (row: number, cell: number, value: any, columnDef: Slick.Column, dataContext: TableDataProviderItem) => { + return (row: number, cell: number, value: any, columnDef: Slick.Column, dataContext: UiTableClientRecordConfig) => { return subfield.field.getReadOnlyHtml(dataContext.values[subfield.config.propertyName], columnDef.width); }; } }); - $wrapper.addClass("static-readonly-UiCompositeField"); - return $("
").append($wrapper.clone()).html(); + $wrapper.classList.add("static-readonly-UiCompositeField"); + return $wrapper.outerHTML; } private static createDomStructure(config: UiCompositeFieldConfig) { - const $wrapper = $(`
`); - const $paddingWrapper = $wrapper.find(".padding-wrapper"); - $paddingWrapper.css("height", config.rowHeights.reduce((sum, height) => sum + height, 0) + "px"); + const $wrapper = parseHtml(`
`); + const $paddingWrapper = $wrapper.querySelector(":scope .padding-wrapper"); + $paddingWrapper.style.height = config.rowHeights.reduce((sum, height) => sum + height, 0) + "px"; let subFieldSkeletons: SubField[] = []; config.subFields.forEach(subFieldConfig => { - const uiField: UiField = subFieldConfig.field; - let $cell = $(`
`); - $cell.appendTo($paddingWrapper); + const uiField: UiField = subFieldConfig.field as UiField; + let $cell = parseHtml(`
`); + $paddingWrapper.appendChild($cell); if (config.drawFieldBorders) { - $cell.addClass("bordered"); + $cell.classList.add("bordered"); } subFieldSkeletons.push({ config: subFieldConfig, @@ -283,7 +280,7 @@ export class UiCompositeField extends UiField { } public onResize(): void { - UiCompositeField.applyLayout(this.subFields, this._config.columnDefinitions, this._config.rowHeights, this._config.horizontalCellSpacing, this._config.verticalCellSpacing, this.getMainInnerDomElement().outerWidth() - this._config.padding); + UiCompositeField.applyLayout(this.subFields, this._config.columnDefinitions, this._config.rowHeights, this._config.horizontalCellSpacing, this._config.verticalCellSpacing, this.getMainInnerDomElement().offsetWidth - this._config.padding); } private static applyLayout(subFieldSkeletons: SubField[], columnDefinitions: UiColumnDefinitionConfig[], rowHeights: number[], horizontalCellSpacing: number, verticalCellSpacing: number, availableWidth: number) { @@ -317,7 +314,7 @@ export class UiCompositeField extends UiField { subFieldSkeletons.forEach(subFieldSkeleton => { let endCol = subFieldSkeleton.config.col + subFieldSkeleton.config.colSpan - 1; let endRow = subFieldSkeleton.config.row + subFieldSkeleton.config.rowSpan - 1; - subFieldSkeleton.$cell.css({ + Object.assign(subFieldSkeleton.$cell.style, { left: cellLeftEdges[subFieldSkeleton.config.col], top: cellTopEdges[subFieldSkeleton.config.row], width: cellRightEdges[endCol] - cellLeftEdges[subFieldSkeleton.config.col], @@ -375,7 +372,7 @@ export class UiCompositeField extends UiField { if (typeof value === "boolean") { let affectedSubFields = this.subFields .filter(subField => subField.config.visibilityPropertyName === fieldName); - this.logger.debug("This value change affects the visibilities of the following fields: " + affectedSubFields.map(f => f.config.field.fieldName).join(', ')); + this.logger.debug("This value change affects the visibilities of the following fields: " + affectedSubFields.map(f => "TODO!" /*f.config.field.fieldName*/).join(', ')); affectedSubFields .forEach(subField => subField.visible = value); UiCompositeField.updateSubFieldVisibilities(this.subFields); diff --git a/teamapps-client/ts/modules/formfield/UiCurrencyField.ts b/teamapps-client/ts/modules/formfield/UiCurrencyField.ts index 1466263eb..f96b7b20f 100644 --- a/teamapps-client/ts/modules/formfield/UiCurrencyField.ts +++ b/teamapps-client/ts/modules/formfield/UiCurrencyField.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,92 +17,111 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; -import {DEFAULT_RENDERING_FUNCTIONS, defaultListQueryFunctionFactory, keyCodes, QueryFunction, TrivialUnitBox} from "trivial-components"; +import {defaultListQueryFunctionFactory, keyCodes, QueryFunction} from "../trivial-components/TrivialCore"; +import {TrivialUnitBox, TrivialUnitBoxChangeEvent} from "../trivial-components/TrivialUnitBox"; import {createUiCurrencyValueConfig, UiCurrencyValueConfig} from "../../generated/UiCurrencyValueConfig"; import {UiFieldEditingMode} from "../../generated/UiFieldEditingMode"; -import {UiCurrencyFieldConfig, UiCurrencyFieldCommandHandler, UiCurrencyFieldEventSource} from "../../generated/UiCurrencyFieldConfig"; +import {UiCurrencyFieldCommandHandler, UiCurrencyFieldConfig, UiCurrencyFieldEventSource} from "../../generated/UiCurrencyFieldConfig"; import {UiField} from "./UiField"; import {TeamAppsUiContext} from "../TeamAppsUiContext"; -import {CURRENCIES, Currency} from "../util/currencies"; -import {formatDecimalNumber} from "../Common"; +import {deepEquals, selectElementContents} from "../Common"; import {TeamAppsUiComponentRegistry} from "../TeamAppsUiComponentRegistry"; -import {UiTextInputHandlingField_SpecialKeyPressedEvent, UiTextInputHandlingField_TextInputEvent} from "../../generated/UiTextInputHandlingFieldConfig"; +import { + UiTextInputHandlingField_SpecialKeyPressedEvent, + UiTextInputHandlingField_TextInputEvent +} from "../../generated/UiTextInputHandlingFieldConfig"; import {TeamAppsEvent} from "../util/TeamAppsEvent"; import {UiSpecialKey} from "../../generated/UiSpecialKey"; -import {EventFactory} from "../../generated/EventFactory"; +import {UiCurrencyUnitConfig} from "../../generated/UiCurrencyUnitConfig"; +import {BigDecimal} from "../util/BigDecimalString"; +import {executeWhenFirstDisplayed} from "../util/ExecuteWhenFirstDisplayed"; export class UiCurrencyField extends UiField implements UiCurrencyFieldEventSource, UiCurrencyFieldCommandHandler { - public readonly onTextInput: TeamAppsEvent = new TeamAppsEvent(this, 250); - public readonly onSpecialKeyPressed: TeamAppsEvent = new TeamAppsEvent(this, 250); + public readonly onTextInput: TeamAppsEvent = new TeamAppsEvent({throttlingMode: "debounce", delay: 250}); + public readonly onSpecialKeyPressed: TeamAppsEvent = new TeamAppsEvent({throttlingMode: "debounce", delay: 250}); - private trivialUnitBox: TrivialUnitBox; - private $originalInput: JQuery; - - private currencyCodeList: string[]; - private showCurrencySymbol: boolean; - private defaultCurrencyCode: string; - - private queryFunction: QueryFunction; + private trivialUnitBox: TrivialUnitBox; + private queryFunction: QueryFunction; + private numberFormat: Intl.NumberFormat; protected initialize(config: UiCurrencyFieldConfig, context: TeamAppsUiContext) { - this.$originalInput = $(''); + let initialPrecision = config.fixedPrecision >= 0 ? config.fixedPrecision : 2; - this.trivialUnitBox = new TrivialUnitBox(this.$originalInput, { + this.trivialUnitBox = new TrivialUnitBox({ + numberFormatFunction: entry => this.getNumberFormat(entry), idFunction: entry => entry.code, - unitValueProperty: 'code', - unitIdProperty: 'code', - decimalPrecision: config.precision, - decimalSeparator: context.config.decimalSeparator, - thousandsSeparator: context.config.thousandsSeparator, unitDisplayPosition: config.showCurrencyBeforeAmount ? 'left' : 'right', // right or left - entryRenderingFunction: DEFAULT_RENDERING_FUNCTIONS.currencySingleLineLong, + entryRenderingFunction: entry => { + entry = entry || {}; + return `
+
+
${entry.code != null ? `${entry.code || ''}` : ''} ${entry.symbol != null ? `${entry.symbol || ''}` : ''}
+
${entry.name || ''}
+
+
`; + }, selectedEntryRenderingFunction: (entry) => { if (entry == null) { return `
-
` - } else if (this.showCurrencySymbol && entry.symbol) { + } else if (this._config.showCurrencySymbol && entry.symbol) { return `
${entry.symbol} (${entry.code})
`; } else { return `
${entry.code}
`; } }, - selectedEntry: $.extend({}, CURRENCIES[this.defaultCurrencyCode]), queryOnNonNumberCharacters: config.alphaKeysQueryForCurrency, editingMode: this.convertToTrivialComponentsEditingMode(config.editingMode), - queryFunction: (queryString, callback) => this.queryFunction(queryString, callback) + queryFunction: (queryString) => this.queryFunction(queryString) }); - $(this.trivialUnitBox.getMainDomElement()).addClass("UiCurrencyField"); - this.trivialUnitBox.onChange.addListener((value: { - unit: string, - unitEntry: any, - amount: number, - amountAsFloatingPointNumber: number - }) => { + this.trivialUnitBox.getMainDomElement().classList.add("UiCurrencyField", "default-min-field-width"); + this.trivialUnitBox.onChange.addListener((value: TrivialUnitBoxChangeEvent) => { this.commit(); }); this.trivialUnitBox.getEditor().addEventListener('keyup', (e: KeyboardEvent) => { if (e.keyCode !== keyCodes.enter && e.keyCode !== keyCodes.tab && !keyCodes.isModifierKey(e)) { - this.onTextInput.fire(EventFactory.createUiTextInputHandlingField_TextInputEvent(this.getId(), $(this.trivialUnitBox.getEditor()).val().toString())); + this.onTextInput.fire({ + enteredString: (this.trivialUnitBox.getEditor() as HTMLInputElement).value + }); } else if (e.keyCode === keyCodes.escape) { - this.onSpecialKeyPressed.fire(EventFactory.createUiTextInputHandlingField_SpecialKeyPressedEvent(this.getId(), UiSpecialKey.ESCAPE)); + this.onSpecialKeyPressed.fire({ + key: UiSpecialKey.ESCAPE + }); } else if (e.keyCode === keyCodes.enter) { - this.onSpecialKeyPressed.fire(EventFactory.createUiTextInputHandlingField_SpecialKeyPressedEvent(this.getId(), UiSpecialKey.ENTER)); + this.onSpecialKeyPressed.fire({ + key: UiSpecialKey.ENTER + }); } }); - this.setDefaultCurrencyCode(config.defaultCurrencyCode); - this.setCurrencyCodeList(config.currencyCodeList || Object.keys(CURRENCIES)); - this.setShowCurrencySymbol(config.showCurrencySymbol); + this.setCurrencyUnits(config.currencyUnits); - $(this.trivialUnitBox.getMainDomElement()).addClass("field-border field-border-glow field-background"); - $(this.trivialUnitBox.getMainDomElement()).find(".tr-editor").addClass("field-background"); - $(this.trivialUnitBox.getMainDomElement()).find(".tr-unitbox-selected-entry-and-trigger-wrapper").addClass("field-border"); - this.trivialUnitBox.onFocus.addListener(() => this.getMainDomElement().addClass("focus")); - this.trivialUnitBox.onBlur.addListener(() => this.getMainDomElement().removeClass("focus")); + this.trivialUnitBox.getMainDomElement().classList.add("field-border", "field-border-glow", "field-background"); + this.trivialUnitBox.getMainDomElement().querySelector(":scope .tr-editor").classList.add("field-background"); + this.trivialUnitBox.getMainDomElement().querySelector(":scope .tr-unitbox-selected-entry-and-trigger-wrapper").classList.add("field-border"); + this.trivialUnitBox.onFocus.addListener(() => this.getMainElement().classList.add("focus")); + this.trivialUnitBox.onBlur.addListener(() => this.getMainElement().classList.remove("focus")); + } + + private getNumberFormat(entry: UiCurrencyUnitConfig) { + if (entry == null) { + let fractionDigits = this._config.fixedPrecision >= 0 ? this._config.fixedPrecision : 2; + return new Intl.NumberFormat(this._config.locale, { + useGrouping: true, + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits + }); + } else { + let fractionDigits = this._config.fixedPrecision >= 0 ? this._config.fixedPrecision : entry.fractionDigits >= 0 ? entry.fractionDigits : 4; + return new Intl.NumberFormat(this._config.locale, { + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + useGrouping: true + }) + } } isValidData(v: UiCurrencyValueConfig): boolean { @@ -113,57 +132,60 @@ export class UiCurrencyField extends UiField(":scope .tr-editor")); } - doDestroy(): void { + destroy(): void { + super.destroy(); this.trivialUnitBox.destroy(); - this.$originalInput.detach(); } getTransientValue(): UiCurrencyValueConfig { - return createUiCurrencyValueConfig(this.trivialUnitBox.getAmount(), this.trivialUnitBox.getSelectedEntry() && this.trivialUnitBox.getSelectedEntry().code); + let amount = this.trivialUnitBox.getAmount(); + return createUiCurrencyValueConfig(this.trivialUnitBox.getSelectedEntry() && this.trivialUnitBox.getSelectedEntry(), this.trivialUnitBox.getAmount()?.value); } protected onEditingModeChanged(editingMode: UiFieldEditingMode): void { - this.getMainDomElement() - .removeClass(Object.values(UiField.editingModeCssClasses).join(" ")) - .addClass(UiField.editingModeCssClasses[editingMode]); + this.getMainElement().classList.remove(...Object.values(UiField.editingModeCssClasses)); + this.getMainElement().classList.add(UiField.editingModeCssClasses[editingMode]); this.trivialUnitBox.setEditingMode(this.convertToTrivialComponentsEditingMode(editingMode)); } public getReadOnlyHtml(value: UiCurrencyValueConfig, availableWidth: number): string { let content: string; if (value != null) { - const currency = CURRENCIES[value.currencyCode || this.defaultCurrencyCode || ""]; - const displayedCurrency = this.showCurrencySymbol ? currency.symbol : currency.code; - const amountAsString = formatDecimalNumber(value.value, this._config.precision, this._context.config.decimalSeparator, this._context.config.thousandsSeparator); - content = (this._config.showCurrencyBeforeAmount ? displayedCurrency + ' ' : '') + amountAsString + (this._config.showCurrencyBeforeAmount ? '' : ' ' + displayedCurrency); + const currency = value.currencyUnit; + let displayedCurrency: string; + if (currency != null) { + displayedCurrency = this._config.showCurrencySymbol ? `${currency?.symbol} (${currency?.code})` : currency?.code; + } else { + displayedCurrency = null; + } + let amount = BigDecimal.of(value.amount); + let formattedAmount = amount?.format(this.getNumberFormat(currency)); + if (this._config.showCurrencyBeforeAmount) { + content = [displayedCurrency, formattedAmount].filter(x => x != null).join(' '); + } else { + content = [formattedAmount, displayedCurrency].filter(x => x != null).join(' '); + } } else { content = ""; } @@ -171,15 +193,12 @@ export class UiCurrencyField extends UiField CURRENCIES[code]), ["code", "name", "symbol"], {matchingMode: "contains", ignoreCase: true}); - if (this.trivialUnitBox.isDropDownOpen()) { - this.trivialUnitBox.updateEntries(this.currencyCodeList.map((code) => CURRENCIES[code])); - } + setCurrencyUnits(currencyUnits: UiCurrencyUnitConfig[]): void { + this._config.currencyUnits = currencyUnits; + this.queryFunction = defaultListQueryFunctionFactory(currencyUnits, ["code", "name", "symbol"], {matchingMode: "contains", ignoreCase: true}); } setShowCurrencyBeforeAmount(showCurrencyBeforeAmount: boolean): void { @@ -187,18 +206,22 @@ export class UiCurrencyField extends UiField implements UiDisplayFieldEventSource, UiDisplayFieldCommandHandler { - private _$field: JQuery; + private _$field: HTMLElement; private showHtml: boolean; private removeStyleTags: boolean; protected initialize(config: UiDisplayFieldConfig, context: TeamAppsUiContext) { - this._$field = $(`
`); + this._$field = parseHtml(`
`); this.setShowBorder(config.showBorder); this.setShowHtml(config.showHtml); @@ -45,11 +45,12 @@ export class UiDisplayField extends UiField implem } setShowBorder(showBorder: boolean): void { - this._$field.toggleClass("border", showBorder); + this._$field.classList.toggle("border", showBorder); } setShowHtml(showHtml: boolean): void { this.showHtml = showHtml; + this._$field.classList.toggle("show-html", showHtml); this.displayCommittedValue(); } @@ -58,28 +59,24 @@ export class UiDisplayField extends UiField implem this.displayCommittedValue(); } - public getMainInnerDomElement(): JQuery { + public getMainInnerDomElement(): HTMLElement { return this._$field; } - - public getFocusableElement(): JQuery { - return null; - } - protected displayCommittedValue(): void { let uiValue = this.getCommittedValue(); if (uiValue) { if (this.showHtml) { const displayedValue = this.removeStyleTags ? removeTags(uiValue, "style") : uiValue; - this._$field.html(displayedValue); + this._$field.innerHTML = displayedValue; } else { - this._$field.text(uiValue); + this._$field.textContent = uiValue || ""; } } else { - this._$field[0].innerHTML = ''; + this._$field.innerHTML = ''; } } + @executeWhenFirstDisplayed() focus(): void { // do nothing } @@ -89,7 +86,7 @@ export class UiDisplayField extends UiField implem } protected onEditingModeChanged(editingMode: UiFieldEditingMode): void { - UiField.defaultOnEditingModeChangedImpl(this); + UiField.defaultOnEditingModeChangedImpl(this, () => null); } public getReadOnlyHtml(value: string, availableWidth: number): string { diff --git a/teamapps-client/ts/modules/formfield/UiField.ts b/teamapps-client/ts/modules/formfield/UiField.ts index f1a2e3711..1079653ec 100644 --- a/teamapps-client/ts/modules/formfield/UiField.ts +++ b/teamapps-client/ts/modules/formfield/UiField.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,17 +19,24 @@ */ import * as log from "loglevel"; import {TeamAppsUiContext} from "../TeamAppsUiContext"; -import {UiField_ValueChangedEvent, UiFieldCommandHandler, UiFieldConfig, UiFieldEventSource} from "../../generated/UiFieldConfig"; +import { + UiField_BlurEvent, + UiField_FocusGainedEvent, + UiField_ValueChangedEvent, + UiFieldCommandHandler, + UiFieldConfig, + UiFieldEventSource +} from "../../generated/UiFieldConfig"; import {TeamAppsEvent} from "../util/TeamAppsEvent"; import {UiFieldEditingMode} from "../../generated/UiFieldEditingMode"; -import {EventFactory} from "../../generated/EventFactory"; -import {UiComponent} from "../UiComponent"; +import {AbstractUiComponent} from "../AbstractUiComponent"; import {UiFieldMessageConfig} from "../../generated/UiFieldMessageConfig"; import {UiFieldMessageSeverity} from "../../generated/UiFieldMessageSeverity"; import {UiFieldMessagePosition} from "../../generated/UiFieldMessagePosition"; import {UiFieldMessageVisibilityMode} from "../../generated/UiFieldMessageVisibilityMode"; -import Popper from "popper.js"; +import {createPopper, Instance as Popper} from '@popperjs/core'; import {bind} from "../util/Bind"; +import {parseHtml, prependChild} from "../Common"; import Logger = log.Logger; @@ -39,15 +46,15 @@ export interface ValueChangeEventData { interface FieldMessage { message: UiFieldMessageConfig, - $message: JQuery + $message: HTMLElement } -export abstract class UiField extends UiComponent implements UiFieldCommandHandler, UiFieldEventSource { +export abstract class UiField extends AbstractUiComponent implements UiFieldCommandHandler, UiFieldEventSource { - public readonly onValueChanged: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onFocused: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onBlurred: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onUserManipulation: TeamAppsEvent = new TeamAppsEvent(this); + public readonly onValueChanged: TeamAppsEvent = new TeamAppsEvent(); + public readonly onFocusGained: TeamAppsEvent = new TeamAppsEvent(); + public readonly onBlur: TeamAppsEvent = new TeamAppsEvent(); + public readonly onUserManipulation: TeamAppsEvent = new TeamAppsEvent(); public static editingModeCssClasses: { [x: number]: string } = { [UiFieldEditingMode.EDITABLE]: "editable", @@ -60,52 +67,57 @@ export abstract class UiField private committedValue: V; - private editingMode: UiFieldEditingMode; - private $fieldWrapper: JQuery; + private $fieldWrapper: HTMLElement; private _messageTooltip: { popper: Popper, - $popperElement: JQuery, - $messageContainer: JQuery + $popperElement: HTMLElement, + $messageContainer: HTMLElement }; - private $messagesContainerAbove: JQuery; - private $messagesContainerBelow: JQuery; + private $messagesContainerAbove: HTMLElement; + private $messagesContainerBelow: HTMLElement; private fieldMessages: FieldMessage[] = []; private hovering: boolean; + private focused: boolean; constructor(_config: C, - _context: TeamAppsUiContext) { + _context: TeamAppsUiContext) { super(_config, _context); + this.$messagesContainerAbove = parseHtml(`
`); + this.$messagesContainerBelow = parseHtml(`
`); + this.$fieldWrapper = parseHtml(`
`); this.initialize(_config, _context); - this.$messagesContainerAbove = $(`
`); - this.$messagesContainerBelow = $(`
`); - this.$fieldWrapper = $(`
`) - .append(this.$messagesContainerAbove) - .append(this.getMainInnerDomElement()) - .append(this.$messagesContainerBelow); + this.$fieldWrapper.appendChild(this.$messagesContainerAbove); + this.$fieldWrapper.appendChild(this.getMainInnerDomElement()); + this.$fieldWrapper.appendChild(this.$messagesContainerBelow); this.setEditingMode(_config.editingMode); this.setCommittedValue(_config.value); + this.setFieldMessages(_config.fieldMessages); + this.onValueChanged.addListener(() => this.onUserManipulation.fire(null)); - this.getFocusableElement() && this.getFocusableElement().on("focus", () => { - this.getMainDomElement().addClass("focus"); - this.onFocused.fire(null); - }); - this.getFocusableElement() && this.getFocusableElement().on("blur", () => { - this.getMainDomElement().removeClass("focus"); - this.onBlurred.fire(null); - }); + this.initFocusHandling(); - this.onFocused.addListener(() => { + this.onFocusGained.addListener(() => { + this.focused = true; + this.getMainElement().classList.add("focus"); this.updateFieldMessageVisibilities(); + }); - this.onBlurred.addListener(() => { + this.onBlur.addListener(() => { + this.focused = false; + this.getMainElement().classList.remove("focus"); this.updateFieldMessageVisibilities(); }); - this.getMainDomElement().on("mouseenter mouseleave", e => { + ["mouseenter", "mouseleave"].forEach(eventName => this.getMainInnerDomElement().addEventListener(eventName, (e) => { this.hovering = e.type === 'mouseenter'; this.updateFieldMessageVisibilities(); - }); + })); + } + + protected initFocusHandling() { + this.getMainElement()?.addEventListener("focusin", () => this.onFocusGained.fire(null)); + this.getMainElement()?.addEventListener("focusout", () => this.onBlur.fire(null)); } private updateFieldMessageVisibilities() { @@ -114,21 +126,21 @@ export abstract class UiField || highestVisibilityByPosition[position] === UiFieldMessageVisibilityMode.ON_HOVER_OR_FOCUS && this.hovering || this.hasFocus(); if (messagesVisible(UiFieldMessagePosition.ABOVE)) { - this.$messagesContainerAbove.removeClass("hidden"); + this.$messagesContainerAbove.classList.remove("hidden"); } else { - this.$messagesContainerAbove.addClass("hidden"); + this.$messagesContainerAbove.classList.add("hidden"); } if (messagesVisible(UiFieldMessagePosition.BELOW)) { - this.$messagesContainerBelow.removeClass("hidden"); + this.$messagesContainerBelow.classList.remove("hidden"); } else { - this.$messagesContainerBelow.addClass("hidden"); + this.$messagesContainerBelow.classList.add("hidden"); } if (this._messageTooltip != null) { if (messagesVisible(UiFieldMessagePosition.POPOVER)) { - this._messageTooltip.$popperElement.removeClass("hidden"); + this._messageTooltip.$popperElement.classList.remove("hidden"); this._messageTooltip.popper.update(); } else { - this._messageTooltip.$popperElement.addClass("hidden"); + this._messageTooltip.$popperElement.classList.add("hidden"); } } } @@ -151,43 +163,37 @@ export abstract class UiField return this._config._type; } - public getMainDomElement(): JQuery { + public doGetMainElement(): HTMLElement { return this.$fieldWrapper; } - abstract getMainInnerDomElement(): JQuery; - - abstract getFocusableElement(): JQuery; + abstract getMainInnerDomElement(): HTMLElement; public hasFocus() { - let focusableElement = this.getFocusableElement(); - return focusableElement && focusableElement.is(":focus"); + return this.focused; } - public focus(): void { - let focusableElement = this.getFocusableElement(); - focusableElement && focusableElement.focus(); - } + abstract focus(): void; destroy() { super.destroy(); if (this._messageTooltip) { this._messageTooltip.popper.destroy(); - this._messageTooltip.$popperElement.detach(); + this._messageTooltip.$popperElement.remove(); this.onResized.removeListener(this.updatePopperPosition) } - this.doDestroy(); } - protected doDestroy() { - // default implementation - }; - abstract isValidData(v: V): boolean; abstract getTransientValue(): V; - abstract getDefaultValue(): V; // the value to be set if no other value has been set. + /** + * the value to be set if no other value has been set. + */ + public getDefaultValue(): V { + return null; + } protected abstract displayCommittedValue(): void; @@ -213,7 +219,9 @@ export abstract class UiField private fireCommittedChangeEvent(): void { this.logger.trace("firing committed change event: " + JSON.stringify(this.committedValue)); - this.onValueChanged.fire(EventFactory.createUiField_ValueChangedEvent(this.getId(), this.convertValueForSendingToServer(this.committedValue))); + this.onValueChanged.fire({ + value: this.convertValueForSendingToServer(this.committedValue) + }); } protected convertValueForSendingToServer(value: V): any { @@ -230,48 +238,47 @@ export abstract class UiField return changed; } - public setEditingMode(editingMode: UiFieldEditingMode): void { - const oldEditingMode = this.editingMode; - this.editingMode = editingMode; + public setEditingMode(editingMode: UiFieldEditingMode = UiFieldEditingMode.EDITABLE): void { + const oldEditingMode = this._config.editingMode; + this._config.editingMode = editingMode; this.onEditingModeChanged(editingMode, oldEditingMode); } protected abstract onEditingModeChanged(editingMode: UiFieldEditingMode, oldEditingMode?: UiFieldEditingMode): void; public getEditingMode(): UiFieldEditingMode { - return this.editingMode; + return this._config.editingMode; } public isEditable(): boolean { return this.getEditingMode() === UiFieldEditingMode.EDITABLE || this.getEditingMode() === UiFieldEditingMode.EDITABLE_IF_FOCUSED; } - public static defaultOnEditingModeChangedImpl(field: UiField) { - field.getMainDomElement() - .removeClass(Object.values(UiField.editingModeCssClasses).join(" ")) - .addClass(UiField.editingModeCssClasses[field.getEditingMode()]); + public static defaultOnEditingModeChangedImpl(field: UiField, $focusableElementProvider: () => HTMLElement) { + field.getMainElement().classList.remove(...Object.values(UiField.editingModeCssClasses)); + field.getMainElement().classList.add(UiField.editingModeCssClasses[field.getEditingMode()]); - let $focusable = field.getFocusableElement(); - if ($focusable) { + const $focusableElement = $focusableElementProvider(); + if ($focusableElement) { switch (field.getEditingMode()) { case UiFieldEditingMode.EDITABLE: - $focusable.prop("readonly", false); - $focusable.prop("disabled", false); - $focusable.attr("tabindex", "0"); + $focusableElement.removeAttribute("readonly"); + $focusableElement.removeAttribute("disabled"); + $focusableElement.setAttribute("tabindex", "0"); break; case UiFieldEditingMode.EDITABLE_IF_FOCUSED: - $focusable.prop("readonly", false); - $focusable.prop("disabled", false); - $focusable.attr("tabindex", "0"); + $focusableElement.removeAttribute("readonly"); + $focusableElement.removeAttribute("disabled"); + $focusableElement.setAttribute("tabindex", "0"); break; case UiFieldEditingMode.DISABLED: - $focusable.prop("readonly", false); - $focusable.prop("disabled", true); + $focusableElement.removeAttribute("readonly"); + $focusableElement.setAttribute("disabled", "disabled"); break; case UiFieldEditingMode.READONLY: - $focusable.prop("readonly", true); - $focusable.prop("disabled", false); - $focusable.attr("tabindex", "-1"); + $focusableElement.setAttribute("readonly", "readonly"); + $focusableElement.removeAttribute("disabled"); + $focusableElement.setAttribute("tabindex", "-1"); break; default: log.getLogger("UiField").error("unknown editing mode! " + field.getEditingMode()); @@ -280,7 +287,7 @@ export abstract class UiField } public getReadOnlyHtml(value: V, availableWidth: number): string { - return ""; + return "TODO: Override getReadOnlyHtml()"; } getFieldMessages() { @@ -292,12 +299,12 @@ export abstract class UiField fieldMessageConfigs = []; } - this.getMainDomElement().removeClass("message-info message-success message-warning message-error"); - this.$messagesContainerAbove[0].innerHTML = ''; - this.$messagesContainerBelow[0].innerHTML = ''; + this.getMainElement().classList.remove("message-info", "message-success", "message-warning", "message-error"); + this.$messagesContainerAbove.innerHTML = ''; + this.$messagesContainerBelow.innerHTML = ''; if (this._messageTooltip != null) { - this._messageTooltip.$messageContainer[0].innerHTML = ''; - this._messageTooltip.$popperElement.removeClass(`tooltip-info tooltip-success tooltip-warning tooltip-error`); + this._messageTooltip.$messageContainer.innerHTML = ''; + this._messageTooltip.$popperElement.classList.remove("ta-tooltip-info", "ta-tooltip-success", "ta-tooltip-warning", "ta-tooltip-error"); } this.fieldMessages = fieldMessageConfigs @@ -311,7 +318,7 @@ export abstract class UiField return messages.reduce((highestSeverity, message) => message.message.severity > highestSeverity ? message.message.severity : highestSeverity, UiFieldMessageSeverity.INFO); }; if (this.fieldMessages && this.fieldMessages.length > 0) { - this.getMainDomElement().addClass("message-" + UiFieldMessageSeverity[getHighestSeverity(this.fieldMessages)].toLowerCase()); + this.getMainElement().classList.add("message-" + UiFieldMessageSeverity[getHighestSeverity(this.fieldMessages)].toLowerCase()); } let fieldMessagesByPosition: { [position in UiFieldMessagePosition]: FieldMessage[] } = { @@ -320,18 +327,18 @@ export abstract class UiField [UiFieldMessagePosition.POPOVER]: this.fieldMessages.filter(m => m.message.position == UiFieldMessagePosition.POPOVER) }; - fieldMessagesByPosition[UiFieldMessagePosition.ABOVE].forEach(message => this.getMessagesContainer(message.message.position).prepend(message.$message)); - fieldMessagesByPosition[UiFieldMessagePosition.BELOW].forEach(message => this.getMessagesContainer(message.message.position).append(message.$message)); + fieldMessagesByPosition[UiFieldMessagePosition.ABOVE].forEach(message => prependChild(this.getMessagesContainer(message.message.position), message.$message)); + fieldMessagesByPosition[UiFieldMessagePosition.BELOW].forEach(message => this.getMessagesContainer(message.message.position).appendChild(message.$message)); if (fieldMessagesByPosition[UiFieldMessagePosition.POPOVER].length > 0) { const highestPopoverSeverity = getHighestSeverity(fieldMessagesByPosition[UiFieldMessagePosition.POPOVER]); - this.messageTooltip.$popperElement.addClass(`tooltip-${UiFieldMessageSeverity[highestPopoverSeverity].toLowerCase()}`); + this.messageTooltip.$popperElement.classList.add(`ta-tooltip-${UiFieldMessageSeverity[highestPopoverSeverity].toLowerCase()}`); fieldMessagesByPosition[UiFieldMessagePosition.POPOVER].forEach(message => { - this.messageTooltip.$messageContainer.append(message.$message); + this.messageTooltip.$messageContainer.appendChild(message.$message); }); - this.messageTooltip.$popperElement.removeClass("empty"); + this.messageTooltip.$popperElement.classList.remove("empty"); this.messageTooltip.popper.update(); } else if (this._messageTooltip != null) { - this.messageTooltip.$popperElement.addClass("empty"); + this.messageTooltip.$popperElement.classList.add("empty"); } this.updateFieldMessageVisibilities(); @@ -341,31 +348,47 @@ export abstract class UiField const severityCssClass = `field-message-${UiFieldMessageSeverity[message.severity].toLowerCase()}`; const positionCssClass = `position-${UiFieldMessagePosition[message.position].toLowerCase()}`; const visibilityCssClass = `visibility-${UiFieldMessageVisibilityMode[message.visibilityMode].toLowerCase()}`; - return $(`
${message.message}
`); + return parseHtml(`
${message.message}
`); } private get messageTooltip() { if (this._messageTooltip == null) { - let $popperElement = $(``) - .appendTo(document.body); - let $messageContainer = $popperElement.find(".tooltip-inner"); - let popper = new Popper(this.getMainInnerDomElement()[0], $popperElement[0], { + let $popperElement = parseHtml(``); + document.body.appendChild($popperElement); + let $messageContainer = $popperElement.querySelector(":scope .ta-tooltip-inner"); + let popper = createPopper(this.getMainInnerDomElement(), $popperElement, { placement: 'right', - modifiers: { - flip: { - behavior: ['right', 'bottom', 'left', 'top'] + modifiers: [ + { + name: "flip", + options: { + fallbackPlacements: ['bottom', 'left', 'top'] + } + }, + { + name: "preventOverflow" + }, + { + name: "offset", + options: { + offset: [0, 6] + } }, - preventOverflow: { - boundariesElement: document.body, + { + name: "arrow", + options: { + element: ".ta-tooltip-arrow", // "[data-popper-arrow]" + padding: 10, // 0 + } } - }, + ] }); this.onResized.addListener(this.updatePopperPosition); - this.onAttachedToDomChanged.addListener(attached => { - if (attached) { - this._messageTooltip.$popperElement.appendTo(document.body); + this.deFactoVisibilityChanged.addListener(visible => { + if (visible) { + document.body.appendChild(this._messageTooltip.$popperElement); } else { - this._messageTooltip.$popperElement.detach(); + this._messageTooltip.$popperElement.remove(); } }); this._messageTooltip = { @@ -379,7 +402,7 @@ export abstract class UiField @bind private updatePopperPosition() { - this.messageTooltip.popper.scheduleUpdate(); + this.messageTooltip.popper.update(); } protected getMessagesContainer(position: UiFieldMessagePosition) { diff --git a/teamapps-client/ts/modules/formfield/UiFieldGroup.ts b/teamapps-client/ts/modules/formfield/UiFieldGroup.ts new file mode 100644 index 000000000..6a4c01049 --- /dev/null +++ b/teamapps-client/ts/modules/formfield/UiFieldGroup.ts @@ -0,0 +1,56 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +import {TeamAppsUiComponentRegistry} from "../TeamAppsUiComponentRegistry"; +import {AbstractUiComponent} from "../AbstractUiComponent"; +import {UiFieldGroupCommandHandler, UiFieldGroupConfig} from "../../generated/UiFieldGroupConfig"; +import {TeamAppsUiContext} from "../TeamAppsUiContext"; +import {parseHtml} from "../Common"; +import {UiField} from "./UiField"; + +export class UiFieldGroup extends AbstractUiComponent implements UiFieldGroupCommandHandler { + + private $main: HTMLElement; + private fields: UiField[]; + + constructor(config: UiFieldGroupConfig, context: TeamAppsUiContext) { + super(config, context); + this.$main = parseHtml(`
`); + this.setFields(config.fields as UiField[]); + } + + doGetMainElement(): HTMLElement { + return this.$main; + } + + public setFields(fields: UiField[]) { + this.fields = fields; + this.$main.innerHTML = ''; + fields.forEach(f => { + this.$main.appendChild(f.getMainElement()) + }); + } + + destroy() { + super.destroy(); + this.fields.forEach(f => f.destroy()); + } +} + +TeamAppsUiComponentRegistry.registerComponentClass("UiFieldGroup", UiFieldGroup); diff --git a/teamapps-client/ts/modules/formfield/UiImageField.ts b/teamapps-client/ts/modules/formfield/UiImageField.ts index 3ac7b7ba6..34c076892 100644 --- a/teamapps-client/ts/modules/formfield/UiImageField.ts +++ b/teamapps-client/ts/modules/formfield/UiImageField.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,9 +17,8 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; import {UiField} from "./UiField"; -import {UiImageFieldConfig, UiImageFieldCommandHandler, UiImageFieldEventSource} from "../../generated/UiImageFieldConfig"; +import {UiImageFieldCommandHandler, UiImageFieldConfig, UiImageFieldEventSource} from "../../generated/UiImageFieldConfig"; import {UiFieldEditingMode} from "../../generated/UiFieldEditingMode"; import {TeamAppsUiContext} from "../TeamAppsUiContext"; import {TeamAppsUiComponentRegistry} from "../TeamAppsUiComponentRegistry"; @@ -27,20 +26,19 @@ import {UiBorderConfig} from "../../generated/UiBorderConfig"; import {UiShadowConfig} from "../../generated/UiShadowConfig"; import {UiImageSizing} from "../../generated/UiImageSizing"; import {createImageSizingCssObject, createUiBorderCssObject, createUiShadowCssObject, cssObjectToString} from "../util/CssFormatUtil"; +import {parseHtml} from "../Common"; +import {executeWhenFirstDisplayed} from "../util/ExecuteWhenFirstDisplayed"; export class UiImageField extends UiField implements UiImageFieldEventSource, UiImageFieldCommandHandler { - private _$field: JQuery; + private _$field: HTMLElement; protected initialize(config: UiImageFieldConfig, context: TeamAppsUiContext) { - this._$field = $(`
`); + this._$field = parseHtml(`
`); - this.setSize(config.width, config.height); - this.setBorder(config.border); - this.setShadow(config.shadow); - this.setImageSizing(config.imageSizing); + this.update(config); - this._$field.click((e) => { + this._$field.addEventListener('click',(e) => { this.commit(true); }); } @@ -49,42 +47,47 @@ export class UiImageField extends UiField implements return v == null || typeof v === "string"; } - setSize(width: number, height: number): void { - this._$field.css({ - width: `${width}px`, - height: `${height}px`, - }) + setSize(width: string, height: string): void { + Object.assign(this._$field.style, { + width: `${width}`, + height: `${height}`, + }); } setBorder(border: UiBorderConfig): void { - this._$field.css(createUiBorderCssObject(border)); + Object.assign(this._$field.style, createUiBorderCssObject(border)); } setShadow(shadow: UiShadowConfig): void { - this._$field.css(createUiShadowCssObject(shadow)); + Object.assign(this._$field.style, createUiShadowCssObject(shadow)); } setImageSizing(imageSizing: UiImageSizing): void { - this._$field.css(createImageSizingCssObject(imageSizing)); + Object.assign(this._$field.style, createImageSizingCssObject(imageSizing)); + } + + update(config: UiImageFieldConfig) { + this._config = config; + this.setSize(config.width, config.height); + this.setBorder(config.border); + this.setShadow(config.shadow); + this.setImageSizing(config.imageSizing); + this._$field.style.backgroundColor = config.backgroundColor; } getDefaultValue(): string { return null; } - public getMainInnerDomElement(): JQuery { + public getMainInnerDomElement(): HTMLElement { return this._$field; } - - public getFocusableElement(): JQuery { - return null; - } - protected displayCommittedValue(): void { let uiValue = this.getCommittedValue(); - this._$field.css("background-image", uiValue ? `url(${uiValue})` : 'none'); + this._$field.style.backgroundImage = uiValue ? `url('${uiValue}')` : 'none'; } + @executeWhenFirstDisplayed() focus(): void { // do nothing } @@ -94,7 +97,7 @@ export class UiImageField extends UiField implements } protected onEditingModeChanged(editingMode: UiFieldEditingMode): void { - UiField.defaultOnEditingModeChangedImpl(this); + UiField.defaultOnEditingModeChangedImpl(this, () => null); } public getReadOnlyHtml(value: string, availableWidth: number): string { @@ -104,7 +107,7 @@ export class UiImageField extends UiField implements ...createUiBorderCssObject(this._config.border), ...createUiShadowCssObject(this._config.shadow), ...createImageSizingCssObject(this._config.imageSizing), - 'background-image': value ? `url(${value})` : 'none' + 'background-image': value ? `url('${value}')` : 'none' }); return `
`; } diff --git a/teamapps-client/ts/modules/formfield/UiLabel.ts b/teamapps-client/ts/modules/formfield/UiLabel.ts index 2bd30772e..24b428b67 100644 --- a/teamapps-client/ts/modules/formfield/UiLabel.ts +++ b/teamapps-client/ts/modules/formfield/UiLabel.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,42 +17,47 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; import {UiField} from "./UiField"; import {TeamAppsUiContext} from "../TeamAppsUiContext"; -import {UiLabel_ClickedEvent, UiLabelConfig, UiLabelCommandHandler, UiLabelEventSource} from "../../generated/UiLabelConfig"; +import {UiLabel_ClickedEvent, UiLabelCommandHandler, UiLabelConfig, UiLabelEventSource} from "../../generated/UiLabelConfig"; import {TeamAppsEvent} from "../util/TeamAppsEvent"; import {UiFieldEditingMode} from "../../generated/UiFieldEditingMode"; import {TeamAppsUiComponentRegistry} from "../TeamAppsUiComponentRegistry"; -import {EventFactory} from "../../generated/EventFactory"; +import {parseHtml} from "../Common"; +import {UiComponent} from "../UiComponent"; +import {executeWhenFirstDisplayed} from "../util/ExecuteWhenFirstDisplayed"; export class UiLabel extends UiField implements UiLabelEventSource, UiLabelCommandHandler { - public readonly onClicked: TeamAppsEvent = new TeamAppsEvent(this); + public readonly onClicked: TeamAppsEvent = new TeamAppsEvent(); - private $main: JQuery; - private $caption: JQuery; - private targetField: UiField; + private $main: HTMLElement; + private $icon: HTMLElement; + private $caption: HTMLElement; + private targetComponent: UiComponent; private targetFieldVisibilityChangeHandler: (visible: boolean) => void; protected initialize(config: UiLabelConfig, context: TeamAppsUiContext): void { - this.$main = $(`
${config.caption}
`); - this.$caption = this.$main.find(".caption"); + this.$main = parseHtml(`
${config.caption}
`); + this.$icon = this.$main.querySelector(":scope .icon"); + this.$caption = this.$main.querySelector(":scope .caption"); this.setIcon(config.icon); - this.$main.click(() => { - this.onClicked.fire(EventFactory.createUiLabel_ClickedEvent(this.getId())); - if (this.targetField != null) { - this.targetField.focus(); + this.$main.addEventListener('click',() => { + this.onClicked.fire({}); + if (this.targetComponent != null) { + if (this.targetComponent instanceof UiField) { + this.targetComponent.focus(); + } } }); this.targetFieldVisibilityChangeHandler = (visible: boolean) => this.setVisible(visible); - this.setTargetField(config.targetField); + this.setTargetComponent(config.targetComponent as UiComponent); } - public setTargetField(targetField: UiField) { - if (this.targetField != null) { - this.targetField.onVisibilityChanged.removeListener(this.targetFieldVisibilityChangeHandler) + public setTargetComponent(targetField: UiComponent) { + if (this.targetComponent != null) { + this.targetComponent.onVisibilityChanged.removeListener(this.targetFieldVisibilityChangeHandler) } - this.targetField = targetField; + this.targetComponent = targetField; if (targetField != null) { this.setVisible(targetField.isVisible()); targetField.onVisibilityChanged.addListener(this.targetFieldVisibilityChangeHandler); @@ -64,23 +69,21 @@ export class UiLabel extends UiField implements UiLabelEv } setCaption(caption: string): void { - this.$caption.text(caption); + this.$caption.textContent = caption || ''; } setIcon(icon: string): void { - this.$main.find(".icon").detach(); - if (icon) { - let iconPath = this._context.getIconPath(icon, 16); - this.$main.prepend(`
`) - } + this.$icon.classList.toggle("hidden", !icon); + this.$icon.style.backgroundImage = icon ? `url('${icon}')` : null; } - public getMainInnerDomElement(): JQuery { + public getMainInnerDomElement(): HTMLElement { return this.$main; } - public getFocusableElement(): JQuery { - return null; + @executeWhenFirstDisplayed() + focus() { + // do nothing } public getTransientValue(): string { @@ -92,7 +95,7 @@ export class UiLabel extends UiField implements UiLabelEv } protected displayCommittedValue(): void { - this.$caption.text(this.getCommittedValue() || this._config.caption || ""); + this.$caption.textContent = this.getCommittedValue() || this._config.caption || ""; } protected onEditingModeChanged(editingMode: UiFieldEditingMode): void { diff --git a/teamapps-client/ts/modules/formfield/UiMultiLineTextField.ts b/teamapps-client/ts/modules/formfield/UiMultiLineTextField.ts index ec165ed58..e578d537f 100644 --- a/teamapps-client/ts/modules/formfield/UiMultiLineTextField.ts +++ b/teamapps-client/ts/modules/formfield/UiMultiLineTextField.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,158 +17,171 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; -import {UiField} from "./UiField"; -import {UiMultiLineTextFieldConfig, UiMultiLineTextFieldCommandHandler, UiMultiLineTextFieldEventSource} from "../../generated/UiMultiLineTextFieldConfig"; +import {keyCodes} from "../trivial-components/TrivialCore"; import {UiFieldEditingMode} from "../../generated/UiFieldEditingMode"; -import {TeamAppsUiContext} from "../TeamAppsUiContext"; -import {Constants, escapeHtml, hasVerticalScrollBar} from "../Common"; +import { + UiMultiLineTextFieldCommandHandler, + UiMultiLineTextFieldConfig, + UiMultiLineTextFieldEventSource +} from "../../generated/UiMultiLineTextFieldConfig"; +import {UiSpecialKey} from "../../generated/UiSpecialKey"; +import { + UiTextInputHandlingField_SpecialKeyPressedEvent, + UiTextInputHandlingField_TextInputEvent +} from "../../generated/UiTextInputHandlingFieldConfig"; +import {Constants, escapeHtml, hasVerticalScrollBar, parseHtml} from "../Common"; import {TeamAppsUiComponentRegistry} from "../TeamAppsUiComponentRegistry"; +import {TeamAppsUiContext} from "../TeamAppsUiContext"; +import {executeWhenFirstDisplayed} from "../util/ExecuteWhenFirstDisplayed"; import {TeamAppsEvent} from "../util/TeamAppsEvent"; -import {UiTextInputHandlingField_SpecialKeyPressedEvent, UiTextInputHandlingField_TextInputEvent} from "../../generated/UiTextInputHandlingFieldConfig"; -import {UiSpecialKey} from "../../generated/UiSpecialKey"; -import {keyCodes} from "trivial-components"; -import {executeWhenAttached} from "../util/ExecuteWhenAttached"; -import {EventFactory} from "../../generated/EventFactory"; +import {UiField} from "./UiField"; export class UiMultiLineTextField extends UiField implements UiMultiLineTextFieldEventSource, UiMultiLineTextFieldCommandHandler { - public readonly onTextInput: TeamAppsEvent = new TeamAppsEvent(this, 250); - public readonly onSpecialKeyPressed: TeamAppsEvent = new TeamAppsEvent(this, 250); - - private $wrapper: JQuery; - private $field: JQuery; - private $clearButton: JQuery; + public readonly onTextInput: TeamAppsEvent = new TeamAppsEvent({ + throttlingMode: "debounce", + delay: 250 + }); + public readonly onSpecialKeyPressed: TeamAppsEvent = new TeamAppsEvent({ + throttlingMode: "debounce", + delay: 250 + }); + + private $wrapper: HTMLElement; + private $field: HTMLTextAreaElement; + private $clearButton: HTMLElement; private showClearButton: boolean; - private minHeight: number; - private maxHeight: number; - protected initialize(config: UiMultiLineTextFieldConfig, context: TeamAppsUiContext) { - this.$wrapper = $(`
+ this.$wrapper = parseHtml(`
-
+
`); - this.$field = this.$wrapper.find("textarea"); - this.$clearButton = this.$wrapper.find('.clear-button'); - this.$clearButton.click(() => { - this.$field.val(""); + this.$field = this.$wrapper.querySelector(":scope textarea"); + this.$clearButton = this.$wrapper.querySelector(':scope .clear-button'); + this.$clearButton.addEventListener('click', () => { + this.$field.value = ""; this.fireTextInput(); this.commit(); this.updateClearButton(); }); - this.setEmptyText(config.emptyText); + this.setPlaceholderText(config.placeholderText); this.setMaxCharacters(config.maxCharacters); this.setShowClearButton(config.showClearButton); - this.$field.on('focus', (e) => { + this.$field.addEventListener('focus', () => { if (this.getEditingMode() !== UiFieldEditingMode.READONLY) { } }); - this.$field.on('blur', (e) => { + this.$field.addEventListener('blur', () => { if (this.getEditingMode() !== UiFieldEditingMode.READONLY) { this.commit(); this.updateClearButton(); } }); - this.$field.on("input", () => { + this.$field.addEventListener("input", () => { this.fireTextInput(); this.updateClearButton(); }); - this.$field.on("keydown", (e) => { + this.$field.addEventListener("keydown", (e) => { if (e.keyCode === keyCodes.escape) { this.displayCommittedValue(); // back to committedValue this.fireTextInput(); - this.onSpecialKeyPressed.fire(EventFactory.createUiTextInputHandlingField_SpecialKeyPressedEvent(this.getId(), UiSpecialKey.ESCAPE)); + this.onSpecialKeyPressed.fire({ + key: UiSpecialKey.ESCAPE + }); } else if (e.keyCode === keyCodes.enter) { - this.onSpecialKeyPressed.fire(EventFactory.createUiTextInputHandlingField_SpecialKeyPressedEvent(this.getId(), UiSpecialKey.ENTER)); + this.onSpecialKeyPressed.fire({ + key: UiSpecialKey.ENTER + }); } }); this.updateClearButton(); - this.setMinHeight(config.minHeight); - this.setMaxHeight(config.maxHeight); - - this.$field.on('input', () => this.updateTextareaHeight()); + this.$field.addEventListener('input', () => this.updateTextareaHeight()); this.updateTextareaHeight(); } private fireTextInput() { - this.onTextInput.fire(EventFactory.createUiTextInputHandlingField_TextInputEvent(this.getId(), this.$field.val().toString())); + this.onTextInput.fire({ + enteredString: this.$field.value + }); } - isValidData(v: string): boolean { + public isValidData(v: string): boolean { return v == null || typeof v === "string"; } - @executeWhenAttached(true) + @executeWhenFirstDisplayed(true) private updateTextareaHeight() { - this.$field[0].style.height = '0px'; - this.$field[0].style.height = (Math.max(this.minHeight - 2, Math.min(this.$field[0].scrollHeight, this.maxHeight - 2))) + 'px'; + if (this._config.adjustHeightToContent) { + this.$field.style.height = '2px'; + this.$field.style.height = this.$field.scrollHeight + 'px'; + } } - setMaxCharacters(maxCharacters: number): void { - this.$field.attr('maxlength', maxCharacters || ""); + public setMaxCharacters(maxCharacters: number): void { + if (maxCharacters) { + this.$field.maxLength = maxCharacters; + } else { + this.$field.removeAttribute("maxLength"); + } } - setShowClearButton(showClearButton: boolean): void { + public setShowClearButton(showClearButton: boolean): void { this.showClearButton = showClearButton; this.updateClearButton(); } - @executeWhenAttached() + @executeWhenFirstDisplayed() private updateClearButton() { - this.$wrapper.toggleClass("clearable", !!(this.showClearButton && this.$field.val())); - this.$clearButton.css("right", hasVerticalScrollBar(this.$field[0]) ? Constants.SCROLLBAR_WIDTH + "px" : 0); + this.$wrapper.classList.toggle("clearable", !!(this.showClearButton && this.$field.value)); + this.$clearButton.style.right = hasVerticalScrollBar(this.$field) ? Constants.SCROLLBAR_WIDTH + "px" : "0"; } - setEmptyText(emptyText: string): void { - this.$field.attr("placeholder", emptyText || ""); + public setPlaceholderText(placeholderText: string): void { + this.$field.placeholder = placeholderText || ''; } - public getMainInnerDomElement(): JQuery { + public getMainInnerDomElement(): HTMLElement { return this.$wrapper; } - - public getFocusableElement(): JQuery { - return this.$field; - } - protected displayCommittedValue(): void { - let value = this.getCommittedValue(); - this.$field.val(value || ""); + const value = this.getCommittedValue(); + this.$field.value = value || ""; this.updateClearButton(); this.updateTextareaHeight(); } public getTransientValue(): string { - return this.$field.val().toString(); + return this.$field.value; } - focus(): void { + @executeWhenFirstDisplayed() + public focus(): void { this.$field.focus(); } - append(s: string, scrollToBottom: boolean): void { - let transientValue = this.getTransientValue(); - let transientValueString = (transientValue && transientValue) || ''; + public append(s: string, scrollToBottom: boolean): void { + const transientValue = this.getTransientValue(); + const transientValueString = (transientValue && transientValue) || ''; this.setCommittedValue(transientValueString + s); if (scrollToBottom) { - this.$wrapper.scrollTop(10000000); + this.$wrapper.scrollTop = 10000000; } } protected onEditingModeChanged(editingMode: UiFieldEditingMode): void { - UiField.defaultOnEditingModeChangedImpl(this); + UiField.defaultOnEditingModeChangedImpl(this, () => this.$field); } public getReadOnlyHtml(value: string, availableWidth: number): string { return `
${value == null ? "" : escapeHtml(value)}
`; } - getDefaultValue(): string { + public getDefaultValue(): string { return ""; } @@ -176,17 +189,6 @@ export class UiMultiLineTextField extends UiField implements UiNumberFieldEventSource, UiNumberFieldCommandHandler { - public readonly onTextInput: TeamAppsEvent = new TeamAppsEvent(this, 250); - public readonly onSpecialKeyPressed: TeamAppsEvent = new TeamAppsEvent(this, 250); - - private $wrapper: JQuery; - private $clearableFieldWrapper: JQuery; - private $field: JQuery; - private precision: number; + public readonly onTextInput: TeamAppsEvent = new TeamAppsEvent({ + throttlingMode: "debounce", + delay: 250 + }); + public readonly onSpecialKeyPressed: TeamAppsEvent = new TeamAppsEvent({ + throttlingMode: "debounce", + delay: 250 + }); + + private $wrapper: HTMLElement; + private $clearableFieldWrapper: HTMLElement; + private $field: HTMLInputElement; private showClearButton: boolean; - private $slider: JQuery; - private $sliderHandle: JQuery; + private $slider: HTMLElement; + private $sliderHandle: HTMLElement; private minValue: number; private maxValue: number; @@ -50,61 +58,72 @@ export class UiNumberField extends UiField implemen private sliderStep: number; private commitOnSliderChange: boolean; + private numberFormat: Intl.NumberFormat; + private numberParser: NumberParser; + protected initialize(config: UiNumberFieldConfig, context: TeamAppsUiContext) { - this.$wrapper = $(`
+ this.numberFormat = new Intl.NumberFormat(config.locale, { + minimumFractionDigits: this._config.precision, + maximumFractionDigits: this._config.precision, + useGrouping: true + }); + this.numberParser = new NumberParser(config.locale); + + this.$wrapper = parseHtml(`
- -
+ +
`); - this.$clearableFieldWrapper = this.$wrapper.find(".clearable-field-wrapper"); - this.$field = this.$wrapper.find("input"); - this.$slider = this.$wrapper.find(".slider"); - const $sliderTrack = this.$wrapper.find(".slider-track"); - this.$sliderHandle = this.$wrapper.find(".slider-handle"); - let $clearButton = this.$wrapper.find('.clear-button'); - $clearButton.click(() => { - this.$field.val(""); + this.$clearableFieldWrapper = this.$wrapper.querySelector(":scope .clearable-field-wrapper"); + this.$field = this.$wrapper.querySelector(":scope input"); + this.$slider = this.$wrapper.querySelector(":scope .slider"); + this.$sliderHandle = this.$wrapper.querySelector(":scope .slider-handle"); + let $clearButton = this.$wrapper.querySelector(':scope .clear-button'); + $clearButton.addEventListener('click', () => { + this.$field.value = ""; this.fireTextInput(); this.commit(); this.updateClearButton(); this.setSliderPositionByValue(this.getTransientValue()); }); - this.setEmptyText(config.emptyText); + this.setPlaceholderText(config.placeholderText); this.setPrecision(config.precision); this.setShowClearButton(config.showClearButton); this.setCommitOnSliderChange(config.commitOnSliderChange); - this.$field.keyup(() => { + this.$field.addEventListener("keyup", () => { this.setSliderPositionByValue(this.getTransientValue()); }); - this.$field.focus(() => { + this.$field.addEventListener("focus", () => { if (this.getEditingMode() !== UiFieldEditingMode.READONLY) { this.$field.select(); } }); - this.$field.blur((e) => { + this.$field.addEventListener("blur", (e) => { if (this.getEditingMode() !== UiFieldEditingMode.READONLY) { this.commit(); this.updateClearButton(); } }); - this.$field.on("input", () => { + this.$field.addEventListener("input", () => { this.fireTextInput(); this.updateClearButton(); }); - this.$field.keydown((e) => { + this.$field.addEventListener("keydown", (e) => { if (e.keyCode === keyCodes.escape) { this.displayCommittedValue(); // back to committedValue this.fireTextInput(); this.$field.select(); - this.onSpecialKeyPressed.fire(EventFactory.createUiTextInputHandlingField_SpecialKeyPressedEvent(this.getId(), UiSpecialKey.ESCAPE)); + this.onSpecialKeyPressed.fire({ + key: UiSpecialKey.ESCAPE + }); } else if (e.keyCode === keyCodes.up_arrow || e.keyCode == keyCodes.down_arrow) { if (this.getTransientValue() != null) { e.preventDefault(); // no jumping cursor to start or end @@ -115,52 +134,23 @@ export class UiNumberField extends UiField implemen } } else if (e.keyCode === keyCodes.enter) { this.commit(); - this.onSpecialKeyPressed.fire(EventFactory.createUiTextInputHandlingField_SpecialKeyPressedEvent(this.getId(), UiSpecialKey.ENTER)); + this.onSpecialKeyPressed.fire({ + key: UiSpecialKey.ENTER + }); } }); - this.$wrapper.click((e) => { - if (e.target !== this.$field[0]) { + this.$wrapper.addEventListener('click', (e) => { + if (e.target !== this.$field) { this.focus(); } }); - this.$sliderHandle.on(`${Constants.POINTER_EVENTS.start}`, (e: any) => { - const isSpecialMouseButton = e.button != null && e.button !== 0; - if (isSpecialMouseButton || this.getEditingMode() === UiFieldEditingMode.DISABLED) { - return; - } - if (!this.hasFocus() && !this._context.config.optimizedForTouch) { - this.focus(); - } - e.preventDefault(); // do not lose the focus! - let pointerSliderHandleOffset = (e.originalEvent.clientX || e.originalEvent.touches[0].clientX) - this.$sliderHandle[0].getBoundingClientRect().left; - let moveHandler = (e: any) => { - let sliderClientX = this.$slider[0].getBoundingClientRect().left; - let aPrioriNewSliderPositionX = (e.originalEvent.clientX || e.originalEvent.touches[0].clientX) - pointerSliderHandleOffset - sliderClientX; - let [minSliderX, maxSliderX] = this.getSliderMinMaxPositionX(); - const aPrioriNewValue = this.minValue + ((aPrioriNewSliderPositionX / (maxSliderX - minSliderX)) * (this.maxValue - this.minValue)); - let newValue = this.coerceToSteppedValue(aPrioriNewValue); - this.setSliderPositionByValue(newValue); - this.$field.val(this.formatNumber(newValue)); - if (!this._context.config.optimizedForTouch) { - this.$field.select(); - } - this.ensureDecimalInput(); - this.updateClearButton(); - }; - $(document).on(`${Constants.POINTER_EVENTS.move}`, moveHandler); - let endHandler = () => { - $(document).off(`${Constants.POINTER_EVENTS.move}`, moveHandler); - $(document).off(`${Constants.POINTER_EVENTS.end}`, endHandler); - if (!this._context.config.optimizedForTouch) { - this.focus(); - } - if (this.commitOnSliderChange) { - this.commit(); - } - }; - $(document).on(`${Constants.POINTER_EVENTS.end}`, endHandler); + this.$sliderHandle.addEventListener(`pointerdown`, (e: PointerEvent & TouchEvent) => { + this.handleDrag(e); + }); + this.$slider.addEventListener(`pointerdown`, (e: PointerEvent & TouchEvent) => { + this.handleDrag(e); }); this.setMinValue(config.minValue); @@ -169,8 +159,51 @@ export class UiNumberField extends UiField implemen this.setSliderStep(config.sliderStep); } + private handleDrag(e: PointerEvent & TouchEvent) { + const isSpecialMouseButton = e.button != null && e.button !== 0; + if (isSpecialMouseButton || this.getEditingMode() === UiFieldEditingMode.DISABLED) { + return; + } + if (!this.hasFocus() && !this._context.config.optimizedForTouch) { + this.focus(); + } + e.preventDefault(); // do not lose the focus! + let pointerSliderHandleOffset = e.target === this.$sliderHandle + ? (e.clientX || e.touches[0].clientX) - this.$sliderHandle.getBoundingClientRect().left + : this.$sliderHandle.getBoundingClientRect().width / 2; + let moveHandler = (e: any) => { + let sliderClientX = this.$slider.getBoundingClientRect().left; + let aPrioriNewSliderPositionX = (e.clientX || e.touches[0].clientX) - pointerSliderHandleOffset - sliderClientX; + let [minSliderX, maxSliderX] = this.getSliderMinMaxPositionX(); + const aPrioriNewValue = this.minValue + ((aPrioriNewSliderPositionX / (maxSliderX - minSliderX)) * (this.maxValue - this.minValue)); + let newValue = this.coerceToSteppedValue(aPrioriNewValue); + this.setSliderPositionByValue(newValue); + this.$field.value = this.formatNumber(newValue); + if (!this._context.config.optimizedForTouch) { + this.$field.select(); + } + this.ensureDecimalInput(); + this.updateClearButton(); + }; + document.addEventListener(`pointermove`, moveHandler); + moveHandler(e); // make sure even a click (without drag) gets handled + let endHandler = () => { + document.removeEventListener(`pointermove`, moveHandler); + document.removeEventListener(`pointerup`, endHandler); + if (!this._context.config.optimizedForTouch) { + this.focus(); + } + if (this.commitOnSliderChange) { + this.commit(); + } + }; + document.addEventListener(`pointerup`, endHandler); + } + private fireTextInput() { - this.onTextInput.fire(EventFactory.createUiTextInputHandlingField_TextInputEvent(this.getId(), this.$field.val().toString())); + this.onTextInput.fire({ + enteredString: this.$field.value + }); } commit(forceEvenIfNotChanged?: boolean): boolean { @@ -180,7 +213,7 @@ export class UiNumberField extends UiField implemen } private setEditorValue(value: number) { - this.$field.val(this.formatNumber(value)); + this.$field.value = this.formatNumber(value); } private setSliderPositionByValue(newValue: number) { @@ -190,11 +223,11 @@ export class UiNumberField extends UiField implemen let [minSliderX, maxSliderX] = this.getSliderMinMaxPositionX(); let newHandleX = minSliderX + (maxSliderX - minSliderX) * ((newValue - this.minValue) / (this.maxValue - this.minValue)); newHandleX = Math.max(minSliderX, Math.min(newHandleX, maxSliderX)); - this.$sliderHandle.css("left", `${newHandleX}px`); + this.$sliderHandle.style.left = `${newHandleX}px`; } private getSliderMinMaxPositionX() { - return [1, this.$slider[0].offsetWidth - 1 - this.$sliderHandle[0].offsetWidth]; + return [1, this.$slider.offsetWidth - 1 - this.$sliderHandle.offsetWidth]; } private coerceToSteppedValue(aPrioriNewValue: number) { @@ -220,18 +253,18 @@ export class UiNumberField extends UiField implemen } private ensureDecimalInput() { - const cursorPosition = (this.$field[0]).selectionEnd; - const oldValue = this.$field.val().toString(); + const cursorPosition = (this.$field).selectionEnd; + const oldValue = this.$field.value; let newValue = this.convertInputToDecimalValue(oldValue); - if (oldValue !== this.formatNumber(newValue)) { + if (!isNaN(newValue) && oldValue !== this.formatNumber(newValue)) { this.setEditorValue(newValue); - const newCursorPosition = Math.min(this.$field.val().toString().length, cursorPosition); + const newCursorPosition = Math.min(this.$field.value.length, cursorPosition); try { - (this.$field[0]).setSelectionRange(newCursorPosition, newCursorPosition); + (this.$field).setSelectionRange(newCursorPosition, newCursorPosition); } catch (e) { // IE throws an error when invoking setSelectionRange before the element is rendered... } @@ -242,45 +275,32 @@ export class UiNumberField extends UiField implemen return v == null || typeof v === "number"; } - private convertInputToDecimalValue(oldValue: string): number { - let decimalSeparator = this._context.config.decimalSeparator; - let newValue = oldValue.replace(new RegExp('[^\-0-9' + decimalSeparator + ']', 'g'), ''); - newValue = newValue.replace(/(\d*\.\d*)\./g, '$1'); // only one decimal separator!! - newValue = newValue.replace(/(.)-*/g, '$1'); // minus may only occure at the beginning - - const decimalSeparatorIndex = newValue.indexOf(decimalSeparator); - if (this.precision >= 0 && decimalSeparatorIndex != -1 && newValue.length - decimalSeparatorIndex - 1 > this.precision) { - newValue = newValue.substring(0, decimalSeparatorIndex + 1 + this.precision); - } - newValue = newValue.replace(decimalSeparator, '.'); - return parseFloat(newValue); + private convertInputToDecimalValue(value: string): number { + return this.numberParser.parse(value); } - public getMainInnerDomElement(): JQuery { + public getMainInnerDomElement(): HTMLElement { return this.$wrapper; } - - public getFocusableElement(): JQuery { - return this.$field; - } - protected displayCommittedValue(): void { let value = this.getCommittedValue(); - this.$field.val(value != null ? this.formatNumber(value) : ""); + this.$field.value = value != null ? this.formatNumber(value) : ""; this.updateClearButton(); this.setSliderPositionByValue(value); } private formatNumber(value: number) { - return formatNumber(value, this.precision, this._context.config.decimalSeparator, ""); + return value == null ? null : this.numberFormat.format(value); } public getTransientValue(): number { - if (this.$field.val() === "") { + if (!this.$field.value) { return null; } else { - let value = this.convertInputToDecimalValue(this.$field.val().toString()); - if (value > this.maxValue) { + let value = this.convertInputToDecimalValue(this.$field.value); + if (isNaN(value)) { + return this.getCommittedValue(); + } else if (value > this.maxValue) { value = this.maxValue; } else if (value < this.minValue) { value = this.minValue; @@ -289,18 +309,19 @@ export class UiNumberField extends UiField implemen } } + @executeWhenFirstDisplayed() focus(): void { this.$field.select(); } protected onEditingModeChanged(editingMode: UiFieldEditingMode): void { - UiField.defaultOnEditingModeChangedImpl(this); + UiField.defaultOnEditingModeChangedImpl(this, () => this.$field); } public getReadOnlyHtml(value: number, availableWidth: number): string { let formatedValue: string; if (value != null && value != null) { - formatedValue = formatNumber(value, this.precision, this._context.config.decimalSeparator, this._context.config.thousandsSeparator); + formatedValue = this.numberFormat.format(value); } else { formatedValue = ""; } @@ -311,13 +332,30 @@ export class UiNumberField extends UiField implemen return null; // yes } + setLocale(locale: string): void { + let value = this.getTransientValue(); + this._config.locale = locale; + this.numberFormat = new Intl.NumberFormat(locale, { + minimumFractionDigits: this._config.precision, + maximumFractionDigits: this._config.precision, + useGrouping: true + }); + this.numberParser = new NumberParser(locale); + this.setEditorValue(value); + } + setPrecision(precision: number): void { - this.precision = precision; + this._config.precision = precision; + this.numberFormat = new Intl.NumberFormat(this._config.locale, { + minimumFractionDigits: this._config.precision, + maximumFractionDigits: this._config.precision, + useGrouping: true + }); this.ensureDecimalInput(); } - setEmptyText(emptyText: string): void { - this.$field.attr('placeholder', emptyText || ''); + setPlaceholderText(placeholderText: string): void { + this.$field.placeholder = placeholderText || ''; } setShowClearButton(showClearButton: boolean): void { @@ -326,7 +364,7 @@ export class UiNumberField extends UiField implemen } private updateClearButton() { - this.$clearableFieldWrapper.toggleClass("clearable", !!(this.showClearButton && this.getTransientValue())); + this.$clearableFieldWrapper.classList.toggle("clearable", !!(this.showClearButton && this.getTransientValue() != null)); } public valuesChanged(v1: number, v2: number): boolean { @@ -345,9 +383,10 @@ export class UiNumberField extends UiField implemen setSliderMode(sliderMode: UiNumberFieldSliderMode): void { this.sliderMode = sliderMode; - this.$wrapper.toggleClass("slider-mode-disabled", sliderMode === UiNumberFieldSliderMode.DISABLED); - this.$wrapper.toggleClass("slider-mode-visible", sliderMode === UiNumberFieldSliderMode.VISIBLE); - this.$wrapper.toggleClass("slider-mode-visible-if-focused", sliderMode === UiNumberFieldSliderMode.VISIBLE_IF_FOCUSED); + this.$wrapper.classList.toggle("slider-mode-disabled", sliderMode === UiNumberFieldSliderMode.DISABLED); + this.$wrapper.classList.toggle("slider-mode-visible", sliderMode === UiNumberFieldSliderMode.VISIBLE); + this.$wrapper.classList.toggle("slider-mode-visible-if-focused", sliderMode === UiNumberFieldSliderMode.VISIBLE_IF_FOCUSED); + this.$wrapper.classList.toggle("slider-mode-slider-only", sliderMode === UiNumberFieldSliderMode.SLIDER_ONLY); } setSliderStep(sliderStep: number): void { diff --git a/teamapps-client/ts/modules/formfield/UiPasswordField.ts b/teamapps-client/ts/modules/formfield/UiPasswordField.ts index 8f8f2fb53..c9b48dfb2 100644 --- a/teamapps-client/ts/modules/formfield/UiPasswordField.ts +++ b/teamapps-client/ts/modules/formfield/UiPasswordField.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,42 +18,54 @@ * =========================LICENSE_END================================== */ import {TeamAppsUiContext} from "../TeamAppsUiContext"; -import * as md5 from "md5"; -import {UiPasswordFieldConfig, UiPasswordFieldCommandHandler, UiPasswordFieldEventSource} from "../../generated/UiPasswordFieldConfig"; +import md5 from "md5"; +import {UiPasswordFieldCommandHandler, UiPasswordFieldConfig, UiPasswordFieldEventSource} from "../../generated/UiPasswordFieldConfig"; import {TeamAppsUiComponentRegistry} from "../TeamAppsUiComponentRegistry"; import {UiTextField} from "./UiTextField"; -import {escapeHtml} from "../Common"; +import {getAutoCompleteOffValue, insertBefore, parseHtml} from "../Common"; +import {parseHTML} from "jquery"; export class UiPasswordField extends UiTextField implements UiPasswordFieldEventSource, UiPasswordFieldCommandHandler { - private salt: string; - private sendValueAsMd5: boolean; - protected initialize(config: UiPasswordFieldConfig, context: TeamAppsUiContext): void { - this.salt = config.salt; - this.sendValueAsMd5 = config.sendValueAsMd5; super.initialize(config, context); - this.$field.attr("type", "password"); + this.$field.type = "password"; + if (!config.autofill) { + this.$field.autocomplete = "new-password"; + this.$field.setAttribute("autocomplete", getAutoCompleteOffValue()); + this.$field.setAttribute("autocorrect", "off"); + this.$field.setAttribute("autocapitalize", "off"); + this.$field.setAttribute("spellcheck", "off"); + } + this.getMainInnerDomElement().classList.add("UiPasswordField"); + let $passwordVisibilityToggleButton: HTMLElement = parseHtml('
'); + this.setPasswordVisibilityToggleEnabled(config.passwordVisibilityToggleEnabled); + insertBefore($passwordVisibilityToggleButton, this.getMainInnerDomElement().querySelector(':scope .clear-button')); + + $passwordVisibilityToggleButton.addEventListener('click', ev => { + let visible = this.getMainInnerDomElement().classList.toggle('password-visible'); + this.$field.type = visible ? 'text' : 'password'; + }) } setSalt(salt: string): void { - this.salt = salt; + this._config.salt = salt; } setSendValueAsMd5(sendValueAsMd5: boolean): void { - this.sendValueAsMd5 = sendValueAsMd5; + this._config.sendValueAsMd5 = sendValueAsMd5; } getTransientValue(): string { - return this.calculateValueString(this.$field.val()); + return this.calculateValueString(this.$field.value); } private calculateValueString(value: any) { let valueString: string; - if (this.sendValueAsMd5) { - if (this.salt) { - valueString = md5(this.salt + md5(value)); + if (this._config.sendValueAsMd5) { + if (this._config.salt) { + valueString = md5(this._config.salt + md5(value)); } else { valueString = md5(value); } @@ -66,6 +78,11 @@ export class UiPasswordField extends UiTextField implemen public getReadOnlyHtml(value: string, availableWidth: number): string { return `
${value != null ? "•".repeat(value.length) : ""}
`; } + + setPasswordVisibilityToggleEnabled(enabled: boolean): any { + this._config.passwordVisibilityToggleEnabled = enabled; + this.getMainInnerDomElement().classList.toggle("password-visibility-toggle-enabled", enabled); + } } TeamAppsUiComponentRegistry.registerFieldClass("UiPasswordField", UiPasswordField); diff --git a/teamapps-client/ts/modules/formfield/UiRichTextEditor.ts b/teamapps-client/ts/modules/formfield/UiRichTextEditor.ts index 2bd41117c..82e427b2e 100644 --- a/teamapps-client/ts/modules/formfield/UiRichTextEditor.ts +++ b/teamapps-client/ts/modules/formfield/UiRichTextEditor.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,10 +17,8 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; import {UiField} from "./UiField"; import {UiFieldEditingMode} from "../../generated/UiFieldEditingMode"; -import {UiCompositeFieldConfig} from "../../generated/UiCompositeFieldConfig"; import {TeamAppsUiContext} from "../TeamAppsUiContext"; import {TeamAppsUiComponentRegistry} from "../TeamAppsUiComponentRegistry"; import { @@ -28,16 +26,16 @@ import { UiRichTextEditor_ImageUploadStartedEvent, UiRichTextEditor_ImageUploadSuccessfulEvent, UiRichTextEditor_ImageUploadTooLargeEvent, - UiRichTextEditorConfig, UiRichTextEditorCommandHandler, + UiRichTextEditorConfig, UiRichTextEditorEventSource } from "../../generated/UiRichTextEditorConfig"; -import * as tinymce from 'tinymce'; -import {Editor, Settings} from 'tinymce'; -import 'tinymce/themes/modern/theme'; -import {executeWhenAttached} from "../util/ExecuteWhenAttached"; +import tinymce, {Editor} from 'tinymce'; +import 'tinymce/themes/silver'; +import 'tinymce/icons/default'; +import {executeWhenFirstDisplayed} from "../util/ExecuteWhenFirstDisplayed"; import {DeferredExecutor} from "../util/DeferredExecutor"; -import {generateUUID, keyCodes} from "trivial-components"; +import {generateUUID, keyCodes} from "../trivial-components/TrivialCore"; // Any plugins you want to use has to be imported import 'tinymce/plugins/lists'; import 'tinymce/plugins/table'; @@ -49,24 +47,31 @@ import 'tinymce/plugins/contextmenu'; import 'tinymce/plugins/searchreplace'; import 'tinymce/plugins/spellchecker'; import 'tinymce/plugins/textcolor'; +import 'tinymce/plugins/print'; import {TeamAppsEvent} from "../util/TeamAppsEvent"; -import {debouncedMethod, DebounceMode} from "../util/debounce"; import {UiToolbarVisibilityMode} from "../../generated/UiToolbarVisibilityMode"; import {UiSpinner} from "../micro-components/UiSpinner"; -import {UiTextInputHandlingField_SpecialKeyPressedEvent, UiTextInputHandlingField_TextInputEvent} from "../../generated/UiTextInputHandlingFieldConfig"; +import { + UiTextInputHandlingField_SpecialKeyPressedEvent, + UiTextInputHandlingField_TextInputEvent +} from "../../generated/UiTextInputHandlingFieldConfig"; import {UiSpecialKey} from "../../generated/UiSpecialKey"; -import {EventFactory} from "../../generated/EventFactory"; -import {removeTags} from "../Common"; +import {parseHtml, removeTags} from "../Common"; +import {throttledMethod} from "../util/throttle"; +import {xhr} from "d3v3"; export class UiRichTextEditor extends UiField implements UiRichTextEditorEventSource, UiRichTextEditorCommandHandler { - public readonly onTextInput: TeamAppsEvent = new TeamAppsEvent(this, 5000); - public readonly onSpecialKeyPressed: TeamAppsEvent = new TeamAppsEvent(this, 250); - public readonly onImageUploadFailed: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onImageUploadStarted: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onImageUploadSuccessful: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onImageUploadTooLarge: TeamAppsEvent = new TeamAppsEvent(this); + public readonly onTextInput: TeamAppsEvent = new TeamAppsEvent(); + public readonly onSpecialKeyPressed: TeamAppsEvent = new TeamAppsEvent({ + throttlingMode: "debounce", + delay: 250 + }); + public readonly onImageUploadFailed: TeamAppsEvent = new TeamAppsEvent(); + public readonly onImageUploadStarted: TeamAppsEvent = new TeamAppsEvent(); + public readonly onImageUploadSuccessful: TeamAppsEvent = new TeamAppsEvent(); + public readonly onImageUploadTooLarge: TeamAppsEvent = new TeamAppsEvent(); private static readonly TRANSLATION_FILES: { [languageIso: string]: string } = { "af": "af_ZA.js", @@ -115,49 +120,48 @@ export class UiRichTextEditor extends UiField im }; - private $main: JQuery; - private $toolbarContainer: JQuery; + private $main: HTMLElement; + private $toolbarContainer: HTMLElement; private editor: Editor; private mceReadyExecutor: DeferredExecutor; private uuid: string; private imageUploadSuccessCallbacksByUuid: { [fileUuid: string]: (location: string) => void } = {}; - private $fileField: JQuery; + private $fileField: HTMLInputElement; private buttonGroupWidths: number[]; private _hasFocus: boolean; - private toolbarVisibilityMode: UiToolbarVisibilityMode; private maxImageFileSizeInBytes: number; private uploadUrl: string; private runningImageUploadsCount: number = 0; - private $spinnerWrapper: JQuery; + private $spinnerWrapper: HTMLElement; private destroying: boolean; - private $readonlyView: JQuery; - private initializationStarted: boolean; protected initialize(config: UiRichTextEditorConfig, context: TeamAppsUiContext) { - this.toolbarVisibilityMode = config.toolbarVisibilityMode; + this._config.toolbarVisibilityMode = config.toolbarVisibilityMode; this.setMaxImageFileSizeInBytes(config.maxImageFileSizeInBytes); this.setUploadUrl(config.uploadUrl); this.mceReadyExecutor = new DeferredExecutor(); this.uuid = "c-" + generateUUID(); - this.$main = $(`
+ this.$main = parseHtml(`
- + -
`); - this.$toolbarContainer = this.$main.find('.toolbar-container'); - this.$spinnerWrapper = this.$main.find('.spinner-wrapper'); - this.$readonlyView = this.$main.find('.readonly-view'); - new UiSpinner().getMainDomElement().appendTo(this.$spinnerWrapper); - this.$fileField = this.$main.find('.file-upload-button'); - this.$fileField.on("change", (e) => { - let files = ( this.$fileField[0]).files; + this.$toolbarContainer = this.$main.querySelector(':scope .toolbar-container'); + this.$spinnerWrapper = this.$main.querySelector(':scope .spinner-wrapper'); + this.$spinnerWrapper.appendChild(new UiSpinner().getMainDomElement()); + this.$fileField = this.$main.querySelector(':scope .file-upload-button'); + this.$fileField.addEventListener("change", (e) => { + let files = (this.$fileField).files; for (let i = 0; i < files.length; i++) { let file = files.item(i); if (file.size > this.maxImageFileSizeInBytes) { - this.onImageUploadTooLarge.fire(EventFactory.createUiRichTextEditor_ImageUploadTooLargeEvent(this.getId(), file.name, file.type, file.size)); + this.onImageUploadTooLarge.fire({ + fileName: file.name, + mimeType: file.type, + sizeInBytes: file.size + }); } else { let fileReader = new FileReader(); fileReader.onload = (e) => { @@ -166,7 +170,7 @@ export class UiRichTextEditor extends UiField im this.editor.uploadImages(() => undefined); }; fileReader.readAsDataURL(files.item(0)); - this.$fileField.val(''); + this.$fileField.value = ''; } } }); @@ -176,31 +180,46 @@ export class UiRichTextEditor extends UiField im this.setMaxHeight(config.maxHeight); } - @executeWhenAttached() + @executeWhenFirstDisplayed() private initTinyMce() { - if (this.isEditable() && !this.initializationStarted) { - this.initializationStarted = true; - const translationFileName = UiRichTextEditor.TRANSLATION_FILES[this._context.config.isoLanguage]; - tinymce.init({ - theme_url: '/runtime-resources/tinymce/themes/modern/theme.js', - skin_url: '/runtime-resources/tinymce/skins/lightgray', - selector: `#${this.uuid} > .inline-editor`, - fixed_toolbar_container: `#${this.uuid} > .toolbar-container`, - branding: false, - menubar: false, - inline: true, - toolbar: "undo redo | styleselect | bold italic underline forecolor backcolor removeformat | alignleft aligncenter alignright alignjustify | blockquote bullist numlist outdent indent | insertimage table | overflowbutton", - plugins: 'lists table image imagetools link autolink contextmenu searchreplace textcolor', - contextmenu: "openlink link unlink searchreplace", - language_url: translationFileName != null ? "/runtime-resources/tinymce/langs/" + translationFileName : undefined, - entity_encoding: "raw", - custom_elements: 'alert', - style_formats: [{ + const translationFileName = UiRichTextEditor.TRANSLATION_FILES[this._config.locale]; + tinymce.init({ + // theme: 'mobile', + // skin: 'oxide', + // icons: 'default', + theme_url: '/runtime-resources/tinymce/themes/silver/index.js', + skin_url: '/runtime-resources/tinymce/skins/ui/oxide', + selector: `#${this.uuid} > .inline-editor`, + readonly: !this.isEditable(), + fixed_toolbar_container: `#${this.uuid} > .toolbar-container`, + toolbar_persist: true, + branding: false, + menubar: false, + inline: true, + toolbar: `undo redo | styleselect | bold italic underline forecolor backcolor removeformat | ${this._config.imageUploadEnabled ? 'insertimagefromdisk |' : ' |'} alignleft aligncenter alignright alignjustify | blockquote bullist numlist outdent indent | table | ${this._config.printPluginEnabled ? 'print |' : ''} overflowbutton`, + plugins: `lists table link autolink contextmenu searchreplace textcolor ${this._config.printPluginEnabled ? 'print' : ''} ${this._config.imageUploadEnabled ? 'image imagetools' : ''}`, + contextmenu: "openlink link unlink searchreplace", + language_url: translationFileName != null ? "/runtime-resources/tinymce/langs/" + translationFileName : undefined, + entity_encoding: "raw", + formats: { + note: {block: 'p', classes: 'note'} + }, + style_formats: [ + // { + // title: 'Frequently Used' + // }, + { title: 'Heading 1', format: 'h1' }, { title: 'Heading 2', format: 'h2' + }, { + title: 'Heading 3', + format: 'h3' + }, { + title: 'Heading 4', + format: 'h4' }, { title: 'Paragraph', format: 'p' @@ -209,19 +228,20 @@ export class UiRichTextEditor extends UiField im format: 'blockquote' }, { title: 'Note', - block: 'alert', - wrapper: 1, + format: "note", remove: 'all' }, { title: 'Pre', format: 'pre', }, { title: 'Code', - icon: 'code', + icon: 'sourcecode', format: 'code' - }, { - title: '|' - }, { + }, + // { + // title: 'All Styles' + // }, + { title: 'Headings', items: [{ title: 'Heading 1', @@ -252,8 +272,9 @@ export class UiRichTextEditor extends UiField im format: 'blockquote' }, { title: 'Note', - block: 'alert', - wrapper: 1, + block: 'p', + wrapper: true, + classes: ".alert", remove: 'all' }, { title: 'Pre', @@ -275,7 +296,7 @@ export class UiRichTextEditor extends UiField im format: 'underline' }, { title: 'Strikethrough', - icon: 'strikethrough', + icon: 'strike-through', format: 'strikethrough' }, { title: 'Superscript', @@ -287,185 +308,201 @@ export class UiRichTextEditor extends UiField im format: 'subscript' }, { title: 'Code', - icon: 'code', + icon: 'sourcecode', format: 'code' }] }], - init_instance_callback: editor => { - if (this.destroying) { // already destroyed - editor.destroy(); - return; - } - this.editor = editor; - - editor.addMenuItem('bullist', {text: 'Bullet list', icon: 'bullist', cmd: 'InsertUnorderedList'}); - editor.addMenuItem('numlist', {text: 'Numbered list', icon: 'numlist', cmd: 'InsertOrderedList'}); - editor.addMenuItem('insertimage', {icon: 'image', text: 'Insert image', onclick: () => this.$fileField[0].click()}); - editor.addButton('overflowbutton', { - type: 'MenuButton', - image: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAAsTAAALEwEAmpwYAAABeUlEQVRYhe2TsWoUQRzGf//dWbYysZW7vIDBOq1NiK2+QcRU6RI4mNtbWLi7DRzoC0iSXkFfQZLq7NRHiBZ2Aatjb/ZvMx66mW1tMj9YmP3mm28+ZhiIRCKRyH1H/gzKsnzinHsN7KnqjYi8qev6IrRoNBo9MMacAS+89GG9XtvFYvEr5B+Pxy9V9UREdoBlmqan0+n0G0DiA4fOuWtgH9gSkV3g3Fp7GAo0xrwHjoFH/jvOsuxd3+bAuc/cAvadc1dFUQw2BYwxr4DtwPrTrlCW5S5w0NVV9Zm19nFAPwnkPlTVo00BYCfUXkSGXa1pmkHI6/135vyx92Yn/mfZk/m5KyRJ8gVoAt4G+BrQg9lt2y43BbIsuwCuO57bNE3vXEFd1z8B25EVsH7uH3zGbUe+yvP8Ev56BVVVmdVqdZgkyZ6qfheRt/P5/EeoPcBkMnnqnHvuN/k4m80+9XmLohio6pGIDNu2XeZ5fllV1brPH4lEIpHIf+U3FW15e2UaPR8AAAAASUVORK5CYII=', - title: '...', - menu: [ - (editor as any).menuItems['undo'], - (editor as any).menuItems['redo'], - (editor as any).menuItems['formats'], - (editor as any).menuItems['bold'], - (editor as any).menuItems['italic'], - (editor as any).menuItems['removeformat'], - (editor as any).menuItems['align'], - (editor as any).menuItems['bullist'], - (editor as any).menuItems['numlist'], - (editor as any).menuItems['insertimage'], - (editor as any).menuItems['inserttable'], - (editor as any).menuItems['openlink'], - (editor as any).menuItems['link'], - (editor as any).menuItems['unlink'], - (editor as any).menuItems['searchreplace'] - ] - }); - editor.addButton('insertimage', {icon: 'image', tooltip: 'Insert image', onclick: () => this.$fileField[0].click()}); - this.editor.fire("focus"); // triggers the initial rendering of the toolbar - this.editor.fire("blur"); // make the editor NOT being rendered as focused - this.updateToolbarOverflow(); // the toolbar got visible only now... - - this.mceReadyExecutor.ready = true; - - this.onResize(); - }, - setup: (editor) => { - editor.on('change undo redo keypress', (e) => { - this.fireTextInputEvent(); - }); - editor.on('keydown', (e) => { - if (e.keyCode === keyCodes.escape) { - this.onSpecialKeyPressed.fire(EventFactory.createUiTextInputHandlingField_SpecialKeyPressedEvent(this.getId(), UiSpecialKey.ESCAPE)); - } else if (e.keyCode === keyCodes.enter) { - this.onSpecialKeyPressed.fire(EventFactory.createUiTextInputHandlingField_SpecialKeyPressedEvent(this.getId(), UiSpecialKey.ENTER)); - } - }); - editor.on('undo redo', (e) => { - this.editor.uploadImages(() => undefined); + init_instance_callback: editor => { + if (this.destroying) { // already destroyed + editor.destroy(); + return; + } + this.editor = editor; + this.mceReadyExecutor.ready = true; + }, + setup: (editor) => { + if (this._config.imageUploadEnabled) { + editor.ui.registry.addMenuItem('insertimagefromdisk', { + icon: 'image', + text: 'Insert image', + onAction: () => this.$fileField.click() }); - editor.on('focus', (e) => { - this._hasFocus = true; - this.getMainDomElement().addClass('focus'); - this.updateToolbarVisibility(); - this.onFocused.fire(null); + editor.ui.registry.addButton('insertimagefromdisk', { + icon: 'image', + tooltip: 'Insert image', + onAction: () => this.$fileField.click() }); - editor.on('blur', (e) => { - this._hasFocus = false; - this.getMainDomElement().removeClass('focus'); - if (this.mayFireChangeEvents()) { - this.commit(); + } + editor.on('change undo redo keypress', (e) => { + this.fireTextInputEventThrottled(); + }); + editor.on('keydown', (e) => { + if (e.keyCode === keyCodes.escape) { + this.onSpecialKeyPressed.fire({ + key: UiSpecialKey.ESCAPE + }); + } else if (e.keyCode === keyCodes.enter) { + this.onSpecialKeyPressed.fire({ + key: UiSpecialKey.ENTER + }); + } + }); + editor.on('undo redo', (e) => { + this.editor.uploadImages(() => undefined); + }); + }, + images_upload_handler: (blobInfo: { base64: () => string, blob: () => Blob, blobUri: () => string, filename: () => string, id: () => string, name: () => string, uri: () => string }, success, failure) => { + this.runningImageUploadsCount++; + this.updateSpinnerVisibility(); + this.onImageUploadStarted.fire({ + fileName: blobInfo.filename(), + mimeType: blobInfo.blob().type, + sizeInBytes: blobInfo.blob().size, + incompleteUploadsCount: this.runningImageUploadsCount + }); + + let upload = (isFirstUpload: boolean) => { + let xhr: XMLHttpRequest; + let formData: FormData; + + xhr = new XMLHttpRequest(); + xhr.withCredentials = false; + xhr.open('POST', this.uploadUrl); + + const handleUploadFailure = () => { + if (isFirstUpload) { + setTimeout(() => upload(false), 5000); + } else { + this.runningImageUploadsCount--; + this.updateSpinnerVisibility(); + this.onImageUploadFailed.fire({ + name: blobInfo.filename(), + mimeType: blobInfo.blob().type, + sizeInBytes: blobInfo.blob().size, + incompleteUploadsCount: this.runningImageUploadsCount + }); + this.editor.undoManager.transact(() => { + this.$main.querySelector(`:scope [src='${blobInfo.blobUri()}']`).remove(); + }); + failure(null); } - this.updateToolbarVisibility(); - this.onBlurred.fire(null); - }); - }, - images_upload_handler: (blobInfo: { base64: () => string, blob: () => Blob, blobUri: () => string, filename: () => string, id: () => string, name: () => string, uri: () => string }, success, failure) => { - this.runningImageUploadsCount++; - this.updateSpinnerVisibility(); - this.onImageUploadStarted.fire(EventFactory.createUiRichTextEditor_ImageUploadStartedEvent(this.getId(), blobInfo.filename(), blobInfo.blob().type, blobInfo.blob().size, this.runningImageUploadsCount)); - - let upload = (isFirstUpload: boolean) => { - let xhr: XMLHttpRequest; - let formData: FormData; - - xhr = new XMLHttpRequest(); - xhr.withCredentials = false; - xhr.open('POST', this.uploadUrl); - - const handleUploadFailure = () => { - if (isFirstUpload) { - setTimeout(() => upload(false), 5000); - } else { - this.runningImageUploadsCount--; - this.updateSpinnerVisibility(); - this.onImageUploadFailed.fire(EventFactory.createUiRichTextEditor_ImageUploadFailedEvent(this.getId(), blobInfo.filename(), blobInfo.blob().type, blobInfo.blob().size, this.runningImageUploadsCount)); - this.editor.undoManager.transact(() => { - this.$main.find(`[src='${blobInfo.blobUri()}']`).detach(); - }); - failure(null); - } - }; - - xhr.onload = () => { - if (xhr.status < 200 || xhr.status >= 300) { - handleUploadFailure(); - } else { - let fileUuid = JSON.parse(xhr.responseText)[0]; - this.imageUploadSuccessCallbacksByUuid[fileUuid] = success; - this.runningImageUploadsCount--; - this.updateSpinnerVisibility(); - this.onImageUploadSuccessful.fire(EventFactory.createUiRichTextEditor_ImageUploadSuccessfulEvent(this.getId(), fileUuid, blobInfo.filename(), blobInfo.blob().type, blobInfo.blob().size, this.runningImageUploadsCount)); - } - }; - xhr.onerror = () => { + }; + + xhr.onload = () => { + if (xhr.status < 200 || xhr.status >= 300) { handleUploadFailure(); - }; + } else { + let fileUuid = JSON.parse(xhr.responseText)[0]; + this.imageUploadSuccessCallbacksByUuid[fileUuid] = success; + this.runningImageUploadsCount--; + this.updateSpinnerVisibility(); + this.onImageUploadSuccessful.fire({ + fileUuid: fileUuid, + name: blobInfo.filename(), + mimeType: blobInfo.blob().type, + sizeInBytes: blobInfo.blob().size, + incompleteUploadsCount: this.runningImageUploadsCount + }); + } + }; + xhr.onerror = () => { + handleUploadFailure(); + }; - formData = new FormData(); - formData.append('files', blobInfo.blob(), blobInfo.filename()); + formData = new FormData(); + formData.append('files', blobInfo.blob(), blobInfo.filename()); - xhr.send(formData); - }; - upload(true); + xhr.send(formData); + }; + upload(true); + } + }); + + this.getMainElement().addEventListener('focusin', (e) => { + if (!this.getMainElement().contains(e.relatedTarget as Node)) { // make sure we REALLY lost the focus + this._hasFocus = true; + this.getMainElement().classList.add('focus'); + this.onFocusGained.fire(null); + this.updateToolbarVisiblity(); + } + }); + this.getMainElement().addEventListener('focusout', (e) => { + // NOTE that it is important to do this on focusout instead of blur, since for some tinymce reason blur is called asynchronously only (setTimeout). + // Being synchronous is important, in order to make sure that the changes are commited before processing a, say, button event that will save the form... + if (!this.getMainElement().contains(e.relatedTarget as Node)) { // make sure we REALLY lost the focus + this._hasFocus = false; + this.getMainElement().classList.remove('focus'); + if (this.mayFireChangeEvents()) { + this.commit(); } - } as Settings); - } + this.onBlur.fire(null); + this.updateToolbarVisiblity(); + } + }); + } + + protected initFocusHandling() { + // do nothing (see editor initialization) } isValidData(v: string): boolean { return v == null || typeof v === "string"; } - private fireTextInputEvent() { + @throttledMethod(5000) + private fireTextInputEventThrottled() { + this.fireTextinputEvent(); + } + + private fireTextinputEvent() { if (this.mayFireChangeEvents()) { - this.onTextInput.fire(EventFactory.createUiTextInputHandlingField_TextInputEvent(this.getId(), this.getTransientValue())); + this.onTextInput.fire({ + enteredString: this.getTransientValue() + }); } } + async commitTransientValue() { + this.fireTextinputEvent(); + this.commit(false); + return this.getCommittedValue(); + } + private updateSpinnerVisibility() { - this.$spinnerWrapper.toggleClass("hidden", this.runningImageUploadsCount === 0); + this.$spinnerWrapper.classList.toggle("hidden", this.runningImageUploadsCount === 0); } setUploadedImageUrl(fileUuid: string, url: string): void { let successCallback = this.imageUploadSuccessCallbacksByUuid[fileUuid]; if (successCallback != null) { successCallback(url); - this.fireTextInputEvent(); // the html has changed at this point with the replacement of the data URL with the server-side URL + this.fireTextInputEventThrottled(); // the html has changed at this point with the replacement of the data URL with the server-side URL } } - public getMainInnerDomElement(): JQuery { + public getMainInnerDomElement(): HTMLElement { return this.$main; } - public getFocusableElement(): JQuery { - return this.editor != null ? $(this.editor.getBody()) : null; - } - protected displayCommittedValue(): void { const value = removeTags(this.getCommittedValue(), "style"); - if (this.isEditable()) { - this.mceReadyExecutor.invokeWhenReady(() => { - this.editor.setContent(value); - this.editor.undoManager.clear(); - }); - } else { - this.$readonlyView.html(value); - } + this.mceReadyExecutor.invokeWhenReady(() => { + this.editor.setContent(value); + this.editor.undoManager.reset(); + }); } + @executeWhenFirstDisplayed() focus(): void { this.editor && this.editor.focus(false); } - doDestroy(): void { + destroy(): void { + super.destroy(); this.destroying = true; - if (this.editor != null) { - this.editor.destroy(); - } + this.mceReadyExecutor.invokeOnceWhenReady(() => { + this.editor.destroy(); + }) } getTransientValue(): string { @@ -473,42 +510,36 @@ export class UiRichTextEditor extends UiField im return content.replace(/src="data:.*?"/g, ""); } - @debouncedMethod(700, DebounceMode.BOTH) - @executeWhenAttached(true) + @executeWhenFirstDisplayed(true) onResize(): void { - this.updateToolbarOverflow(); + this.rerenderToolbar(); } - private updateToolbarOverflow() { - if (!this.buttonGroupWidths) { - let buttonGroupWidths = this.$toolbarContainer.find(".mce-btn-group:not(:last-child)").toArray() - .map(e => e.offsetWidth); - if (buttonGroupWidths.reduce((sum, groupWidth) => sum + groupWidth, 0) === 0) { // toolbar apparently not visible - return; - } else { - this.buttonGroupWidths = buttonGroupWidths; - } - } + protected onEditingModeChanged(editingMode: UiFieldEditingMode, oldEditingMode: UiFieldEditingMode): void { + this.mceReadyExecutor?.invokeOnceWhenReady(() => { + // this MUST be done after initializing! Don't skip it when the editor is null, + // since the editor might just be initializing and thereby setting the readonly value to an old value! (actually happened!) + this.editor.setMode(this.isEditable() ? 'design' : 'readonly'); + this.updateToolbarVisiblity(); + this.rerenderToolbar(); + }) + let wasEditable = !(oldEditingMode === UiFieldEditingMode.DISABLED || oldEditingMode === UiFieldEditingMode.READONLY); + UiField.defaultOnEditingModeChangedImpl(this, () => this.editor != null ? this.editor.getBody() : null); + } - let availableWidth = this.$toolbarContainer[0].offsetWidth - 50 /*overflowbutton*/; - let $groupButtons = this.$toolbarContainer.find(".mce-btn-group:not(:last-child)"); - let consumedWidth = 0; - this.buttonGroupWidths.forEach((groupWidth, index) => { - consumedWidth = consumedWidth + groupWidth; - $($groupButtons.get(index)).toggleClass('hidden', consumedWidth > availableWidth); - }); + private updateToolbarVisiblity() { // both visibility and content (+overflow) are updated on show() + this.$toolbarContainer.classList.toggle("hidden", !this.toolbarShouldBeShown()); } - protected onEditingModeChanged(editingMode: UiFieldEditingMode, oldEditingMode: UiFieldEditingMode): void { - let wasEditable = !(oldEditingMode === UiFieldEditingMode.DISABLED || oldEditingMode === UiFieldEditingMode.READONLY); - if (this.isEditable()) { - this.initTinyMce(); - } else if (wasEditable && !this.isEditable()) { - this.$readonlyView.html(removeTags(this.getCommittedValue(), "style")); + private rerenderToolbar() { // both visibility and content (+overflow) are updated on show() + if (this.editor != null) { + this.editor.ui.show(); // effectively rerenders the toolbar } - UiField.defaultOnEditingModeChangedImpl(this); - this.$main.toggleClass("editable-if-focused", editingMode === UiFieldEditingMode.EDITABLE_IF_FOCUSED); - this.updateToolbarVisibility(); + } + + private toolbarShouldBeShown() { + return this.isEditable() && + (this._config.toolbarVisibilityMode == UiToolbarVisibilityMode.VISIBLE || (this._config.toolbarVisibilityMode == UiToolbarVisibilityMode.VISIBLE_IF_FOCUSED && this.hasFocus())); } getDefaultValue(): string { @@ -516,19 +547,8 @@ export class UiRichTextEditor extends UiField im } setToolbarVisibilityMode(toolbarVisibilityMode: UiToolbarVisibilityMode): void { - this.toolbarVisibilityMode = toolbarVisibilityMode; - this.updateToolbarVisibility(); - } - - private updateToolbarVisibility() { - const toolbarVisible = - (this.isEditable() && (this.toolbarVisibilityMode === UiToolbarVisibilityMode.VISIBLE || (this.toolbarVisibilityMode === UiToolbarVisibilityMode.VISIBLE_IF_FOCUSED && this._hasFocus))); - this.$toolbarContainer.toggleClass('hidden', !toolbarVisible); - this.updateToolbarOverflow(); - } - - hasFocus(): boolean { - return this._hasFocus; + this._config.toolbarVisibilityMode = toolbarVisibilityMode; + this.updateToolbarVisiblity(); } setMaxImageFileSizeInBytes(maxImageFileSizeInBytes: number): void { @@ -552,16 +572,16 @@ export class UiRichTextEditor extends UiField im } public setMinHeight(minHeight: number) { - this.$main.css("min-height", minHeight ? minHeight + "px" : ""); + this.$main.style.minHeight = minHeight ? minHeight + "px" : ""; } public setMaxHeight(maxHeight: number) { - this.$main.css("max-height", maxHeight ? maxHeight + "px" : ""); + this.$main.style.maxHeight = maxHeight ? maxHeight + "px" : ""; } append(s: string, scrollToBottom: boolean): void { // this.mceReadyExecutor.invokeWhenReady(() => { - // this.editor.append(s); + // this.editor.appendChild(s); // this.editor. // }); } diff --git a/teamapps-client/ts/modules/formfield/UiSlider.ts b/teamapps-client/ts/modules/formfield/UiSlider.ts deleted file mode 100644 index dc7dbc76d..000000000 --- a/teamapps-client/ts/modules/formfield/UiSlider.ts +++ /dev/null @@ -1,186 +0,0 @@ -/*- - * ========================LICENSE_START================================= - * TeamApps - * --- - * Copyright (C) 2014 - 2019 TeamApps.org - * --- - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * =========================LICENSE_END================================== - */ -import * as $ from "jquery"; -import * as noUiSlider from "nouislider"; -import {UiField} from "./UiField"; -import {UiSliderConfig, UiSliderCommandHandler, UiSliderEventSource} from "../../generated/UiSliderConfig"; -import {TeamAppsUiContext} from "../TeamAppsUiContext"; -import {TeamAppsUiComponentRegistry} from "../TeamAppsUiComponentRegistry"; -import {UiFieldEditingMode} from "../../generated/UiFieldEditingMode"; -import {UiColorConfig} from "../../generated/UiColorConfig"; -import {generateUUID, humanReadableFileSize} from "../Common"; -import {createUiColorCssString} from "../util/CssFormatUtil"; - -const wNumb: (options: wNumb.Options) => wNumb.Instance = require('wnumb'); - - -export class UiSlider extends UiField implements UiSliderEventSource, UiSliderCommandHandler { - - private uuid: string; - private $main: JQuery; - private $style: JQuery; - private $slider: JQuery; - private slider: noUiSlider.noUiSlider; - - private min: number | undefined; - private max: number | undefined; - private step: number | undefined; - private displayedDecimals: number | undefined; - private tooltipPrefix: string | undefined; - private tooltipPostfix: string | undefined; - private humanReadableFileSize: boolean | undefined; - - protected initialize(config: UiSliderConfig, context: TeamAppsUiContext) { - this.uuid = generateUUID(); - this.$main = $(`
- -
-
`); - this.$style = this.$main.find('style'); - this.$slider = this.$main.find('.slider'); - - this.min = config.min; - this.max = config.max; - this.step = config.step; - this.displayedDecimals = config.displayedDecimals; - this.tooltipPrefix = config.tooltipPrefix; - this.tooltipPostfix = config.tooltipPostfix; - this.humanReadableFileSize = config.humanReadableFileSize; - - noUiSlider.create(this.$slider[0], this.createNoUiSliderOptions()); - this.slider = (this.$slider[0] as any).noUiSlider; - this.slider.on('set', () => this.commit()); - - const $handle = this.$slider.find('.noUi-handle'); - $handle.on('keydown', (e) => { - const value = Number(this.slider.get()); - if (e.which === 37) { - this.slider.set(value - this.step); - } else if (e.which === 39) { - this.slider.set(value + this.step); - } - }); - - this.setSelectionColor(config.selectionColor); - } - - isValidData(v: number): boolean { - return v == null || typeof v === "number"; - } - - private createNoUiSliderOptions(): noUiSlider.Options { - return { - start: [this.min], - tooltips: this.humanReadableFileSize ? { - to: (num: number) => (this.tooltipPrefix || "") + humanReadableFileSize(num, true) + (this.tooltipPostfix || "") - } : wNumb({ - decimals: this.displayedDecimals, - thousand: this._context.config.thousandsSeparator, - mark: this._context.config.decimalSeparator, - prefix: this.tooltipPrefix, - postfix: this.tooltipPostfix - }), - step: this.step, - connect: [true, false], - range: { - 'min': this.min, - 'max': this.max - } - }; - } - - protected displayCommittedValue(): void { - this.slider.set(this.getCommittedValue()); - } - - getDefaultValue(): number { - return this._config.min; - } - - getFocusableElement(): JQuery { - return this.$slider.find('.noUi-handle').first(); - } - - getMainInnerDomElement(): JQuery { - return this.$main; - } - - getTransientValue(): number { - return parseFloat(this.slider.get() as string); - } - - protected onEditingModeChanged(editingMode: UiFieldEditingMode): void { - if (editingMode === UiFieldEditingMode.DISABLED || editingMode === UiFieldEditingMode.READONLY) { - this.$slider.prop('disabled', true); - } else if (editingMode === UiFieldEditingMode.EDITABLE || editingMode === UiFieldEditingMode.EDITABLE_IF_FOCUSED) { - this.$slider.prop('disabled', false); - } - } - - valuesChanged(v1: number, v2: number): boolean { - return v1 !== v2; - } - - public setSelectionColor(selectionColor: UiColorConfig) { - this.$style.html(` - [data-uuid="${this.uuid}"] .noUi-connect { - background-color: ${createUiColorCssString(selectionColor)}; - } - `) - } - - setDisplayedDecimals(displayedDecimals: number): void { - this.displayedDecimals = displayedDecimals; - this.slider.updateOptions(this.createNoUiSliderOptions(), false); - } - - setMax(max: number): void { - this.max = max; - this.slider.updateOptions(this.createNoUiSliderOptions(), false); - } - - setMin(min: number): void { - this.min = min; - this.slider.updateOptions(this.createNoUiSliderOptions(), false); - } - - setStep(step: number): void { - this.step = step; - this.slider.updateOptions(this.createNoUiSliderOptions(), false); - } - - setTooltipPostfix(tooltipPostfix: string): void { - this.tooltipPostfix = tooltipPostfix; - this.slider.updateOptions(this.createNoUiSliderOptions(), false); - } - - setTooltipPrefix(tooltipPrefix: string): void { - this.tooltipPrefix = tooltipPrefix; - this.slider.updateOptions(this.createNoUiSliderOptions(), false); - } - - setHumanReadableFileSize(humanReadableFileSize: boolean): void { - this.humanReadableFileSize = humanReadableFileSize; - this.slider.updateOptions(this.createNoUiSliderOptions(), false); - } - -} - -TeamAppsUiComponentRegistry.registerFieldClass("UiSlider", UiSlider); diff --git a/teamapps-client/ts/modules/formfield/UiTagComboBox.ts b/teamapps-client/ts/modules/formfield/UiTagComboBox.ts index 868f73855..52740dc14 100644 --- a/teamapps-client/ts/modules/formfield/UiTagComboBox.ts +++ b/teamapps-client/ts/modules/formfield/UiTagComboBox.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,67 +17,50 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; -import {defaultTreeQueryFunctionFactory, keyCodes, ResultCallback, trivialMatch, TrivialTagComboBox, TrivialTreeBox, wrapWithDefaultTagWrapper} from "trivial-components"; - -import {UiTagComboBox_WrappingMode, UiTagComboBoxConfig, UiTagComboBoxCommandHandler, UiTagComboBoxEventSource} from "../../generated/UiTagComboBoxConfig"; +import {wrapWithDefaultTagWrapper} from "../trivial-components/TrivialCore"; +import {TrivialTagComboBox} from "../trivial-components/TrivialTagComboBox"; + +import { + UiTagComboBox_WrappingMode, + UiTagComboBoxCommandHandler, + UiTagComboBoxConfig, + UiTagComboBoxEventSource +} from "../../generated/UiTagComboBoxConfig"; import {UiFieldEditingMode} from "../../generated/UiFieldEditingMode"; import {UiField} from "./UiField"; import {TeamAppsUiContext} from "../TeamAppsUiContext"; import {TeamAppsUiComponentRegistry} from "../TeamAppsUiComponentRegistry"; import {TeamAppsEvent} from "../util/TeamAppsEvent"; -import {UiComboBox_LazyChildDataRequestedEvent} from "../../generated/UiComboBoxConfig"; -import {UiTextInputHandlingField_SpecialKeyPressedEvent, UiTextInputHandlingField_TextInputEvent} from "../../generated/UiTextInputHandlingFieldConfig"; +import { + UiTextInputHandlingField_SpecialKeyPressedEvent, + UiTextInputHandlingField_TextInputEvent +} from "../../generated/UiTextInputHandlingFieldConfig"; import {UiSpecialKey} from "../../generated/UiSpecialKey"; import {UiComboBoxTreeRecordConfig} from "../../generated/UiComboBoxTreeRecordConfig"; import {UiTemplateConfig} from "../../generated/UiTemplateConfig"; -import {isFreeTextEntry, UiComboBox} from "./UiComboBox"; -import {buildObjectTree, NodeWithChildren, Renderer} from "../Common"; -import {EventFactory} from "../../generated/EventFactory"; +import {isFreeTextEntry} from "./UiComboBox"; +import {buildObjectTree, getAutoCompleteOffValue, NodeWithChildren, parseHtml, Renderer} from "../Common"; +import {TreeBoxDropdown} from "../trivial-components/dropdown/TreeBoxDropdown"; +import {TrivialTreeBox} from "../trivial-components/TrivialTreeBox"; +import {executeWhenFirstDisplayed} from "../util/ExecuteWhenFirstDisplayed"; export class UiTagComboBox extends UiField implements UiTagComboBoxEventSource, UiTagComboBoxCommandHandler { - public readonly onTextInput: TeamAppsEvent = new TeamAppsEvent(this, 250); - public readonly onSpecialKeyPressed: TeamAppsEvent = new TeamAppsEvent(this, 250); - public readonly onLazyChildDataRequested: TeamAppsEvent = new TeamAppsEvent(this); + public readonly onTextInput: TeamAppsEvent = new TeamAppsEvent({throttlingMode: "debounce", delay: 250}); + public readonly onSpecialKeyPressed: TeamAppsEvent = new TeamAppsEvent({throttlingMode: "debounce", delay: 250}); - private $originalInput: JQuery; + private $originalInput: HTMLElement; private trivialTagComboBox: TrivialTagComboBox>; private templateRenderers: { [name: string]: Renderer }; - private lastResultCallback: Function; + private resultCallbacksQueue: ((result: NodeWithChildren[]) => void)[] = []; private freeTextIdEntryCounter = -1; protected initialize(config: UiTagComboBoxConfig, context: TeamAppsUiContext) { - this.$originalInput = $(''); + this.$originalInput = parseHtml(``); this.templateRenderers = config.templates != null ? context.templateRegistry.createTemplateRenderers(config.templates) : {}; - let localQueryFunction: Function; - const objectTree = buildObjectTree(config.staticData, "id", "parentId"); - if (config.staticData != null && config.staticData.length > 0) { - let trivialMatchingOptions = UiComboBox.createTrivialMatchingOptions(config); - localQueryFunction = defaultTreeQueryFunctionFactory(objectTree, (entry: NodeWithChildren, queryString: string) => { - if (config.staticDataMatchPropertyNames) { - return config.staticDataMatchPropertyNames.some(fieldName => entry.values[fieldName] && trivialMatch(entry.values[fieldName], queryString, trivialMatchingOptions).length > 0); - } else { - return trivialMatch(entry.asString, queryString, trivialMatchingOptions).length > 0; - } - }, "__children", "expanded"); - } - let queryFunction = (queryString: string, resultCallback: ResultCallback>) => { - this.lastResultCallback = resultCallback; - this.onTextInput.fire(EventFactory.createUiTextInputHandlingField_TextInputEvent(this.getId(), queryString)); - if (localQueryFunction != null) { - localQueryFunction(queryString, resultCallback); - } - }; - - this.trivialTagComboBox = new TrivialTagComboBox>(this.$originalInput, { - childrenProperty: "__children", - expandedProperty: "expanded", - showExpanders: config.showExpanders, - queryFunction: queryFunction, - entryRenderingFunction: (entry) => this.renderRecord(entry, true), + this.trivialTagComboBox = new TrivialTagComboBox>({ selectedEntryRenderingFunction: (entry) => { if (isFreeTextEntry(entry)) { return wrapWithDefaultTagWrapper(`
${entry.asString}
`, config.showClearButton); @@ -88,50 +71,68 @@ export class UiTagComboBox extends UiField) => { - this.onLazyChildDataRequested.fire(EventFactory.createUiComboBox_LazyChildDataRequestedEvent(this.getId(), node.id)); - }, - lazyChildrenFlag: entry => entry.lazyChildren, - spinnerTemplate: `
`, + spinnerTemplate: `
`, textHighlightingEntryLimit: config.textHighlightingEntryLimit, showDropDownOnResultsOnly: config.showDropDownAfterResultsArrive, - autoCompleteFunction: (editorText, entry) => { - const entryAsString = entry.asString; - if (entryAsString.toLowerCase().indexOf(editorText.toLowerCase()) === 0) { - return entryAsString; - } else { - return ""; - } - }, + entryToEditorTextFunction: e => e.asString, freeTextEntryFactory: (freeText) => { return {id: this.freeTextIdEntryCounter--, values: {}, asString: freeText}; }, - idFunction: entry => entry && entry.id, selectionAcceptor: entry => { const violatesDistinctSetting = config.distinct && this.trivialTagComboBox.getSelectedEntries().map(e => e.id).indexOf(entry.id) !== -1; const violatesMaxEntriesSetting = !!config.maxEntries && this.trivialTagComboBox.getSelectedEntries().length >= config.maxEntries; const violatesFreeTextSetting = !config.allowAnyText && isFreeTextEntry(entry); return !violatesDistinctSetting && !violatesMaxEntriesSetting && !violatesFreeTextSetting; + }, + twoStepDeletion: this._config.twoStepDeletion, + preselectFirstQueryResult: config.highlightFirstResultEntry, + placeholderText: config.placeholderText, + dropDownMaxHeight: config.dropDownMaxHeight, + dropDownMinWidth: config.dropDownMinWidth + }, new TreeBoxDropdown({ + queryFunction: (queryString: string) => { + this.onTextInput.fire({enteredString: queryString}); // TODO this is definitely the wrong place for this!! + return config.retrieveDropdownEntries({queryString}) + .then(entries => buildObjectTree(entries, "id", "parentId")); + }, + textHighlightingEntryLimit: config.textHighlightingEntryLimit, + preselectionMatcher: (query, entry) => entry.asString.toLowerCase().indexOf(query.toLowerCase()) >= 0 + }, new TrivialTreeBox>({ + childrenProperty: "__children", + expandedProperty: "expanded", + showExpanders: config.showExpanders, + entryRenderingFunction: entry => this.renderRecord(entry, true), + idFunction: entry => entry && entry.id, + lazyChildrenQueryFunction: async (node: NodeWithChildren) => buildObjectTree(await config.lazyChildren({parentId: node.id}), "id", "parentId"), + lazyChildrenFlag: entry => entry.lazyChildren, + selectableDecider: entry => entry.selectable, + selectOnHover: true, + animationDuration: this._config.animate ? 120 : 0 + }))); + this.trivialTagComboBox.getMainDomElement().classList.add("UiTagComboBox", "default-min-field-width"); + this.trivialTagComboBox.getMainDomElement().classList.toggle("wrapping-mode-single-line", config.wrappingMode === UiTagComboBox_WrappingMode.SINGLE_LINE); + this.trivialTagComboBox.getMainDomElement().classList.toggle("wrapping-mode-single-tag-per-line", config.wrappingMode === UiTagComboBox_WrappingMode.SINGLE_TAG_PER_LINE); + this.trivialTagComboBox.onValueChanged.addListener(() => this.commit()); + this.trivialTagComboBox.getEditor().addEventListener("keydown", (e: KeyboardEvent) => { + if (e.key === "Escape") { + this.onSpecialKeyPressed.fire({ + key: UiSpecialKey.ESCAPE + }); + } else if (e.key === "Enter") { + this.onSpecialKeyPressed.fire({ + key: UiSpecialKey.ENTER + }); } }); - $(this.trivialTagComboBox.getMainDomElement()) - .addClass("UiTagComboBox") - .toggleClass("wrapping-mode-single-line", config.wrappingMode === UiTagComboBox_WrappingMode.SINGLE_LINE) - .toggleClass("wrapping-mode-single-tag-per-line", config.wrappingMode === UiTagComboBox_WrappingMode.SINGLE_TAG_PER_LINE); - this.trivialTagComboBox.onSelectedEntryChanged.addListener(() => this.commit()); - $(this.trivialTagComboBox.getEditor()).on("keydown", (e) => { - if (e.keyCode === keyCodes.escape) { - this.onSpecialKeyPressed.fire(EventFactory.createUiTextInputHandlingField_SpecialKeyPressedEvent(this.getId(), UiSpecialKey.ESCAPE)); - } else if (e.keyCode === keyCodes.enter) { - this.onSpecialKeyPressed.fire(EventFactory.createUiTextInputHandlingField_SpecialKeyPressedEvent(this.getId(), UiSpecialKey.ENTER)); - } - }); - $(this.trivialTagComboBox.getMainDomElement()).addClass("field-border field-border-glow field-background"); - $(this.trivialTagComboBox.getMainDomElement()).find(".tr-editor").addClass("field-background"); - $(this.trivialTagComboBox.getMainDomElement()).find(".tr-trigger").addClass("field-border"); - this.trivialTagComboBox.onFocus.addListener(() => this.getMainDomElement().addClass("focus")); - this.trivialTagComboBox.onBlur.addListener(() => this.getMainDomElement().removeClass("focus")); + this.trivialTagComboBox.getMainDomElement().classList.add("field-border", "field-border-glow", "field-background"); + this.trivialTagComboBox.getMainDomElement().querySelector(":scope .tr-editor").classList.add("field-background"); + this.trivialTagComboBox.getMainDomElement().querySelector(":scope .tr-trigger").classList.add("field-border"); + } + + protected initFocusHandling() { + this.trivialTagComboBox.onFocus.addListener(() => this.onFocusGained.fire({})); + this.trivialTagComboBox.onBlur.addListener(() => this.onBlur.fire({})); } private renderRecord(record: NodeWithChildren, dropdown: boolean): string { @@ -148,29 +149,8 @@ export class UiTagComboBox extends UiField 0 && this.hasFocus()) { - this.trivialTagComboBox.openDropDown(); - } - } - - setChildNodes(parentId: number, recordList: UiComboBoxTreeRecordConfig[]): void { - const objectTree = buildObjectTree(recordList, "id", "parentId"); - (this.trivialTagComboBox.getDropDownComponent() as TrivialTreeBox>).updateChildren(parentId as any, objectTree); - } - - public getMainInnerDomElement(): JQuery { - return $(this.trivialTagComboBox.getMainDomElement()); - } - - public getFocusableElement(): JQuery { - return $(this.trivialTagComboBox.getMainDomElement()); + public getMainInnerDomElement(): HTMLElement { + return this.trivialTagComboBox.getMainDomElement() as HTMLElement; } protected displayCommittedValue(): void { @@ -186,18 +166,14 @@ export class UiTagComboBox extends UiField isFreeTextEntry(value) ? value.asString : value.id); } + @executeWhenFirstDisplayed() focus(): void { this.trivialTagComboBox.focus(); // TODO } - public hasFocus(): boolean { - return this.getMainInnerDomElement().is('.focus'); - } - protected onEditingModeChanged(editingMode: UiFieldEditingMode): void { - this.getMainDomElement() - .removeClass(Object.values(UiField.editingModeCssClasses).join(" ")) - .addClass(UiField.editingModeCssClasses[editingMode]); + this.getMainElement().classList.remove(...Object.values(UiField.editingModeCssClasses)); + this.getMainElement().classList.add(UiField.editingModeCssClasses[editingMode]); if (editingMode === UiFieldEditingMode.READONLY) { this.trivialTagComboBox.setEditingMode("readonly"); } else if (editingMode === UiFieldEditingMode.DISABLED) { @@ -226,9 +202,10 @@ export class UiTagComboBox extends UiField implements UiTemplateFieldCommandHandler, UiTemplateFieldEventSource { + + public readonly onClicked: TeamAppsEvent = new TeamAppsEvent(); + + private $main: HTMLElement; + private templateRenderer: Renderer; + + constructor(config: UiTemplateFieldConfig, context: TeamAppsUiContext) { + super(config, context); + } + + protected initialize(config: UiTemplateFieldConfig, context: TeamAppsUiContext): void { + this.$main = parseHtml(`
`); + this.$main.addEventListener("click", ev => this.onClicked.fire({})); + this.update(config); + } + + update(config: UiTemplateFieldConfig): void { + this.templateRenderer = this._context.templateRegistry.createTemplateRenderer(config.template); + this.displayCommittedValue(); + } + + getMainInnerDomElement(): HTMLElement { + return this.$main; + } + + protected displayCommittedValue(): void { + this.$main.innerHTML = this.templateRenderer.render(this.getCommittedValue() && this.getCommittedValue().values); + } + + @executeWhenFirstDisplayed() + focus() { + // do nothing + } + + getTransientValue(): UiClientRecordConfig { + return this.getCommittedValue(); + } + + isValidData(v: UiClientRecordConfig): boolean { + return true; + } + + protected onEditingModeChanged(editingMode: UiFieldEditingMode, oldEditingMode?: UiFieldEditingMode): void { + // nothing to do! + } + + valuesChanged(v1: UiClientRecordConfig, v2: UiClientRecordConfig): boolean { + return false; + } + + + getReadOnlyHtml(value: UiClientRecordConfig, availableWidth: number): string { + return `
${value != null ? this.templateRenderer.render(value.values) : ""}
`; + } +} + +TeamAppsUiComponentRegistry.registerFieldClass("UiTemplateField", UiTemplateField); diff --git a/teamapps-client/ts/modules/formfield/UiTextColorMarkerField.ts b/teamapps-client/ts/modules/formfield/UiTextColorMarkerField.ts new file mode 100644 index 000000000..2b9a03d85 --- /dev/null +++ b/teamapps-client/ts/modules/formfield/UiTextColorMarkerField.ts @@ -0,0 +1,504 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +import {UiFieldEditingMode} from "../../generated/UiFieldEditingMode"; +import { + UiTextColorMarkerField_TextSelectedEvent, + UiTextColorMarkerFieldCommandHandler, + UiTextColorMarkerFieldConfig, + UiTextColorMarkerFieldEventSource +} from "../../generated/UiTextColorMarkerFieldConfig"; +import {UiTextColorMarkerFieldMarkerConfig} from "../../generated/UiTextColorMarkerFieldMarkerConfig"; +import {UiTextColorMarkerFieldMarkerDefinitionConfig} from "../../generated/UiTextColorMarkerFieldMarkerDefinitionConfig"; +import {UiTextColorMarkerFieldValueConfig} from "../../generated/UiTextColorMarkerFieldValueConfig"; +import {escapeHtml, parseHtml} from "../Common"; +import {TeamAppsUiComponentRegistry} from "../TeamAppsUiComponentRegistry"; +import {TeamAppsUiContext} from "../TeamAppsUiContext"; +import {TeamAppsEvent} from "../util/TeamAppsEvent"; +import {UiField} from "./UiField"; + +export class UiTextColorMarkerField extends UiField implements UiTextColorMarkerFieldCommandHandler, UiTextColorMarkerFieldEventSource { + + public readonly onTextSelected: TeamAppsEvent = new TeamAppsEvent(); + + private $main: HTMLElement; + private $toolbarWrapper: HTMLElement; + private $editor: HTMLDivElement; + + private markerDefinitions: UiTextColorMarkerFieldMarkerDefinitionConfig[]; + private toolbarEnabled: boolean; + private currentSelection: UiTextColorMarkerField_TextSelectedEvent | null = null; + + protected initialize(config: UiTextColorMarkerFieldConfig, context: TeamAppsUiContext): void { + this.$main = parseHtml(`
+ +
+
`); + this.$toolbarWrapper = this.$main.querySelector(":scope > .toolbar-wrapper"); + this.$editor = this.$main.querySelector(":scope > .editor"); + //this.$editor.contentEditable = 'true'; + this.toolbarEnabled = config.toolbarEnabled; + this.setupEventListeners(); + this.setMarkerDefinitions(config.markerDefinitions, config.value); + } + + private getMarkerDefinitionById(id: number): UiTextColorMarkerFieldMarkerDefinitionConfig | undefined { + return this.markerDefinitions.find(definition => definition.id === id); + } + + public setMarkerDefinitions(markerDefinitions: UiTextColorMarkerFieldMarkerDefinitionConfig[], newValue: UiTextColorMarkerFieldValueConfig): void { + this.markerDefinitions = markerDefinitions; + this.setValue(this.createFieldValue( + newValue?.text, + newValue?.markers + )); + } + + public setMarker(markerDefinitionId: number, start: number, end: number, fireMarkerChangeEvent: boolean = false): void { + const newMarkers = this.getCommittedValue().markers.filter(m => m.markerDefinitionId !== markerDefinitionId); + newMarkers.push(this.createMarker(markerDefinitionId, start, end)); + + this.commitAndSetMarkers(this.normalizeMarkers(newMarkers), fireMarkerChangeEvent); + } + + public removeMarker(markerDefinitionId: number): void { + const transientMarkers = this.getTransientMarkers(); + const newMarkers = transientMarkers.filter(m => m.markerDefinitionId !== markerDefinitionId); + this.commitAndSetMarkers(newMarkers, true); + } + + private commitAndSetMarkers(markers: UiTextColorMarkerFieldMarkerConfig[], fireMarkerChangeEvent: boolean): void { + const value = this.createFieldValue(this.getTransientText(), markers); + const committedValue = this.getCommittedValue(); + const changed = this.valuesChanged(value, committedValue); + if (changed) { + this.setCommittedValue(value); + if (fireMarkerChangeEvent || this.textChanged(committedValue?.text, value?.text)) { + this.fireValueChangedEvent(); + } + } + } + + private fireValueChangedEvent(): void { + this.logger.trace("firing value changed event: " + JSON.stringify(this.getCommittedValue())); + this.onValueChanged.fire({ + value: this.convertValueForSendingToServer(this.getCommittedValue()) + }); + } + + public valuesChanged(v1: UiTextColorMarkerFieldValueConfig, v2: UiTextColorMarkerFieldValueConfig): boolean { + if (!v1 || !v2) { + return v1 !== v2; + } + return this.textChanged(v1.text, v2.text) || this.markersChanged(v1.markers, v2.markers); + } + + private textChanged(v1: string, v2: string): boolean { + return v1 !== v2; + } + + private markersChanged(v1: UiTextColorMarkerFieldMarkerConfig[], v2: UiTextColorMarkerFieldMarkerConfig[]): boolean { + return JSON.stringify(v1) !== JSON.stringify(v2); + } + + public getTransientValue(): UiTextColorMarkerFieldValueConfig { + const text = this.getTransientText()?.replace(/\u00A0/g, ' '); + const markers = this.getTransientMarkers(); + + return this.createFieldValue(text, markers); + } + + private getTransientText(node: Node = this.$editor): string { + if (!node.childNodes || node.childNodes.length === 0) { + if (node.nodeName.toUpperCase() === 'BR') { return '\n'; } + return node.textContent || ''; + } + let text = ''; + for (let i = 0; i < node.childNodes.length; i++) { + text += this.getTransientText(node.childNodes.item(i)); + } + return text; + } + + private getTransientMarkers(): UiTextColorMarkerFieldMarkerConfig[] { + const markers: UiTextColorMarkerFieldMarkerConfig[] = []; + + // Find all marker spans and extract their data + const markerSpans = this.$editor.querySelectorAll('span[data-marker-id]'); + markerSpans.forEach(span => { + const markerId = span.getAttribute('data-marker-id'); + if (markerId) { + const markerDefinitionId = Number(markerId); + const start = this.getNodePosition(span.firstChild || span); + const end = start + this.getTransientText(span).length; + markers.push(this.createMarker(markerDefinitionId, start, end)); + } + }); + return this.normalizeMarkers(markers); + } + + public setValue(value: UiTextColorMarkerFieldValueConfig): void { + // Sort markers by their ID + remove empty markers (start == end) + value.markers = this.normalizeMarkers(value.markers); + + // Check if this would actually change anything + if (this.valuesChanged(this.getCommittedValue(), value)) { + this.setCommittedValue(value); + } + } + + private normalizeMarkers(markers: UiTextColorMarkerFieldMarkerConfig[]): UiTextColorMarkerFieldMarkerConfig[] { + return [...markers.filter(m => m.start !== m.end)] + .sort((a, b) => a.markerDefinitionId - b.markerDefinitionId); + } + + protected displayCommittedValue(): void { + const value = this.getCommittedValue(); + this.$editor.innerHTML = this.renderWithMarkers(value.text ?? '', value.markers ?? []); + } + + // Injects marker spans at the correct offsets + private renderWithMarkers(text: string, markers: UiTextColorMarkerFieldMarkerConfig[]): string { + const operations: Array<{ type: 'open' | 'close', marker: UiTextColorMarkerFieldMarkerConfig }> = []; + + for (const marker of markers) { + operations.push({ type: 'open', marker }); + operations.push({ type: 'close', marker }); + } + + operations.sort((a, b) => { + const posA = a.type === 'open' ? a.marker.start! : a.marker.end!; + const posB = b.type === 'open' ? b.marker.start! : b.marker.end!; + if (posA !== posB) { return posA - posB; } + if (a.type !== b.type) { return a.type === 'open' ? 1 : -1; } + if (a.type === 'open') { return a.marker.end! > b.marker.end! ? -1 : 1; } + return a.marker.end! > b.marker.end! ? 1 : -1; // else + }); + + const result: string[] = []; + let currentPos = 0; + + for (const op of operations) { + const pos = op.type === 'open' ? op.marker.start! : op.marker.end!; + + if (pos > currentPos) { + result.push(this.escapeHtml(text.slice(currentPos, pos))); + } + + if (op.type === 'open') { + const def = this.getMarkerDefinitionById(op.marker.markerDefinitionId); + const style = []; + if (def?.backgroundColor) { + style.push(`--marker-bg-color:${this.escapeHtmlAttr(def.backgroundColor)}`); + style.push(`--marker-text-color:${this.getContrastColor(def.backgroundColor)}`); + } + if (def?.borderColor) { + style.push(`--marker-border-color:${this.escapeHtmlAttr(def.borderColor)}`); + } + result.push(``); + } else { + result.push(''); + } + + currentPos = pos; + } + + if (currentPos < text.length) { + result.push(this.escapeHtml(text.slice(currentPos))); + } + + return result.join(''); + } + + private escapeHtml(text: string): string { + return text?.replace(/[&<>]/g, c => { + if (c === '<') { return '<'; } + if (c === '>') { return '>'; } + if (c === '&') { return '&'; } + return c; + }); + } + + private escapeHtmlAttr(text: string): string { + return escapeHtml(text?.replace(/"'/g, c => { + if (c === '"') { return '"'; } + if (c === "'") { return '''; } + return c; + })); + } + + private setupEventListeners(): void { + const handleSelection = () => { + let noSelection = true; + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + if (this.$editor.contains(range.commonAncestorContainer)) { + const start = this.getNodePosition(range.startContainer) + range.startOffset; + const end = this.getNodePosition(range.endContainer) + range.endOffset; + if (start < end) { + noSelection = false; + this.triggerSelection({ start, end }); + } + } + } + if (noSelection) { + this.triggerDeselection(); + } + }; + this.$editor.addEventListener('mouseup', handleSelection); + this.$editor.addEventListener('keyup', handleSelection); // only needed for selection via keyboard + this.$editor.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + if (target.classList.contains('marker') && this.isMarkerChangeAllowed()) { + const markerId = parseInt(target.getAttribute('data-marker-id') || '0', 10); + if (markerId) { + this.removeMarker(markerId); + } + } + }); + + this.$editor.addEventListener('blur', e => { + this.commit(); + // Only hide toolbar if the focus is not moving to the toolbar + if (!this.$toolbarWrapper.contains(e.relatedTarget as Node)) { + this.triggerDeselection(); + } + }); + } + + public setToolbarEnabled(enabled: boolean): void { + this.toolbarEnabled = enabled; + if (!enabled) { + this.hideToolbar(); + } + } + + private triggerSelection(selection: UiTextColorMarkerField_TextSelectedEvent): void { + this.currentSelection = selection; + if (this.isMarkerChangeAllowed()) { + if (this.toolbarEnabled) { + this.showToolbar(); + } + this.onTextSelected.fire(this.currentSelection); + } + } + + private triggerDeselection(forceCursorReset: boolean = false): void { + if (this.currentSelection) { + if (forceCursorReset) { + window.getSelection()?.removeAllRanges(); // preventing missplaced cursor after setting marker via toolbar for Safari + } + this.currentSelection = null; + this.onTextSelected.fire({ start: 0, end: 0 }); // inform about selection removal + } + this.hideToolbar(); + } + + private showToolbar(): void { + this.updateToolbarContent(); + this.$toolbarWrapper.classList.remove('hidden'); + } + + private hideToolbar(): void { + this.$toolbarWrapper.classList.add('hidden'); + } + + private updateToolbarContent(): void { + this.$toolbarWrapper.innerHTML = ''; + + this.markerDefinitions.forEach(def => { + const button = document.createElement('button'); + button.className = 'toolbar-button'; + button.innerHTML = this.escapeHtml(def.hint ?? '').replace(/\n/g, '
'); + + // Style button with marker colors + if (def.backgroundColor) { + button.style.backgroundColor = def.backgroundColor; + button.style.color = this.getContrastColor(def.backgroundColor); + } + if (def.borderColor) { + button.style.borderColor = def.borderColor; + } + + // Check if this marker is already applied + if (this.getTransientMarkers().some(m => m.markerDefinitionId === def.id)) { + button.classList.add('applied'); + } + + // Add click handler + button.addEventListener('click', () => { + if (this.currentSelection) { + this.setMarker(def.id, this.currentSelection.start, this.currentSelection.end, true); + } + this.triggerDeselection(true); + }); + + this.$toolbarWrapper.appendChild(button); + }); + } + + private getContrastColor(hexColor: string): string { + // tslint:disable-next-line:one-variable-per-declaration + let r: number, g: number, b: number; + if (hexColor.startsWith('rgb(')) { + const rgb = hexColor.replace('rgb(', '').replace(')', '').split(','); + r = parseInt(rgb[0], 10); + g = parseInt(rgb[1], 10); + b = parseInt(rgb[2], 10); + } else { + const hex = hexColor.replace('#', ''); + r = parseInt(hex.substr(0, 2), 16); + g = parseInt(hex.substr(2, 2), 16); + b = parseInt(hex.substr(4, 2), 16); + } + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + + return luminance > 0.5 ? '#000000' : '#FFFFFF'; + } + + private getNodePosition(node: Node): number { + let position = 0; + const walker = document.createTreeWalker( + this.$editor, + // tslint:disable-next-line:no-bitwise + NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, + null + ); + + let currentNode: Node | null = walker.firstChild(); + while (currentNode && currentNode !== node) { // stops looping at "node" + if (currentNode.nodeType === Node.TEXT_NODE) { + position += currentNode.textContent?.length || 0; + } else if (currentNode.nodeType === Node.ELEMENT_NODE) { + const element = currentNode as HTMLElement; + if (element.tagName === 'BR') { + position += 1; // Count newlines + } + } + currentNode = walker.nextNode(); + } + + return position; + } + + public getDefaultValue(): UiTextColorMarkerFieldValueConfig { + return this.createFieldValue('', []); + } + + public getMainInnerDomElement(): HTMLElement { + return this.$main; + } + + public focus(): void { + this.$editor.focus(); + } + + public isValidData(v: any): boolean { + if (v == null || v.markers == null) { + return true; + } + return !v.markers.some((m: any) => m === null + || !this.isMarkerValid(m, v)); + } + + private isMarkerValid(marker: UiTextColorMarkerFieldMarkerConfig, value: UiTextColorMarkerFieldValueConfig): boolean { + if (!this.getMarkerDefinitionById(marker.markerDefinitionId)) { + this.logger.warn("No definition found for this marker. Invalid marker: " + marker.markerDefinitionId); + return false; + } + if (this.isMarkerOutOfRange(marker, value.text.length)) { + this.logger.warn("Marker out of range. Invalid marker: " + JSON.stringify(marker)); + return false; + } + if (value.markers.some(otherMarker => this.isMarkerOverlappedButNotNested(otherMarker, marker))) { + this.logger.warn("Marker overlaps with existing marker. Invalid marker: " + JSON.stringify(marker) + "\n Existing markers: " + JSON.stringify(value.markers)); + return false; + } + return true; + } + + private isMarkerOutOfRange(marker: UiTextColorMarkerFieldMarkerConfig, textLength: number): boolean { + return marker.start < 0 + || marker.end > textLength + || marker.start > marker.end; + } + + private isMarkerOverlappedButNotNested(otherMarker: UiTextColorMarkerFieldMarkerConfig, marker: UiTextColorMarkerFieldMarkerConfig): boolean { + if (otherMarker.markerDefinitionId === marker.markerDefinitionId) { return false; } // Skip the same marker + // Two markers overlap if one starts before the other ends and ends after the other starts + // But we allow them to be nested + const hasOverlap = () => marker.start < otherMarker.end && marker.end > otherMarker.start; + const isNested = () => (marker.start <= otherMarker.start && marker.end >= otherMarker.end) || + (otherMarker.start <= marker.start && otherMarker.end >= marker.end); + return hasOverlap() && !isNested(); + } + + protected onEditingModeChanged(editingMode: UiFieldEditingMode, oldEditingMode?: UiFieldEditingMode): void { + this.getMainElement().classList.remove(...Object.values(UiField.editingModeCssClasses)); + this.getMainElement().classList.add(UiField.editingModeCssClasses[this.getEditingMode()]); + this.getMainInnerDomElement().classList.remove(...Object.values(UiField.editingModeCssClasses)); + this.getMainInnerDomElement().classList.add(UiField.editingModeCssClasses[this.getEditingMode()]); + + const toolbarTabIndex = this.isMarkerChangeAllowed() ? '0' : '-1'; + switch (editingMode) { + case UiFieldEditingMode.EDITABLE: + case UiFieldEditingMode.EDITABLE_IF_FOCUSED: + this.$editor.contentEditable = 'true'; + this.$editor.setAttribute('tabindex', '0'); + this.$toolbarWrapper.setAttribute('tabindex', toolbarTabIndex); + break; + case UiFieldEditingMode.DISABLED: + this.$editor.contentEditable = 'false'; + this.$editor.setAttribute('tabindex', '-1'); + this.$toolbarWrapper.setAttribute('tabindex', toolbarTabIndex); + break; + case UiFieldEditingMode.READONLY: + this.$editor.contentEditable = 'false'; + this.$editor.setAttribute('tabindex', '-1'); + this.$toolbarWrapper.setAttribute('tabindex', toolbarTabIndex); + break; + default: + this.logger.error("unknown editing mode! " + editingMode); + } + } + + private isMarkerChangeAllowed(): boolean { + return ![UiFieldEditingMode.DISABLED].includes(this.getEditingMode()); + } + + private createFieldValue(text: string, markers: UiTextColorMarkerFieldMarkerConfig[]): UiTextColorMarkerFieldValueConfig { + return { + _type: "UiTextColorMarkerFieldValue", + text: text ?? '', + markers: [...(markers ?? [])] + }; + } + + private createMarker(markerDefinitionId: number, start: number, end: number): UiTextColorMarkerFieldMarkerConfig { + return { + _type: "UiTextColorMarkerFieldMarker", + markerDefinitionId, + start, + end + }; + } +} + +TeamAppsUiComponentRegistry.registerComponentClass("UiTextColorMarkerField", UiTextColorMarkerField); diff --git a/teamapps-client/ts/modules/formfield/UiTextField.ts b/teamapps-client/ts/modules/formfield/UiTextField.ts index f4a839bc6..42fbc7b1f 100644 --- a/teamapps-client/ts/modules/formfield/UiTextField.ts +++ b/teamapps-client/ts/modules/formfield/UiTextField.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,83 +17,96 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; import {UiField} from "./UiField"; import {UiFieldEditingMode} from "../../generated/UiFieldEditingMode"; -import {UiCompositeFieldConfig} from "../../generated/UiCompositeFieldConfig"; import {TeamAppsUiContext} from "../TeamAppsUiContext"; -import {escapeHtml} from "../Common"; -import {UiTextFieldConfig, UiTextFieldCommandHandler, UiTextFieldEventSource} from "../../generated/UiTextFieldConfig"; -import {keyCodes} from "trivial-components"; +import {escapeHtml, getAutoCompleteOffValue, parseHtml} from "../Common"; +import {UiTextFieldCommandHandler, UiTextFieldConfig, UiTextFieldEventSource} from "../../generated/UiTextFieldConfig"; +import {keyCodes} from "../trivial-components/TrivialCore"; import {TeamAppsUiComponentRegistry} from "../TeamAppsUiComponentRegistry"; import {TeamAppsEvent} from "../util/TeamAppsEvent"; -import {UiTextInputHandlingField_SpecialKeyPressedEvent, UiTextInputHandlingField_TextInputEvent} from "../../generated/UiTextInputHandlingFieldConfig"; +import { + UiTextInputHandlingField_SpecialKeyPressedEvent, + UiTextInputHandlingField_TextInputEvent +} from "../../generated/UiTextInputHandlingFieldConfig"; import {UiSpecialKey} from "../../generated/UiSpecialKey"; -import {EventFactory} from "../../generated/EventFactory"; +import {executeWhenFirstDisplayed} from "../util/ExecuteWhenFirstDisplayed"; export class UiTextField extends UiField implements UiTextFieldEventSource, UiTextFieldCommandHandler { - public readonly onTextInput: TeamAppsEvent = new TeamAppsEvent(this, 250); - public readonly onSpecialKeyPressed: TeamAppsEvent = new TeamAppsEvent(this, 250); + public readonly onTextInput: TeamAppsEvent = new TeamAppsEvent({throttlingMode: "debounce", delay: 250}); + public readonly onSpecialKeyPressed: TeamAppsEvent = new TeamAppsEvent({throttlingMode: "debounce", delay: 250}); - private $wrapper: JQuery; - protected $field: JQuery; + private $wrapper: HTMLElement; + protected $field: HTMLInputElement; private showClearButton: boolean; protected initialize(config: C, context: TeamAppsUiContext) { - this.$wrapper = $(`
- -
+ this.$wrapper = parseHtml(`
+ +
`); - this.$field = this.$wrapper.find("input"); - let $clearButton = this.$wrapper.find('.clear-button'); - $clearButton.click(() => { - this.$field.val(""); + this.$field = this.$wrapper.querySelector(":scope input"); + if (!config.autofill) { + this.$field.autocomplete = getAutoCompleteOffValue(); + } + let $clearButton = this.$wrapper.querySelector(':scope .clear-button'); + $clearButton.addEventListener('click',() => { + this.$field.value = ""; this.fireTextInput(); this.commit(); this.updateClearButton(); }); - this.setEmptyText(config.emptyText); + this.setPlaceholderText(config.placeholderText); this.setMaxCharacters(config.maxCharacters); this.setShowClearButton(config.showClearButton); - this.$field.on("focus", () => { + this.$field.addEventListener("focus", () => { if (this.getEditingMode() !== UiFieldEditingMode.READONLY) { this.$field.select(); } }); - this.$field.on("blur", () => { + this.$field.addEventListener("change", () => { if (this.getEditingMode() !== UiFieldEditingMode.READONLY) { this.commit(); this.updateClearButton(); } }); - this.$field.on("input", () => { + this.$field.addEventListener("input", () => { this.fireTextInput(); this.updateClearButton(); }); - this.$field.keydown((e) => { + this.$field.addEventListener("keydown", (e) => { if (e.keyCode === keyCodes.escape) { this.displayCommittedValue(); // back to committedValue this.fireTextInput(); this.$field.select(); - this.onSpecialKeyPressed.fire(EventFactory.createUiTextInputHandlingField_SpecialKeyPressedEvent(this.getId(), UiSpecialKey.ESCAPE)); + this.onSpecialKeyPressed.fire({ + key: UiSpecialKey.ESCAPE + }); } else if (e.keyCode === keyCodes.enter) { - this.commit(); - this.onSpecialKeyPressed.fire(EventFactory.createUiTextInputHandlingField_SpecialKeyPressedEvent(this.getId(), UiSpecialKey.ENTER)); + if (this.getEditingMode() !== UiFieldEditingMode.READONLY) { + // this needs to be done here, additionally, since otherwise the onSpecialKeyPressed gets fired before the commit... + this.commit(); + } + this.onSpecialKeyPressed.fire({ + key: UiSpecialKey.ENTER + }); } }); - this.$wrapper.click((e) => { - if (e.target !== this.$field[0]) { + this.$wrapper.addEventListener('click',(e) => { + if (e.target !== this.$field) { this.focus(); } }); } private fireTextInput() { - this.onTextInput.fire(EventFactory.createUiTextInputHandlingField_TextInputEvent(this.getId(), this.$field.val().toString())); + this.onTextInput.fire({ + enteredString: this.$field.value + }); } isValidData(v: string): boolean { @@ -101,7 +114,11 @@ export class UiTextField extend } setMaxCharacters(maxCharacters: number): void { - this.$field.attr('maxlength', maxCharacters || ""); + if (maxCharacters) { + this.$field.maxLength = maxCharacters; + } else { + this.$field.removeAttribute("maxLength"); + } } setShowClearButton(showClearButton: boolean): void { @@ -110,37 +127,33 @@ export class UiTextField extend } private updateClearButton() { - this.$wrapper.toggleClass("clearable", !!(this.showClearButton && this.$field.val())); + this.$wrapper.classList.toggle("clearable", !!(this.showClearButton && this.$field.value)); } - setEmptyText(emptyText: string): void { - this.$field.attr("placeholder", emptyText || ""); + setPlaceholderText(placeholderText: string): void { + this.$field.placeholder = placeholderText || ''; } - public getMainInnerDomElement(): JQuery { + public getMainInnerDomElement(): HTMLElement { return this.$wrapper; } - - public getFocusableElement(): JQuery { - return this.$field; - } - protected displayCommittedValue(): void { let value = this.getCommittedValue(); - this.$field.val(value || ""); + this.$field.value = value || ""; this.updateClearButton(); } public getTransientValue(): string { - return this.$field.val().toString(); + return this.$field.value; } + @executeWhenFirstDisplayed() focus(): void { this.$field.focus(); } protected onEditingModeChanged(editingMode: UiFieldEditingMode): void { - UiField.defaultOnEditingModeChangedImpl(this); + UiField.defaultOnEditingModeChangedImpl(this, () => this.$field); } public getReadOnlyHtml(value: string, availableWidth: number): string { diff --git a/teamapps-client/ts/modules/formfield/datetime/AbstractUiDateField.ts b/teamapps-client/ts/modules/formfield/datetime/AbstractUiDateField.ts deleted file mode 100644 index a93776f17..000000000 --- a/teamapps-client/ts/modules/formfield/datetime/AbstractUiDateField.ts +++ /dev/null @@ -1,223 +0,0 @@ -/*- - * ========================LICENSE_START================================= - * TeamApps - * --- - * Copyright (C) 2014 - 2019 TeamApps.org - * --- - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * =========================LICENSE_END================================== - */ -import * as $ from "jquery"; -import * as Mustache from "mustache"; -import {keyCodes, ResultCallback, TrivialComboBox, TrivialDateSuggestionEngine} from "trivial-components"; -import * as moment from "moment-timezone"; -import {UiFieldEditingMode} from "../../../generated/UiFieldEditingMode"; -import {UiField} from "../UiField"; -import {TeamAppsUiContext} from "../../TeamAppsUiContext"; -import {UiTextInputHandlingField_SpecialKeyPressedEvent, UiTextInputHandlingField_TextInputEvent} from "../../../generated/UiTextInputHandlingFieldConfig"; -import {TeamAppsEvent} from "../../util/TeamAppsEvent"; -import {UiSpecialKey} from "../../../generated/UiSpecialKey"; -import {AbstractUiDateFieldConfig, AbstractUiDateFieldCommandHandler, AbstractUiDateFieldEventSource} from "../../../generated/AbstractUiDateFieldConfig"; -import Moment = moment.Moment; -import {convertJavaDateTimeFormatToMomentDateTimeFormat} from "../../Common"; -import {EventFactory} from "../../../generated/EventFactory"; - -interface DateSuggestion { - moment: Moment; - ymdOrder: string; -} - -export interface DateComboBoxEntry { - day: number, - weekDay: string, - month: number, - year: number, - displayString: string -} - -export abstract class AbstractUiDateField extends UiField implements AbstractUiDateFieldEventSource, AbstractUiDateFieldCommandHandler { - - public readonly onTextInput: TeamAppsEvent = new TeamAppsEvent(this, 250); - public readonly onSpecialKeyPressed: TeamAppsEvent = new TeamAppsEvent(this, 250); - - public static comboBoxTemplate = `
- - - - - - - - - - - - - - - - {{weekDay}} - - -
{{displayString}}
-
`; - - private $originalInput: JQuery; - protected trivialComboBox: TrivialComboBox; - private dateSuggestionEngine: TrivialDateSuggestionEngine; - - private favorPastDates: boolean; - private dateFormat: string; - - protected initialize(config: AbstractUiDateFieldConfig, context: TeamAppsUiContext) { - this.$originalInput = $(''); - - this.favorPastDates = config.favorPastDates; - this.dateFormat = convertJavaDateTimeFormatToMomentDateTimeFormat(config.dateFormat); - - this.updateDateSuggestionEngine(); - this.trivialComboBox = new TrivialComboBox(this.$originalInput, { - queryFunction: (searchString: string, resultCallback: ResultCallback) => { - this.onTextInput.fire(EventFactory.createUiTextInputHandlingField_TextInputEvent(this.getId(), searchString)); - let comboBoxEntries = this.dateSuggestionEngine.generateSuggestions(searchString, moment() as any) - .map(s => AbstractUiDateField.createDateComboBoxEntryFromMoment(s.moment as any, this.getDateFormat())); - resultCallback(comboBoxEntries); - }, - showTrigger: config.showDropDownButton, - textHighlightingEntryLimit: -1, // no highlighting! - autoCompleteFunction: function (editorText, entry) { - if (editorText && entry.displayString.toLowerCase().indexOf(editorText.toLowerCase()) === 0) { - return entry.displayString; - } else { - return null; - } - }, - entryToEditorTextFunction: entry => { - return entry.displayString; - }, - entryRenderingFunction: (entry) => { - return entry ? Mustache.render(AbstractUiDateField.comboBoxTemplate, entry) : ""; - }, - editingMode: config.editingMode === UiFieldEditingMode.READONLY ? 'readonly' : config.editingMode === UiFieldEditingMode.DISABLED ? 'disabled' : 'editable', - showClearButton: config.showClearButton - }); - $(this.trivialComboBox.getMainDomElement()).addClass("AbstractUiDateField"); - this.trivialComboBox.onSelectedEntryChanged.addListener(() => this.commit()); - $(this.trivialComboBox.getEditor()).on("keydown", (e) => { - if (e.keyCode === keyCodes.escape) { - this.onSpecialKeyPressed.fire(EventFactory.createUiTextInputHandlingField_SpecialKeyPressedEvent(this.getId(), UiSpecialKey.ESCAPE)); - } else if (e.keyCode === keyCodes.enter) { - this.onSpecialKeyPressed.fire(EventFactory.createUiTextInputHandlingField_SpecialKeyPressedEvent(this.getId(), UiSpecialKey.ENTER)); - } - }); - - $(this.trivialComboBox.getMainDomElement()).addClass("field-border field-border-glow field-background"); - $(this.trivialComboBox.getMainDomElement()).find(".tr-editor").addClass("field-background"); - $(this.trivialComboBox.getMainDomElement()).find(".tr-trigger").addClass("field-border"); - this.trivialComboBox.onFocus.addListener(() => this.getMainDomElement().addClass("focus")); - this.trivialComboBox.onBlur.addListener(() => this.getMainDomElement().removeClass("focus")); - } - - private updateDateSuggestionEngine() { - this.dateSuggestionEngine = new TrivialDateSuggestionEngine({ - preferredDateFormat: this.getDateFormat(), - favorPastDates: this.favorPastDates - }); - } - - public getMainInnerDomElement(): JQuery { - return $(this.trivialComboBox.getMainDomElement()); - } - - public getFocusableElement(): JQuery { - return $(this.trivialComboBox.getMainDomElement()).find(".tr-editor"); - } - - protected getDateFormat() { - return this.dateFormat || this._context.config.dateFormat; - } - - public static createDateComboBoxEntryFromMoment(m: Moment, dateFormat: string): DateComboBoxEntry { - return { - day: m.date(), - weekDay: m.format('dd'), // see UiRootPanel.setConfig()... - month: m.month() + 1, - year: m.year(), - displayString: m.format(dateFormat) - }; - } - - public static createDateComboBoxEntryFromLocalValues(year: number, month: number, day: number, dateFormat: string): DateComboBoxEntry { - let m = moment({year: year, month: month - 1, day: day}); - return { - day: day, - weekDay: m.format('dd'), // see UiRootPanel.setConfig()... - month: month, - year: year, - displayString: m.format(dateFormat) - }; - } - - focus(): void { - this.trivialComboBox.focus(); - } - - public hasFocus(): boolean { - return this.getMainInnerDomElement().is('.focus'); - } - - protected onEditingModeChanged(editingMode: UiFieldEditingMode): void { - this.getMainDomElement() - .removeClass(Object.values(UiField.editingModeCssClasses).join(" ")) - .addClass(UiField.editingModeCssClasses[editingMode]); - if (editingMode === UiFieldEditingMode.READONLY) { - this.trivialComboBox.setEditingMode("readonly"); - } else if (editingMode === UiFieldEditingMode.DISABLED) { - this.trivialComboBox.setEditingMode("disabled"); - } else { - this.trivialComboBox.setEditingMode("editable"); - } - } - - doDestroy(): void { - this.trivialComboBox.destroy(); - this.$originalInput.detach(); - } - - getDefaultValue(): V { - return null; - } - - setDateFormat(dateFormat: string): void { - this.dateFormat = convertJavaDateTimeFormatToMomentDateTimeFormat(dateFormat); - this.updateDateSuggestionEngine(); - let selectedEntry = this.trivialComboBox.getSelectedEntry(); - selectedEntry.displayString = moment({year: selectedEntry.year, month: selectedEntry.month - 1, day: selectedEntry.day}).format(this.getDateFormat()); - this.trivialComboBox.setSelectedEntry(selectedEntry, true); - this.trivialComboBox.updateEntries([]); - } - - setFavorPastDates(favorPastDates: boolean): void { - this.favorPastDates = favorPastDates; - this.updateDateSuggestionEngine(); - } - - setShowDropDownButton(showDropDownButton: boolean): void { - this.trivialComboBox.setShowTrigger(showDropDownButton); - } - - setShowClearButton(showClearButton: boolean): void { - this.trivialComboBox.setShowClearButton(showClearButton); - } - -} diff --git a/teamapps-client/ts/modules/formfield/datetime/AbstractUiDateTimeField.ts b/teamapps-client/ts/modules/formfield/datetime/AbstractUiDateTimeField.ts index 8dafc33cb..0fd75c71d 100644 --- a/teamapps-client/ts/modules/formfield/datetime/AbstractUiDateTimeField.ts +++ b/teamapps-client/ts/modules/formfield/datetime/AbstractUiDateTimeField.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,94 +17,117 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; -import {TrivialDateTimeField} from "trivial-components"; -import * as moment from "moment-timezone"; +import {TrivialDateTimeField} from "../../trivial-components/TrivialDateTimeField"; import {UiFieldEditingMode} from "../../../generated/UiFieldEditingMode"; import {UiField} from "../UiField"; import {TeamAppsUiContext} from "../../TeamAppsUiContext"; -import {TeamAppsUiComponentRegistry} from "../../TeamAppsUiComponentRegistry"; -import {AbstractUiDateTimeFieldConfig, AbstractUiDateTimeFieldCommandHandler, AbstractUiDateTimeFieldEventSource} from "../../../generated/AbstractUiDateTimeFieldConfig"; -import {convertJavaDateTimeFormatToMomentDateTimeFormat} from "../../Common"; +import { + AbstractUiDateTimeFieldCommandHandler, + AbstractUiDateTimeFieldConfig, + AbstractUiDateTimeFieldEventSource +} from "../../../generated/AbstractUiDateTimeFieldConfig"; +import {UiDateTimeFormatDescriptorConfig} from "../../../generated/UiDateTimeFormatDescriptorConfig"; +import {DateTime} from "luxon"; +import {DateSuggestionEngine} from "./DateSuggestionEngine"; +import {createDateRenderer, createTimeRenderer} from "./datetime-rendering"; +import {executeWhenFirstDisplayed} from "../../util/ExecuteWhenFirstDisplayed"; export abstract class AbstractUiDateTimeField extends UiField implements AbstractUiDateTimeFieldEventSource, AbstractUiDateTimeFieldCommandHandler { - private $originalInput: JQuery; protected trivialDateTimeField: TrivialDateTimeField; - private dateFormat: string; - private timeFormat: string; + protected dateSuggestionEngine: DateSuggestionEngine; + protected dateRenderer: (time: DateTime) => string; + protected timeRenderer: (time: DateTime) => string; protected initialize(config: AbstractUiDateTimeFieldConfig, context: TeamAppsUiContext) { - this.$originalInput = $(''); - - this.dateFormat = convertJavaDateTimeFormatToMomentDateTimeFormat(config.dateFormat); - this.timeFormat = convertJavaDateTimeFormatToMomentDateTimeFormat(config.timeFormat); - - this.trivialDateTimeField = new TrivialDateTimeField(this.$originalInput, { - dateFormat: this.getDateFormat(), - timeFormat: this.getTimeFormat(), + this.updateDateSuggestionEngine(); + this.dateRenderer = this.createDateRenderer(); + this.timeRenderer = this.createTimeRenderer(); + + this.trivialDateTimeField = new TrivialDateTimeField({ + timeZone: this.getTimeZone(), + locale: config.locale, + dateFormat: config.dateFormat, + timeFormat: config.timeFormat, showTrigger: config.showDropDownButton, editingMode: config.editingMode === UiFieldEditingMode.READONLY ? 'readonly' : config.editingMode === UiFieldEditingMode.DISABLED ? 'disabled' : 'editable', favorPastDates: config.favorPastDates }); - $(this.trivialDateTimeField.getMainDomElement()).addClass("AbstractUiDateTimeField"); + this.trivialDateTimeField.getMainDomElement().classList.add("AbstractUiDateTimeField"); this.trivialDateTimeField.onChange.addListener(() => this.commit()); - $(this.trivialDateTimeField.getMainDomElement()).addClass("field-border field-border-glow field-background"); - $(this.trivialDateTimeField.getMainDomElement()).find(".tr-date-editor, .tr-time-editor").addClass("field-background"); - $(this.trivialDateTimeField.getMainDomElement()).find(".tr-trigger").addClass("field-border"); - $(this.trivialDateTimeField.getMainDomElement()).find(".tr-date-editor, .tr-time-editor").on("focus blur", e => this.getMainDomElement().toggleClass("focus", e.type === "focus")); + this.trivialDateTimeField.getMainDomElement().classList.add("field-border", "field-border-glow", "field-background"); + this.trivialDateTimeField.getMainDomElement().querySelectorAll(":scope .tr-date-editor, :scope .tr-time-editor").forEach(element => element.classList.add("field-background")); + this.trivialDateTimeField.getMainDomElement().querySelector(":scope .tr-trigger").classList.add("field-border"); } - protected getDateFormat() { - return this.dateFormat || this._context.config.dateFormat; + protected abstract getTimeZone(): string; + + protected createDateRenderer(): (time: DateTime) => string { + return createDateRenderer(this._config.locale, this._config.dateFormat, true); } - protected getTimeFormat() { - return this.timeFormat || this._context.config.timeFormat; + protected createTimeRenderer(): (time: DateTime) => string { + return createTimeRenderer(this._config.locale, this._config.timeFormat, true); } - public getMainInnerDomElement(): JQuery { - return $(this.trivialDateTimeField.getMainDomElement()); + protected dateTimeToDateString(dateTime: DateTime): string { + return dateTime.setLocale(this._config.locale).toLocaleString(this._config.dateFormat); } - public getFocusableElement(): JQuery { - return $(this.trivialDateTimeField.getMainDomElement()).find('.tr-editor'); + protected dateTimeToTimeString(dateTime: DateTime): string { + return dateTime.setLocale(this._config.locale).toLocaleString(this._config.timeFormat); } - focus(): void { - this.trivialDateTimeField.focus(); + private updateDateSuggestionEngine() { + this.dateSuggestionEngine = new DateSuggestionEngine({ + locale: this._config.locale, + favorPastDates: this._config.favorPastDates + }); + } + + public getMainInnerDomElement(): HTMLElement { + return this.trivialDateTimeField.getMainDomElement() as HTMLElement; } - public hasFocus(): boolean { - return this.getMainInnerDomElement().is('.focus'); + protected initFocusHandling() { + this.trivialDateTimeField.onFocus.addListener(() => this.onFocusGained.fire({})); + this.trivialDateTimeField.onBlur.addListener(() => this.onBlur.fire({})); + } + + @executeWhenFirstDisplayed() + focus(): void { + this.trivialDateTimeField.focus(); } protected onEditingModeChanged(editingMode: UiFieldEditingMode): void { - this.getMainDomElement() - .removeClass(Object.values(UiField.editingModeCssClasses).join(" ")) - .addClass(UiField.editingModeCssClasses[editingMode]); + this.getMainElement().classList.remove(...Object.values(UiField.editingModeCssClasses)); + this.getMainElement().classList.add(UiField.editingModeCssClasses[editingMode]); if (editingMode === UiFieldEditingMode.READONLY) { - // this.trivialDateTimeField.setEditingMode("readonly"); + this.trivialDateTimeField.setEditingMode("readonly"); } else if (editingMode === UiFieldEditingMode.DISABLED) { - // this.trivialDateTimeField.setEditingMode("disabled"); + this.trivialDateTimeField.setEditingMode("disabled"); } else { - // this.trivialDateTimeField.setEditingMode("editable"); + this.trivialDateTimeField.setEditingMode("editable"); } } - doDestroy(): void { + destroy(): void { + super.destroy(); this.trivialDateTimeField.destroy(); - this.$originalInput.detach(); } getDefaultValue(): V { return null; } - setDateFormat(dateFormat: string): void { - // TODO - this.logger.warn("TODO: implement AbstractUiDateTimeField.setDateFormat()") + setLocaleAndFormats(locale: string, dateFormat: UiDateTimeFormatDescriptorConfig, timeFormat: UiDateTimeFormatDescriptorConfig): void { + this._config.locale = locale; + this._config.dateFormat = dateFormat; + this._config.timeFormat = timeFormat; + this.updateDateSuggestionEngine(); + this.dateRenderer = this.createDateRenderer(); + this.trivialDateTimeField.setLocaleAndFormats(locale, dateFormat, timeFormat); } setFavorPastDates(favorPastDates: boolean): void { @@ -117,9 +140,4 @@ export abstract class AbstractUiDateTimeField extends UiField implements AbstractUiTimeFieldEventSource, AbstractUiTimeFieldCommandHandler { - public readonly onTextInput: TeamAppsEvent = new TeamAppsEvent(this, 250); - public readonly onSpecialKeyPressed: TeamAppsEvent = new TeamAppsEvent(this, 250); +export abstract class AbstractUiTimeField extends UiField implements AbstractUiTimeFieldEventSource, AbstractUiTimeFieldCommandHandler { - public static comboBoxTemplate = '
' + - ' ' + - '' + - '' + - ' ' + - ' ' + - ' ' + - '' + - '
{{displayString}}
' + - '
'; + public readonly onTextInput: TeamAppsEvent = new TeamAppsEvent({throttlingMode: "debounce", delay: 250}); + public readonly onSpecialKeyPressed: TeamAppsEvent = new TeamAppsEvent({throttlingMode: "debounce", delay: 250}); - private $originalInput: JQuery; - protected trivialComboBox: TrivialComboBox; - private timeFormat: string; + protected trivialComboBox: TrivialComboBox; + protected timeRenderer: (time: LocalDateTime) => string; protected initialize(config: AbstractUiTimeFieldConfig, context: TeamAppsUiContext) { - this.$originalInput = $(''); + let timeSuggestionEngine = new TimeSuggestionEngine(); + this.timeRenderer = this.createTimeRenderer(); - this.timeFormat = convertJavaDateTimeFormatToMomentDateTimeFormat(config.timeFormat); - - let timeSuggestionEngine = new TrivialTimeSuggestionEngine(); - this.trivialComboBox = new TrivialComboBox(this.$originalInput, { - queryFunction: (searchString: string, resultCallback: Function) => { - this.onTextInput.fire(EventFactory.createUiTextInputHandlingField_TextInputEvent(this.getId(), searchString)); - - let comboBoxEntries = timeSuggestionEngine.generateSuggestions(searchString) - .map(s => AbstractUiTimeField.createTimeComboBoxEntry(s.hour, s.minute, this.getTimeFormat())); - resultCallback(comboBoxEntries); - }, + this.trivialComboBox = new TrivialComboBox({ showTrigger: config.showDropDownButton, + entryToEditorTextFunction: entry => this.localDateTimeToString(entry), + editingMode: config.editingMode === UiFieldEditingMode.READONLY ? 'readonly' : config.editingMode === UiFieldEditingMode.DISABLED ? 'disabled' : 'editable', + selectedEntryRenderingFunction: localDateTime => this.timeRenderer(localDateTime), + }, new TreeBoxDropdown({ + queryFunction: (searchString: string) => timeSuggestionEngine.generateSuggestions(searchString), textHighlightingEntryLimit: -1, // no highlighting! - autoCompleteFunction: function (editorText, entry) { - if (editorText && entry.displayString.toLowerCase().indexOf(editorText.toLowerCase()) === 0) { - return entry.displayString; - } else { - return null; - } - }, - entryToEditorTextFunction: entry => entry.displayString, - entryRenderingFunction: (entry) => Mustache.render(AbstractUiTimeField.comboBoxTemplate, entry || {hourAngle: 0, minuteAngle: 0}), - editingMode: config.editingMode === UiFieldEditingMode.READONLY ? 'readonly' : config.editingMode === UiFieldEditingMode.DISABLED ? 'disabled' : 'editable' - }); - $(this.trivialComboBox.getMainDomElement()).addClass("AbstractUiTimeField"); + preselectionMatcher: (query, entry) => this.localDateTimeToString(entry).toLowerCase().indexOf(query.toLowerCase()) >= 0 + }, new TrivialTreeBox({ + entryRenderingFunction: localDateTime => this.timeRenderer(localDateTime), + }))); + + this.trivialComboBox.getEditor().addEventListener("input", e => this.onTextInput.fire({enteredString: (e.target as HTMLInputElement).value})); + this.trivialComboBox.getMainDomElement().classList.add("AbstractUiTimeField", "default-min-field-width"); this.trivialComboBox.onSelectedEntryChanged.addListener(() => this.commit()); - $(this.trivialComboBox.getEditor()).on("keydown", (e) => { - if (e.keyCode === keyCodes.escape) { - this.onSpecialKeyPressed.fire(EventFactory.createUiTextInputHandlingField_SpecialKeyPressedEvent(this.getId(), UiSpecialKey.ESCAPE)); - } else if (e.keyCode === keyCodes.enter) { - this.onSpecialKeyPressed.fire(EventFactory.createUiTextInputHandlingField_SpecialKeyPressedEvent(this.getId(), UiSpecialKey.ENTER)); + this.trivialComboBox.getEditor().addEventListener("keydown", (e: KeyboardEvent) => { + if (e.key === "Escape") { + this.onSpecialKeyPressed.fire({ + key: UiSpecialKey.ESCAPE + }); + } else if (e.key === "Enter") { + this.onSpecialKeyPressed.fire({ + key: UiSpecialKey.ENTER + }); } }); - $(this.trivialComboBox.getMainDomElement()).addClass("field-border field-border-glow field-background"); - $(this.trivialComboBox.getMainDomElement()).find(".tr-editor").addClass("field-background"); - $(this.trivialComboBox.getMainDomElement()).find(".tr-trigger").addClass("field-border"); - this.trivialComboBox.onFocus.addListener(() => this.getMainDomElement().addClass("focus")); - this.trivialComboBox.onBlur.addListener(() => this.getMainDomElement().removeClass("focus")); + this.trivialComboBox.getMainDomElement().classList.add("field-border", "field-border-glow", "field-background"); + this.trivialComboBox.getMainDomElement().querySelector(":scope .tr-editor").classList.add("field-background"); + this.trivialComboBox.getMainDomElement().querySelector(":scope .tr-trigger").classList.add("field-border"); } - public getMainInnerDomElement(): JQuery { - return $(this.trivialComboBox.getMainDomElement()); - } + protected abstract localDateTimeToString(entry: LocalDateTime): string; - public getFocusableElement(): JQuery { - return $(this.trivialComboBox.getMainDomElement()); - } + protected abstract createTimeRenderer(): (time: LocalDateTime) => string; - protected getTimeFormat() { - return this.timeFormat || this._context.config.timeFormat; + public getMainInnerDomElement(): HTMLElement { + return this.trivialComboBox.getMainDomElement() as HTMLElement; } + protected initFocusHandling() { + this.trivialComboBox.onFocus.addListener(() => this.onFocusGained.fire({})); + this.trivialComboBox.onBlur.addListener(() => this.onBlur.fire({})); + } + @executeWhenFirstDisplayed() focus(): void { this.trivialComboBox.focus(); } - public hasFocus(): boolean { - return this.getMainInnerDomElement().is('.focus'); - } - - private static intRange(fromInclusive: number, toInclusive: number) { - const ints = []; - for (let i = fromInclusive; i <= toInclusive; i++) { - ints.push(i) - } - return ints; - } - - private static pad(num: number, size: number) { - let s = num + ""; - while (s.length < size) s = "0" + s; - return s; - } - - public static createTimeComboBoxEntry(h: number, m: number, timeFormat: string) { - return { - hour: h, - minute: m, - hourString: AbstractUiTimeField.pad(h, 2), - minuteString: AbstractUiTimeField.pad(m, 2), - displayString: moment().hour(h).minute(m).second(0).millisecond(0).format(timeFormat), - hourAngle: ((h % 12) + m / 60) * 30, - minuteAngle: m * 6, - isNight: h < 6 || h >= 20 - }; - } - - public static createTimeComboBoxEntryFromMoment(mom: moment.Moment, timeFormat: string) { - return { - hour: mom.hour(), - minute: mom.minute(), - hourString: AbstractUiTimeField.pad(mom.hour(), 2), - minuteString: AbstractUiTimeField.pad(mom.minute(), 2), - displayString: mom.format(timeFormat), - hourAngle: ((mom.hour() % 12) + mom.minute() / 60) * 30, - minuteAngle: mom.minute() * 6, - isNight: mom.hour() < 6 || mom.hour() >= 20 - }; - } - protected onEditingModeChanged(editingMode: UiFieldEditingMode): void { - this.getMainInnerDomElement() - .removeClass(Object.values(UiField.editingModeCssClasses).join(" ")) - .addClass(UiField.editingModeCssClasses[editingMode]); + this.getMainInnerDomElement().classList.remove(...Object.values(UiField.editingModeCssClasses)); + this.getMainInnerDomElement().classList.add(UiField.editingModeCssClasses[editingMode]); if (editingMode === UiFieldEditingMode.READONLY) { this.trivialComboBox.setEditingMode("readonly"); } else if (editingMode === UiFieldEditingMode.DISABLED) { @@ -170,21 +116,20 @@ export abstract class AbstractUiTimeField s.date); + } else { + return []; + } + } + + let suggestions: DateSuggestion[]; + if (searchString.match(/[^\d]/)) { + let fragments = searchString.split(/[^\d]/).filter(f => !!f); + suggestions = this.createSuggestionsForFragments(fragments, now); + } else { + suggestions = this.generateSuggestionsForDigitsOnlyInput(searchString, now); + } + + // sort by relevance + let preferredYmdOrder: YearMonthDayOrder = this.preferredYmdOrder; + suggestions.sort(function (a, b) { + if (preferredYmdOrder.indexOf(a.ymdOrder) === -1 && preferredYmdOrder.indexOf(b.ymdOrder) !== -1) { + return 1; + } else if (preferredYmdOrder.indexOf(a.ymdOrder) !== -1 && preferredYmdOrder.indexOf(b.ymdOrder) === -1) { + return -1; + } else if (a.ymdOrder.length != b.ymdOrder.length) { // D < DM < DMY + return a.ymdOrder.length - b.ymdOrder.length; + } else { + return a.date.diff(now, 'days').days - b.date.diff(now, 'days').days; // nearer is better + } + }); + + return suggestions + .filter(value => { + return options.shuffledFormatSuggestionsEnabled || preferredYmdOrder.indexOf(value.ymdOrder) !== -1 + }) + .map(s => s.date) + .filter(this.removeDuplicateDates()); + } + + private removeDuplicateDates() { + let seenDates: LocalDateTime[] = []; + return (d: LocalDateTime) => { + let dateAlreadyContained = seenDates.filter(seenDate => d.equals(seenDate)).length > 0; + if (dateAlreadyContained) { + return false; + } else { + seenDates.push(d); + return true; + } + }; + } + + public generateSuggestionsForDigitsOnlyInput(input: string, today: LocalDateTime): DateSuggestion[] { + input = input || ""; + + if (input.length === 0) { + return this.createSuggestionsForFragments([], today); + } else if (input.length > 8) { + return []; + } + + let fragmentSets: [string, string, string][] = []; + for (let i = 1; i <= input.length; i++) { + for (let j = Math.min(input.length, i + 1); j <= input.length && j - i <= 4; j - i === 2 ? j += 2 : j++) { + let fragments: [string, string, string] = [input.substring(0, i), input.substring(i, j), input.substring(j, input.length)]; + if (this.validateFragments(fragments)){ + fragmentSets.push(fragments); + } + } + } + + let allSuggestions: DateSuggestion[] = []; + for (const fragments of fragmentSets) { + let suggestions = this.createSuggestionsForFragments(fragments, today); + allSuggestions = allSuggestions.concat(suggestions); + } + + return allSuggestions; + } + + private validateFragments(fragments: [string, string, string]) { + if (fragments.some(f => f.length === 3)) { // do not allow fragments of length 3 + return false; + } + if (fragments.some(f => f.length > 4)) { // do not allow fragments of length > 4 + return false; + } + if (fragments.filter(f => f.length > 2).length > 1) { // do not allow more than one fragment with length > 2 (so 4) + return false; + } + if (fragments.some(f => f.length == 4 && f.charAt(0) == '0')) { // do not allow 4 digit fragments padded with zeros + return false; + } + return true; + } + + todayOrFavoriteDirection(date: LocalDateTime, today: LocalDateTime): boolean { + return this.favorPastDates ? today.startOf("day") >= date.startOf("day") : today.startOf("day") <= date.startOf("day"); + } + + private createSuggestionsForFragments(fragments: string[], today: LocalDateTime): DateSuggestion[] { + function mod(n: number, m: number) { + return ((n % m) + m) % m; + } + + function numberToYear(n: number): number { + let shortYear = today.year % 100; + let yearSuggestionBoundary = (shortYear + 20) % 100; // suggest 20 years into the future and 80 year backwards + let currentCentury = Math.floor(today.year / 100) * 100; + if (n >= 1000 && n <= 9999) { // four digit year + if (n >= 1900 && n <= 2100) { + return n; + } else { + return null; // we're not getting more historic or futuristic here + } + } else { + if (n < yearSuggestionBoundary) { + return currentCentury + n; + } else if (n < 100) { + return currentCentury - 100 + n; + } else if (n > today.year - 120 && n < today.year + 120) { + return n; + } else { + return null; + } + } + } + + let [s1, s2, s3] = fragments; + let [n1, n2, n3] = [parseInt(s1), parseInt(s2), parseInt(s3)]; + let suggestions = []; + + if (!s1 && !s2 && !s3) { + return this.createAdjacentWeekDaySuggestions(today); + } else if (s1 && !s2 && !s3) { + if (n1 > 0 && n1 <= 31) { + let nextValidDate = this.findNextValidDate({ + year: today.year, + month: today.month, + day: n1 + }, (currentDate) => { + // increase month + return { + year: currentDate.year + (this.favorPastDates ? (currentDate.month == 1 ? -1 : 0) : (currentDate.month == 12 ? 1 : 0)), + month: mod((currentDate.month - 1) + (this.favorPastDates ? -1 : 1), 12) + 1, + day: currentDate.day + } + }, today); + if (nextValidDate) { + suggestions.push(createSuggestion(nextValidDate, "D")); + } + } + } else if (s1 && s2 && !s3) { + if (n1 <= 12 && n2 > 0 && n2 <= 31) { + let nextValidDate = this.findNextValidDate({ + year: today.year, + month: n1, + day: n2 + }, (currentDate) => { + return { + year: currentDate.year + (this.favorPastDates ? -1 : 1), + month: currentDate.month, + day: currentDate.day + } + }, today); + if (nextValidDate) { + suggestions.push(createSuggestion(nextValidDate, "MD")); + } + } + if (n2 <= 12 && n1 > 0 && n1 <= 31) { + let nextValidDate = this.findNextValidDate({ + year: today.year, + month: n2, + day: n1 + }, (currentDate) => { + return { + year: currentDate.year + (this.favorPastDates ? -1 : 1), + month: currentDate.month, + day: currentDate.day + } + }, today); + if (nextValidDate) { + suggestions.push(createSuggestion(nextValidDate, "DM")); + } + } + } else { // s1 && s2 && s3 + let dateTime; + if (numberToYear(n1) != null) { + dateTime = LocalDateTime.fromObject({year: numberToYear(n1), month: n2, day: n3}); + if (dateTime.isValid) { + suggestions.push(createSuggestion(dateTime, "YMD")); + } + dateTime = LocalDateTime.fromObject({year: numberToYear(n1), month: n3, day: n2}); + if (dateTime.isValid) { + suggestions.push(createSuggestion(dateTime, "YDM")); + } + } + if (numberToYear(n2) != null) { + dateTime = LocalDateTime.fromObject({year: numberToYear(n2), month: n1, day: n3}); + if (dateTime.isValid) { + suggestions.push(createSuggestion(dateTime, "MYD")); + } + dateTime = LocalDateTime.fromObject({year: numberToYear(n2), month: n3, day: n1}); + if (dateTime.isValid) { + suggestions.push(createSuggestion(dateTime, "DYM")); + } + } + if (numberToYear(n3) != null) { + dateTime = LocalDateTime.fromObject({year: numberToYear(n3), month: n1, day: n2}); + if (dateTime.isValid) { + suggestions.push(createSuggestion(dateTime, "MDY")); + } + dateTime = LocalDateTime.fromObject({year: numberToYear(n3), month: n2, day: n1}); + if (dateTime.isValid) { + suggestions.push(createSuggestion(dateTime, "DMY")); + } + } + } + + return suggestions; + }; + + private createAdjacentWeekDaySuggestions(today: LocalDateTime) { + let result = []; + for (let i = 0; i < 7; i++) { + result.push(createSuggestion(today.plus({days: this.favorPastDates ? -i : i}), "")); + } + return result; + } + + private findNextValidDate(startDate: FictiveLocalDate, incementor: (currentDate: FictiveLocalDate) => FictiveLocalDate, today: LocalDateTime): LocalDateTime { + let currentFictiveDate = startDate; + let currentDateTime: LocalDateTime = LocalDateTime.fromObject(currentFictiveDate); + let numberOfIterations = 0; + while (!(currentDateTime.isValid && this.todayOrFavoriteDirection(currentDateTime, today)) && numberOfIterations < 4) { + currentFictiveDate = incementor(currentFictiveDate); + currentDateTime = LocalDateTime.fromObject(currentFictiveDate); + numberOfIterations++; + } + return currentDateTime.isValid ? currentDateTime : null; + } +} + +function createSuggestion(date: LocalDateTime, ymdOrder: string): DateSuggestion { + return {date, ymdOrder}; +} + +export function getYearMonthDayOrderFromDateFormat(dateFormat: string): YearMonthDayOrder { + let ymdIndexes: { [key: string]: number } = { + Y: dateFormat.toUpperCase().indexOf("Y"), + M: dateFormat.toUpperCase().indexOf("M"), + D: dateFormat.toUpperCase().indexOf("D") + }; + return (["D", "M", "Y"].sort((a, b) => ymdIndexes[a] - ymdIndexes[b]).join("")); +} + +export function getYearMonthDayOrderForLocale(locale: string): YearMonthDayOrder { + let parts = LocalDateTime.fromObject({ + year: 2020, + month: 11, + day: 30 + }).setLocale(locale).toLocaleParts({ + year: "numeric", + month: "2-digit", + day: "2-digit" + }); + return parts + .filter(p => p.type === "year" || p.type === "month" || p.type === "day") + .map(p => (p.type as string).charAt(0).toUpperCase()) + .join("") as YearMonthDayOrder; +} diff --git a/teamapps-client/ts/modules/formfield/datetime/TimeSuggestionEngine.ts b/teamapps-client/ts/modules/formfield/datetime/TimeSuggestionEngine.ts new file mode 100644 index 000000000..3620d776b --- /dev/null +++ b/teamapps-client/ts/modules/formfield/datetime/TimeSuggestionEngine.ts @@ -0,0 +1,110 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ + +import {LocalDateTime} from "../../datetime/LocalDateTime"; + +export class TimeSuggestionEngine { + + constructor() { + } + + public generateSuggestions(searchString: string): LocalDateTime[] { + let suggestions: LocalDateTime[] = []; + + const match = searchString.match(/[^\d]/); + const colonIndex = match != null ? match.index : null; + if (colonIndex !== null) { + const hourString = searchString.substring(0, colonIndex); + const minuteString = searchString.substring(colonIndex + 1); + suggestions = suggestions.concat(TimeSuggestionEngine.createTimeSuggestions(TimeSuggestionEngine.createHourSuggestions(hourString), TimeSuggestionEngine.createMinuteSuggestions(minuteString))); + } else if (searchString.length > 0) { // is a number! + if (searchString.length >= 2) { + const hourString = searchString.substr(0, 2); + const minuteString = searchString.substring(2, searchString.length); + suggestions = suggestions.concat(TimeSuggestionEngine.createTimeSuggestions(TimeSuggestionEngine.createHourSuggestions(hourString), TimeSuggestionEngine.createMinuteSuggestions(minuteString))); + } + const hourString = searchString.substr(0, 1); + const minuteString = searchString.substring(1, searchString.length); + if (minuteString.length <= 2) { + suggestions = suggestions.concat(TimeSuggestionEngine.createTimeSuggestions(TimeSuggestionEngine.createHourSuggestions(hourString), TimeSuggestionEngine.createMinuteSuggestions(minuteString))); + } + } else { + suggestions = suggestions.concat(TimeSuggestionEngine.createTimeSuggestions(TimeSuggestionEngine.intRange(6, 24).concat(TimeSuggestionEngine.intRange(1, 5)), [0])); + } + return suggestions; + } + + private static intRange(fromInclusive: number, toInclusive: number) { + const ints = []; + for (let i = fromInclusive; i <= toInclusive; i++) { + ints.push(i) + } + return ints; + } + + private static createTimeSuggestions(hourValues: number[], minuteValues: number[]): LocalDateTime[] { + const entries: LocalDateTime[] = []; + for (let i = 0; i < hourValues.length; i++) { + const hour = hourValues[i]; + for (let j = 0; j < minuteValues.length; j++) { + const minute = minuteValues[j]; + entries.push(LocalDateTime.fromObject({hour, minute})); // local! + } + } + return entries; + } + + private static createMinuteSuggestions(minuteString: string): number[] { + const m = parseInt(minuteString); + if (m < 0) { + return []; + } else if (isNaN(m)) { + return [0, 30]; + } else if (minuteString.length === 1) { + return []; // do not suggest something like "20" for input "2" - we simply cannot suggest anything satisfying at this point. + } else if (minuteString.length === 2) { + if (m > 59) { + return []; + } else { + return [m]; + } + } else { // minuteString.length > 2... + return []; + } + } + + private static createHourSuggestions(hourString: string): number[] { + const h = parseInt(hourString); + if (isNaN(h)) { + return TimeSuggestionEngine.intRange(1, 24); + //} else if (h < 10) { + // return [(h + 12) % 24, h]; // afternoon first + //} else if (h >= 10 && h < 12) { + // return [h, (h + 12) % 24]; // morning first + } else if (h < 12) { + return [h, (h + 12) % 24]; // morning first + + } else if (h <= 24) { + return [h % 24]; + } else { + return []; + } + } +} diff --git a/teamapps-client/ts/modules/formfield/datetime/UiInstantDateField.ts b/teamapps-client/ts/modules/formfield/datetime/UiInstantDateField.ts deleted file mode 100644 index dbe604dd8..000000000 --- a/teamapps-client/ts/modules/formfield/datetime/UiInstantDateField.ts +++ /dev/null @@ -1,88 +0,0 @@ -/*- - * ========================LICENSE_START================================= - * TeamApps - * --- - * Copyright (C) 2014 - 2019 TeamApps.org - * --- - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * =========================LICENSE_END================================== - */ -import * as Mustache from "mustache"; -import * as moment from "moment-timezone"; -import {UiInstantDateFieldConfig, UiInstantDateFieldCommandHandler, UiInstantDateFieldEventSource} from "../../../generated/UiInstantDateFieldConfig"; -import {TeamAppsUiContext} from "../../TeamAppsUiContext"; -import {TeamAppsUiComponentRegistry} from "../../TeamAppsUiComponentRegistry"; -import {AbstractUiDateField, DateComboBoxEntry} from "./AbstractUiDateField"; -import Moment = moment.Moment; -import {convertJavaDateTimeFormatToMomentDateTimeFormat} from "../../Common"; - -export class UiInstantDateField extends AbstractUiDateField implements UiInstantDateFieldEventSource, UiInstantDateFieldCommandHandler { - - private timeZoneId: string; - - protected initialize(config: UiInstantDateFieldConfig, context: TeamAppsUiContext) { - super.initialize(config, context); - this.getMainInnerDomElement().addClass("UiInstantDateField"); - this.timeZoneId = config.timeZoneId; - } - - isValidData(v: number): boolean { - return v == null || typeof v === "number"; - } - - protected displayCommittedValue(): void { - let uiValue = this.getCommittedValue(); - if (uiValue) { - let newMoment: Moment = moment.tz(uiValue, this.getTimeZoneId()); - this.trivialComboBox.setSelectedEntry(UiInstantDateField.createDateComboBoxEntryFromMoment(newMoment, this.getDateFormat()), true); - } else { - this.trivialComboBox.setSelectedEntry(null, true); - } - } - - private getTimeZoneId() { - return this.timeZoneId || this._context.config.timeZoneId; - } - - public getTransientValue(): number { - let selectedEntry: DateComboBoxEntry = this.trivialComboBox.getSelectedEntry(); - if (selectedEntry) { - return moment.tz({ - year: selectedEntry.year, - month: selectedEntry.month - 1, - day: selectedEntry.day - }, this.getTimeZoneId()).valueOf(); - } else { - return null; - } - } - - public getReadOnlyHtml(value: number, availableWidth: number): string { - if (value != null) { - let mom = moment.tz(value, this.timeZoneId || this._context.config.timeZoneId); - return `
` + Mustache.render(UiInstantDateField.comboBoxTemplate, UiInstantDateField.createDateComboBoxEntryFromMoment(mom, this._config.dateFormat || this._context.config.dateFormat)) + '
'; - } else { - return ""; - } - } - - setTimeZoneId(timeZoneId: string): void { - this.timeZoneId = timeZoneId; - } - - public valuesChanged(v1: number, v2: number): boolean { - return v1 !== v2; - } -} - -TeamAppsUiComponentRegistry.registerFieldClass("UiInstantDateField", UiInstantDateField); diff --git a/teamapps-client/ts/modules/formfield/datetime/UiInstantDateTimeField.ts b/teamapps-client/ts/modules/formfield/datetime/UiInstantDateTimeField.ts index 4d40721f7..84b86833b 100644 --- a/teamapps-client/ts/modules/formfield/datetime/UiInstantDateTimeField.ts +++ b/teamapps-client/ts/modules/formfield/datetime/UiInstantDateTimeField.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,25 +17,25 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as Mustache from "mustache"; -import * as moment from "moment-timezone"; -import {UiInstantDateField} from "./UiInstantDateField"; import {TeamAppsUiContext} from "../../TeamAppsUiContext"; import {TeamAppsUiComponentRegistry} from "../../TeamAppsUiComponentRegistry"; -import {AbstractUiTimeField} from "./AbstractUiTimeField"; import {AbstractUiDateTimeField} from "./AbstractUiDateTimeField"; -import {UiInstantDateTimeFieldConfig, UiInstantDateTimeFieldCommandHandler, UiInstantDateTimeFieldEventSource} from "../../../generated/UiInstantDateTimeFieldConfig"; -import Moment = moment.Moment; -import {convertJavaDateTimeFormatToMomentDateTimeFormat} from "../../Common"; +import { + UiInstantDateTimeFieldCommandHandler, + UiInstantDateTimeFieldConfig, + UiInstantDateTimeFieldEventSource +} from "../../../generated/UiInstantDateTimeFieldConfig"; +import {DateTime} from "luxon"; export class UiInstantDateTimeField extends AbstractUiDateTimeField implements UiInstantDateTimeFieldEventSource, UiInstantDateTimeFieldCommandHandler { - private timeZoneId: string; - protected initialize(config: UiInstantDateTimeFieldConfig, context: TeamAppsUiContext) { super.initialize(config, context); - this.getMainInnerDomElement().addClass("UiDateTimeField"); - this.timeZoneId = config.timeZoneId; + this.getMainInnerDomElement().classList.add("UiDateTimeField", "default-min-field-width"); + } + + protected getTimeZone(): string { + return this._config.timeZoneId; } isValidData(v: number): boolean { @@ -45,38 +45,30 @@ export class UiInstantDateTimeField extends AbstractUiDateTimeField` - + Mustache.render(UiInstantDateField.comboBoxTemplate, UiInstantDateField.createDateComboBoxEntryFromMoment(mom, this._config.dateFormat || this._context.config.dateFormat)) - + Mustache.render(AbstractUiTimeField.comboBoxTemplate, AbstractUiTimeField.createTimeComboBoxEntryFromMoment(mom, this._config.timeFormat || this._context.config.timeFormat)) + + this.dateRenderer(this.toDateTime(value)) + + this.timeRenderer(this.toDateTime(value)) + '
'; } else { return ""; @@ -92,9 +84,8 @@ export class UiInstantDateTimeField extends AbstractUiDateTimeField implements UiInstantTimeFieldEventSource, UiInstantTimeFieldCommandHandler { - - private timeZoneId: string; - - protected initialize(config: UiInstantTimeFieldConfig, context: TeamAppsUiContext) { - super.initialize(config, context); - this.timeZoneId = config.timeZoneId; - this.getMainInnerDomElement().addClass("UiInstantTimeField"); - } - - isValidData(v: number): boolean { - return v == null || typeof v === "number"; - } - - protected displayCommittedValue(): void { - let uiValue = this.getCommittedValue(); - if (uiValue) { - let newMoment: Moment = moment.tz(uiValue, this.getTimeZoneId()); - this.trivialComboBox.setSelectedEntry(UiInstantTimeField.createTimeComboBoxEntry(newMoment.hour(), newMoment.minute(), this.getTimeFormat()), true); - } else { - this.trivialComboBox.setSelectedEntry(null, true); - } - } - - public getTransientValue(): number { - let selectedEntry = this.trivialComboBox.getSelectedEntry(); - if (selectedEntry) { - let selectedMoment = moment.tz({ - hour: selectedEntry.hour, - minute: selectedEntry.minute - }, this.getTimeZoneId()); - return selectedMoment.valueOf(); - } else { - return null; - } - } - - private getTimeZoneId() { - return this.timeZoneId || this._context.config.timeZoneId; - } - - public getReadOnlyHtml(value: number, availableWidth: number): string { - if (value != null && value != null) { - let mom = moment.tz(value, this.timeZoneId || this._context.config.timeZoneId); - return `
` + Mustache.render(UiInstantTimeField.comboBoxTemplate, UiInstantTimeField.createTimeComboBoxEntryFromMoment(mom, this._config.timeFormat || this._context.config.timeFormat)) + '
'; - } else { - return ""; - } - } - - setTimeZoneId(timeZoneId: string): void { - this.timeZoneId = timeZoneId; - this.displayCommittedValue(); - } - - public valuesChanged(v1: number, v2: number): boolean { - return v1 !== v2; - } -} - -TeamAppsUiComponentRegistry.registerFieldClass("UiInstantTimeField", UiInstantTimeField); diff --git a/teamapps-client/ts/modules/formfield/datetime/UiLocalDateField.ts b/teamapps-client/ts/modules/formfield/datetime/UiLocalDateField.ts index fd8fc5657..701271289 100644 --- a/teamapps-client/ts/modules/formfield/datetime/UiLocalDateField.ts +++ b/teamapps-client/ts/modules/formfield/datetime/UiLocalDateField.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,55 +17,254 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as Mustache from "mustache"; -import {UiLocalDateFieldConfig, UiLocalDateFieldCommandHandler, UiLocalDateFieldEventSource} from "../../../generated/UiLocalDateFieldConfig"; +import {TrivialComboBox} from "../../trivial-components/TrivialComboBox"; +import {DateSuggestionEngine} from "./DateSuggestionEngine"; +import {UiFieldEditingMode} from "../../../generated/UiFieldEditingMode"; +import {UiField} from "../UiField"; import {TeamAppsUiContext} from "../../TeamAppsUiContext"; +import { + UiTextInputHandlingField_SpecialKeyPressedEvent, + UiTextInputHandlingField_TextInputEvent +} from "../../../generated/UiTextInputHandlingFieldConfig"; +import {TeamAppsEvent} from "../../util/TeamAppsEvent"; +import {UiSpecialKey} from "../../../generated/UiSpecialKey"; +import {deepEquals} from "../../Common"; +import {UiDateTimeFormatDescriptorConfig} from "../../../generated/UiDateTimeFormatDescriptorConfig"; +import {LocalDateTime} from "../../datetime/LocalDateTime"; +import {createDateRenderer} from "./datetime-rendering"; import {TeamAppsUiComponentRegistry} from "../../TeamAppsUiComponentRegistry"; -import {AbstractUiDateField, DateComboBoxEntry} from "./AbstractUiDateField"; -import {arraysEqual} from "../../Common"; +import { + UiLocalDateField_DropDownMode, + UiLocalDateFieldCommandHandler, + UiLocalDateFieldConfig, + UiLocalDateFieldEventSource +} from "../../../generated/UiLocalDateFieldConfig"; +import {TreeBoxDropdown} from "../../trivial-components/dropdown/TreeBoxDropdown"; +import {TrivialTreeBox} from "../../trivial-components/TrivialTreeBox"; +import {CalendarBoxDropdown} from "../../trivial-components/dropdown/CalendarBoxDropdown"; +import {TrivialCalendarBox} from "../../trivial-components/TrivialCalendarBox"; +import {createUiLocalDateConfig, UiLocalDateConfig} from "../../../generated/UiLocalDateConfig"; +import {executeWhenFirstDisplayed} from "../../util/ExecuteWhenFirstDisplayed"; -type LocalDate = [number, number, number]; +export class UiLocalDateField extends UiField implements UiLocalDateFieldEventSource, UiLocalDateFieldCommandHandler { -export class UiLocalDateField extends AbstractUiDateField implements UiLocalDateFieldEventSource, UiLocalDateFieldCommandHandler { + public readonly onTextInput: TeamAppsEvent = new TeamAppsEvent({ + throttlingMode: "debounce", + delay: 250 + }); + public readonly onSpecialKeyPressed: TeamAppsEvent = new TeamAppsEvent({ + throttlingMode: "debounce", + delay: 250 + }); + + protected trivialComboBox: TrivialComboBox; + protected dateSuggestionEngine: DateSuggestionEngine; + protected dateRenderer: (time: LocalDateTime) => string; + + private calendarBoxDropdown: CalendarBoxDropdown; protected initialize(config: UiLocalDateFieldConfig, context: TeamAppsUiContext) { - super.initialize(config, context); - this.getMainInnerDomElement().addClass("UiLocalDateField"); + this.updateDateSuggestionEngine(); + this.dateRenderer = this.createDateRenderer(); + + let treeBoxDropdown = new TreeBoxDropdown({ + queryFunction: (searchString: string) => { + return this.dateSuggestionEngine.generateSuggestions(searchString, this.getDefaultDate(), { + shuffledFormatSuggestionsEnabled: this._config.shuffledFormatSuggestionsEnabled + }); + }, + textHighlightingEntryLimit: -1, // no highlighting! + preselectionMatcher: (query, entry) => this.localDateTimeToString(entry).toLowerCase().indexOf(query.toLowerCase()) >= 0 + }, new TrivialTreeBox({ + entryRenderingFunction: (localDateTime) => this.dateRenderer(localDateTime), + selectOnHover: true + })); + + this.calendarBoxDropdown = new CalendarBoxDropdown(new TrivialCalendarBox({ + locale: config.locale, + // firstDayOfWeek?: config., TODO + highlightKeyboardNavigationState: false + }), query => this.dateSuggestionEngine.generateSuggestions(query, this.getDefaultDate(), { + shuffledFormatSuggestionsEnabled: this._config.shuffledFormatSuggestionsEnabled + })[0], this.getDefaultDate()); + + this.trivialComboBox = new TrivialComboBox({ + showTrigger: config.showDropDownButton, + entryToEditorTextFunction: entry => { + return this.localDateTimeToString(entry); + }, + selectedEntryRenderingFunction: (localDateTime) => this.dateRenderer(localDateTime), + textToEntryFunction: freeText => { + let suggestions = this.dateSuggestionEngine.generateSuggestions(freeText, this.getDefaultDate(), { + suggestAdjacentWeekForEmptyInput: false, + shuffledFormatSuggestionsEnabled: this._config.shuffledFormatSuggestionsEnabled + }); + return suggestions.length > 0 ? suggestions[0] : null; + }, + editingMode: config.editingMode === UiFieldEditingMode.READONLY ? 'readonly' : config.editingMode === UiFieldEditingMode.DISABLED ? 'disabled' : 'editable', + showClearButton: config.showClearButton, + placeholderText: config.placeholderText + }, this._config.dropDownMode === UiLocalDateField_DropDownMode.CALENDAR ? this.calendarBoxDropdown : treeBoxDropdown); + + [this.trivialComboBox.onBeforeQuery, this.trivialComboBox.onBeforeDropdownOpens].forEach(event => event.addListener(queryString => { + if (this._config.dropDownMode == UiLocalDateField_DropDownMode.CALENDAR + || this._config.dropDownMode == UiLocalDateField_DropDownMode.CALENDAR_SUGGESTION_LIST && !queryString) { + this.trivialComboBox.setDropDownComponent(this.calendarBoxDropdown); + } else { + this.trivialComboBox.setDropDownComponent(treeBoxDropdown); + } + })); + this.trivialComboBox.getMainDomElement().classList.add("AbstractUiDateField", "default-min-field-width"); + this.trivialComboBox.onSelectedEntryChanged.addListener(() => this.commit()); + this.trivialComboBox.getEditor().addEventListener("keydown", (e: KeyboardEvent) => { + if (e.key === "Escape") { + this.onSpecialKeyPressed.fire({ + key: UiSpecialKey.ESCAPE + }); + } else if (e.key === "Enter") { + this.onSpecialKeyPressed.fire({ + key: UiSpecialKey.ENTER + }); + } + }); + this.trivialComboBox.getEditor().addEventListener("input", e => this.onTextInput.fire({enteredString: (e.target as HTMLInputElement).value})); + + this.trivialComboBox.getMainDomElement().classList.add("field-border", "field-border-glow", "field-background"); + this.trivialComboBox.getMainDomElement().querySelector(":scope .tr-editor").classList.add("field-background"); + this.trivialComboBox.getMainDomElement().querySelector(":scope .tr-trigger").classList.add("field-border"); + + this.getMainInnerDomElement().classList.add("UiLocalDateField"); + } + + private getDefaultDate() { + return UiLocalDateField.UiLocalDateToLocalDateTime(this._config.defaultSuggestionDate) ?? LocalDateTime.local(); + } + + protected localDateTimeToString(entry: LocalDateTime): string { + return entry.setLocale(this._config.locale).toLocaleString(this._config.dateFormat); + } + + protected createDateRenderer(): (time: LocalDateTime) => string { + let dateRenderer = createDateRenderer(this._config.locale, this._config.dateFormat, this._config.calendarIconEnabled, "static-readonly-UiDateField"); + return entry => dateRenderer(entry?.toUTC()); + } + + private updateDateSuggestionEngine() { + this.dateSuggestionEngine = new DateSuggestionEngine({ + locale: this._config.locale, + favorPastDates: this._config.favorPastDates + }); + } + + public getMainInnerDomElement(): HTMLElement { + return this.trivialComboBox.getMainDomElement() as HTMLElement; + } + + protected initFocusHandling() { + this.trivialComboBox.onFocus.addListener(() => this.onFocusGained.fire({})); + this.trivialComboBox.onBlur.addListener(() => this.onBlur.fire({})); + } + + @executeWhenFirstDisplayed() + focus(): void { + this.trivialComboBox.focus(); } - isValidData(v: LocalDate): boolean { - return v == null || (Array.isArray(v) && typeof v[0] === "number" && typeof v[1] === "number" && typeof v[2] === "number"); + protected onEditingModeChanged(editingMode: UiFieldEditingMode): void { + this.getMainElement().classList.remove(...Object.values(UiField.editingModeCssClasses)); + this.getMainElement().classList.add(UiField.editingModeCssClasses[editingMode]); + if (editingMode === UiFieldEditingMode.READONLY) { + this.trivialComboBox.setEditingMode("readonly"); + } else if (editingMode === UiFieldEditingMode.DISABLED) { + this.trivialComboBox.setEditingMode("disabled"); + } else { + this.trivialComboBox.setEditingMode("editable"); + } + } + + isValidData(v: UiLocalDateConfig): boolean { + return v == null || v._type === "UiLocalDate"; } protected displayCommittedValue(): void { let uiValue = this.getCommittedValue(); if (uiValue) { - this.trivialComboBox.setSelectedEntry(UiLocalDateField.createDateComboBoxEntryFromLocalValues(uiValue[0], uiValue[1], uiValue[2], this.getDateFormat()), true); + this.trivialComboBox.setValue(UiLocalDateField.UiLocalDateToLocalDateTime(uiValue)); } else { - this.trivialComboBox.setSelectedEntry(null, true); + this.trivialComboBox.setValue(null); } } - public getTransientValue(): LocalDate { - let selectedEntry: DateComboBoxEntry = this.trivialComboBox.getSelectedEntry(); + public getTransientValue(): UiLocalDateConfig { + let selectedEntry = this.trivialComboBox.getValue(); if (selectedEntry) { - return [selectedEntry.year, selectedEntry.month, selectedEntry.day]; + return createUiLocalDateConfig(selectedEntry.year, selectedEntry.month, selectedEntry.day); } else { return null; } } - public getReadOnlyHtml(value: LocalDate, availableWidth: number): string { + public getReadOnlyHtml(value: UiLocalDateConfig, availableWidth: number): string { if (value != null) { - return `
` + Mustache.render(UiLocalDateField.comboBoxTemplate, UiLocalDateField.createDateComboBoxEntryFromLocalValues(value[0], value[1], value[2], this._config.dateFormat || this._context.config.dateFormat)) + '
'; + return this.dateRenderer(UiLocalDateField.UiLocalDateToLocalDateTime(value)); } else { return ""; } } - public valuesChanged(v1: LocalDate, v2: LocalDate): boolean { - return !arraysEqual(v1, v2); + public valuesChanged(v1: UiLocalDateConfig, v2: UiLocalDateConfig): boolean { + return !deepEquals(v1, v2); + } + + private static UiLocalDateToLocalDateTime(uiValue: UiLocalDateConfig) { + return uiValue != null ? LocalDateTime.fromObject({year: uiValue.year, month: uiValue.month, day: uiValue.day}) : null; + } + + destroy(): void { + super.destroy(); + this.trivialComboBox.destroy(); + } + + update(config: UiLocalDateFieldConfig): any { + this.setShowDropDownButton(config.showDropDownButton); + this.setShowClearButton(config.showClearButton); + this.setFavorPastDates(config.favorPastDates); + this.setLocaleAndDateFormat(config.locale, config.dateFormat); + this.trivialComboBox.setPlaceholderText(config.placeholderText); + this.calendarBoxDropdown.defaultDate = UiLocalDateField.UiLocalDateToLocalDateTime(config.defaultSuggestionDate); + this.setCalendarIconEnabled(config.calendarIconEnabled); + this._config = config; + } + + setLocaleAndDateFormat(locale: string, dateFormat: UiDateTimeFormatDescriptorConfig): void { + this._config.locale = locale; + this._config.dateFormat = dateFormat; + this.updateDateSuggestionEngine(); + this.dateRenderer = this.createDateRenderer(); + this.trivialComboBox.setValue(this.trivialComboBox.getValue()); + } + + setFavorPastDates(favorPastDates: boolean): void { + this._config.favorPastDates = favorPastDates; + this.updateDateSuggestionEngine(); + } + + setShowDropDownButton(showDropDownButton: boolean): void { + this._config.showDropDownButton; + this.trivialComboBox.setShowTrigger(showDropDownButton); + } + + setShowClearButton(showClearButton: boolean): void { + this._config.showClearButton; + this.trivialComboBox.setShowClearButton(showClearButton); + } + + setCalendarIconEnabled(calendarIconEnabled: boolean) { + this._config.calendarIconEnabled = calendarIconEnabled; + this.dateRenderer = this.createDateRenderer(); + this.trivialComboBox.setValue(this.trivialComboBox.getValue()); } } TeamAppsUiComponentRegistry.registerFieldClass("UiLocalDateField", UiLocalDateField); + diff --git a/teamapps-client/ts/modules/formfield/datetime/UiLocalDateTimeField.ts b/teamapps-client/ts/modules/formfield/datetime/UiLocalDateTimeField.ts index e89e02d57..f90213ae7 100644 --- a/teamapps-client/ts/modules/formfield/datetime/UiLocalDateTimeField.ts +++ b/teamapps-client/ts/modules/formfield/datetime/UiLocalDateTimeField.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,66 +17,81 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as Mustache from "mustache"; -import * as moment from "moment-timezone"; -import {UiInstantDateField} from "./UiInstantDateField"; import {TeamAppsUiContext} from "../../TeamAppsUiContext"; import {TeamAppsUiComponentRegistry} from "../../TeamAppsUiComponentRegistry"; -import {AbstractUiTimeField} from "./AbstractUiTimeField"; import {AbstractUiDateTimeField} from "./AbstractUiDateTimeField"; -import {UiLocalDateTimeFieldConfig, UiLocalDateTimeFieldCommandHandler, UiLocalDateTimeFieldEventSource} from "../../../generated/UiLocalDateTimeFieldConfig"; +import { + UiLocalDateTimeFieldCommandHandler, + UiLocalDateTimeFieldConfig, + UiLocalDateTimeFieldEventSource +} from "../../../generated/UiLocalDateTimeFieldConfig"; import {arraysEqual} from "../../Common"; -import Moment = moment.Moment; +import {DateTime} from "luxon"; -type LocalDateTime = [number, number, number, number, number, number, number]; +type LocalDateTimeArray = [number, number, number, number, number, number, number]; -export class UiLocalDateTimeField extends AbstractUiDateTimeField implements UiLocalDateTimeFieldEventSource, UiLocalDateTimeFieldCommandHandler { +export class UiLocalDateTimeField extends AbstractUiDateTimeField implements UiLocalDateTimeFieldEventSource, UiLocalDateTimeFieldCommandHandler { protected initialize(config: UiLocalDateTimeFieldConfig, context: TeamAppsUiContext) { super.initialize(config, context); - this.getMainInnerDomElement().addClass("UiDateTimeField"); + this.getMainInnerDomElement().classList.add("UiDateTimeField", "default-min-field-width"); } - isValidData(v: LocalDateTime): boolean { + protected getTimeZone(): string { + return "UTC"; + } + + isValidData(v: LocalDateTimeArray): boolean { return v == null || (Array.isArray(v) && typeof v[0] === "number" && typeof v[1] === "number" && typeof v[2] === "number" && typeof v[3] === "number" && typeof v[4] === "number"); } protected displayCommittedValue(): void { let uiValue = this.getCommittedValue(); if (uiValue) { - let newMoment: Moment = moment([uiValue[0], uiValue[1] - 1, uiValue[2], uiValue[3], uiValue[4], uiValue[5] || 0, uiValue[6] || 0]); - this.trivialDateTimeField.setValue(newMoment as any); + this.trivialDateTimeField.setValue(UiLocalDateTimeField.toDateTime(uiValue)); } else { this.trivialDateTimeField.setValue(null); } } - public getTransientValue(): LocalDateTime { + private static toDateTime(uiValue: LocalDateTimeArray): DateTime { + return DateTime.fromObject({ + year: uiValue[0], + month: uiValue[1], + day: uiValue[2], + hour: uiValue[3], + minute: uiValue[4], + second: uiValue[5] || 0, + millisecond: uiValue[6] || 0, + zone: "UTC" + }); + } + + public getTransientValue(): LocalDateTimeArray { let value = this.trivialDateTimeField.getValue(); if (value) { - return [value.year, value.month, value.day, value.hour, value.minute, 0, 0]; + return [value.year || 0, value.month || 0, value.day || 0, value.hour || 0, value.minute || 0, 0, 0]; } else { return null; } } - public getReadOnlyHtml(value: LocalDateTime, availableWidth: number): string { + public getReadOnlyHtml(value: LocalDateTimeArray, availableWidth: number): string { if (value != null) { - // console.log(value[0], value[1], value[2]); return `
` - + Mustache.render(UiInstantDateField.comboBoxTemplate, UiInstantDateField.createDateComboBoxEntryFromLocalValues(value[0], value[1], value[2], this._config.dateFormat || this._context.config.dateFormat)) - + Mustache.render(AbstractUiTimeField.comboBoxTemplate, AbstractUiTimeField.createTimeComboBoxEntry(value[3], value[4], this._config.timeFormat || this._context.config.timeFormat)) + + this.dateRenderer(UiLocalDateTimeField.toDateTime(value)) + + this.timeRenderer(UiLocalDateTimeField.toDateTime(value)) + '
'; } else { return ""; } } - getDefaultValue(): LocalDateTime { + getDefaultValue(): LocalDateTimeArray { return null; } - public valuesChanged(v1: LocalDateTime, v2: LocalDateTime): boolean { + public valuesChanged(v1: LocalDateTimeArray, v2: LocalDateTimeArray): boolean { return !arraysEqual(v1, v2); } diff --git a/teamapps-client/ts/modules/formfield/datetime/UiLocalTimeField.ts b/teamapps-client/ts/modules/formfield/datetime/UiLocalTimeField.ts index 83588c166..4cd279b51 100644 --- a/teamapps-client/ts/modules/formfield/datetime/UiLocalTimeField.ts +++ b/teamapps-client/ts/modules/formfield/datetime/UiLocalTimeField.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,12 +17,17 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as Mustache from "mustache"; -import {UiLocalTimeFieldConfig, UiLocalTimeFieldCommandHandler, UiLocalTimeFieldEventSource} from "../../../generated/UiLocalTimeFieldConfig"; +import { + UiLocalTimeFieldCommandHandler, + UiLocalTimeFieldConfig, + UiLocalTimeFieldEventSource +} from "../../../generated/UiLocalTimeFieldConfig"; import {TeamAppsUiContext} from "../../TeamAppsUiContext"; import {TeamAppsUiComponentRegistry} from "../../TeamAppsUiComponentRegistry"; import {AbstractUiTimeField} from "./AbstractUiTimeField"; import {arraysEqual} from "../../Common"; +import {LocalDateTime} from "../../datetime/LocalDateTime"; +import {createTimeRenderer} from "./datetime-rendering"; type LocalTime = [number, number, number, number]; @@ -30,7 +35,7 @@ export class UiLocalTimeField extends AbstractUiTimeField` + Mustache.render(UiLocalTimeField.comboBoxTemplate, UiLocalTimeField.createTimeComboBoxEntry(value[0], value[1], this._config.timeFormat || this._context.config.timeFormat)) + '
'; + if (value != null) { + return this.timeRenderer(UiLocalTimeField.localTimeToLocalDateTime(value)); } else { return ""; } @@ -66,6 +71,19 @@ export class UiLocalTimeField extends AbstractUiTimeField string { + let timeRenderer = createTimeRenderer(this._config.locale, this._config.timeFormat, this._config.clockIconEnabled, "static-readonly-UiTimeField"); + return entry => timeRenderer(entry?.toUTC()); + } } TeamAppsUiComponentRegistry.registerFieldClass("UiLocalTimeField", UiLocalTimeField); diff --git a/teamapps-client/ts/modules/formfield/datetime/datetime-rendering.ts b/teamapps-client/ts/modules/formfield/datetime/datetime-rendering.ts new file mode 100644 index 000000000..6a79d5c63 --- /dev/null +++ b/teamapps-client/ts/modules/formfield/datetime/datetime-rendering.ts @@ -0,0 +1,161 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +import {DateTime} from "luxon"; +import DateTimeFormatOptions = Intl.DateTimeFormatOptions; +import moment from "moment-timezone"; + +export function createDateIconRenderer(locale: string): (time: DateTime) => string { + const weekDayString = (dateTime: DateTime) => { + let s = dateTime != null ? dateTime.setLocale(locale).toFormat("ccc") : ""; + return s.length > 2 ? s.substr(0, 2) : s; + } + return dateTime => { + return ` + + + + + + + + + + + + + + + ${weekDayString(dateTime)} + + `; + } +} + +export function createDateRenderer(locale: string, dateFormat: DateTimeFormatOptions, withIcon: boolean, additionalClass?: string): (time: DateTime) => string { + let dateIconRenderer = createDateIconRenderer(locale); + return (dateTime: DateTime) => { + if (dateTime == null) { + return ""; + } + let dateTimeWithLocale = dateTime.setLocale(locale); + return `
+ ${withIcon ? dateIconRenderer(dateTime) : ''} +
${dateTimeWithLocale.toLocaleString(dateFormat)}
+
`; + }; +} + +export function createClockIconRenderer(): (time: DateTime) => string { + const hourHandAngle = (time: DateTime) => time != null ? ((time.hour % 12) + time.minute / 60) * 30 : 0; + const minuteHandAngle = (time: DateTime) => time != null ? time.minute * 6 : 0; + const isNight = (time: DateTime) => time != null ? time.hour < 6 || time.hour >= 20 : false; + return (dateTime: DateTime) => { + return ` + + + + + + ` + } +} + +export function createTimeRenderer(locale: string, timeFormat: DateTimeFormatOptions, withIcon: boolean, additionalClass?: string): (time: DateTime) => string { + let clockIconRenderer = createClockIconRenderer(); + return (dateTime: DateTime) => { + if (dateTime == null) { + return ""; + } + return `
+ ${withIcon ? clockIconRenderer(dateTime) : ''} +
${dateTime.setLocale(locale).toLocaleString(timeFormat)}
+
`; + }; +} + +// ==== LEGACY ==== + +export var dateTemplate = `
+ + + + + + + + + + + + + + + + {{weekDay}} + + +
{{displayString}}
+
`; + +export var timeTemplate = '
' + + ' ' + + '' + + '' + + ' ' + + ' ' + + ' ' + + '' + + '
{{displayString}}
' + + '
'; + + +export function createTimseComboBoxEntry(h: number, m: number, locale: string, timeFormat: DateTimeFormatOptions) { + return { + hour: h, + minute: m, + hourString: pad(h, 2), + minuteString: pad(m, 2), + displayString: DateTime.fromObject({hour: h, minute: m}).setLocale(locale).toLocaleString(timeFormat), + hourAngle: ((h % 12) + m / 60) * 30, + minuteAngle: m * 6, + isNight: h < 6 || h >= 20 + }; +} + +export function createTimeComboBoxEntryFromMoment(mom: moment.Moment, timeFormat: string) { + return { + hour: mom.hour(), + minute: mom.minute(), + hourString: pad(mom.hour(), 2), + minuteString: pad(mom.minute(), 2), + displayString: mom.format(timeFormat), + hourAngle: ((mom.hour() % 12) + mom.minute() / 60) * 30, + minuteAngle: mom.minute() * 6, + isNight: mom.hour() < 6 || mom.hour() >= 20 + }; +} + +function pad(num: number, size: number) { + let s = num + ""; + while (s.length < size) s = "0" + s; + return s; +} + + diff --git a/teamapps-client/ts/modules/formfield/UiFileField.ts b/teamapps-client/ts/modules/formfield/file/UiFileField.ts similarity index 55% rename from teamapps-client/ts/modules/formfield/UiFileField.ts rename to teamapps-client/ts/modules/formfield/file/UiFileField.ts index 2956e0900..7965e4a7e 100644 --- a/teamapps-client/ts/modules/formfield/UiFileField.ts +++ b/teamapps-client/ts/modules/formfield/file/UiFileField.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,12 +17,11 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; -import {UiField} from "./UiField"; -import {UiFileFieldDisplayType} from "../../generated/UiFileFieldDisplayType"; -import {UiFieldEditingMode} from "../../generated/UiFieldEditingMode"; -import {TeamAppsUiContext} from "../TeamAppsUiContext"; -import {generateUUID, humanReadableFileSize, Renderer} from "../Common"; +import {UiField} from "../UiField"; +import {UiFileFieldDisplayType} from "../../../generated/UiFileFieldDisplayType"; +import {UiFieldEditingMode} from "../../../generated/UiFieldEditingMode"; +import {TeamAppsUiContext} from "../../TeamAppsUiContext"; +import {generateUUID, humanReadableFileSize, parseHtml, prependChild, removeClassesByFunction, Renderer} from "../../Common"; import { UiFileField_FileItemClickedEvent, UiFileField_FileItemRemoveButtonClickedEvent, @@ -34,35 +33,35 @@ import { UiFileFieldCommandHandler, UiFileFieldConfig, UiFileFieldEventSource -} from "../../generated/UiFileFieldConfig"; -import {TeamAppsEvent} from "../util/TeamAppsEvent"; -import {TeamAppsUiComponentRegistry} from "../TeamAppsUiComponentRegistry"; -import {UiTemplateConfig} from "../../generated/UiTemplateConfig"; -import {StaticIcons} from "../util/StaticIcons"; -import {ProgressIndicator} from "../micro-components/ProgressIndicator"; -import {ProgressCircle} from "../micro-components/ProgressCircle"; -import {ProgressBar} from "../micro-components/ProgressBar"; +} from "../../../generated/UiFileFieldConfig"; +import {TeamAppsEvent} from "../../util/TeamAppsEvent"; +import {TeamAppsUiComponentRegistry} from "../../TeamAppsUiComponentRegistry"; +import {UiTemplateConfig} from "../../../generated/UiTemplateConfig"; +import {StaticIcons} from "../../util/StaticIcons"; +import {ProgressIndicator} from "../../micro-components/ProgressIndicator"; +import {ProgressCircle} from "../../micro-components/ProgressCircle"; +import {ProgressBar} from "../../micro-components/ProgressBar"; import * as log from "loglevel"; import {Logger} from "loglevel"; -import {keyCodes} from "trivial-components"; -import {EventFactory} from "../../generated/EventFactory"; -import {UiIdentifiableClientRecordConfig} from "../../generated/UiIdentifiableClientRecordConfig"; -import {FileUploader} from "../util/FileUploader"; +import {keyCodes} from "../../trivial-components/TrivialCore"; +import {UiIdentifiableClientRecordConfig} from "../../../generated/UiIdentifiableClientRecordConfig"; +import {FileUploader} from "../../util/FileUploader"; +import {executeWhenFirstDisplayed} from "../../util/ExecuteWhenFirstDisplayed"; export class UiFileField extends UiField implements UiFileFieldEventSource, UiFileFieldCommandHandler { - public readonly onFileItemClicked: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onFileItemRemoveButtonClicked: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onUploadCanceled: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onUploadFailed: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onUploadStarted: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onUploadSuccessful: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onUploadTooLarge: TeamAppsEvent = new TeamAppsEvent(this); - - private $wrapper: JQuery; - private $uploadButton: JQuery; - private $fileInput: JQuery; - private $uploadButtonTemplate: JQuery; + public readonly onFileItemClicked: TeamAppsEvent = new TeamAppsEvent(); + public readonly onFileItemRemoveButtonClicked: TeamAppsEvent = new TeamAppsEvent(); + public readonly onUploadCanceled: TeamAppsEvent = new TeamAppsEvent(); + public readonly onUploadFailed: TeamAppsEvent = new TeamAppsEvent(); + public readonly onUploadStarted: TeamAppsEvent = new TeamAppsEvent(); + public readonly onUploadSuccessful: TeamAppsEvent = new TeamAppsEvent(); + public readonly onUploadTooLarge: TeamAppsEvent = new TeamAppsEvent(); + + private $wrapper: HTMLElement; + private $uploadButton: HTMLElement; + private $fileInput: HTMLInputElement; + private $uploadButtonTemplate: HTMLElement; private fileItems: UploadItem[]; private itemRenderer: Renderer; @@ -72,56 +71,55 @@ export class UiFileField extends UiField + this.$wrapper = parseHtml(`
- +
`); - this.$wrapper - .on("dragover", (e) => { - this.$wrapper.addClass("drop-zone-active"); - // preventDefault() is important as it indicates that the drop is possible!!! see https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Drag_operations#droptargets - e.preventDefault(); - }) - .on("dragleave", (e) => { - this.$wrapper.removeClass("drop-zone-active"); - e.preventDefault(); - }) - .on("dragend", (e) => { - this.$wrapper.removeClass("drop-zone-active"); - e.preventDefault(); - }); - this.$wrapper[0].ondrop = (e) => { // I don't know why I cannot just register this the same way as the other handlers... + this.$wrapper.addEventListener("dragover", (e) => { + this.$wrapper.classList.add("drop-zone-active"); + // preventDefault() is important as it indicates that the drop is possible!!! see https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Drag_operations#droptargets + e.preventDefault(); + }); + this.$wrapper.addEventListener("dragleave", (e) => { + this.$wrapper.classList.remove("drop-zone-active"); + e.preventDefault(); + }); + this.$wrapper.addEventListener("dragend", (e) => { + this.$wrapper.classList.remove("drop-zone-active"); + e.preventDefault(); + }); + this.$wrapper.ondrop = (e) => { // I don't know why I cannot just register this the same way as the other handlers... e.stopPropagation(); e.preventDefault(); - this.$wrapper.removeClass("drop-zone-active"); + this.$wrapper.classList.remove("drop-zone-active"); const files = e.dataTransfer.files; this.handleFiles(files); }; - this.$uploadButtonTemplate = this.$wrapper.find('.upload-button-template'); - this.$uploadButton = this.$wrapper.find('.upload-button-wrapper') - .keydown(e => { - if (e.keyCode === keyCodes.enter || e.keyCode === keyCodes.space) { - this.$fileInput.click(); - return false; // no scrolling when space is pressed! - } - }); - this.$fileInput = this.$wrapper.find('.file-input'); - this.$fileInput.change(() => { - const files = ( this.$fileInput[0]).files; + this.$uploadButtonTemplate = this.$wrapper.querySelector(':scope .upload-button-template'); + this.$uploadButton = this.$wrapper.querySelector(':scope .upload-button-wrapper'); + this.$uploadButton.addEventListener('keydown', e => { + if (e.keyCode === keyCodes.enter || e.keyCode === keyCodes.space) { + this.$fileInput.click(); + return false; // no scrolling when space is pressed! + } + }); + this.$fileInput = this.$wrapper.querySelector(':scope .file-input'); + this.$fileInput.addEventListener('change', () => { + const files = (this.$fileInput).files; this.handleFiles(files); }); - this.$fileList = this.$wrapper.find('.list'); + this.$fileList = this.$wrapper.querySelector(':scope .list'); this.setItemTemplate(config.itemTemplate); this.setMaxBytesPerFile(config.maxBytesPerFile); @@ -131,6 +129,7 @@ export class UiFileField extends UiField { let fileItem = this.createFileItem(); fileItem.data = record; - fileItem.getMainDomElement().prependTo(this.$fileList); + prependChild(this.$fileList, fileItem.getMainDomElement()); this.fileItems.push(fileItem); }); } @@ -260,29 +274,27 @@ export class UiFileField extends UiField item !== itemToBeRemoved); } private createFileItem() { - let fileItem = new UploadItem(this.displayType === UiFileFieldDisplayType.FLOATING, this.maxBytesPerFile, this._config.fileTooLargeMessage, this._config.uploadErrorMessage, this.uploadUrl, this.itemRenderer, this.getId()); - fileItem.onClick.addListener((eventObject, emitter) => { - this.onFileItemClicked.fire(EventFactory.createUiFileField_FileItemClickedEvent(this.getId(), (emitter as UploadItem).data.id)); + let fileItem = new UploadItem(this.displayType === UiFileFieldDisplayType.FLOATING, this.maxBytesPerFile, this._config.fileTooLargeMessage, this._config.uploadErrorMessage, this.uploadUrl, this.itemRenderer, this.isEditable()); + fileItem.onClick.addListener((eventObject) => { + this.onFileItemClicked.fire({ + clientId: fileItem.data.id + }); }); - fileItem.onDeleteButtonClick.addListener((eventObject, emitter) => { - let fileItem = emitter as UploadItem; - let data = (fileItem).data; + fileItem.onDeleteButtonClick.addListener((eventObject) => { + let data = fileItem.data; if (data) { - this.onFileItemRemoveButtonClicked.fire(EventFactory.createUiFileField_FileItemRemoveButtonClickedEvent(this.getId(), data.id)); - } - this.removeFileItem(fileItem); - this.updateVisibilities(); - if (fileItem.state === FileItemState.DISPLAYING) { - this.commit(); + this.onFileItemRemoveButtonClicked.fire({ + clientId: data.id + }); } + this.removeItem(fileItem); }); fileItem.onUploadTooLarge.addListener(subEvent => { - subEvent.incompleteUploadsCount = this.numberOfUploadingFileItems; this.onUploadTooLarge.fire(subEvent); this.updateVisibilities(); }); @@ -303,6 +315,14 @@ export class UiFileField extends UiField fileItem.state === FileItemState.DISPLAYING) @@ -310,7 +330,8 @@ export class UiFileField extends UiField this.$uploadButton); + this.fileItems.forEach(fi => fi.deletable = this.isEditable()); this.updateVisibilities(); } @@ -321,7 +342,7 @@ export class UiFileField extends UiField${content}
` + return `
${content}
` } getDefaultValue(): UiIdentifiableClientRecordConfig[] { @@ -331,6 +352,22 @@ export class UiFileField extends UiField this.cancelUpload(fi.uuid)); + } + + public cancelUpload(uuid: string) { + console.log("Canceling upload for " + uuid); + let correspondingItem = uuid && this.fileItems.filter(fileItem => fileItem.uuid === uuid)[0]; + if (correspondingItem) { + correspondingItem.cancelUpload(false); + this.removeItem(correspondingItem); + } else { + this.logger.warn("Could not cancel upload of non-existing file item"); + } + } } enum FileItemState { @@ -342,25 +379,25 @@ enum FileItemState { class UploadItem { private static LOGGER: Logger = log.getLogger("UploadItem"); - public readonly onDeleteButtonClick: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onClick: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onUploadCanceled: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onUploadFailed: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onUploadSuccessful: TeamAppsEvent = new TeamAppsEvent(this); - public readonly onUploadTooLarge: TeamAppsEvent = new TeamAppsEvent(this); + public readonly onDeleteButtonClick: TeamAppsEvent = new TeamAppsEvent(); + public readonly onClick: TeamAppsEvent = new TeamAppsEvent(); + public readonly onUploadCanceled: TeamAppsEvent = new TeamAppsEvent(); + public readonly onUploadFailed: TeamAppsEvent = new TeamAppsEvent(); + public readonly onUploadSuccessful: TeamAppsEvent = new TeamAppsEvent(); + public readonly onUploadTooLarge: TeamAppsEvent = new TeamAppsEvent(); - private $main: JQuery; + private $main: HTMLElement; private _data: UiIdentifiableClientRecordConfig; private _state: FileItemState | null; - private $fileInfo: JQuery; - private $progressIndicator: JQuery; - private $deleteButton: JQuery; - private $fileSize: JQuery; - private $fileName: JQuery; + private $fileInfo: HTMLElement; + private $progressIndicator: HTMLElement; + private $deleteButton: HTMLElement; + private $fileSize: HTMLElement; + private $fileName: HTMLElement; public readonly uuid: string = generateUUID(); private progressIndicator: ProgressIndicator; - private $templateWrapper: JQuery; + private $templateWrapper: HTMLElement; private uploadingFile: File; private uploader: FileUploader; @@ -372,29 +409,26 @@ class UploadItem { private uploadErrorMessage: string, private uploadUrl: string, private renderer: Renderer, - private componentId: string + private _deletable: boolean ) { - this.$main = $(`
+ this.$main = parseHtml(`
- delete + delete
`); - this.$fileInfo = this.$main.find(".file-info"); - this.$fileName = this.$main.find(".file-name"); - this.$fileSize = this.$main.find(".file-size"); - this.$templateWrapper = this.$main.find(".template-wrapper"); - this.$progressIndicator = this.$main.find(".progress-indicator"); - this.$deleteButton = this.$main.find(".delete-button"); - this.$main.click((e) => { - if (e.target === this.$deleteButton[0]) { - if (this.uploader) { - this.uploader.abort(); - this.onUploadCanceled.fire(EventFactory.createUiFileField_UploadCanceledEvent(this.componentId, this.uuid, this.uploadingFile.name, this.uploadingFile.type, this.uploadingFile.size, null)); - } + this.$fileInfo = this.$main.querySelector(":scope .file-info"); + this.$fileName = this.$main.querySelector(":scope .file-name"); + this.$fileSize = this.$main.querySelector(":scope .file-size"); + this.$templateWrapper = this.$main.querySelector(":scope .template-wrapper"); + this.$progressIndicator = this.$main.querySelector(":scope .progress-indicator"); + this.$deleteButton = this.$main.querySelector(":scope .delete-button"); + this.$main.addEventListener('click', (e) => { + if (e.target === this.$deleteButton) { + this.cancelUpload(true); this.onDeleteButtonClick.fire(null); } else { this.onClick.fire(null) @@ -404,18 +438,23 @@ class UploadItem { public upload(file: File) { this.uploadingFile = file; - this.$fileName.text(file.name); - this.$fileSize.text(humanReadableFileSize(file.size, true)); + this.$fileName.textContent = file.name; + this.$fileSize.textContent = humanReadableFileSize(file.size, true); this.progressIndicator = this.renderAsCircle ? new ProgressCircle(0, { circleRadius: 16, circleStrokeWidth: 2 }) : new ProgressBar(0, {}); - this.progressIndicator.getMainDomElement().appendTo(this.$progressIndicator); + this.$progressIndicator.appendChild(this.progressIndicator.getMainDomElement()); if (file.size > this.maxBytes) { this.progressIndicator.setErrorMessage(this.fileTooLargeMessage); this.setState(FileItemState.ERROR); - this.onUploadTooLarge.fire(EventFactory.createUiFileField_UploadTooLargeEvent(this.componentId, this.uuid, file.name, file.type, file.size)); + this.onUploadTooLarge.fire({ + fileItemUuid: this.uuid, + fileName: file.name, + mimeType: file.type, + sizeInBytes: file.size + }); } else { this.setState(FileItemState.UPLOADING); @@ -424,20 +463,47 @@ class UploadItem { this.uploader.onProgress.addListener(progress => this.progressIndicator.setProgress(progress)); this.uploader.onSuccess.addListener(fileUuid => { this.setState(FileItemState.SUCCESS); - this.onUploadSuccessful.fire(EventFactory.createUiFileField_UploadSuccessfulEvent(this.componentId, this.uuid, fileUuid, file.name, file.type, file.size, null)); + this.onUploadSuccessful.fire({ + fileItemUuid: this.uuid, + uploadedFileUuid: fileUuid, + fileName: file.name, + mimeType: file.type, + sizeInBytes: file.size, + incompleteUploadsCount: null + }); }); this.uploader.onError.addListener(() => { this.setState(FileItemState.ERROR); this.progressIndicator.setErrorMessage(this.uploadErrorMessage); - this.onUploadFailed.fire(EventFactory.createUiFileField_UploadFailedEvent(this.componentId, this.uuid, file.name, file.type, file.size, null)); + this.onUploadFailed.fire({ + fileItemUuid: this.uuid, + fileName: file.name, + mimeType: file.type, + sizeInBytes: file.size, + incompleteUploadsCount: null + }); }); this.uploader.onComplete.addListener(() => this.uploader = null) } } + public cancelUpload(fireEvent: boolean) { + if (this.uploader) { + this.uploader.abort(); + this.onUploadCanceled.fire({ + fileItemUuid: this.uuid, + fileName: this.uploadingFile.name, + mimeType: this.uploadingFile.type, + sizeInBytes: this.uploadingFile.size, + incompleteUploadsCount: null + }); + this._state = FileItemState.ERROR; + } + } + public set data(data: UiIdentifiableClientRecordConfig) { this._data = data; - this.$templateWrapper.html(this.renderer.render(data.values)); + this.$templateWrapper.innerHTML = this.renderer.render(data.values); this.setState(FileItemState.DISPLAYING); } @@ -452,10 +518,8 @@ class UploadItem { private setState(state: FileItemState) { this._state = state; - this.$main.removeClass(function (index, classNames) { - return (classNames.match(/(^|\s)state-\S+/g) || []).join(' '); - }); - this.$main.addClass("state-" + state); + removeClassesByFunction(this.$main.classList, className => className.startsWith("state-")); + this.$main.classList.add("state-" + state); } public get state() { @@ -466,6 +530,11 @@ class UploadItem { return this.$main; } + public set deletable(deletable: boolean) { + this._deletable = deletable; + this.$main.classList.toggle("deletable", deletable) + } + } TeamAppsUiComponentRegistry.registerFieldClass("UiFileField", UiFileField); diff --git a/teamapps-client/ts/modules/formfield/file/UiFileItem.ts b/teamapps-client/ts/modules/formfield/file/UiFileItem.ts new file mode 100644 index 000000000..f7eff3019 --- /dev/null +++ b/teamapps-client/ts/modules/formfield/file/UiFileItem.ts @@ -0,0 +1,217 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +import * as log from "loglevel"; +import {Logger} from "loglevel"; +import {TeamAppsEvent} from "../../util/TeamAppsEvent"; +import {ProgressIndicator} from "../../micro-components/ProgressIndicator"; +import {FileUploader} from "../../util/FileUploader"; +import {UiFileFieldDisplayType} from "../../../generated/UiFileFieldDisplayType"; +import {UiFileItemConfig} from "../../../generated/UiFileItemConfig"; +import {TeamAppsUiContext} from "../../TeamAppsUiContext"; +import {humanReadableFileSize, parseHtml, removeClassesByFunction} from "../../Common"; +import {StaticIcons} from "../../util/StaticIcons"; +import {ProgressBar} from "../../micro-components/ProgressBar"; +import {ProgressCircle} from "../../micro-components/ProgressCircle"; + +export class UiFileItem { + private static LOGGER: Logger = log.getLogger("UploadItem"); + public readonly onClick: TeamAppsEvent = new TeamAppsEvent(); + public readonly onDeleteButtonClick: TeamAppsEvent = new TeamAppsEvent(); + public readonly onUploadCanceled: TeamAppsEvent = new TeamAppsEvent(); + public readonly onUploadFailed: TeamAppsEvent = new TeamAppsEvent(); + public readonly onUploadSuccessful: TeamAppsEvent = new TeamAppsEvent(); + public readonly onUploadTooLarge: TeamAppsEvent = new TeamAppsEvent(); + + private $main: HTMLElement; + private $progressIndicator: HTMLElement; + private $deleteButton: HTMLElement; + private $fileIcon: HTMLElement; + private $fileName: HTMLElement; + private $fileDescription: HTMLElement; + private $fileSize: HTMLElement; + + private progressIndicator: ProgressIndicator; + private uploader: FileUploader; + + constructor( + private displayMode: UiFileFieldDisplayType, + private maxBytes: number, + private fileTooLargeMessage: string, + private uploadErrorMessage: string, + private uploadUrl: string, + private config: UiFileItemConfig, + public state: FileItemState, + private _deletable: boolean + ) { + this.$main = parseHtml(` +
+ delete +
+
+
+
+
+
+
`); + this.$fileIcon = this.$main.querySelector(":scope .file-icon"); + this.$fileName = this.$main.querySelector(":scope .file-name"); + this.$fileDescription = this.$main.querySelector(":scope .file-description"); + this.$fileSize = this.$main.querySelector(":scope .file-size"); + this.$progressIndicator = this.$main.querySelector(":scope .progress-indicator"); + this.$deleteButton = this.$main.querySelector(":scope .delete-button"); + this.$main.addEventListener('click', (e) => { + if (e.target === this.$deleteButton) { + if (this.uploader) { + this.uploader.abort(); + this.onUploadCanceled.fire(null); + } + this.onDeleteButtonClick.fire(null); + e.preventDefault(); // do not download! + e.stopPropagation(); + } else { + this.onClick.fire(null) + } + }); + this.setDisplayMode(displayMode); + this.update(config); + } + + public upload(file: File) { + this.config.fileName = file.name; + this.config.description = file.type; + this.config.size = file.size; + this.update(this.config); + + this.progressIndicator = UiFileItem.createProgressIndicator(this.displayMode); + this.$progressIndicator.appendChild(this.progressIndicator.getMainDomElement()); + + if (file.size > this.maxBytes) { + this.progressIndicator.setErrorMessage(this.fileTooLargeMessage); + this.setState(FileItemState.FAILED); + this.onUploadTooLarge.fire(null); + } else { + this.setState(FileItemState.UPLOADING); + + this.uploader = new FileUploader(); + this.uploader.upload(file, this.uploadUrl); + this.uploader.onProgress.addListener(progress => this.progressIndicator.setProgress(progress)); + this.uploader.onSuccess.addListener(fileUuid => { + this.setState(FileItemState.DONE); + this.onUploadSuccessful.fire(fileUuid); + }); + this.uploader.onError.addListener(() => { + this.setState(FileItemState.FAILED); + this.progressIndicator.setErrorMessage(this.uploadErrorMessage); + this.onUploadFailed.fire(null); + }); + this.uploader.onComplete.addListener(() => this.uploader = null) + } + } + + private static createProgressIndicator(displayMode: UiFileFieldDisplayType) { + return displayMode == UiFileFieldDisplayType.FLOATING ? new ProgressCircle(0, { + circleRadius: 24, circleStrokeWidth: 3 + }) : new ProgressBar(0, {}); + } + + public setDisplayMode(displayMode: UiFileFieldDisplayType) { + this.displayMode = displayMode; + if (this.progressIndicator != null) { + this.progressIndicator.getMainDomElement().remove(); + this.progressIndicator = UiFileItem.createProgressIndicator(displayMode); + this.$progressIndicator.appendChild(this.progressIndicator.getMainDomElement()); + } + this.$fileName.classList.toggle("line-clamp-2", displayMode == UiFileFieldDisplayType.FLOATING); + this.$fileIcon.classList.toggle("img-48", displayMode == UiFileFieldDisplayType.FLOATING); + this.$fileIcon.classList.toggle("img-32", displayMode == UiFileFieldDisplayType.LIST); + } + + private setState(state: FileItemState) { + this.state = state; + removeClassesByFunction(this.$main.classList, className => className.startsWith("state-")); + this.$main.classList.add("state-" + FileItemState[state].toLowerCase()); + } + + public getMainDomElement() { + return this.$main; + } + + public destroy() { + this.uploader && this.uploader.abort(); + this.getMainDomElement().remove(); + } + + update(config: UiFileItemConfig) { + this.config = config; + + if (config.thumbnail) { + this.$fileIcon.style.backgroundImage = `url('${config.thumbnail}')`; + } else { + this.$fileIcon.style.backgroundImage = `url('${config.icon}')`; + } + this.$fileName.textContent = config.fileName; + this.$fileDescription.textContent = config.description; + this.$fileSize.textContent = humanReadableFileSize(config.size); + this.$main.classList.toggle("no-link", config.linkUrl == null); + this.$main.classList.toggle("no-link", config.linkUrl == null); + this.$main.setAttribute("href", config.linkUrl); + } + + public get uuid() { + return this.config.uuid + } + + public get icon() { + return this.config.icon + } + + public get thumbnail() { + return this.config.thumbnail + } + + public get fileName() { + return this.config.fileName + } + + public get description() { + return this.config.description + } + + public get size() { + return this.config.size + } + + public get linkUrl() { + return this.config.linkUrl + } + + public set deletable(deletable: boolean) { + this._deletable = deletable; + this.$main.classList.toggle("deletable", deletable) + } +} + +export enum FileItemState { + INITIATING, + TOO_LARGE, + UPLOADING, + FAILED, + DONE +} diff --git a/teamapps-client/ts/modules/formfield/file/UiPictureChooser.ts b/teamapps-client/ts/modules/formfield/file/UiPictureChooser.ts new file mode 100644 index 000000000..e09c1d394 --- /dev/null +++ b/teamapps-client/ts/modules/formfield/file/UiPictureChooser.ts @@ -0,0 +1,291 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ + +import {UiField} from "../UiField"; +import {UiFieldEditingMode} from "../../../generated/UiFieldEditingMode"; +import {TeamAppsUiContext} from "../../TeamAppsUiContext"; +import {humanReadableFileSize, parseHtml} from "../../Common"; +import {TeamAppsEvent} from "../../util/TeamAppsEvent"; +import {TeamAppsUiComponentRegistry} from "../../TeamAppsUiComponentRegistry"; +import {keyCodes} from "../../trivial-components/TrivialCore"; +import { + UiPictureChooser_UploadCanceledEvent, + UiPictureChooser_UploadFailedEvent, + UiPictureChooser_UploadInitiatedByUserEvent, + UiPictureChooser_UploadStartedEvent, + UiPictureChooser_UploadSuccessfulEvent, + UiPictureChooser_UploadTooLargeEvent, + UiPictureChooserCommandHandler, + UiPictureChooserConfig, + UiPictureChooserEventSource +} from "../../../generated/UiPictureChooserConfig"; +import {FileUploader} from "../../util/FileUploader"; +import {ProgressIndicator} from "../../micro-components/ProgressIndicator"; +import {ProgressCircle} from "../../micro-components/ProgressCircle"; +import {executeWhenFirstDisplayed} from "../../util/ExecuteWhenFirstDisplayed"; + +/** + * @author Yann Massard (yamass@gmail.com) + */ +export class UiPictureChooser extends UiField implements UiPictureChooserEventSource, UiPictureChooserCommandHandler { + + public readonly onUploadCanceled: TeamAppsEvent = new TeamAppsEvent(); + public readonly onUploadFailed: TeamAppsEvent = new TeamAppsEvent(); + public readonly onUploadInitiatedByUser: TeamAppsEvent = new TeamAppsEvent(); + public readonly onUploadStarted: TeamAppsEvent = new TeamAppsEvent(); + public readonly onUploadSuccessful: TeamAppsEvent = new TeamAppsEvent(); + public readonly onUploadTooLarge: TeamAppsEvent = new TeamAppsEvent(); + + private $main: HTMLElement; + private $picture: HTMLElement; + private $uploadButton: HTMLElement; + private $fileInput: HTMLInputElement; + private $fileItemWrapper: HTMLElement; + private $progressWrapper: HTMLElement; + private $pictureWrapper: HTMLElement; + private $deleteButton: HTMLElement; + + private maxFileSize: number; + private fileTooLargeMessage: string; + private uploadErrorMessage: string; + private uploadUrl: string; + private uploader: FileUploader; + private progressIndicator: ProgressIndicator; + + protected initialize(config: UiPictureChooserConfig, context: TeamAppsUiContext): void { + this.$main = parseHtml(`
+
+ + + +
+
+
+
`); + + this.$main.addEventListener("dragover", (e) => { + this.$main.classList.add("drop-zone-active"); + // preventDefault() is important as it indicates that the drop is possible!!! see https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Drag_operations#droptargets + e.preventDefault(); + }); + this.$main.addEventListener("dragleave", (e) => { + this.$main.classList.remove("drop-zone-active"); + e.preventDefault(); + }); + this.$main.addEventListener("dragend", (e) => { + this.$main.classList.remove("drop-zone-active"); + e.preventDefault(); + }); + this.$main.addEventListener("mousedown", (e) => { + this.focus(); + }); + this.$main.ondrop = (e) => { // I don't know why I cannot just register this the same way as the other handlers... + e.stopPropagation(); + e.preventDefault(); + this.$main.classList.remove("drop-zone-active"); + const files = e.dataTransfer.files; + this.handleFiles(files); + }; + + this.$pictureWrapper = this.$main.querySelector(":scope .picture-wrapper"); + this.$picture = this.$main.querySelector(":scope .picture"); + + this.$uploadButton = this.$main.querySelector(':scope .upload-button'); + ["click", "keypress"].forEach(eventName => this.$uploadButton.addEventListener(eventName, (e: MouseEvent & KeyboardEvent) => { + if (e.button == 0 || e.keyCode === keyCodes.enter || e.keyCode === keyCodes.space) { + this.$fileInput.click(); + e.stopPropagation(); + return false; // no scrolling when space is pressed! + } + })); + this.$main.addEventListener('click', e => { + if (this.isEditable() && this.getCommittedValue() == null) { + this.$fileInput.click(); + } + }); + + this.$deleteButton = this.$main.querySelector(':scope .delete-button'); + this.$deleteButton.addEventListener('click', e => { + e.stopPropagation(); + if (this.getCommittedValue() != null) { + this.setCommittedValue(null); + this.commit(true); + } + }); + this.$fileInput = this.$main.querySelector(':scope .file-input'); + this.$fileInput.addEventListener('change', () => { + const files = (this.$fileInput).files; + this.handleFiles(files); + }); + + this.$fileItemWrapper = this.$main.querySelector(':scope .picture-wrapper'); + + this.$progressWrapper = this.$main.querySelector(':scope .progress-wrapper'); + this.progressIndicator = new ProgressCircle(0, { + circleRadius: 24, circleStrokeWidth: 3 + }); + this.$main.querySelector(":scope .progress-wrapper") + .appendChild(this.progressIndicator.getMainDomElement()); + + this.setBrowseButtonIcon(config.browseButtonIcon); + this.setMaxFileSize(config.maxFileSize); + this.setFileTooLargeMessage(config.fileTooLargeMessage); + this.setUploadErrorMessage(config.uploadErrorMessage); + this.setUploadUrl(config.uploadUrl); + this.setImageDisplaySize(config.imageDisplayWidth, config.imageDisplayHeight); + } + + protected displayCommittedValue(): void { + this.getMainInnerDomElement().classList.toggle("empty", this.getCommittedValue() == null); + this.$deleteButton.classList.toggle("hidden", this.getCommittedValue() == null); + if (this.getCommittedValue() != null) { + this.$picture.classList.remove("hidden"); + this.$picture.style.backgroundImage = `url('${this.getCommittedValue()}')`; + } else { + this.$picture.classList.add("hidden"); + } + } + + getDefaultValue(): string { + return null; + } + + @executeWhenFirstDisplayed() + focus(): void { + this.$uploadButton.focus(); + } + getMainInnerDomElement(): HTMLElement { + return this.$main; + } + + getTransientValue(): string { + return this.getCommittedValue(); + } + + isValidData(v: string): boolean { + return v == null || typeof v === "string"; + } + + protected onEditingModeChanged(editingMode: UiFieldEditingMode, oldEditingMode?: UiFieldEditingMode): void { + UiField.defaultOnEditingModeChangedImpl(this, () => this.$uploadButton); + this.updateVisibilities(); + } + + valuesChanged(v1: string, v2: string): boolean { + return v1 === v2; + } + + setBrowseButtonIcon(browseButtonIcon: string): void { + this.$uploadButton.style.backgroundImage = browseButtonIcon; + } + + setFileTooLargeMessage(fileTooLargeMessage: string): void { + this.fileTooLargeMessage = fileTooLargeMessage; + } + + setMaxFileSize(maxFileSize: number): void { + this.maxFileSize = maxFileSize; + } + + setUploadErrorMessage(uploadErrorMessage: string): void { + this.uploadErrorMessage = uploadErrorMessage; + } + + setUploadUrl(uploadUrl: string): void { + this.uploadUrl = uploadUrl + } + + private updateVisibilities() { + this.$uploadButton.classList.toggle("hidden", !this.isEditable()); + } + + private handleFiles(files: FileList) { + const file = files[0]; + + this.onUploadInitiatedByUser.fire({ + fileName: file.name, + mimeType: file.type, + sizeInBytes: file.size + }); + + this.$progressWrapper.classList.remove("hidden"); + + if (file.size > this.maxFileSize) { + this.onUploadTooLarge.fire({ + fileName: file.name, + mimeType: file.type, + sizeInBytes: file.size + }); + this.progressIndicator.setErrorMessage(formatString(this.fileTooLargeMessage, humanReadableFileSize(this.maxFileSize))); + return; + } + + this.onUploadStarted.fire({ + fileName: file.name, + mimeType: file.type, + sizeInBytes: file.size + }); + + this.uploader = new FileUploader(); + this.uploader.upload(file, this.uploadUrl); + this.uploader.onProgress.addListener(progress => this.progressIndicator.setProgress(progress)); + this.uploader.onSuccess.addListener(fileUuid => { + this.onUploadSuccessful.fire({ + fileName: file.name, + mimeType: file.type, + sizeInBytes: file.size, + uploadedFileUuid: fileUuid + }); + this.$progressWrapper.classList.add("hidden"); + }); + this.uploader.onError.addListener(() => { + this.progressIndicator.setErrorMessage(this.uploadErrorMessage); + this.onUploadFailed.fire({ + fileName: file.name, + mimeType: file.type, + sizeInBytes: file.size + }); + }); + this.uploader.onComplete.addListener(() => { + this.uploader = null; + this.progressIndicator.setProgress(0); + }); + this.$fileInput.value = ''; + } + + + private setImageDisplaySize(imageDisplayWidth: number, imageDisplayHeight: number) { + this.$pictureWrapper.style.width = imageDisplayWidth + "px"; + this.$pictureWrapper.style.height = imageDisplayHeight + "px"; + } + + cancelUpload(): any { + // TODO + } +} + +export function formatString(s: string, ...params: any[]) { + for (let i = 0; i < params.length; i++) { + s = s.replace(`{${i}}`, params[i]); + } + return s; +} + +TeamAppsUiComponentRegistry.registerFieldClass("UiPictureChooser", UiPictureChooser); diff --git a/teamapps-client/ts/modules/formfield/file/UiSimpleFileField.ts b/teamapps-client/ts/modules/formfield/file/UiSimpleFileField.ts new file mode 100644 index 000000000..8ac42b4e3 --- /dev/null +++ b/teamapps-client/ts/modules/formfield/file/UiSimpleFileField.ts @@ -0,0 +1,342 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ + +import {UiField} from "../UiField"; +import {UiFileFieldDisplayType} from "../../../generated/UiFileFieldDisplayType"; +import {UiFieldEditingMode} from "../../../generated/UiFieldEditingMode"; +import {TeamAppsUiContext} from "../../TeamAppsUiContext"; +import {arraysEqual, generateUUID, parseHtml} from "../../Common"; +import {TeamAppsEvent} from "../../util/TeamAppsEvent"; +import {TeamAppsUiComponentRegistry} from "../../TeamAppsUiComponentRegistry"; +import {keyCodes} from "../../trivial-components/TrivialCore"; +import {createUiFileItemConfig, UiFileItemConfig} from "../../../generated/UiFileItemConfig"; +import { + UiSimpleFileField_FileItemClickedEvent, + UiSimpleFileField_FileItemRemovedEvent, + UiSimpleFileField_UploadCanceledEvent, + UiSimpleFileField_UploadFailedEvent, + UiSimpleFileField_UploadInitiatedByUserEvent, + UiSimpleFileField_UploadStartedEvent, + UiSimpleFileField_UploadSuccessfulEvent, + UiSimpleFileField_UploadTooLargeEvent, + UiSimpleFileFieldCommandHandler, + UiSimpleFileFieldConfig, + UiSimpleFileFieldEventSource +} from "../../../generated/UiSimpleFileFieldConfig"; +import {FileItemState, UiFileItem} from "./UiFileItem"; +import {executeWhenFirstDisplayed} from "../../util/ExecuteWhenFirstDisplayed"; + +/** + * @author Yann Massard (yamass@gmail.com) + */ +export class UiSimpleFileField extends UiField implements UiSimpleFileFieldEventSource, UiSimpleFileFieldCommandHandler { + + public readonly onFileItemClicked: TeamAppsEvent = new TeamAppsEvent(); + public readonly onFileItemRemoved: TeamAppsEvent = new TeamAppsEvent(); + public readonly onUploadCanceled: TeamAppsEvent = new TeamAppsEvent(); + public readonly onUploadFailed: TeamAppsEvent = new TeamAppsEvent(); + public readonly onUploadInitiatedByUser: TeamAppsEvent = new TeamAppsEvent(); + public readonly onUploadStarted: TeamAppsEvent = new TeamAppsEvent(); + public readonly onUploadSuccessful: TeamAppsEvent = new TeamAppsEvent(); + public readonly onUploadTooLarge: TeamAppsEvent = new TeamAppsEvent(); + + private $main: HTMLElement; + private $uploadButton: HTMLElement; + private $fileInput: HTMLInputElement; + private $fileList: HTMLElement; + + private displayMode: any; + private maxFiles: number; + private fileItems: { [uuid: string]: UiFileItem }; + private maxBytesPerFile: number; + private fileTooLargeMessage: string; + private uploadErrorMessage: string; + private uploadUrl: string; + + protected initialize(config: UiSimpleFileFieldConfig, context: TeamAppsUiContext): void { + this.fileItems = {}; + + this.$main = parseHtml(`
+
+
+
+
${config.browseButtonCaption}
+ +
+
`); + + this.$main.addEventListener("dragover", (e) => { + this.$main.classList.add("drop-zone-active"); + // preventDefault() is important as it indicates that the drop is possible!!! see https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Drag_operations#droptargets + e.preventDefault(); + }); + this.$main.addEventListener("dragleave", (e) => { + this.$main.classList.remove("drop-zone-active"); + e.preventDefault(); + }); + this.$main.addEventListener("dragend", (e) => { + this.$main.classList.remove("drop-zone-active"); + e.preventDefault(); + }); + this.$main.addEventListener("mousedown", (e) => { + this.focus(); + }); + this.$main.ondrop = (e) => { // I don't know why I cannot just register this the same way as the other handlers... + e.stopPropagation(); + e.preventDefault(); + this.$main.classList.remove("drop-zone-active"); + const files = e.dataTransfer.files; + this.handleFiles(files); + }; + + this.$uploadButton = this.$main.querySelector(':scope .upload-button'); + ["click", "keypress"].forEach(eventName => this.$uploadButton.addEventListener(eventName, (e: any) => { + if (e.button == 0 || e.keyCode === keyCodes.enter || e.keyCode === keyCodes.space) { + this.$fileInput.click(); + return false; // no scrolling when space is pressed! + } + })); + this.$fileInput = this.$main.querySelector(':scope .file-input'); + this.$fileInput.addEventListener('change', () => { + const files = (this.$fileInput).files; + this.handleFiles(files); + }); + + this.$fileList = this.$main.querySelector(':scope .file-list'); + this.$fileList.addEventListener('click', e => { + if (this.getTransientValue().length == 0) { + this.$fileInput.click(); + } + }); + + this.setBrowseButtonCaption(config.browseButtonCaption); + this.setBrowseButtonIcon(config.browseButtonIcon); + this.setDisplayMode(config.displayMode); + this.setMaxBytesPerFile(config.maxBytesPerFile); + this.setMaxFiles(config.maxFiles); + this.setAcceptedFileTypes(config.acceptedFileTypes); + this.setFileTooLargeMessage(config.fileTooLargeMessage); + this.setUploadErrorMessage(config.uploadErrorMessage); + this.setUploadUrl(config.uploadUrl); + } + + protected displayCommittedValue(): void { + const existingItemUuids = Object.keys(this.fileItems || []); + const committedItemUuids = this.getCommittedValue().map(item => item.uuid); + existingItemUuids.filter(exId => committedItemUuids.indexOf(exId) === -1) + .forEach(uuid => this.removeFileItem(uuid)); + committedItemUuids.filter(cId => existingItemUuids.indexOf(cId) === -1) + .forEach(uuid => this.addFileItem(this.getCommittedValue().filter(item => item.uuid === uuid)[0])); + } + + getDefaultValue(): UiFileItemConfig[] { + return []; + } + + @executeWhenFirstDisplayed() + focus(): void { + this.$uploadButton.focus(); + } + + getMainInnerDomElement(): HTMLElement { + return this.$main; + } + + getTransientValue(): UiFileItemConfig[] { + return Object.values(this.fileItems) + // .filter(item => item.state === FileItemState.DONE) + .map(item => createUiFileItemConfig({ + uuid: item.uuid, + icon: item.icon, + thumbnail: item.thumbnail, + fileName: item.fileName, + description: item.description, + size: item.size, + linkUrl: item.linkUrl + })); + } + + isValidData(v: UiFileItemConfig[]): boolean { + return v == null || Array.isArray(v); + } + + protected onEditingModeChanged(editingMode: UiFieldEditingMode, oldEditingMode?: UiFieldEditingMode): void { + UiField.defaultOnEditingModeChangedImpl(this, () => this.$uploadButton); + Object.values(this.fileItems).forEach(fi => fi.deletable = this.isEditable()); + this.updateVisibilities(); + } + + valuesChanged(v1: UiFileItemConfig[], v2: UiFileItemConfig[]): boolean { + return !arraysEqual(v1.map(item => item.uuid), v2.map(item => item.uuid)); + } + + addFileItem(itemConfig: UiFileItemConfig, state: FileItemState = FileItemState.DONE): void { + const fileItem = new UiFileItem(this.displayMode, this.maxBytesPerFile, this.fileTooLargeMessage, this.uploadErrorMessage, this.uploadUrl, itemConfig, state, this.isEditable()); + this.fileItems[itemConfig.uuid] = fileItem; + this.$fileList.appendChild(fileItem.getMainDomElement()); + } + + removeFileItem(itemUuid: string): void { + const fileItem = this.fileItems[itemUuid]; + fileItem.getMainDomElement().remove(); + delete this.fileItems[itemUuid]; + } + + setBrowseButtonCaption(browseButtonCaption: string): void { + this.$uploadButton.querySelector(":scope .caption").textContent = browseButtonCaption; + } + + setBrowseButtonIcon(browseButtonIcon: string): void { + this.$uploadButton.querySelector(":scope .icon") + .style.backgroundImage = browseButtonIcon; + } + + setDisplayMode(displayMode: UiFileFieldDisplayType): void { + this.displayMode = displayMode; + this.$main.classList.toggle("float-style-vertical-list", displayMode === UiFileFieldDisplayType.LIST); + this.$main.classList.toggle("float-style-horizontal", displayMode === UiFileFieldDisplayType.FLOATING); + Object.values(this.fileItems).forEach(item => item.setDisplayMode(displayMode)); + } + + setFileTooLargeMessage(fileTooLargeMessage: string): void { + this.fileTooLargeMessage = fileTooLargeMessage; + } + + setMaxBytesPerFile(maxBytesPerFile: number): void { + this.maxBytesPerFile = maxBytesPerFile; + } + + setMaxFiles(maxFiles: number): void { + this.maxFiles = maxFiles || Number.MAX_SAFE_INTEGER; + this.updateVisibilities(); + } + + setAcceptedFileTypes(acceptedFileTypes: string[]) { + this.$fileInput.accept = acceptedFileTypes != null ? (acceptedFileTypes.join(',')) : null; + } + + setUploadErrorMessage(uploadErrorMessage: string): void { + this.uploadErrorMessage = uploadErrorMessage; + } + + setUploadUrl(uploadUrl: string): void { + this.uploadUrl = uploadUrl + } + + updateFileItem(itemConfig: UiFileItemConfig): void { + const fileItem = this.fileItems[itemConfig.uuid]; + if (fileItem != null) { + fileItem.update(itemConfig); + } + } + + private get numberOfNonErrorFileItems() { + return Object.values(this.fileItems).filter(item => item.state !== FileItemState.FAILED && item.state !== FileItemState.TOO_LARGE).length; + } + + private get numberOfUploadingFileItems() { + return Object.values(this.fileItems).filter(item => item.state === FileItemState.UPLOADING).length; + } + + private updateVisibilities() { + this.$uploadButton.classList.toggle("hidden", !this.isEditable() || this.numberOfNonErrorFileItems >= this.maxFiles); + } + + private resetFileInput() { + this.$fileInput.value = ''; + } + + private handleFiles(files: FileList) { + let numberOfFilesToAdd = files.length; + if (this.maxFiles && this.numberOfNonErrorFileItems + files.length > this.maxFiles) { + numberOfFilesToAdd = this.maxFiles - this.numberOfNonErrorFileItems; + } + + for (let i = 0; i < numberOfFilesToAdd; i++) { + const file = files[i]; + let fileItem = this.createUploadFileItem(); + this.onUploadInitiatedByUser.fire({ + uuid: fileItem.uuid, + fileName: file.name, + mimeType: file.type, + sizeInBytes: file.size + }); + this.$fileList.appendChild(fileItem.getMainDomElement()); + this.fileItems[fileItem.uuid] = fileItem; + fileItem.upload(file); + this.onUploadStarted.fire({ + fileItemUuid: fileItem.uuid + }); + } + + this.updateVisibilities(); + this.resetFileInput(); + this.commit(); + } + + private createUploadFileItem() { + let fileItem = new UiFileItem(this.displayMode, this.maxBytesPerFile, this.fileTooLargeMessage, this.uploadErrorMessage, this.uploadUrl, { + uuid: generateUUID() + }, FileItemState.INITIATING, this.isEditable()); + fileItem.onClick.addListener(() => { + this.onFileItemClicked.fire({ + fileItemUuid: fileItem.uuid + }); + }); + fileItem.onDeleteButtonClick.addListener(() => { + this.onFileItemRemoved.fire({ + fileItemUuid: fileItem.uuid + }); + this.removeFileItem(fileItem.uuid); + this.updateVisibilities(); + this.commit(); + }); + fileItem.onUploadTooLarge.addListener(() => { + this.onUploadTooLarge.fire({ + fileItemUuid: fileItem.uuid + }); + this.updateVisibilities(); + }); + fileItem.onUploadSuccessful.addListener(uploadedFileUuid => { + this.onUploadSuccessful.fire({ + fileItemUuid: fileItem.uuid, + uploadedFileUuid: uploadedFileUuid + }); + this.updateVisibilities(); + }); + fileItem.onUploadCanceled.addListener(() => { + this.onUploadCanceled.fire({ + fileItemUuid: fileItem.uuid + }); + this.updateVisibilities(); + }); + fileItem.onUploadFailed.addListener(() => { + this.onUploadFailed.fire({ + fileItemUuid: fileItem.uuid + }); + this.updateVisibilities(); + }); + return fileItem; + } + +} + + +TeamAppsUiComponentRegistry.registerFieldClass("UiSimpleFileField", UiSimpleFileField); diff --git a/teamapps-client/ts/modules/index.ts b/teamapps-client/ts/modules/index.ts index bc588a55b..bf700d1c4 100644 --- a/teamapps-client/ts/modules/index.ts +++ b/teamapps-client/ts/modules/index.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,15 +19,14 @@ */ import "@less/teamapps"; +import 'typeface-roboto'; + (window as any).jQuery = (window as any).$ = require("jquery"); // needs to be global for fullcalendar (at least) require("jquery-ui/ui/version.js"); require("jquery-ui/ui/position.js"); -require("jquery-ui/ui/widgets/draggable.js"); -require("jquery-ui/ui/widgets/resizable.js"); (window as any).moment = require("moment"); // needs to be a global variable for fullcalendar -require("../lib/jquery.event.drag-2.3.0-fixed.js"); require("slickgrid/slick.core"); require("slickgrid/plugins/slick.checkboxselectcolumn"); require("slickgrid/plugins/slick.autotooltips"); @@ -37,6 +36,7 @@ require("slickgrid/controls/slick.columnpicker"); require("slickgrid/slick.formatters"); require("slickgrid/slick.editors"); require("slickgrid/slick.grid"); +require("slickgrid/slick.interactions"); require("webui-popover"); @@ -44,7 +44,7 @@ require("slick-carousel"); export {DefaultTeamAppsUiContext} from "./DefaultTeamAppsUiContext"; export {UiWorkSpaceLayoutChildWindowTeamAppsUiContext} from "./workspace-layout/UiWorkSpaceLayoutChildWindowTeamAppsUiContext"; -export {TeamAppsConnectionImpl} from "../shared/TeamAppsConnectionImpl"; +export {TeamAppsConnectionImpl} from "./communication/TeamAppsConnectionImpl"; export {UiApplicationLayout} from "./UiApplicationLayout"; export {UiCalendar} from "./UiCalendar"; @@ -57,8 +57,10 @@ export {UiGridForm} from "./UiGridForm"; export {UiImageCropper} from "./UiImageCropper"; export {UiImageDisplay} from "./UiImageDisplay"; export {UiInfiniteItemView} from "./UiInfiniteItemView"; +export {UiInfiniteItemView2} from "./UiInfiniteItemView2"; export {UiLiveStreamComponent} from "./UiLiveStreamComponent"; export {UiMap} from "./UiMap"; +export {UiMap2} from "./UiMap2"; export {UiMediaTrackGraph} from "./UiMediaTrackGraph"; export {UiMobileLayout} from "./UiMobileLayout"; export {UiNavigationBar} from "./UiNavigationBar"; @@ -70,23 +72,33 @@ export {UiSplitPane} from "./UiSplitPane"; export {UiItemView} from "./UiItemView"; export {UiTable} from "./table/UiTable"; export {UiTabPanel} from "./UiTabPanel"; -export {UiTemplateTestContainer} from "./UiTemplateTestContainer"; -export {UiTimeGraph} from "./UiTimeGraph"; +export {UiTimeGraph} from "./charting/UiTimeGraph"; export {UiToolAccordion} from "./tool-container/tool-accordion/UiToolAccordion"; export {UiToolbar} from "./tool-container/toolbar/UiToolbar"; export {UiTree} from "./UiTree"; export {UiVerticalLayout} from "./UiVerticalLayout"; export {UiVideoPlayer} from "./UiVideoPlayer"; -export {UiWebRtcPublisher} from "./UiWebRtcPublisher"; +export {UiShakaPlayer} from "./shakaplayer/UiShakaPlayer"; export {UiWebRtcPlayer} from "./UiWebRtcPlayer"; -export {UiWindow} from "./UiWindow"; +export {UiWindow} from "./UiWindow"; export {UiWorkSpaceLayout} from "./workspace-layout/UiWorkSpaceLayout"; export {UiIFrame} from "./UiIFrame"; -export {WebWorkerTeamAppsConnection} from "./WebWorkerTeamAppsConnection"; export {UiFlexContainer} from "./UiFlexContainer"; export {UiChatDisplay} from "./UiChatDisplay"; export {UiChatInput} from "./UiChatInput"; export {UiAbsoluteLayout} from "./UiAbsoluteLayout"; +export {UiFloatingComponent} from "./UiFloatingComponent"; +export {UiPopup} from "./UiPopup"; +export {UiProgressDisplay} from "./UiProgressDisplay"; +export {UiDefaultMultiProgressDisplay} from "./UiDefaultMultiProgressDisplay"; +export {UiNotification} from "./UiNotification"; +export {UiNotificationBar} from "./UiNotificationBar"; +export {UiQrCodeScanner} from "./UiQrCodeScanner"; +export {UiMediaSoupV3WebRtcClient} from "./webrtc/mediasoup-v3/UiMediaSoupV3WebRtcClient"; +export {UiAudioLevelIndicator} from "./UiAudioLevelIndicator"; +export {UiHtmlView} from "./UiHtmlView"; +export {UiPdfViewer} from "./UiPdfViewer" +export {UiPlayground} from "./UiPlayground"; export {UiGauge} from "./UiGauge"; @@ -96,33 +108,44 @@ export {UiComboBox} from "./formfield/UiComboBox"; export {UiComponentField} from "./formfield/UiComponentField"; export {UiCompositeField} from "./formfield/UiCompositeField"; export {UiCurrencyField} from "./formfield/UiCurrencyField"; -export {UiInstantDateField} from "./formfield/datetime/UiInstantDateField"; export {UiLocalDateField} from "./formfield/datetime/UiLocalDateField"; export {UiInstantDateTimeField} from "./formfield/datetime/UiInstantDateTimeField"; export {UiLocalDateTimeField} from "./formfield/datetime/UiLocalDateTimeField"; -export {UiInstantTimeField} from "./formfield/datetime/UiInstantTimeField"; export {UiLocalTimeField} from "./formfield/datetime/UiLocalTimeField"; export {UiDisplayField} from "./formfield/UiDisplayField"; export {UiField} from "./formfield/UiField"; export {UiNumberField} from "./formfield/UiNumberField"; export {UiImageField} from "./formfield/UiImageField"; export {UiLabel} from "./formfield/UiLabel"; -export {UiFileField} from "./formfield/UiFileField"; +export {UiFileField} from "./formfield/file/UiFileField"; +export {UiSimpleFileField} from "./formfield/file/UiSimpleFileField"; +export {UiPictureChooser} from "./formfield/file/UiPictureChooser"; export {UiMultiLineTextField} from "./formfield/UiMultiLineTextField"; export {UiPasswordField} from "./formfield/UiPasswordField"; export {UiTagComboBox} from "./formfield/UiTagComboBox"; export {UiTextField} from "./formfield/UiTextField"; export {UiRichTextEditor} from "./formfield/UiRichTextEditor"; -export {UiSlider} from "./formfield/UiSlider"; export {UiColorPicker} from "./formfield/UiColorPicker"; +export {UiFieldGroup} from "./formfield/UiFieldGroup"; +export {UiTemplateField} from "./formfield/UiTemplateField"; +export {UiTextColorMarkerField} from "./formfield/UiTextColorMarkerField"; +export {UiLinkButton} from "./UiLinkButton"; +export {UiDiv} from "./UiDiv"; +export {UiCollapsible} from "./UiCollapsible"; export {UiToolButton} from "./micro-components/UiToolButton"; export {UiPieChart} from "./UiPieChart"; +export {UiTreeGraph} from "./UiTreeGraph"; + +export {UiTestHarness} from "./UiTestHarness"; +export {draggable} from "./util/draggable"; -// export {UiTestHarness} from "./UiTestHarness"; +export {UiScript} from "./UiScript"; // export {typescriptDeclarationFixConstant as AbstractUiChartConfig} from "./../generated/AbstractUiChartConfig"; import * as log from "loglevel"; (window as any).log = log; + +export {VideoTrackMixer} from "./webrtc/VideoTrackMixer" diff --git a/teamapps-client/ts/modules/live-stream/LiveStreamPlayer.ts b/teamapps-client/ts/modules/live-stream/LiveStreamPlayer.ts index c02aba66f..49379d643 100644 --- a/teamapps-client/ts/modules/live-stream/LiveStreamPlayer.ts +++ b/teamapps-client/ts/modules/live-stream/LiveStreamPlayer.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,5 +24,5 @@ export interface LiveStreamPlayer { stop(): void; isPlaying(): boolean; setVolume(volume: number): void; - getMainDomElement(): JQuery; + getMainElement(): HTMLElement; } diff --git a/teamapps-client/ts/modules/live-stream/UiHttpLiveStreamPlayer.ts b/teamapps-client/ts/modules/live-stream/UiHttpLiveStreamPlayer.ts index 108afe0f5..4761b4442 100644 --- a/teamapps-client/ts/modules/live-stream/UiHttpLiveStreamPlayer.ts +++ b/teamapps-client/ts/modules/live-stream/UiHttpLiveStreamPlayer.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,54 +17,55 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; + import {LiveStreamPlayer} from "./LiveStreamPlayer"; import {UiSpinner} from "../micro-components/UiSpinner"; -import {UiComponent} from "../UiComponent"; +import {AbstractUiComponent} from "../AbstractUiComponent"; import {TeamAppsUiContext} from "../TeamAppsUiContext"; import {UiHttpLiveStreamPlayerConfig} from "../../generated/UiHttpLiveStreamPlayerConfig"; +import {parseHtml} from "../Common"; -export class UiHttpLiveStreamPlayer extends UiComponent implements LiveStreamPlayer { - private $main: JQuery; +export class UiHttpLiveStreamPlayer extends AbstractUiComponent implements LiveStreamPlayer { + private $main: HTMLElement; private $videoContainer: any; private $notSupportedMessage: any; private video: HTMLVideoElement; private url: string; private shouldBePlayingUnlessUserPressedPause: boolean; - private $spinnerContainer: JQuery; + private $spinnerContainer: HTMLElement; private resetTimer: number = null; constructor(config: UiHttpLiveStreamPlayerConfig, context: TeamAppsUiContext) { super(config, context); - this.$main = $(` + this.$main = parseHtml(`
HTML5 or HLS are not supported in this browser.
-
`); - this.$spinnerContainer = this.$main.find('.spinner-container'); + this.$spinnerContainer = this.$main.querySelector(':scope .spinner-container'); this.$spinnerContainer.append(new UiSpinner().getMainDomElement()); - this.$notSupportedMessage = this.$main.find('.not-supported-message'); - this.$videoContainer = this.$main.find('.video-container'); + this.$notSupportedMessage = this.$main.querySelector(':scope .not-supported-message'); + this.$videoContainer = this.$main.querySelector(':scope .video-container'); - this.video = this.$main.find('video')[0] as HTMLVideoElement; + this.video = this.$main.querySelector(':scope video') as HTMLVideoElement; this.video.addEventListener("error", this.failed.bind(this)); this.video.addEventListener("load", () => this.video.play()); ['loadedmetadata', 'loadstart', 'loadeddata', 'playing', 'stalled', 'suspend', 'waiting', 'canplay', 'canplaythrough'].forEach(eventName => { this.video.addEventListener(eventName, () => { - this.logger.debug(eventName + "; videoTracks: " + this.video.videoTracks.length + "; audioTracks: " + this.video.audioTracks.length); + // this.logger.debug(eventName + "; videoTracks: " + this.video.videoTracks.length + "; audioTracks: " + this.video.audioTracks.length); this.updateState(); }) }); let supported = this.isHlsSupported(); - this.$notSupportedMessage.toggleClass('hidden', supported); - this.$videoContainer.toggleClass('hidden', !supported); + this.$notSupportedMessage.classList.toggle('hidden', supported); + this.$videoContainer.classList.toggle('hidden', !supported); } public isHlsSupported() { @@ -117,7 +118,7 @@ export class UiHttpLiveStreamPlayer extends UiComponent this.retryPlaying(), 15000); } - this.$spinnerContainer.toggleClass("hidden", !this.shouldBePlayingUnlessUserPressedPause || playingOrReady); + this.$spinnerContainer.classList.toggle("hidden", !this.shouldBePlayingUnlessUserPressedPause || playingOrReady); } private isActuallyReadyToPlay() { @@ -147,11 +148,8 @@ export class UiHttpLiveStreamPlayer extends UiComponent this.retryPlaying(), 5000); } - getMainDomElement(): JQuery { + doGetMainElement(): HTMLElement { return this.$main; } - destroy(): void { - // nothing to do... - } } diff --git a/teamapps-client/ts/modules/live-stream/UiLiveStreamComPlayer.ts b/teamapps-client/ts/modules/live-stream/UiLiveStreamComPlayer.ts index bd60e53da..f4dc6463a 100644 --- a/teamapps-client/ts/modules/live-stream/UiLiveStreamComPlayer.ts +++ b/teamapps-client/ts/modules/live-stream/UiLiveStreamComPlayer.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,32 +17,32 @@ * limitations under the License. * =========================LICENSE_END================================== */ -import * as $ from "jquery"; + import {LiveStreamPlayer} from "./LiveStreamPlayer"; -import {UiComponent} from "../UiComponent"; +import {AbstractUiComponent} from "../AbstractUiComponent"; import {TeamAppsUiContext} from "../TeamAppsUiContext"; import {UiLiveStreamComPlayerConfig} from "../../generated/UiLiveStreamComPlayerConfig"; +import {parseHtml} from "../Common"; -export class UiLiveStreamComPlayer extends UiComponent implements LiveStreamPlayer { +export class UiLiveStreamComPlayer extends AbstractUiComponent implements LiveStreamPlayer { private playing: boolean = false; - private $wrapper: JQuery; + private $wrapper: HTMLElement; private player: Element; constructor(config: UiLiveStreamComPlayerConfig, context: TeamAppsUiContext) { super(config, context); - this.$wrapper = $('
'); + this.$wrapper = parseHtml('
'); } play(url: string, streamName?: string) { this.playing = true; - this.player = $(``) - .appendTo(this.$wrapper) - [0]; + this.player = parseHtml(``); + this.$wrapper.appendChild(this.player); } stop() { - this.$wrapper[0].innerHTML = ''; + this.$wrapper.innerHTML = ''; this.playing = false; } @@ -54,12 +54,8 @@ export class UiLiveStreamComPlayer extends UiComponent implements LiveStreamPlayer { - - private browserSupported = Player.isBrowserSupported(); - - private $main: JQuery; - private $videoContainer: any; - private $notSupportedMessage: any; - private shouldBePlayingUnlessUserPressedPause: boolean; - private $spinnerContainer: JQuery; - private resetTimer: number = null; - private player: Player; - private url: string; - - constructor(config: UiMpegDashPlayerConfig, context: TeamAppsUiContext) { - super(config, context); - this.$main = $(` -
-
- MPEG Dash does not seem to be supported by your browser. -
-
- -
-
-`); - this.$spinnerContainer = this.$main.find('.spinner-container'); - this.$spinnerContainer.append(new UiSpinner().getMainDomElement()); - this.$notSupportedMessage = this.$main.find('.not-supported-message'); - this.$videoContainer = this.$main.find('.video-container'); - - let video = this.$main.find('video')[0] as HTMLVideoElement; - video.addEventListener('playing', () => this.onPlaying()); - - this.$notSupportedMessage.toggleClass('hidden', this.browserSupported); - this.$videoContainer.toggleClass('hidden', !this.browserSupported); - if (this.browserSupported) { - this.player = new Player(video); - this.player.addEventListener('error', (e) => this.onError((e as any).detail)); - } - } - - public play(url: string) { - this.stop(); - this.shouldBePlayingUnlessUserPressedPause = true; - this.url = url; - this.retryPlaying(); - } - - private retryPlaying() { - clearTimeout(this.resetTimer); - this.resetTimer = null; - let shouldRetry = this.shouldBePlayingUnlessUserPressedPause; - this.logger.debug("retryPlaying: " + shouldRetry + " " + this.url); - if (shouldRetry) { - this.player.load(this.url).catch(e => this.onError(e)); - this.resetTimer = window.setTimeout(() => this.retryPlaying(), 15000); // if it did not work after 15 seconds, retry again - } - } - - public stop() { - this.shouldBePlayingUnlessUserPressedPause = false; - this.player.unload(); - } - - public isPlaying(): boolean { - return this.shouldBePlayingUnlessUserPressedPause; - } - - public setVolume(volume: number): void { - this.player.getMediaElement().volume = volume; - } - - private onPlaying() { - clearTimeout(this.resetTimer); - this.resetTimer = null; - this.$spinnerContainer.toggleClass("hidden", true); - } - - private onError(e: util.Error) { - this.logger.warn('Error while playing video: code', e.code, 'object', JSON.stringify(e)); - this.$spinnerContainer.toggleClass("hidden", !this.shouldBePlayingUnlessUserPressedPause); - clearTimeout(this.resetTimer); - this.resetTimer = window.setTimeout(() => this.retryPlaying(), 5000); - } - - getMainDomElement(): JQuery { - return this.$main; - } - - destroy(): void { - this.player && this.player.destroy(); - } -} - -polyfill.installAll(); diff --git a/teamapps-client/ts/modules/live-stream/UiYoutubePlayer.ts b/teamapps-client/ts/modules/live-stream/UiYoutubePlayer.ts index f784f41dc..36e47ff02 100644 --- a/teamapps-client/ts/modules/live-stream/UiYoutubePlayer.ts +++ b/teamapps-client/ts/modules/live-stream/UiYoutubePlayer.ts @@ -2,7 +2,7 @@ * ========================LICENSE_START================================= * TeamApps * --- - * Copyright (C) 2014 - 2019 TeamApps.org + * Copyright (C) 2014 - 2026 TeamApps.org * --- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,21 +19,21 @@ */ /// -import * as $ from "jquery"; + import {LiveStreamPlayer} from "./LiveStreamPlayer"; -import {UiComponent} from "../UiComponent"; +import {AbstractUiComponent} from "../AbstractUiComponent"; import {TeamAppsUiContext} from "../TeamAppsUiContext"; -import {generateUUID} from "../Common"; +import {generateUUID, parseHtml} from "../Common"; import {UiYoutubePlayerConfig} from "../../generated/UiYoutubePlayerConfig"; -export class UiYoutubePlayer extends UiComponent implements LiveStreamPlayer { +export class UiYoutubePlayer extends AbstractUiComponent implements LiveStreamPlayer { private static scriptTagAdded: boolean = false; private static scriptLoaded: boolean = false; private static commandsToInvokeWhenScripLoaded: Function[] = []; private playing: boolean = false; - private $wrapper: JQuery; - private $player: JQuery; + private $wrapper: HTMLElement; + private $player: HTMLElement; private player: YT.Player; private playerReady: boolean; private commandsToInvokeWhenPlayerReady: Function[] = []; @@ -56,10 +56,11 @@ export class UiYoutubePlayer extends UiComponent implemen constructor(config: UiYoutubePlayerConfig, context: TeamAppsUiContext) { super(config, context); - this.$wrapper = $("
"); + this.$wrapper = parseHtml("
"); let elementUuid = generateUUID(); // 1. The