diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 4be26a57..efb5dc3c 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -6,18 +6,14 @@ on: - opened - synchronize jobs: - sonarcloud: - name: SonarCloud + delete-comments: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - name: SonarCloud Scan - uses: SonarSource/sonarcloud-github-action@master - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + - uses: izhangzhihao/delete-comment@master + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + delete_user_name: SuperViz-Dev + issue_number: ${{ github.event.number }} test-unit: runs-on: ubuntu-latest steps: @@ -32,7 +28,7 @@ jobs: touch .version.js && echo "echo \"export const version = 'test'\" > .version.js" | bash - - name: Create a .remote-config.js file run: | - touch .remote-config.js && echo "echo \"export default { apiUrl: 'https://localhost:3000', conferenceLayerUrl: 'https://localhost:8080' }\" > .remote-config.js" | bash - + touch .remote-config.js && echo "echo \"export default { apiUrl: 'https://localhost:3000', conferenceLayerUrl: 'https://localhost:8080' }\" > .remote-config.js" | bash - - name: Run tests run: yarn test:unit:ci --coverage - name: Code Coverage Report diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 8f58a745..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,137 +0,0 @@ -name: Publish SDK Package -on: - push: - branches: - - main - - beta - - lab -jobs: - development: - if: github.ref == 'refs/heads/beta' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v1 - with: - node-version: '18.x' - - name: Install dependencies - run: yarn install - - name: Create .version file with beta version - run: | - touch .version.js && echo "echo \"export const version = 'beta'\" > .version.js" | bash - - - name: Create a .remote-config.js file - run: | - touch .remote-config.js && echo "echo \"export default { apiUrl: 'https://localhost:3000', conferenceLayerUrl: 'https://localhost:8080' }\" > .remote-config.js" | bash - - - name: Build package - run: | - yarn build - - name: Push - uses: s0/git-publish-subdir-action@develop - env: - REPO: self - BRANCH: beta-release - FOLDER: lib - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - MESSAGE: 'BUILD: ({sha}) {msg}' - lab: - if: github.ref == 'refs/heads/lab' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v1 - with: - node-version: '18.x' - - name: Install dependencies - run: yarn install - - name: Create .version file with lab version - run: | - touch .version.js && echo "echo \"export const version = 'lab'\" > .version.js" | bash - - - name: Create a .remote-config.js file - run: | - touch .remote-config.js && echo "echo \"export default { apiUrl: 'https://localhost:3000', conferenceLayerUrl: 'https://localhost:8080' }\" > .remote-config.js" | bash - - - name: Build package - run: | - yarn build - - name: Push - uses: s0/git-publish-subdir-action@develop - env: - REPO: self - BRANCH: lab-release - FOLDER: lib - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - MESSAGE: 'BUILD: ({sha}) {msg}' - main: - if: github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v1 - with: - node-version: '18.x' - - name: Install dependencies - run: yarn install - env: - NPM_CONFIG_USERCONFIG: .npmrc.ci - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Create a .remote-config.js file - run: | - touch .remote-config.js && echo "echo \"export default { apiUrl: 'https://localhost:3000', conferenceLayerUrl: 'https://localhost:8080' }\" > .remote-config.js" | bash - - - run: git config --global user.name SuperViz - - run: git config --global user.email ci@superviz.com - - name: Publish npm package - run: npm whoami && npm run semantic-release - env: - NPM_CONFIG_USERCONFIG: .npmrc.ci - GITHUB_TOKEN: ${{ secrets.TOKEN_GITHUB }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - slackNotificationDev: - needs: development - name: Slack Notification - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Slack Notification - uses: rtCamp/action-slack-notify@v2 - env: - SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} - SLACK_ICON: https://avatars.slack-edge.com/2020-11-18/1496892993975_af721d1c045bea2d5a46_48.png - MSG_MINIMAL: true - SLACK_USERNAME: Deploy BETA SDK - slackNotificationLab: - needs: lab - name: Slack Notification - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Slack Notification - uses: rtCamp/action-slack-notify@v2 - env: - SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} - SLACK_ICON: https://avatars.slack-edge.com/2020-11-18/1496892993975_af721d1c045bea2d5a46_48.png - MSG_MINIMAL: true - SLACK_USERNAME: Deploy LAB SDK - slackNotificationProd: - needs: main - name: Slack Notification - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Slack Notification - uses: rtCamp/action-slack-notify@v2 - env: - SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} - SLACK_ICON: https://avatars.slack-edge.com/2020-11-18/1496892993975_af721d1c045bea2d5a46_48.png - MSG_MINIMAL: true - SLACK_USERNAME: Deploy SDK - updateSamplesVersion: - needs: main - name: Update samples version - runs-on: ubuntu-latest - steps: - - name: Repository Dispatch - uses: peter-evans/repository-dispatch@v2 - with: - token: ${{ secrets.SUPERVIZ_DEV_USER_TOKEN }} - repository: superviz/samples - event-type: new-release - client-payload: '{"version": "v0.0.0"}' diff --git a/.github/workflows/publish-beta-release.yml b/.github/workflows/publish-beta-release.yml new file mode 100644 index 00000000..b7c56c68 --- /dev/null +++ b/.github/workflows/publish-beta-release.yml @@ -0,0 +1,43 @@ +name: Publish Beta Version +on: + push: + branches: + - beta +jobs: + beta: + if: github.ref == 'refs/heads/beta' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: '18.x' + - name: Install dependencies + run: yarn install + env: + NPM_CONFIG_USERCONFIG: .npmrc.ci + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Create a .remote-config.js file + run: | + touch .remote-config.js && echo "echo \"export default { apiUrl: 'https://localhost:3000', conferenceLayerUrl: 'https://localhost:8080' }\" > .remote-config.js" | bash - + - run: git config --global user.name SuperViz + - run: git config --global user.email ci@superviz.com + - name: Publish npm package + run: npm whoami && npm run semantic-release + env: + NPM_CONFIG_USERCONFIG: .npmrc.ci + GITHUB_TOKEN: ${{ secrets.TOKEN_GITHUB }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + slack: + needs: beta + name: Slack Notification + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Slack Notification + uses: rtCamp/action-slack-notify@v2 + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + SLACK_ICON: https://avatars.slack-edge.com/2020-11-18/1496892993975_af721d1c045bea2d5a46_48.png + MSG_MINIMAL: true + SLACK_USERNAME: Deploy SDK beta version diff --git a/.github/workflows/publish-lab-release.yml b/.github/workflows/publish-lab-release.yml new file mode 100644 index 00000000..bc1ccfd0 --- /dev/null +++ b/.github/workflows/publish-lab-release.yml @@ -0,0 +1,43 @@ +name: Publish Lab Version +on: + push: + branches: + - lab +jobs: + lab: + if: github.ref == 'refs/heads/lab' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: '18.x' + - name: Install dependencies + run: yarn install + env: + NPM_CONFIG_USERCONFIG: .npmrc.ci + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Create a .remote-config.js file + run: | + touch .remote-config.js && echo "echo \"export default { apiUrl: 'https://localhost:3000', conferenceLayerUrl: 'https://localhost:8080' }\" > .remote-config.js" | bash - + - run: git config --global user.name SuperViz + - run: git config --global user.email ci@superviz.com + - name: Publish npm package + run: npm whoami && npm run semantic-release + env: + NPM_CONFIG_USERCONFIG: .npmrc.ci + GITHUB_TOKEN: ${{ secrets.TOKEN_GITHUB }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + slack: + needs: lab + name: Slack Notification + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Slack Notification + uses: rtCamp/action-slack-notify@v2 + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + SLACK_ICON: https://avatars.slack-edge.com/2020-11-18/1496892993975_af721d1c045bea2d5a46_48.png + MSG_MINIMAL: true + SLACK_USERNAME: Deploy SDK lab version diff --git a/.github/workflows/publish-prod-release.yml b/.github/workflows/publish-prod-release.yml new file mode 100644 index 00000000..81f3aec5 --- /dev/null +++ b/.github/workflows/publish-prod-release.yml @@ -0,0 +1,55 @@ +name: Publish Latest Version +on: + push: + branches: + - main +jobs: + main: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: '18.x' + - name: Install dependencies + run: yarn install + env: + NPM_CONFIG_USERCONFIG: .npmrc.ci + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Create a .remote-config.js file + run: | + touch .remote-config.js && echo "echo \"export default { apiUrl: 'https://localhost:3000', conferenceLayerUrl: 'https://localhost:8080' }\" > .remote-config.js" | bash - + - run: git config --global user.name SuperViz + - run: git config --global user.email ci@superviz.com + - name: Publish npm package + run: npm whoami && npm run semantic-release + env: + NPM_CONFIG_USERCONFIG: .npmrc.ci + GITHUB_TOKEN: ${{ secrets.TOKEN_GITHUB }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + slack: + needs: main + name: Slack Notification + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Slack Notification + uses: rtCamp/action-slack-notify@v2 + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + SLACK_ICON: https://avatars.slack-edge.com/2020-11-18/1496892993975_af721d1c045bea2d5a46_48.png + MSG_MINIMAL: true + SLACK_USERNAME: Deploy SDK latest version + samples: + needs: main + name: Update samples version + runs-on: ubuntu-latest + steps: + - name: Repository Dispatch + uses: peter-evans/repository-dispatch@v2 + with: + token: ${{ secrets.SUPERVIZ_DEV_USER_TOKEN }} + repository: superviz/samples + event-type: new-release + client-payload: '{"version": "v0.0.0"}' diff --git a/.releaserc b/.releaserc index 67b7d5e8..0c835b13 100644 --- a/.releaserc +++ b/.releaserc @@ -1,5 +1,9 @@ { - "branches": ["main"], + "branches": [ + "main", + { "name": "beta", "channel": "beta", "prerelease": true }, + { "name": "lab", "channel": "lab", "prerelease": true } + ], "plugins": [ "@semantic-release/commit-analyzer", "semantic-release-version-file", diff --git a/__mocks__/realtime.mock.ts b/__mocks__/realtime.mock.ts index a02019dc..0028e1ee 100644 --- a/__mocks__/realtime.mock.ts +++ b/__mocks__/realtime.mock.ts @@ -76,6 +76,7 @@ export const ABLY_REALTIME_MOCK: AblyRealtimeService = { privateModeWIOObserver: MOCK_OBSERVER_HELPER, followWIOObserver: MOCK_OBSERVER_HELPER, gatherWIOObserver: MOCK_OBSERVER_HELPER, + sameAccountObserver: MOCK_OBSERVER_HELPER, subscribeToParticipantUpdate: jest.fn(), unsubscribeFromParticipantUpdate: jest.fn(), updateMyProperties: jest.fn(), diff --git a/sonar-project.properties b/sonar-project.properties deleted file mode 100644 index f8830a18..00000000 --- a/sonar-project.properties +++ /dev/null @@ -1,2 +0,0 @@ -sonar.projectKey=superviz_sdk -sonar.organization=superviz \ No newline at end of file diff --git a/src/common/types/cdn.types.ts b/src/common/types/cdn.types.ts index 0912dee7..f89886e3 100644 --- a/src/common/types/cdn.types.ts +++ b/src/common/types/cdn.types.ts @@ -1,5 +1,6 @@ import { CanvasPin, + HTMLPin, Comments, MousePointers, Realtime, @@ -46,6 +47,7 @@ export interface SuperVizCdn { Realtime: typeof Realtime; Comments: typeof Comments; CanvasPin: typeof CanvasPin; + HTMLPin: typeof HTMLPin; WhoIsOnline: typeof WhoIsOnline; RealtimeComponentState: typeof RealtimeComponentState; RealtimeComponentEvent: typeof RealtimeComponentEvent; diff --git a/src/common/types/events.types.ts b/src/common/types/events.types.ts index 67de42d9..790e586c 100644 --- a/src/common/types/events.types.ts +++ b/src/common/types/events.types.ts @@ -85,6 +85,7 @@ export enum ParticipantEvent { LOCAL_LEFT = 'participant.local-left', LOCAL_UPDATED = 'participant.updated', LIST_UPDATED = 'participant.list-updated', + SAME_ACCOUNT_ERROR = 'participant.same-account-error', } /** diff --git a/src/components/base/index.test.ts b/src/components/base/index.test.ts index d506687a..5821f44a 100644 --- a/src/components/base/index.test.ts +++ b/src/components/base/index.test.ts @@ -45,6 +45,8 @@ describe('BaseComponent', () => { let DummyComponentInstance: DummyComponent; beforeEach(() => { + console.error = jest.fn(); + jest.clearAllMocks(); DummyComponentInstance = new DummyComponent(); }); diff --git a/src/components/comments/canvas-pin-adapter/index.test.ts b/src/components/comments/canvas-pin-adapter/index.test.ts index 5283ff57..003299e0 100644 --- a/src/components/comments/canvas-pin-adapter/index.test.ts +++ b/src/components/comments/canvas-pin-adapter/index.test.ts @@ -183,6 +183,9 @@ describe('CanvasPinAdapter', () => { const canvasPinAdapter = new CanvasPin('canvas'); canvasPinAdapter.setActive(true); expect(canvasPinAdapter).toBeInstanceOf(CanvasPin); + expect(canvasPinAdapter['canvas'].style.cursor).toBe( + 'url("https://production.cdn.superviz.com/static/pin-html.png") 0 100, pointer', + ); }); test('should throw an error if no canvas element is found', () => { @@ -194,7 +197,7 @@ describe('CanvasPinAdapter', () => { test('should add event listeners to the canvas element', () => { const addEventListenerSpy = jest.spyOn(instance['canvas'], 'addEventListener'); instance['addListeners'](); - expect(addEventListenerSpy).toHaveBeenCalledTimes(5); + expect(addEventListenerSpy).toHaveBeenCalledTimes(2); }); test('should destroy the canvas pin adapter', () => { @@ -202,29 +205,7 @@ describe('CanvasPinAdapter', () => { instance.destroy(); - expect(instance['mouseElement']).toBeNull(); - expect(removeEventListenerSpy).toHaveBeenCalledTimes(5); - }); - - test('when mouse enters canvas, should create a new mouse element', () => { - const canvasPinAdapter = new CanvasPin('canvas'); - canvasPinAdapter.setActive(true); - const mock = jest.fn().mockImplementation(() => document.createElement('div')); - canvasPinAdapter['createMouseElement'] = mock; - - canvasPinAdapter['canvas'].dispatchEvent(new MouseEvent('mouseenter')); - - expect(canvasPinAdapter['createMouseElement']).toHaveBeenCalledTimes(1); - }); - - test('when mouse leaves canvas, should remove the mouse element', () => { - const canvasPinAdapter = new CanvasPin('canvas'); - canvasPinAdapter.setActive(true); - - canvasPinAdapter['canvas'].dispatchEvent(new MouseEvent('mouseenter')); - canvasPinAdapter['canvas'].dispatchEvent(new MouseEvent('mouseout')); - - expect(canvasPinAdapter['mouseElement']).toBeNull(); + expect(removeEventListenerSpy).toHaveBeenCalledTimes(2); }); test('should create temporary pin when mouse clicks canvas', () => { @@ -358,21 +339,6 @@ describe('CanvasPinAdapter', () => { expect(instance['pins'].size).toEqual(0); }); - test('should update the position of the mouse element', () => { - const event = new MouseEvent('mousemove', { clientX: 100, clientY: 200 }); - const customEvent = { - ...event, - x: event.clientX, - y: event.clientY, - }; - - instance['onMouseMove'](customEvent); - - const element = instance['mouseElement']; - expect(element).toBeDefined(); - expect(element.getAttribute('position')).toBe(JSON.stringify({ x: 100, y: 200 })); - }); - test('should update mouse coordinates on mousedown event', () => { const event = new MouseEvent('mousedown', { clientX: 100, clientY: 200 }); instance['setMouseDownCoordinates'] = jest diff --git a/src/components/comments/canvas-pin-adapter/index.ts b/src/components/comments/canvas-pin-adapter/index.ts index 8528af80..99637c7f 100644 --- a/src/components/comments/canvas-pin-adapter/index.ts +++ b/src/components/comments/canvas-pin-adapter/index.ts @@ -9,7 +9,6 @@ export class CanvasPin implements PinAdapter { private canvas: HTMLCanvasElement; private canvasSides: CanvasSides; private divWrapper: HTMLElement; - private mouseElement: HTMLElement; private isActive: boolean; private isPinsVisible: boolean = true; private annotations: Annotation[]; @@ -23,6 +22,7 @@ export class CanvasPin implements PinAdapter { private commentsSide: 'left' | 'right' = 'left'; private movedTemporaryPin: boolean; private localParticipant: SimpleParticipant = {}; + private originalCanvasCursor: string; constructor( canvasId: string, @@ -59,7 +59,6 @@ export class CanvasPin implements PinAdapter { public destroy(): void { this.removeListeners(); this.removeAnnotationsPins(); - this.mouseElement = null; this.pins = new Map(); this.divWrapper.remove(); this.onPinFixedObserver.destroy(); @@ -88,14 +87,19 @@ export class CanvasPin implements PinAdapter { */ public setActive(isOpen: boolean): void { this.isActive = isOpen; - this.canvas.style.cursor = isOpen ? 'none' : 'default'; + // this.canvas.style.cursor = isOpen ? 'none' : 'default'; if (this.isActive) { + this.originalCanvasCursor = this.canvas.style.cursor; + this.canvas.style.cursor = + 'url("https://production.cdn.superviz.com/static/pin-html.png") 0 100, pointer'; this.addListeners(); return; } + this.resetPins(); this.removeListeners(); + this.canvas.style.cursor = this.originalCanvasCursor; } /** @@ -128,6 +132,9 @@ export class CanvasPin implements PinAdapter { pinElement.remove(); this.pins.delete(uuid); + + if (uuid === 'temporary-pin') return; + this.annotations = this.annotations.filter((annotation) => annotation.uuid !== uuid); } @@ -179,12 +186,10 @@ export class CanvasPin implements PinAdapter { private addListeners(): void { this.canvas.addEventListener('click', this.onClick); this.canvas.addEventListener('mousedown', this.setMouseDownCoordinates); - this.canvas.addEventListener('mousemove', this.onMouseMove); - this.canvas.addEventListener('mouseout', this.onMouseLeave); - this.canvas.addEventListener('mouseenter', this.onMouseEnter); document.body.addEventListener('keyup', this.resetPins); document.body.addEventListener('select-annotation', this.annotationSelected); document.body.addEventListener('toggle-annotation-sidebar', this.onToggleAnnotationSidebar); + document.body.addEventListener('click', this.hideTemporaryPin); } public setCommentsMetadata = (side: 'left' | 'right', avatar: string, name: string): void => { @@ -201,29 +206,10 @@ export class CanvasPin implements PinAdapter { private removeListeners(): void { this.canvas.removeEventListener('click', this.onClick); this.canvas.removeEventListener('mousedown', this.setMouseDownCoordinates); - this.canvas.removeEventListener('mousemove', this.onMouseMove); - this.canvas.removeEventListener('mouseout', this.onMouseLeave); - this.canvas.removeEventListener('mouseenter', this.onMouseEnter); document.body.removeEventListener('keyup', this.resetPins); document.body.removeEventListener('select-annotation', this.annotationSelected); document.body.removeEventListener('toggle-annotation-sidebar', this.onToggleAnnotationSidebar); - } - - /** - * @function createMouseElement - * @description Creates a new mouse element for the canvas pin adapter. - * @returns {HTMLElement} The newly created mouse element. - */ - private createMouseElement(): HTMLElement { - const mouseElement = document.createElement('superviz-comments-annotation-pin'); - mouseElement.setAttribute('type', PinMode.ADD); - mouseElement.setAttribute('annotation', JSON.stringify({})); - mouseElement.setAttribute('position', JSON.stringify({ x: 0, y: 0 })); - document.body.appendChild(mouseElement); - - this.canvas.style.cursor = 'none'; - - return mouseElement; + document.body.addEventListener('click', this.hideTemporaryPin); } /** @@ -260,7 +246,7 @@ export class CanvasPin implements PinAdapter { private animate = (): void => { if (this.isActive || this.isPinsVisible) { this.renderAnnotationsPins(); - this.renderDivWrapper(); + this.divWrapper = this.renderDivWrapper(); } if (this.temporaryPinCoordinates) { @@ -277,24 +263,30 @@ export class CanvasPin implements PinAdapter { * */ private renderDivWrapper(): HTMLElement { const canvasRect = this.canvas.getBoundingClientRect(); - const divWrapper = document.createElement('div'); - divWrapper.id = 'superviz-canvas-wrapper'; + let wrapper = this.divWrapper; + + if (!wrapper) { + wrapper = document.createElement('div') + wrapper.id = 'superviz-canvas-wrapper'; + if (['', 'static'].includes(this.canvas.parentElement.style.position)) { + this.canvas.parentElement.style.position = 'relative'; + }; + } - this.canvas.parentElement.style.position = 'relative'; - divWrapper.style.position = 'fixed'; - divWrapper.style.top = `${canvasRect.top}px`; - divWrapper.style.left = `${canvasRect.left}px`; - divWrapper.style.width = `${canvasRect.width}px`; - divWrapper.style.height = `${canvasRect.height}px`; - divWrapper.style.pointerEvents = 'none'; - divWrapper.style.overflow = 'hidden'; + wrapper.style.position = 'absolute'; + wrapper.style.top = `${this.canvas.offsetTop}px`; + wrapper.style.left = `${this.canvas.offsetLeft}px`; + wrapper.style.width = `${canvasRect.width}px`; + wrapper.style.height = `${canvasRect.height}px`; + wrapper.style.pointerEvents = 'none'; + wrapper.style.overflow = 'hidden'; if (!document.getElementById('superviz-canvas-wrapper')) { - this.canvas.parentElement.appendChild(divWrapper); + this.canvas.parentElement.appendChild(wrapper); } - return divWrapper; + return wrapper; } /** @@ -303,7 +295,7 @@ export class CanvasPin implements PinAdapter { * @returns {void} */ private renderAnnotationsPins(): void { - if (!this.annotations || this.canvas.style.display === 'none') { + if ((!this.annotations.length || this.canvas.style.display === 'none') && !this.pins.get('temporary-pin')) { this.removeAnnotationsPins(); return; } @@ -386,7 +378,7 @@ export class CanvasPin implements PinAdapter { * @param {CustomEvent} event * @returns {void} */ - private annotationSelected = ({ detail: { uuid } }: CustomEvent): void => { + private annotationSelected = ({ detail: { uuid, haltGoToPin } }: CustomEvent): void => { if (!uuid) return; const annotation = JSON.parse(this.selectedPin?.getAttribute('annotation') ?? '{}'); @@ -404,6 +396,9 @@ export class CanvasPin implements PinAdapter { pinElement.setAttribute('active', ''); this.selectedPin = pinElement; + + if (haltGoToPin) return; + this.goToPin(uuid); }; @@ -460,7 +455,7 @@ export class CanvasPin implements PinAdapter { const transform = context.getTransform(); const invertedMatrix = transform.inverse(); - const transformedPoint = new DOMPoint(x, y).matrixTransform(invertedMatrix); + const transformedPoint = new DOMPoint(x, y - 31).matrixTransform(invertedMatrix); this.onPinFixedObserver.publish({ x: transformedPoint.x, @@ -483,51 +478,6 @@ export class CanvasPin implements PinAdapter { document.body.dispatchEvent(new CustomEvent('unselect-annotation')); }; - /** - * @function onMouseMove - * @description handles the mouse move event on the canvas. - * @param event - The mouse event object. - * @returns {void} - */ - private onMouseMove = (event: MouseEvent): void => { - const { x, y } = event; - - if (!this.mouseElement) { - this.mouseElement = this.createMouseElement(); - } - - this.mouseElement.setAttribute('position', JSON.stringify({ x, y })); - }; - - /** - * @function onMouseLeave - * @description - Removes the mouse element and sets the canvas cursor - to default when the mouse leaves the canvas. - * @returns {void} - */ - private onMouseLeave = (): void => { - if (this.mouseElement) { - this.mouseElement.remove(); - this.mouseElement = null; - } - - this.canvas.style.cursor = 'default'; - }; - - /** - * @function onMouseEnter - * @description - Handles the mouse enter event for the canvas pin adapter. - If there is no mouse element, creates one. - * @returns {void} - */ - private onMouseEnter = (): void => { - if (this.mouseElement) return; - - this.mouseElement = this.createMouseElement(); - }; - /** * @function onToggleAnnotationSidebar * @description Removes temporary pin and unselects selected pin @@ -547,4 +497,19 @@ export class CanvasPin implements PinAdapter { this.removeAnnotationPin('temporary-pin'); } }; + + /** + * @function hideTemporaryPin + * @description hides the temporary pin if click outside an observed element + * @param {MouseEvent} event the mouse event object + * @returns {void} + */ + private hideTemporaryPin = (event: MouseEvent): void => { + const target = event.target as HTMLElement; + + if (this.canvas.contains(target) || this.pins.get('temporary-pin')?.contains(target)) return; + + this.removeAnnotationPin('temporary-pin'); + this.temporaryPinCoordinates = null; + }; } diff --git a/src/components/comments/html-pin-adapter/index.test.ts b/src/components/comments/html-pin-adapter/index.test.ts new file mode 100644 index 00000000..da5f1cb4 --- /dev/null +++ b/src/components/comments/html-pin-adapter/index.test.ts @@ -0,0 +1,1214 @@ +import { MOCK_ANNOTATION } from '../../../../__mocks__/comments.mock'; + +import { HTMLPin } from '.'; + +const MOCK_ANNOTATION_HTML = { + ...MOCK_ANNOTATION, + position: JSON.stringify({ + x: 100, + y: 100, + z: null, + type: 'html', + elementId: '1', + }), +}; + +describe('HTMLPinAdapter', () => { + let instance: HTMLPin; + let target: HTMLElement; + let currentTarget: HTMLElement; + + beforeEach(() => { + document.body.innerHTML = ` +
+
+
+
+
+ `; + + instance = new HTMLPin('container'); + instance.setActive(true); + instance['mouseDownCoordinates'] = { x: 100, y: 100 }; + target = instance['divWrappers'].get('1') as HTMLElement; + currentTarget = target; + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + describe('constructor', () => { + test('should create a new instance of HTMLPinAdapter', () => { + const canvasPinAdapter = new HTMLPin('container'); + canvasPinAdapter.setActive(true); + expect(canvasPinAdapter).toBeInstanceOf(HTMLPin); + }); + + test('should throw an error if no html element is found', () => { + expect(() => new HTMLPin('not-found-html')).toThrowError( + 'Element with id not-found-html not found', + ); + }); + + test('should throw error if second argument is not of type object', () => { + expect(() => new HTMLPin('container', 'not-object' as any)).toThrowError( + 'Second argument of the HTMLPin constructor must be an object', + ); + }); + + test('should throw error if dataAttributeName is an empty string', () => { + expect(() => new HTMLPin('container', { dataAttributeName: '' })).toThrowError( + 'dataAttributeName must be a non-empty string', + ); + }); + + test('should throw error if dataAttributeName is null', () => { + expect(() => new HTMLPin('container', { dataAttributeName: null as any })).toThrowError( + 'dataAttributeName cannot be null', + ); + }); + + test('should throw error if dataAttributeName is not a string', () => { + expect(() => new HTMLPin('container', { dataAttributeName: 123 as any })).toThrowError( + 'dataAttributeName must be a non-empty string', + ); + }); + + test('should call requestAnimationFrame if there is a void element', () => { + document.body.innerHTML = '
'; + const pin = new HTMLPin('container'); + expect(pin['animateFrame']).toBeTruthy(); + }); + }); + + describe('destroy', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should destroy the HTML pin adapter', () => { + instance.updateAnnotations([MOCK_ANNOTATION_HTML]); + const removeListenersSpy = jest.spyOn(instance as any, 'removeListeners'); + const removeObserversSpy = jest.spyOn(instance as any, 'removeObservers'); + const onPinFixedObserverSpy = jest.spyOn(instance['onPinFixedObserver'], 'destroy'); + const removeElementListenersSpy = jest.spyOn(document.body as any, 'removeEventListener'); + const removeSpy = jest.fn(); + const removeEventListenerSpy = jest.fn(); + + const getAttribute = jest + .fn() + .mockResolvedValue(Math.random() > 0.5 ? '' : 'data-wrapper-type'); + const parentElement = { + remove: removeSpy, + }; + + const wrappers = [...instance['divWrappers']].map(([entry, value]) => { + return [ + entry, + { + ...value, + remove: removeSpy, + removeEventListener: removeEventListenerSpy, + getAttribute, + parentElement, + }, + ]; + }); + instance['divWrappers'] = new Map(wrappers as [key: any, value: any][]); + + instance.destroy(); + + expect(removeListenersSpy).toHaveBeenCalled(); + expect(removeObserversSpy).toHaveBeenCalled(); + expect(onPinFixedObserverSpy).toHaveBeenCalled(); + expect(removeElementListenersSpy).toHaveBeenCalled(); + + expect(removeSpy).toHaveBeenCalledTimes(3); + + expect(instance['annotations']).toEqual([]); + expect(instance['elementsWithDataId']).toEqual(undefined); + expect(instance['divWrappers']).toEqual(undefined); + expect(instance['pins']).toEqual(undefined); + expect(instance['onPinFixedObserver']).toEqual(undefined); + expect(instance['divWrappers']).toEqual(undefined); + expect(instance['mutationObserver']).toEqual(undefined); + }); + }); + + describe('listeners', () => { + afterEach(() => { + jest.restoreAllMocks(); + instance['divWrappers'].clear(); + instance['prepareElements'](); + }); + + test('should add event listeners to the HTML container', () => { + const bodyAddEventListenerSpy = jest.spyOn(document.body, 'addEventListener'); + const wrapperAddEventListenerSpy = jest.fn(); + + const wrappers = [...instance['divWrappers']].map(([entry, value]) => { + return [entry, { ...value, addEventListener: wrapperAddEventListenerSpy }]; + }); + + instance['divWrappers'] = new Map(wrappers as [key: any, value: any][]); + + instance['addListeners'](); + + expect(bodyAddEventListenerSpy).toHaveBeenCalledTimes(3); + expect(wrapperAddEventListenerSpy).toHaveBeenCalledTimes(6); + }); + + test('should remove event listeners from the HTML container', () => { + const bodyRemoveEventListenerSpy = jest.spyOn(document.body, 'removeEventListener'); + const wrapperRemoveEventListenerSpy = jest.fn(); + + const wrappers = [...instance['divWrappers']].map(([entry, value]) => { + return [entry, { ...value, removeEventListener: wrapperRemoveEventListenerSpy }]; + }); + + instance['divWrappers'] = new Map(wrappers as [key: any, value: any][]); + + instance['removeListeners'](); + + expect(bodyRemoveEventListenerSpy).toHaveBeenCalledTimes(2); + expect(wrapperRemoveEventListenerSpy).toHaveBeenCalledTimes(6); + }); + }); + + describe('annotationSelected', () => { + afterAll(() => { + jest.restoreAllMocks(); + }); + + test('should select annotation pin', () => { + instance.updateAnnotations([MOCK_ANNOTATION_HTML]); + + expect(instance['selectedPin']).toBeNull(); + + instance['annotationSelected']( + new CustomEvent('select-annotation', { + detail: { + uuid: MOCK_ANNOTATION_HTML.uuid, + }, + }), + ); + + expect([...instance['pins'].values()].some((pin) => pin.hasAttribute('active'))).toBeTruthy(); + }); + + test('should not select annotation pin if uuid is not defined', () => { + instance.updateAnnotations([MOCK_ANNOTATION_HTML]); + + expect(instance['selectedPin']).toBeNull(); + + instance['annotationSelected']( + new CustomEvent('select-annotation', { + detail: { + uuid: undefined, + }, + }), + ); + + expect([...instance['pins'].values()].some((pin) => pin.hasAttribute('active'))).toBeFalsy(); + }); + }); + + describe('renderAnnotationsPins', () => { + afterAll(() => { + jest.restoreAllMocks(); + instance['pins'].clear(); + }); + + test('should not render anything if annotations list is empty', () => { + instance['annotations'] = []; + instance['pins'].clear(); + const spy = jest.spyOn(instance as any, 'removeAnnotationsPins'); + + instance['renderAnnotationsPins'](); + + expect(spy).toHaveBeenCalled(); + expect(instance['pins'].size).toEqual(0); + }); + + test('should render annotations pins', () => { + instance['annotations'] = [MOCK_ANNOTATION_HTML]; + instance['pins'].clear(); + + instance['renderAnnotationsPins'](); + + expect(instance['pins'].size).toEqual(1); + }); + + test('should not render annotation pin if annotation is resolved', () => { + instance['annotations'] = [ + { + ...MOCK_ANNOTATION_HTML, + resolved: true, + }, + ]; + instance['pins'].clear(); + + instance['renderAnnotationsPins'](); + + expect(instance['pins'].size).toEqual(0); + }); + + test('should not render annotation pin if pin was not set using html adapter', () => { + instance['annotations'] = [MOCK_ANNOTATION]; + instance['pins'].clear(); + + instance['renderAnnotationsPins'](); + + expect(instance['pins'].size).toEqual(0); + }); + + test('should not render annotation pin if element with the elementId of the annotation is not found', () => { + instance['annotations'] = [ + { + ...MOCK_ANNOTATION_HTML, + position: JSON.stringify({ + x: 100, + y: 100, + z: null, + type: 'html', + elementId: 'not-found', + }), + }, + ]; + instance['pins'].clear(); + + instance['renderAnnotationsPins'](); + + expect(instance['pins'].size).toEqual(0); + }); + + test('should not render annotation pin if wrapper associated with the elementId of the annotation is not found', () => { + instance['annotations'] = [ + { + ...MOCK_ANNOTATION_HTML, + position: JSON.stringify({ + x: 100, + y: 100, + z: null, + type: 'html', + elementId: '1', + }), + }, + ]; + + instance['divWrappers'].delete('1'); + instance['pins'].clear(); + + instance['renderAnnotationsPins'](); + + expect(instance['pins'].size).toEqual(0); + }); + + test('should not render new annotation pin if it already exists', () => { + instance['annotations'] = [MOCK_ANNOTATION_HTML]; + instance['pins'].clear(); + + instance['renderAnnotationsPins'](); + + expect(instance['pins'].size).toEqual(1); + + instance['renderAnnotationsPins'](); + + expect(instance['pins'].size).toEqual(1); + }); + + test('should not create pin if annotation element id is not found', () => { + document.body.innerHTML = ``; + }); + }); + + describe('onClick', () => { + let element: HTMLElement; + + beforeEach(() => { + element = document.body.querySelector('[data-superviz-id="1"]') as HTMLElement; + instance['annotations'] = [MOCK_ANNOTATION_HTML]; + instance['pins'].clear(); + instance['setElementReadyToPin'](element, '1'); + instance['renderAnnotationsPins'](); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should create temporary pin when mouse clicks canvas', () => { + instance['onClick']({ + clientX: 100, + clientY: 100, + target, + currentTarget, + } as unknown as MouseEvent); + + expect(instance['pins'].has('temporary-pin')).toBeTruthy(); + }); + + test('should not create a temporary pin if the adapter is not active', () => { + instance.setActive(false); + + instance['onClick']({ + x: 100, + y: 100, + target, + currentTarget, + } as unknown as MouseEvent); + + instance.setActive(true); + expect(instance['pins'].has('temporary-pin')).toBeFalsy(); + }); + + test('should remove temporary pin when selecting another pin', () => { + instance['onClick']({ + clientX: 100, + clientY: 100, + target, + currentTarget, + } as unknown as MouseEvent); + expect(instance['pins'].has('temporary-pin')).toBeTruthy(); + + instance['annotationSelected']( + new CustomEvent('select-annotation', { + detail: { + uuid: MOCK_ANNOTATION_HTML.uuid, + }, + }), + ); + + expect(instance['pins'].has('temporary-pin')).toBeFalsy(); + }); + + test('should not create a temporary pin if clicking over another pin', () => { + const pin = instance['pins'].get(MOCK_ANNOTATION_HTML.uuid); + instance['onClick']({ + clientX: 100, + clientY: 100, + target: pin, + currentTarget, + } as unknown as MouseEvent); + + expect(instance['pins'].has('temporary-pin')).toBeFalsy(); + }); + + test('should not create a temporary pin if distance between mouse down and mouse up is more than 10px', () => { + instance['onMouseDown']({ x: 100, y: 100 } as unknown as MouseEvent); + + instance['onClick']({ + clientX: 100, + clientY: 111, + target, + currentTarget, + } as unknown as MouseEvent); + + expect(instance['pins'].has('temporary-pin')).toBeFalsy(); + }); + }); + + describe('clearElement', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should remove pins and listeners and set cursor to default on element being cleared', () => { + instance['prepareElements'](); + instance['annotations'] = [MOCK_ANNOTATION_HTML]; + instance['renderAnnotationsPins'](); + + expect(instance['divWrappers'].get('1')).not.toEqual(undefined); + + const wrapper = instance['divWrappers'].get('1') as HTMLElement; + + expect(wrapper.style.cursor).not.toEqual('default'); + expect(instance['pins'].size).toEqual(1); + expect(Object.keys(instance['elementsWithDataId']).length).toEqual(3); + + const spy = jest.spyOn(instance as any, 'removeElementListeners'); + instance['clearElement']('1'); + + expect(spy).toHaveBeenCalled(); + expect(instance['pins'].size).toEqual(0); + expect(instance['elementsWithDataId']['1']).toEqual(undefined); + expect(instance['divWrappers'].get('1')).toEqual(undefined); + }); + + test('should not clear element if it is not stored in elementsWithDataId', () => { + instance['prepareElements'](); + instance['annotations'] = [MOCK_ANNOTATION_HTML]; + instance['renderAnnotationsPins'](); + + const wrapper = instance['divWrappers'].get('1') as HTMLElement; + + expect(wrapper.style.cursor).not.toEqual('default'); + expect(instance['pins'].size).toEqual(1); + expect(Object.keys(instance['elementsWithDataId']).length).toEqual(3); + + const spy = jest.spyOn(instance as any, 'removeElementListeners'); + instance['clearElement']('not-found'); + + expect(spy).not.toHaveBeenCalled(); + expect(instance['pins'].size).toEqual(1); + expect(Object.keys(instance['elementsWithDataId']).length).toEqual(3); + expect(wrapper.style.cursor).not.toEqual('default'); + }); + }); + + describe('setCommentsMetadata', () => { + test('should store updated data about comments and local participant', () => { + instance.setCommentsMetadata('right', 'user-avatar', 'user name'); + + expect(instance['commentsSide']).toEqual('right'); + expect(instance['localParticipant']).toEqual({ + avatar: 'user-avatar', + name: 'user name', + }); + }); + }); + + describe('resetPins', () => { + test('should remove active on Escape key', () => { + instance.updateAnnotations([MOCK_ANNOTATION_HTML]); + const detail = { + uuid: MOCK_ANNOTATION_HTML.uuid, + }; + + instance['annotationSelected']({ detail } as unknown as CustomEvent); + + expect(instance['selectedPin']).not.toBeNull(); + + instance['resetPins']({ key: 'Escape' } as unknown as KeyboardEvent); + + expect(instance['selectedPin']).toBeNull(); + }); + + test('should reset on KeyBoardEvent if the key is Escape', () => { + instance['onClick']({ + clientX: 100, + clientY: 100, + target, + currentTarget, + } as unknown as MouseEvent); + + expect(instance['pins'].has('temporary-pin')).toBeTruthy(); + + instance['resetPins']({ key: 'Escape' } as unknown as KeyboardEvent); + + expect(instance['pins'].has('temporary-pin')).toBeFalsy(); + }); + + test('should not reset on KeyboardEvent if the key is not Escape', () => { + instance['onClick']({ + clientX: 100, + clientY: 100, + target, + currentTarget, + } as unknown as MouseEvent); + + expect(instance['pins'].has('temporary-pin')).toBeTruthy(); + + instance['resetPins']({ key: 'Enter' } as unknown as KeyboardEvent); + + expect(instance['pins'].has('temporary-pin')).toBeTruthy(); + }); + }); + + describe('annotationSelected', () => { + test('should toggle active attribute when click same annotation twice', () => { + const detail = { + uuid: MOCK_ANNOTATION_HTML.uuid, + }; + + instance.updateAnnotations([MOCK_ANNOTATION_HTML]); + instance['annotationSelected']({ detail } as unknown as CustomEvent); + + expect(instance['selectedPin']).not.toBeNull(); + expect(instance['selectedPin']?.hasAttribute('active')).toBeTruthy(); + + instance['annotationSelected']({ detail } as unknown as CustomEvent); + + expect(instance['selectedPin']).toBeNull(); + }); + + test('should not select annotation pin if it does not exist', () => { + instance.updateAnnotations([MOCK_ANNOTATION_HTML]); + + expect(instance['selectedPin']).toBeNull(); + + instance['annotationSelected']( + new CustomEvent('select-annotation', { + detail: { + uuid: 'not-found', + }, + }), + ); + + expect([...instance['pins'].values()].some((pin) => pin.hasAttribute('active'))).toBeFalsy(); + }); + + test('should remove highlight from annotation pin when sidebar is closed', () => { + instance.updateAnnotations([MOCK_ANNOTATION_HTML]); + instance['annotationSelected']( + new CustomEvent('select-annotation', { + detail: { + uuid: MOCK_ANNOTATION_HTML.uuid, + }, + }), + ); + + let pin = instance['pins'].get(MOCK_ANNOTATION_HTML.uuid); + + expect(pin?.hasAttribute('active')).toBeTruthy(); + + instance['onToggleAnnotationSidebar']( + new CustomEvent('toggle-annotation-sidebar', { + detail: { + open: false, + }, + }), + ); + + pin = instance['pins'].get(MOCK_ANNOTATION_HTML.uuid); + + expect(pin?.hasAttribute('active')).toBeFalsy(); + }); + + test('should not remove highlight from annotation pin when sibar is opened', () => { + instance.updateAnnotations([MOCK_ANNOTATION_HTML]); + instance['annotationSelected']( + new CustomEvent('select-annotation', { + detail: { + uuid: MOCK_ANNOTATION_HTML.uuid, + }, + }), + ); + + let pin = instance['pins'].get(MOCK_ANNOTATION_HTML.uuid); + + expect(pin?.hasAttribute('active')).toBeTruthy(); + + instance['onToggleAnnotationSidebar']( + new CustomEvent('toggle-annotation-sidebar', { + detail: { + open: true, + }, + }), + ); + + pin = instance['pins'].get(MOCK_ANNOTATION_HTML.uuid); + + expect(pin?.hasAttribute('active')).toBeTruthy(); + }); + }); + + describe('removeAnnotationPin', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should remove annotation pin', () => { + instance.updateAnnotations([MOCK_ANNOTATION_HTML]); + + expect(instance['pins'].size).toEqual(1); + + instance.removeAnnotationPin(MOCK_ANNOTATION_HTML.uuid); + + expect(instance['pins'].size).toEqual(0); + }); + + test('should not remove annotation pin if it does not exist', () => { + instance.updateAnnotations([MOCK_ANNOTATION_HTML]); + + expect(instance['pins'].size).toEqual(1); + + instance.removeAnnotationPin('not_found_uuid'); + + expect(instance['pins'].size).toEqual(1); + }); + }); + + describe('updateAnnotations', () => { + test('should not render annotations if visibility is false', () => { + instance.setPinsVisibility(false); + + instance.updateAnnotations([MOCK_ANNOTATION_HTML]); + + expect(instance['pins'].size).toEqual(0); + }); + + test('should remove pins when visibility is false', () => { + instance.setPinsVisibility(true); + + instance.updateAnnotations([MOCK_ANNOTATION_HTML]); + + expect(instance['pins'].size).toEqual(1); + + instance.setPinsVisibility(false); + + expect(instance['pins'].size).toEqual(0); + }); + + test('should not render annotation if the coordinate type is not canvas', () => { + instance.updateAnnotations([ + { + ...MOCK_ANNOTATION_HTML, + uuid: 'not-canvas', + position: JSON.stringify({ + x: 100, + y: 100, + type: 'not-canvas', + }), + }, + ]); + + expect(instance['pins'].has('not-canvas')).toBeFalsy(); + }); + + test('should remove annotation pin when it is resolved', () => { + const annotation = { + ...MOCK_ANNOTATION_HTML, + resolved: false, + }; + + instance.updateAnnotations([ + { ...annotation, uuid: '000 ' }, + { ...annotation, uuid: '123' }, + { ...annotation, uuid: '321' }, + ]); + + expect(instance['pins'].size).toEqual(3); + + instance.updateAnnotations([ + { ...annotation, uuid: '000 ' }, + { ...annotation, uuid: '123', resolved: true }, + { ...annotation, uuid: '321', resolved: true }, + ]); + + expect(instance['pins'].size).toEqual(1); + }); + + test('should not render annotations if the canvas is hidden', () => { + instance.updateAnnotations([MOCK_ANNOTATION_HTML]); + + expect(instance['pins'].size).toEqual(1); + + instance['container'].style.display = 'none'; + + instance.updateAnnotations([]); + + expect(instance['pins'].size).toEqual(0); + }); + }); + + describe('onMouseDown', () => { + test('should update mouse coordinates on mousedown event', () => { + instance['onMouseDown']({ x: 351, y: 153 } as unknown as MouseEvent); + expect(instance['mouseDownCoordinates']).toEqual({ x: 351, y: 153 }); + }); + }); + + describe('renderTemporaryPin', () => { + afterAll(() => { + jest.restoreAllMocks(); + }); + + test('should remove previous temporary pin when rendering temporary pin over another element', () => { + instance['onClick']({ + clientX: 100, + clientY: 100, + target, + currentTarget, + } as unknown as MouseEvent); + + expect(instance['pins'].has('temporary-pin')).toBeTruthy(); + expect(instance['temporaryPinCoordinates'].elementId).toBe( + currentTarget.getAttribute('data-wrapper-id'), + ); + + const deleteSpy = jest.spyOn(instance['pins'], 'delete'); + + instance['onClick']({ + clientX: 100, + clientY: 100, + target: document.body.querySelector('[data-wrapper-id="2"]') as HTMLElement, + currentTarget: document.body.querySelector('[data-wrapper-id="2"]') as HTMLElement, + } as unknown as MouseEvent); + + expect(deleteSpy).toHaveBeenCalled(); + expect(instance['pins'].has('temporary-pin')).toBeTruthy(); + expect(instance['temporaryPinCoordinates'].elementId).toBe('2'); + }); + }); + + describe('setElementReadyToPin', () => { + beforeEach(() => { + instance['divWrappers'].get('1')!.style.cursor = 'default'; + }); + + afterEach(() => { + jest.restoreAllMocks(); + instance['divWrappers'].clear(); + }); + + test('should change cursor and add event listeners', () => { + const element = document.body.querySelector('[data-superviz-id="1"]') as HTMLElement; + const wrapper = instance['divWrappers'].get('1') as HTMLElement; + const spy = jest.spyOn(instance as any, 'addElementListeners'); + delete instance['elementsWithDataId']['1']; + instance['setElementReadyToPin'](element, '1'); + + expect(wrapper.style.cursor).not.toEqual('default'); + expect(spy).toHaveBeenCalled(); + }); + + test('should not change cursor and add event listeners if comments are not active', () => { + const element = document.body.querySelector('[data-superviz-id="1"]') as HTMLElement; + const wrapper = instance['divWrappers'].get('1') as HTMLElement; + const spy = jest.spyOn(instance as any, 'addElementListeners'); + instance.setActive(false); + delete instance['elementsWithDataId']['1']; + instance['setElementReadyToPin'](element, '1'); + + expect(wrapper.style.cursor).toEqual('default'); + expect(spy).not.toHaveBeenCalled(); + }); + + test('should not change cursor and add event listeners if pins are not visible', () => { + const element = document.body.querySelector('[data-superviz-id="1"]') as HTMLElement; + const wrapper = instance['divWrappers'].get('1') as HTMLElement; + const spy = jest.spyOn(instance as any, 'addElementListeners'); + + instance.setPinsVisibility(false); + delete instance['elementsWithDataId']['1']; + instance['setElementReadyToPin'](element, '1'); + + expect(wrapper.style.cursor).toEqual('default'); + expect(spy).not.toHaveBeenCalled(); + }); + + test('should create new divWrapper if divWrapper not found', () => { + const element = document.body.querySelector('[data-superviz-id="1"]') as HTMLElement; + const spySet = jest.spyOn(instance['divWrappers'], 'set'); + const spyCreate = jest.spyOn(instance as any, 'createWrapper'); + + instance['divWrappers'].clear(); + + delete instance['elementsWithDataId']['1']; + + instance['setElementReadyToPin'](element, '1'); + + expect(spyCreate).toHaveBeenCalled(); + expect(spySet).toHaveBeenCalled(); + }); + }); + + describe('addTemporaryPinToElement', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should add temporary pin to element', () => { + const element = document.body.querySelector('[data-superviz-id="1"]') as HTMLElement; + const pin = document.createElement('div'); + pin.id = 'temp-pin'; + + instance['addTemporaryPinToElement']('1', pin); + + const wrapper = instance['divWrappers'].get('1') as HTMLElement; + expect(element.firstElementChild).toBe(wrapper); + expect(wrapper.firstElementChild).toBe(pin); + }); + + test('should not add temporary pin to element if element is not found', () => { + const pin = document.createElement('div'); + pin.id = 'temp-pin'; + + instance['addTemporaryPinToElement']('not-found', pin); + + expect(instance['divWrappers'].get('1')?.querySelector('#temp-pin')).toBe(null); + }); + + test('should not add temporary pin to element if wrapper is not found', () => { + const pin = document.createElement('div'); + pin.id = 'temp-pin'; + + instance['divWrappers'].delete('1'); + instance['addTemporaryPinToElement']('1', pin); + + expect(document.getElementById('temp-pin')).toBe(null); + }); + }); + + describe('createWrapper', () => { + test('should create a new wrapper', () => { + const element = document.body.querySelector('[data-superviz-id="1"]') as HTMLElement; + + instance['divWrappers'].clear(); + const wrapper = instance['createWrapper'](element, '1'); + + expect(wrapper).toBeInstanceOf(HTMLDivElement); + expect(wrapper.style.position).toEqual('absolute'); + expect(wrapper.style.top).toEqual('0px'); + expect(wrapper.style.left).toEqual('0px'); + expect(wrapper.style.width).toEqual('100%'); + expect(wrapper.style.height).toEqual('100%'); + expect(wrapper.style.pointerEvents).toEqual('none'); + expect(wrapper.style.cursor).toEqual('default'); + expect(wrapper.getAttribute('data-wrapper-id')).toEqual('1'); + expect(wrapper.id).toEqual('superviz-id-1'); + }); + + test('should not create a new wrapper if wrapper already exists', () => { + const element = document.body.querySelector('[data-superviz-id="1"]') as HTMLElement; + + instance['divWrappers'].clear(); + + const wrapper1 = instance['createWrapper'](element, '1'); + instance['divWrappers'].set('1', wrapper1); + + const wrapper2 = instance['createWrapper'](element, '1'); + + expect(wrapper1).toBeInstanceOf(HTMLDivElement); + expect(wrapper2).toBe(undefined); + }); + + test('should create wrapper as sibling of the element if element is a void element', () => { + document.body.innerHTML = ''; + const element = document.querySelector('img') as HTMLElement; + + instance['divWrappers'].clear(); + instance['elementsWithDataId']['1'] = element; + + const wrapper = instance['createWrapper'](element, '1'); + + const containerRect = element.getBoundingClientRect(); + + expect(wrapper.parentElement).toEqual(element.parentElement); + expect(wrapper.style.position).toEqual('fixed'); + expect(wrapper.style.top).toEqual(`${containerRect.top}px`); + expect(wrapper.style.left).toEqual(`${containerRect.left}px`); + expect(wrapper.style.width).toEqual(`${containerRect.width}px`); + expect(wrapper.style.height).toEqual(`${containerRect.height}px`); + + expect(instance['voidElementsWrappers'].get('1')).toEqual(wrapper); + }); + + test('should append wrapper of void element to body if element parent is not found', () => { + document.body.innerHTML = ''; + const element = document.createElement('img') as HTMLElement; + element.setAttribute('data-superviz-id', '1'); + + jest.spyOn(instance as any, 'setPositionNotStatic').mockImplementation(() => {}); + + instance['divWrappers'].clear(); + instance['elementsWithDataId']['1'] = element; + + const wrapper = instance['createWrapper'](element, '1'); + + expect(wrapper.parentElement).toEqual(document.body); + }); + }); + + describe('handleMutationObserverChanges', () => { + let setElementsSpy: jest.SpyInstance; + let renderAnnotationsSpy: jest.SpyInstance; + let clearElementSpy: jest.SpyInstance; + let removeAnnotationSpy: jest.SpyInstance; + + beforeEach(() => { + setElementsSpy = jest.spyOn(instance as any, 'setElementReadyToPin'); + renderAnnotationsSpy = jest.spyOn(instance as any, 'renderAnnotationsPins'); + clearElementSpy = jest.spyOn(instance as any, 'clearElement'); + removeAnnotationSpy = jest.spyOn(instance as any, 'removeAnnotationPin'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should set elements and update pins when a new element with the specified attribute appears', () => { + const change = { + target: document.body.querySelector('[data-superviz-id="1"]') as HTMLElement, + oldValue: null, + } as unknown as MutationRecord; + + instance['handleMutationObserverChanges']([change]); + + expect(setElementsSpy).toHaveBeenCalled(); + expect(renderAnnotationsSpy).toHaveBeenCalled(); + expect(clearElementSpy).not.toHaveBeenCalled(); + expect(removeAnnotationSpy).not.toHaveBeenCalled(); + }); + + test('should clear elements and remove pins if the attribute is removed from the element', () => { + const change = { + target: document.createElement('div') as HTMLElement, + oldValue: '1', + } as unknown as MutationRecord; + + instance['handleMutationObserverChanges']([change]); + + expect(clearElementSpy).toHaveBeenCalled(); + expect(removeAnnotationSpy).toHaveBeenCalled(); + expect(renderAnnotationsSpy).not.toHaveBeenCalled(); + expect(setElementsSpy).not.toHaveBeenCalled(); + }); + + test('should unselect pin if the attribute is removed from the element', () => { + const change = { + target: document.createElement('div') as HTMLElement, + oldValue: '1', + } as unknown as MutationRecord; + + const selectedPin = document.createElement('div'); + selectedPin.setAttribute('elementId', '1'); + instance['selectedPin'] = selectedPin; + + const spy = jest.spyOn(document.body, 'dispatchEvent'); + instance['handleMutationObserverChanges']([change]); + + expect(spy).toHaveBeenCalledWith(new CustomEvent('select-annotation')); + }); + + test('should clear element if the attribute changes, but still exists', () => { + const change = { + target: document.body.querySelector('[data-superviz-id="1"]') as HTMLElement, + oldValue: '2', + } as unknown as MutationRecord; + + instance['handleMutationObserverChanges']([change]); + + expect(clearElementSpy).toHaveBeenCalled(); + expect(renderAnnotationsSpy).toHaveBeenCalled(); + expect(setElementsSpy).toHaveBeenCalled(); + expect(removeAnnotationSpy).not.toHaveBeenCalled(); + }); + + test('should do nothing if there is not new nor old value to the attribute', () => { + const target = document.createElement('div') as HTMLElement; + target.setAttribute('data-superviz-id', ''); + const change = { + target, + oldValue: null, + } as unknown as MutationRecord; + + instance['handleMutationObserverChanges']([change]); + + expect(clearElementSpy).not.toHaveBeenCalled(); + expect(removeAnnotationSpy).not.toHaveBeenCalled(); + expect(renderAnnotationsSpy).not.toHaveBeenCalled(); + expect(setElementsSpy).not.toHaveBeenCalled(); + }); + + test('should do nothing if the new value is the same as the old attribute value', () => { + const change = { + target: document.body.querySelector('[data-superviz-id="1"]') as HTMLElement, + oldValue: '1', + } as unknown as MutationRecord; + + instance['handleMutationObserverChanges']([change]); + + expect(clearElementSpy).not.toHaveBeenCalled(); + expect(removeAnnotationSpy).not.toHaveBeenCalled(); + expect(renderAnnotationsSpy).not.toHaveBeenCalled(); + expect(setElementsSpy).not.toHaveBeenCalled(); + }); + + test('should clear element then do nothing if new value is filtered', () => { + document.body.innerHTML = + '
'; + instance = new HTMLPin('container', { dataAttributeValueFilters: [/.*-matches$/] }); + const change = { + target: document.body.querySelector('[data-superviz-id="1-matches"]') as HTMLElement, + oldValue: '2', + } as unknown as MutationRecord; + + setElementsSpy = jest.spyOn(instance as any, 'setElementReadyToPin'); + renderAnnotationsSpy = jest.spyOn(instance as any, 'renderAnnotationsPins'); + clearElementSpy = jest.spyOn(instance as any, 'clearElement'); + removeAnnotationSpy = jest.spyOn(instance as any, 'removeAnnotationPin'); + + instance['handleMutationObserverChanges']([change]); + + expect(clearElementSpy).toHaveBeenCalled(); + expect(renderAnnotationsSpy).not.toHaveBeenCalled(); + expect(setElementsSpy).not.toHaveBeenCalled(); + expect(removeAnnotationSpy).not.toHaveBeenCalled(); + }); + + test('should not clear element if old value was skipped', () => { + document.body.innerHTML = + '
'; + instance = new HTMLPin('container', { dataAttributeValueFilters: [/.*-matches$/] }); + const change = { + target: document.body.querySelector('[data-superviz-id="does-not-match"]') as HTMLElement, + oldValue: '1-matches', + } as unknown as MutationRecord; + + setElementsSpy = jest.spyOn(instance as any, 'setElementReadyToPin'); + renderAnnotationsSpy = jest.spyOn(instance as any, 'renderAnnotationsPins'); + clearElementSpy = jest.spyOn(instance as any, 'clearElement'); + removeAnnotationSpy = jest.spyOn(instance as any, 'removeAnnotationPin'); + + instance['handleMutationObserverChanges']([change]); + + expect(clearElementSpy).not.toHaveBeenCalled(); + expect(renderAnnotationsSpy).toHaveBeenCalled(); + expect(setElementsSpy).toHaveBeenCalled(); + expect(removeAnnotationSpy).not.toHaveBeenCalled(); + }); + }); + + describe('onToggleAnnotationSidebar', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should remove active attribute from selected pin if sidebar is closed', () => { + const spy = jest.spyOn(instance as any, 'resetSelectedPin'); + instance['onToggleAnnotationSidebar']({ detail: { open: false } } as unknown as CustomEvent); + expect(spy).toHaveBeenCalled(); + }); + + test('should not remove active attribute from selected pin if sidebar is opened', () => { + const spy = jest.spyOn(instance as any, 'resetSelectedPin'); + instance['onToggleAnnotationSidebar']({ detail: { open: true } } as unknown as CustomEvent); + expect(spy).not.toHaveBeenCalled(); + }); + + test('should remove temporary pin if sidebar is closed', () => { + const spy = jest.spyOn(instance as any, 'removeAnnotationPin'); + instance['onClick']({ + clientX: 100, + clientY: 100, + target, + currentTarget, + } as unknown as MouseEvent); + + expect(instance['pins'].has('temporary-pin')).toBeTruthy(); + + instance['onToggleAnnotationSidebar']({ detail: { open: false } } as unknown as CustomEvent); + + expect(spy).toHaveBeenCalledWith('temporary-pin'); + }); + }); + + describe('animate', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + test('should update pins positions', () => { + const spy = jest.spyOn(instance as any, 'updatePinsPositions'); + window.requestAnimationFrame = jest.fn(); + instance['animate'](); + expect(spy).toHaveBeenCalled(); + expect(window.requestAnimationFrame).toHaveBeenCalledWith(instance['animate']); + }); + }); + + describe('updatePinsPositions', () => { + beforeEach(() => { + document.body.innerHTML = '
'; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should update position of all wrappers of void elements', () => { + const div = document.body.querySelector('div') as HTMLDivElement; + const image = document.body.querySelector('img') as HTMLImageElement; + + instance['container'] = div as HTMLElement; + instance['prepareElements'](); + + image.getBoundingClientRect = jest.fn().mockReturnValue({ + left: 40, + top: 30, + width: 50, + height: 60, + }); + + const wrapper = instance['divWrappers'].get('image-id') as HTMLElement; + const spy = jest.spyOn(wrapper.style, 'setProperty'); + + instance['updatePinsPositions'](); + + expect(spy).toHaveBeenNthCalledWith(1, 'top', '30px'); + expect(spy).toHaveBeenNthCalledWith(2, 'left', '40px'); + expect(spy).toHaveBeenNthCalledWith(3, 'width', '50px'); + expect(spy).toHaveBeenNthCalledWith(4, 'height', '60px'); + }); + + test('should not update if positions are the same', () => { + const div = document.body.querySelector('div') as HTMLDivElement; + const image = document.body.querySelector('img') as HTMLImageElement; + + instance['container'] = div as HTMLElement; + instance['prepareElements'](); + + image.getBoundingClientRect = jest.fn().mockReturnValue({ + left: 40, + top: 30, + width: 50, + height: 60, + }); + + const wrapper = instance['divWrappers'].get('image-id') as HTMLElement; + + wrapper.getBoundingClientRect = jest.fn().mockReturnValue({ + left: 40, + top: 30, + width: 50, + height: 60, + }); + + const spy = jest.spyOn(wrapper.style, 'setProperty'); + + instance['updatePinsPositions'](); + + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe('setPositionNotStatic', () => { + test('should set element position to relative if it is static', () => { + const element = document.createElement('div'); + + element.style.position = 'static'; + + instance['setPositionNotStatic'](element); + + expect(element.style.position).toEqual('relative'); + }); + + test('should do nothing if element position is not static', () => { + const element = document.createElement('div'); + + element.style.position = 'absolute'; + + instance['setPositionNotStatic'](element); + + expect(element.style.position).toEqual('absolute'); + }); + }); + + describe('prepareElements', () => { + test('should not prepare element if data attribute value matches filter', () => { + document.body.innerHTML = + '
'; + const container = document.getElementById('container') as HTMLElement; + + instance = new HTMLPin('container', { dataAttributeValueFilters: [/.*-matches$/] }); + const spy = jest.spyOn(instance as any, 'setElementReadyToPin'); + + instance['container'] = container; + instance['prepareElements'](); + + expect(spy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/components/comments/html-pin-adapter/index.ts b/src/components/comments/html-pin-adapter/index.ts new file mode 100644 index 00000000..bf98a89a --- /dev/null +++ b/src/components/comments/html-pin-adapter/index.ts @@ -0,0 +1,970 @@ +import { isEqual } from 'lodash'; + +import { Logger, Observer } from '../../../common/utils'; +import { PinMode } from '../../../web-components/comments/components/types'; +import { Annotation, PinAdapter, PinCoordinates } from '../types'; + +import { + HorizontalSide, + Simple2DPoint, + SimpleParticipant, + TemporaryPinData, + HTMLPinOptions, +} from './types'; + +export class HTMLPin implements PinAdapter { + // Public properties + // Observers + public onPinFixedObserver: Observer; + + // Private properties + // Comments data + private annotations: Annotation[]; + private localParticipant: SimpleParticipant = {}; + + // Loggers + private logger: Logger; + + // Booleans + private isActive: boolean; + private isPinsVisible: boolean = true; + private movedTemporaryPin: boolean; + + // Data about the current state of the application + private selectedPin: HTMLElement | null = null; + private dataAttribute: string = 'data-superviz-id'; + private animateFrame: number; + private dataAttributeValueFilters: RegExp[]; + + // Coordinates/Positions + private mouseDownCoordinates: Simple2DPoint; + private commentsSide: HorizontalSide = 'left'; + private temporaryPinCoordinates: TemporaryPinData = {}; + + // Elements + private container: HTMLElement; + private elementsWithDataId: Record = {}; + private divWrappers: Map = new Map(); + private pins: Map; + private voidElementsWrappers: Map = new Map(); + private svgWrappers: HTMLElement; + // Observers + private mutationObserver: MutationObserver; + + // Consts + private readonly VOID_ELEMENTS = [ + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'link', + 'meta', + 'param', + 'source', + 'track', + 'wbr', + 'svg', + 'rect', + 'ellipse', + ]; + + constructor(containerId: string, options: HTMLPinOptions = {}) { + this.logger = new Logger('@superviz/sdk/comments-component/container-pin-adapter'); + this.container = document.getElementById(containerId) as HTMLElement; + + if (!this.container) { + const message = `Element with id ${containerId} not found`; + this.logger.log(message); + throw new Error(message); + } + + if (typeof options !== 'object') + throw new Error('Second argument of the HTMLPin constructor must be an object'); + + const { dataAttributeName, dataAttributeValueFilters } = options; + + if (dataAttributeName === '') throw new Error('dataAttributeName must be a non-empty string'); + if (dataAttributeName === null) throw new Error('dataAttributeName cannot be null'); + if (dataAttributeName !== undefined && typeof dataAttributeName !== 'string') + throw new Error('dataAttributeName must be a non-empty string'); + + this.dataAttribute = dataAttributeName || this.dataAttribute; + this.dataAttributeValueFilters = dataAttributeValueFilters || []; + + this.isActive = false; + this.prepareElements(); + + this.mutationObserver = new MutationObserver(this.handleMutationObserverChanges); + this.observeContainer(); + + this.pins = new Map(); + this.onPinFixedObserver = new Observer({ logger: this.logger }); + this.annotations = []; + + document.body.addEventListener('select-annotation', this.annotationSelected); + + if (!this.voidElementsWrappers.size) return; + + this.animateFrame = requestAnimationFrame(this.animate); + } + + // ------- setup ------- + /** + * @function destroy + * @description destroys the pin adapter. + * @returns {void} + * */ + public destroy(): void { + this.logger.log('Destroying HTML Pin Adapter for Comments'); + this.removeListeners(); + this.removeObservers(); + this.divWrappers.forEach((divWrapper) => divWrapper.remove()); + this.divWrappers.clear(); + this.pins.forEach((pin) => pin.remove()); + this.pins.clear(); + this.divWrappers = undefined; + this.pins = undefined; + this.elementsWithDataId = undefined; + this.logger = undefined; + this.onPinFixedObserver.destroy(); + this.onPinFixedObserver = undefined; + this.container = undefined; + this.voidElementsWrappers.clear(); + this.voidElementsWrappers = undefined; + this.annotations = []; + this.svgWrappers?.remove(); + this.svgWrappers = undefined; + document.body.removeEventListener('select-annotation', this.annotationSelected); + document.body.removeEventListener('toggle-annotation-sidebar', this.onToggleAnnotationSidebar); + + cancelAnimationFrame(this.animateFrame); + } + + /** + * @function addElementListeners + * @description adds event listeners to the element. + * @param {string} id the id of the element to add the listeners to. + * @returns {void} + */ + private addElementListeners(id: string): void { + this.divWrappers.get(id).addEventListener('click', this.onClick, true); + this.divWrappers.get(id).addEventListener('mousedown', this.onMouseDown); + } + + /** + * @function removeElementListeners + * @description removes event listeners from the element + * @param {string} id the id of the element to remove the listeners from. + * @returns {void} + */ + private removeElementListeners(id: string): void { + this.divWrappers.get(id).removeEventListener('click', this.onClick, true); + this.divWrappers.get(id).removeEventListener('mousedown', this.onMouseDown); + } + + /** + * @function removeObservers + * @description disconnects the observers. + * @returns {void} + */ + private removeObservers(): void { + this.mutationObserver.disconnect(); + this.mutationObserver = undefined; + } + + /** + * @function addListeners + * @description adds event listeners to the container element. + * @returns {void} + */ + private addListeners(): void { + this.divWrappers.forEach((_, id) => this.addElementListeners(id)); + document.body.addEventListener('keyup', this.resetPins); + document.body.addEventListener('toggle-annotation-sidebar', this.onToggleAnnotationSidebar); + document.body.addEventListener('click', this.hideTemporaryPin); + } + + /** + * @function animate + * @description updates the position of the wrappers of the void elements. + * @returns {void} + */ + private animate = (): void => { + if (this.voidElementsWrappers) { + this.updatePinsPositions(); + this.animateFrame = requestAnimationFrame(this.animate); + } + }; + + /** + * @function removeListeners + * @description removes event listeners from the container element. + * @returns {void} + * */ + private removeListeners(): void { + this.divWrappers.forEach((_, id) => this.removeElementListeners(id)); + document.body.removeEventListener('keyup', this.resetPins); + document.body.removeEventListener('click', this.hideTemporaryPin); + } + + /** + * @function observeContainer + * @description observes the container for changes in the specified data attribute. + * @returns {void} + */ + private observeContainer(): void { + this.mutationObserver.observe(this.container, { + subtree: true, + attributes: true, + attributeFilter: [this.dataAttribute], + attributeOldValue: true, + }); + } + + /** + * @function prepareElements + * @description sets elements with the specified data attribute as pinnable. + * @returns {void} + */ + private prepareElements(): void { + const elementsWithDataId = this.container.querySelectorAll(`[${this.dataAttribute}]`); + + elementsWithDataId.forEach((el: HTMLElement) => { + const id = el.getAttribute(this.dataAttribute); + const skip = this.dataAttributeValueFilters.some((filter) => { + return id.match(filter); + }); + + if (skip) return; + + this.setElementReadyToPin(el, id); + }); + } + + /** + * @function addAllCursors + * @description sets the mouse cursor to a special cursor when hovering over all the elements with the specified data-attribute. + * @returns {void} + */ + private addAllCursors(): void { + this.divWrappers.forEach((wrapper, id) => { + this.addCursor(wrapper, id); + }); + } + + /** + * @function removeAddCursor + * @description removes the special cursor. + * @returns {void} + */ + private removeAddCursor(): void { + this.divWrappers.forEach((wrapper, id) => { + let element: HTMLElement | SVGElement = wrapper; + + const isSvgElement = wrapper.getAttribute('data-wrapper-type'); + if (isSvgElement) { + const elementTagname = isSvgElement.split('-')[2]; + element = this.divWrappers.get(id).querySelector(elementTagname); + } + + element.style.setProperty('cursor', 'default'); + element.style.setProperty('pointer-events', 'none'); + }); + } + + // ------- public methods ------- + /** + * @function setPinsVisibility + * @description sets the visibility of the pins, hides them if it is not visible. + * @param {boolean} isVisible controls the visibility of the pins. + * @returns {void} + */ + public setPinsVisibility(isVisible: boolean): void { + this.isPinsVisible = isVisible; + + if (this.isPinsVisible) { + this.renderAnnotationsPins(); + } + + this.removeAnnotationsPins(); + } + + /** + * @function removeAnnotationPin + * @description Removes an annotation pin from the container. + * @param {string} uuid - The uuid of the annotation to be removed. + * @returns {void} + * */ + public removeAnnotationPin(uuid: string): void { + const pinElement = this.pins.get(uuid); + + if (!pinElement && uuid === 'temporary-pin') return; + + if (pinElement) { + pinElement.remove(); + this.pins.delete(uuid); + } + + if (uuid === 'temporary-pin') return; + + this.annotations = this.annotations.filter((annotation) => { + return annotation.uuid !== uuid; + }); + } + + /** + * @function setCommentsMetadata + * @description stores data related to the local participant + * @param {HorizontalSide} side whether the comments sidebar is on the left or right side of the screen + * @param {string} avatar the avatar of the local participant + * @param {string} name the name of the local participant + * @returns {void} + */ + public setCommentsMetadata = (side: HorizontalSide, avatar: string, name: string): void => { + this.commentsSide = side; + this.localParticipant.avatar = avatar; + this.localParticipant.name = name; + }; + + /** + * @function updateAnnotations + * @description updates the annotations of the container. + * @param {Annotation[]} annotations new annotation to be added to the container. + * @returns {void} + */ + public updateAnnotations(annotations: Annotation[]): void { + this.logger.log('updateAnnotations', annotations); + + this.annotations = annotations; + + if (!this.isPinsVisible) return; + + this.removeAnnotationsPins(); + this.renderAnnotationsPins(); + } + + /** + * @function setActive + * @description sets the container pin adapter as active or not + * @param {boolean} isOpen whether the container pin adapter is active or not. + * @returns {void} + */ + public setActive(isOpen: boolean): void { + this.isActive = isOpen; + + if (this.isActive) { + this.addListeners(); + this.addAllCursors(); + this.prepareElements(); + return; + } + + this.removeListeners(); + this.removeAddCursor(); + } + + /** + * @function renderTemporaryPin + * @description creates a temporary pin with the id temporary-pin to mark where the annotation is being created + * @param {string} elementId the id of the element where the temporary pin will be rendered. + * @returns {void} + */ + public renderTemporaryPin(elementId?: string): void { + this.temporaryPinCoordinates.elementId ||= elementId; + let temporaryPin = this.pins.get('temporary-pin'); + + if (elementId && elementId !== this.temporaryPinCoordinates.elementId) { + this.pins.get('temporary-pin').remove(); + this.pins.delete('temporary-pin'); + + this.temporaryPinCoordinates.elementId = elementId; + temporaryPin = null; + } + + if (!temporaryPin) { + const elementSides = this.elementsWithDataId[elementId]?.getBoundingClientRect(); + + temporaryPin = document.createElement('superviz-comments-annotation-pin'); + temporaryPin.id = 'superviz-temporary-pin'; + temporaryPin.setAttribute('type', PinMode.ADD); + temporaryPin.setAttribute('showInput', ''); + temporaryPin.setAttribute('containerSides', JSON.stringify(elementSides)); + temporaryPin.setAttribute('commentsSide', this.commentsSide); + temporaryPin.setAttribute('position', JSON.stringify(this.temporaryPinCoordinates)); + temporaryPin.setAttribute('annotation', JSON.stringify({})); + temporaryPin.setAttribute('localAvatar', this.localParticipant.avatar ?? ''); + temporaryPin.setAttribute('localName', this.localParticipant.name ?? ''); + temporaryPin.setAttribute('keepPositionRatio', ''); + temporaryPin.setAttributeNode(document.createAttribute('active')); + + this.addTemporaryPinToElement(elementId, temporaryPin); + } + + const { x, y } = this.temporaryPinCoordinates; + + temporaryPin.setAttribute('position', JSON.stringify({ x, y })); + + this.pins.set('temporary-pin', temporaryPin); + } + + // ------- regular methods ------- + /** + * @function renderAnnotationsPins + * @description appends the pins on the container. + * @returns {void} + */ + private renderAnnotationsPins(): void { + if (!this.annotations.length) { + this.removeAnnotationsPins(); + return; + } + + this.annotations.forEach((annotation) => { + if (annotation.resolved) { + return; + } + + const { x, y, elementId, type } = JSON.parse(annotation.position) as PinCoordinates; + if (type !== 'html') return; + + if (this.pins.has(annotation.uuid)) { + return; + } + + const element = this.elementsWithDataId[elementId]; + if (!element) return; + + const wrapper = this.divWrappers.get(elementId); + if (!wrapper) return; + + const pinElement = this.createPin(annotation, x, y); + wrapper.appendChild(pinElement); + this.pins.set(annotation.uuid, pinElement); + }); + } + + /** + * @function updatePinsPositions + * @description updates the position of the wrappers of the void elements. + * @returns {void} + */ + private updatePinsPositions() { + this.voidElementsWrappers.forEach((wrapper, id) => { + const wrapperRect = JSON.stringify(wrapper.getBoundingClientRect()); + const elementRect = this.elementsWithDataId[id].getBoundingClientRect(); + + if (isEqual(JSON.stringify(elementRect), wrapperRect)) return; + + wrapper.style.setProperty('top', `${elementRect.top}px`); + wrapper.style.setProperty('left', `${elementRect.left}px`); + wrapper.style.setProperty('width', `${elementRect.width}px`); + wrapper.style.setProperty('height', `${elementRect.height}px`); + }); + } + + /** + * @function hideTemporaryPin + * @description hides the temporary pin if click outside an observed element + * @param {MouseEvent} event the mouse event object + * @returns {void} + */ + private hideTemporaryPin = (event: MouseEvent): void => { + const target = event.target as HTMLElement; + const temporaryPinWrapper = this.divWrappers.get(this.temporaryPinCoordinates.elementId); + + if (!temporaryPinWrapper) return; + + const clickedOnWrapper = temporaryPinWrapper.contains(target); + const clickedOnTemporaryPin = this.pins.get('temporary-pin')?.contains(target); + + if (clickedOnWrapper || clickedOnTemporaryPin) return; + + this.removeAnnotationPin('temporary-pin'); + this.temporaryPinCoordinates = {}; + }; + + /** + * @function clearElement + * @description clears an element that no longer has the specified data attribute + * @param {string} id the id of the element to be cleared + * @returns {void} + */ + private clearElement(id: string): void { + const element = this.elementsWithDataId[id]; + if (!element) return; + + const wrapper = this.divWrappers.get(id); + if (wrapper) { + const pins = wrapper.children; + const { length } = pins; + + for (let i = 0; i < length; ++i) { + const pin = pins.item(i); + this.pins.delete(pin.id); + } + + wrapper.remove(); + } + + this.voidElementsWrappers.delete(id); + this.removeElementListeners(id); + this.divWrappers.delete(id); + this.elementsWithDataId[id] = undefined; + + if (!this.voidElementsWrappers.size) { + cancelAnimationFrame(this.animateFrame); + this.animateFrame = undefined; + } + } + + /** + * @function resetSelectedPin + * @description Unselects a pin by removing its 'active' attribute + * @returns {void} + * */ + private resetSelectedPin(): void { + if (!this.selectedPin) return; + this.selectedPin.removeAttribute('active'); + this.selectedPin = null; + } + + /** + * @function removeAnnotationsPins + * @description clears all pins from the container. + * @returns {void} + */ + private removeAnnotationsPins(): void { + this.pins.forEach((pinElement) => { + pinElement.remove(); + }); + + this.pins.clear(); + } + + /** + * @function setElementReadyToPin + * @description prepare an element with all necessary to add pins over it + * @param {HTMLElement} element + * @param {string} id + * @returns {void} + */ + private setElementReadyToPin(element: Element, id: string): void { + if (this.elementsWithDataId[id]) return; + + this.elementsWithDataId[id] = element as HTMLElement; + + if (!this.divWrappers.get(id)) { + const divWrapper = this.createWrapper(element, id); + this.divWrappers.set(id, divWrapper); + } + + if (!this.isActive || !this.isPinsVisible) return; + + this.addCursor(this.divWrappers.get(id), id); + this.addElementListeners(id); + } + + /** + * @function handleSvgElement + */ + private handleSvgElement(element: Element, wrapper: HTMLDivElement): HTMLDivElement { + const viewport = (element as SVGElement).viewportElement; + + const isNormalHTML = viewport === undefined; + if (isNormalHTML) return; + + const isSvgElement = element.tagName.toLowerCase() === 'svg'; + if (isSvgElement) { + const foreignObject = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject'); + foreignObject.setAttribute('height', '100%'); + foreignObject.setAttribute('width', '100%'); + foreignObject.style.setProperty('overflow', 'visible'); + foreignObject.appendChild(wrapper); + element.appendChild(foreignObject); + (element as SVGElement).style.setProperty('overflow', 'visible'); + // wrapper.setAttribute() + return wrapper; + } + + const elementName = element.tagName.toLowerCase(); + const isEllipseElement = elementName === 'ellipse'; + const isRectElement = elementName === 'rect'; + + if (!isEllipseElement && !isRectElement) return; + + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + const svgElement = document.createElementNS('http://www.w3.org/2000/svg', elementName); + + let x: number | string; + let y: number | string; + let width: string; + let height: string; + let rx: string; + let ry: string; + + if (isRectElement) { + const rect = element as SVGRectElement; + x = rect.getAttribute('x'); + y = rect.getAttribute('y'); + width = rect.getAttribute('width'); + height = rect.getAttribute('height'); + rx = rect.getAttribute('rx'); + ry = rect.getAttribute('ry'); + svgElement.setAttribute('fill', 'transparent'); + svgElement.setAttribute('stroke', 'transparent'); + svgElement.setAttribute('x', '0'); + svgElement.setAttribute('y', '0'); + svgElement.setAttribute('rx', rx); + svgElement.setAttribute('ry', ry); + } + + if (isEllipseElement) { + const cx = element.getAttribute('cx'); + const cy = element.getAttribute('cy'); + + rx = element.getAttribute('rx'); + ry = element.getAttribute('ry'); + x = Number(cx) - Number(rx); + y = Number(cy) - Number(ry); + width = String(2 * Number(cx)); + height = String(2 * Number(cy)); + + svgElement.setAttribute('fill', 'transparent'); + svgElement.setAttribute('stroke', 'transparent'); + svgElement.setAttribute('cx', `${Number(cx) - x}`); + svgElement.setAttribute('cy', `${Number(cy) - y}`); + svgElement.setAttribute('rx', rx); + svgElement.setAttribute('ry', ry); + } + + svgElement.setAttribute('height', height); + svgElement.setAttribute('width', width); + + svg.setAttribute('height', height); + svg.setAttribute('width', width); + + svg.appendChild(svgElement); + + wrapper.appendChild(svg); + + (element as SVGElement).style.setProperty('overflow', 'visible'); + wrapper.setAttribute('data-wrapper-type', `svg-${elementName}`); + return wrapper; + } + + /** + * @function resetPins + * @description Unselects selected pin and removes temporary pin. + * @param {KeyboardEvent} event the keyboard event object, this should be 'Escape'. + * @returns {void} + * */ + private resetPins = (event?: KeyboardEvent): void => { + if (event && event?.key !== 'Escape') return; + + this.resetSelectedPin(); + + if (!this.temporaryPinCoordinates.elementId) return; + + this.removeAnnotationPin('temporary-pin'); + this.temporaryPinCoordinates = {}; + }; + + /** + * @function annotationSelected + * @description highlights the selected annotation + * @param {CustomEvent} event the emitted event object with the uuid of the selected annotation + * @returns {void} + */ + private annotationSelected = ({ detail: { uuid } }: CustomEvent): void => { + if (!uuid) return; + + const annotation = this.annotations.find( + (annotation) => annotation.uuid === this.selectedPin?.id, + ); + + this.resetPins(); + + if (annotation?.uuid === uuid) return; + + document.body.dispatchEvent(new CustomEvent('close-temporary-annotation')); + + const pinElement = this.pins.get(uuid); + + if (!pinElement) return; + + pinElement.setAttribute('active', ''); + + this.selectedPin = pinElement; + + const newSelectedAnnotation = this.annotations.find((annotation) => annotation.uuid === uuid); + this.selectedPin.setAttribute( + 'elementId', + JSON.parse(newSelectedAnnotation.position).elementId, + ); + }; + + /** + * @function addTemporaryPinToElement + * @description adds the temporary pin and the temporary pin container to the element with the specified id. + * @param {string} elementId the id of the element where the temporary pin will be rendered. + * @param {HTMLElement} pin the temporary pin to be rendered. + * @returns {void} + */ + private addTemporaryPinToElement(elementId: string, pin: HTMLElement): void { + const element = this.elementsWithDataId[elementId]; + if (!element) return; + + const wrapper = this.divWrappers.get(elementId); + if (!wrapper) return; + + wrapper.appendChild(pin); + wrapper.parentElement.appendChild(wrapper); + } + + // ------- helper functions ------- + /** + * @function createPin + * @description creates a pin element and sets its properties + * @param {Annotation} annotation the annotation associated to the pin to be rendered + * @param {number} x the x coordinate of the pin + * @param {number} y the y coordinate of the pin + * @returns {HTMLElement} the pin element + */ + private createPin(annotation: Annotation, x: number, y: number) { + const pinElement = document.createElement('superviz-comments-annotation-pin'); + pinElement.setAttribute('type', PinMode.SHOW); + pinElement.setAttribute('annotation', JSON.stringify(annotation)); + pinElement.setAttribute('position', JSON.stringify({ x, y })); + pinElement.setAttribute('keepPositionRatio', ''); + pinElement.id = annotation.uuid; + + return pinElement; + } + + /** + * @function addCursor + * @description sets the mouse cursor to a special cursor when hovering over the element with the specified id + * @param {HTMLElement} wrapper the wrapper of the element + * @param {string} id the id of the element + * @returns {void} + */ + private addCursor(wrapper: HTMLElement | SVGElement, id: string): void { + let element: HTMLElement | SVGElement = wrapper; + + const isSvgElement = wrapper.getAttribute('data-wrapper-type'); + if (isSvgElement) { + const elementTagname = isSvgElement.split('-')[1]; + element = this.divWrappers.get(id).querySelector(elementTagname); + } + + element.style.setProperty( + 'cursor', + 'url("https://production.cdn.superviz.com/static/pin-html.png") 0 100, pointer', + ); + element.style.setProperty('pointer-events', 'auto'); + } + + /** + * @function createWrapper + * @description creates a wrapper for the element with the specified id + * @param {HTMLElement} element the element to be wrapped + * @param {string} id the id of the element to be wrapped + * @returns {HTMLElement} the new wrapper element + */ + private createWrapper(element: Element, id: string): HTMLElement { + const wrapperId = `superviz-id-${id}`; + + if (this.divWrappers.get(id)) return; + + const containerRect = element.getBoundingClientRect(); + + let containerWrapper = document.createElement('div'); + containerWrapper.setAttribute('data-wrapper-id', id); + containerWrapper.id = wrapperId; + + containerWrapper.style.position = 'absolute'; + containerWrapper.style.pointerEvents = 'none'; + containerWrapper.style.transform = 'translateX(0) translateY(0) scale(1)'; + containerWrapper.style.cursor = 'default'; + containerWrapper.style.top = `0`; + containerWrapper.style.left = `0`; + containerWrapper.style.width = `100%`; + containerWrapper.style.height = `100%`; + + if (!this.VOID_ELEMENTS.includes(this.elementsWithDataId[id].tagName.toLowerCase())) { + this.elementsWithDataId[id].appendChild(containerWrapper); + this.setPositionNotStatic(this.elementsWithDataId[id]); + return containerWrapper; + } + + containerWrapper = this.handleSvgElement(element, containerWrapper) ?? containerWrapper; + + containerWrapper.style.position = 'fixed'; + containerWrapper.style.top = `${containerRect.top}px`; + containerWrapper.style.left = `${containerRect.left}px`; + containerWrapper.style.width = `${containerRect.width}px`; + containerWrapper.style.height = `${containerRect.height}px`; + + let parent = this.elementsWithDataId[id].parentElement; + + if (!parent || (element as SVGElement).viewportElement) parent = document.body; + + this.setPositionNotStatic(parent as HTMLElement); + parent.appendChild(containerWrapper); + + this.voidElementsWrappers.set(id, containerWrapper); + + if (!this.animateFrame) { + this.animateFrame = requestAnimationFrame(this.animate); + } + + return containerWrapper; + } + + /** + * @function setPositionNotStatic + * @description sets the position of the element to relative if it is static + * @param {HTMLElement} element the element to be checked + * @returns {void} + */ + private setPositionNotStatic(element: HTMLElement): void { + const { position } = window.getComputedStyle(element); + if (position !== 'static') return; + + element.style.setProperty('position', 'relative'); + } + + // ------- callbacks ------- + /** + * @function onClick + * @description handles the click event on the container; mainly, creates or moves the temporary pin + * @param {MouseEvent} event the mouse event object + * @returns {void} + */ + private onClick = (event: MouseEvent): void => { + if (!this.isActive || event.target === this.pins.get('temporary-pin')) return; + + const target = event.target as HTMLElement; + const wrapper = event.currentTarget as HTMLElement; + + if (target !== wrapper && this.pins.has(target.id)) return; + + const elementId = wrapper.getAttribute('data-wrapper-id'); + const rect = wrapper.getBoundingClientRect(); + const { x: mouseDownX, y: mouseDownY } = this.mouseDownCoordinates; + + let x = event.clientX - rect.left; + let y = event.clientY - rect.top; + + const originalX = mouseDownX - rect.x; + const originalY = mouseDownY - rect.y; + + const distance = Math.hypot(x - originalX, y - originalY); + if (distance > 10) return; + + const { width, height } = wrapper.getBoundingClientRect(); + + const cursorHeight = 32; + const scale = wrapper.getBoundingClientRect().width / wrapper.offsetWidth || 1; + + // save coordinates as percentages + x = (x * 100) / width; + y = ((y - cursorHeight * scale) * 100) / height; + + this.onPinFixedObserver.publish({ + x, + y, + type: 'html', + elementId, + } as PinCoordinates); + + this.resetSelectedPin(); + this.temporaryPinCoordinates = { ...this.temporaryPinCoordinates, x, y }; + this.renderTemporaryPin(elementId); + + const temporaryPin = this.pins.get('temporary-pin'); + + // we don't care about the actual movedTemporaryPin value + // it only needs to trigger an update + this.movedTemporaryPin = !this.movedTemporaryPin; + temporaryPin.setAttribute('movedPosition', String(this.movedTemporaryPin)); + + document.body.dispatchEvent(new CustomEvent('unselect-annotation')); + }; + + /** + * @function onMouseDown + * @description stores the mouse down coordinates + * @param {MouseEvent} event - The mouse event object. + * @returns {void} + */ + private onMouseDown = ({ x, y }: MouseEvent) => { + this.mouseDownCoordinates = { x, y }; + }; + + /** + * @function handleMutationObserverChanges + * @description handles the changes in the value of the specified data attribute of the elements inside the container + * @param {MutationRecord[]} changes the changes in the value of the specified data attribute of the elements inside the container + * @returns {void} + */ + private handleMutationObserverChanges = (changes: MutationRecord[]): void => { + changes.forEach((change) => { + const { target, oldValue } = change; + const dataId = (target as HTMLElement).getAttribute(this.dataAttribute); + if ((!dataId && !oldValue) || dataId === oldValue) return; + + const oldValueSkipped = this.dataAttributeValueFilters.some((filter) => + oldValue.match(filter), + ); + + const attributeRemoved = !dataId && oldValue && !oldValueSkipped; + + if (attributeRemoved) { + this.removeAnnotationPin('temporary-pin'); + this.clearElement(oldValue); + + if (this.selectedPin?.getAttribute('elementId') === oldValue) { + document.body.dispatchEvent(new CustomEvent('unselect-annotation')); + this.selectedPin = null; + } + + return; + } + + const skip = this.dataAttributeValueFilters.some((filter) => dataId.match(filter)); + + if ((oldValue && this.elementsWithDataId[oldValue]) || skip) { + this.clearElement(oldValue); + } + + if (skip) return; + + this.setElementReadyToPin(target as HTMLElement, dataId); + this.renderAnnotationsPins(); + }); + }; + + /** + * @function onToggleAnnotationSidebar + * @description Removes temporary pin and unselects selected pin + * @param {CustomEvent} event the emitted event object with the info about if the annotation sidebar is open or not + * @returns {void} + */ + private onToggleAnnotationSidebar = ({ detail }: CustomEvent): void => { + const { open } = detail; + + if (open) return; + + this.resetSelectedPin(); + + if (this.pins.has('temporary-pin')) { + this.removeAnnotationPin('temporary-pin'); + this.temporaryPinCoordinates.elementId = undefined; + } + }; +} diff --git a/src/components/comments/html-pin-adapter/types.ts b/src/components/comments/html-pin-adapter/types.ts new file mode 100644 index 00000000..70694d3d --- /dev/null +++ b/src/components/comments/html-pin-adapter/types.ts @@ -0,0 +1,20 @@ +export interface SimpleParticipant { + name?: string; + avatar?: string; +} + +export interface Simple2DPoint { + x: number; + y: number; +} + +export interface TemporaryPinData extends Partial { + elementId?: string; +} + +export type HorizontalSide = 'left' | 'right'; + +export interface HTMLPinOptions { + dataAttributeName?: string; + dataAttributeValueFilters?: RegExp[]; +} diff --git a/src/components/comments/index.ts b/src/components/comments/index.ts index 11058525..a6fd5775 100644 --- a/src/components/comments/index.ts +++ b/src/components/comments/index.ts @@ -302,7 +302,7 @@ export class Comments extends BaseComponent { document.body.dispatchEvent( new CustomEvent('select-annotation', { - detail: { uuid: annotation.uuid }, + detail: { uuid: annotation.uuid, haltGoToPin: true }, composed: true, bubbles: true, }), diff --git a/src/components/comments/types.ts b/src/components/comments/types.ts index 3ff1be60..29f57382 100644 --- a/src/components/comments/types.ts +++ b/src/components/comments/types.ts @@ -33,7 +33,8 @@ export interface PinCoordinates { x: number; y: number; z?: number; - type: 'canvas' | 'matterport' | 'threejs' | 'autodesk'; + elementId?: string; + type: 'canvas' | 'matterport' | 'threejs' | 'autodesk' | 'html'; } // @NOTE - this is used for 3d annotations diff --git a/src/components/index.ts b/src/components/index.ts index 3633f486..fb8c9221 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,5 +1,6 @@ export { Comments } from './comments'; export { CanvasPin } from './comments/canvas-pin-adapter'; +export { HTMLPin } from './comments/html-pin-adapter'; export { VideoConference } from './video'; export { MousePointers } from './presence-mouse'; export { Realtime } from './realtime'; diff --git a/src/components/video/index.ts b/src/components/video/index.ts index 0df54e87..9e76ebb8 100644 --- a/src/components/video/index.ts +++ b/src/components/video/index.ts @@ -168,13 +168,13 @@ export class VideoConference extends BaseComponent { /** * @function startVideo * @description start video manager - * @param {VideoManagerOptions} options - video manager params * @returns {void} */ private startVideo = (): void => { this.videoConfig = { language: this.params?.language, canUseTranscription: this.params?.transcriptOff === false, + canShowAudienceList: this.params?.showAudienceList ?? true, canUseChat: !this.params?.chatOff, canUseCams: !this.params?.camsOff, canUseScreenshare: !this.params?.screenshareOff, diff --git a/src/components/video/types.ts b/src/components/video/types.ts index 8788b42b..ce7ff758 100644 --- a/src/components/video/types.ts +++ b/src/components/video/types.ts @@ -9,6 +9,7 @@ import { } from '../../services/video-conference-manager/types'; export interface VideoComponentOptions { + showAudienceList?: boolean; camsOff?: boolean; screenshareOff?: boolean; chatOff?: boolean; diff --git a/src/components/who-is-online/index.ts b/src/components/who-is-online/index.ts index cdcfadcc..694b270a 100644 --- a/src/components/who-is-online/index.ts +++ b/src/components/who-is-online/index.ts @@ -133,7 +133,7 @@ export class WhoIsOnline extends BaseComponent { const { color } = this.realtime.getSlotColor(slotIndex); const isLocal = this.localParticipant.id === id; const joinedPresence = activeComponents.some((component) => component.includes('presence')); - this.setLocalData(isLocal, !joinedPresence, color); + this.setLocalData(isLocal, !joinedPresence, color, joinedPresence); return { name, id, slotIndex, color, isLocal, joinedPresence, avatar }; }); @@ -149,11 +149,16 @@ export class WhoIsOnline extends BaseComponent { this.element.updateParticipants(this.participants); }; - private setLocalData = (local: boolean, disable: boolean, color: string) => { + private setLocalData = ( + local: boolean, + disable: boolean, + color: string, + joinedPresence: boolean, + ) => { if (!local) return; this.element.disableDropdown = disable; - this.element.localParticipantData = { color, id: this.localParticipant.id }; + this.element.localParticipantData = { color, id: this.localParticipant.id, joinedPresence }; }; /** diff --git a/src/core/index.test.ts b/src/core/index.test.ts index cb31db30..ddb7bfa3 100644 --- a/src/core/index.test.ts +++ b/src/core/index.test.ts @@ -149,15 +149,4 @@ describe('initialization errors', () => { 'Color sv-primary-900 is not a valid color variable value. Please check the documentation for more information.', ); }); - - test('should throw an error if the participant is already in the room', async () => { - ApiService.validadeParticipantIsEnteringTwice = jest.fn().mockResolvedValue(true); - ApiService.fetchConfig = jest.fn().mockResolvedValue({ - ablyKey: 'unit-test-ably-key', - }); - - await expect(sdk(UNIT_TEST_API_KEY, SIMPLE_INITIALIZATION_MOCK)).rejects.toThrow( - 'Participant is already in the room', - ); - }); }); diff --git a/src/core/index.ts b/src/core/index.ts index 735ec865..cf212287 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -107,17 +107,6 @@ const init = async (apiKey: string, options: SuperVizSdkOptions): Promise { let LauncherInstance: Launcher; beforeEach(() => { + console.warn = jest.fn(); + console.error = jest.fn(); + console.log = jest.fn(); + jest.clearAllMocks(); LauncherInstance = new Launcher(DEFAULT_INITIALIZATION_MOCK); - console.error = jest.fn(); - console.log = jest.fn(); }); test('should be defined', () => { @@ -403,30 +405,50 @@ describe('Launcher', () => { expect(LauncherInstance['activeComponentsInstances'].length).toBe(0); }); - }); - test('should destroy the instance', () => { - LauncherInstance.destroy(); + test('should publish REALTIME_SAME_ACCOUNT_ERROR, when same account callback is called', () => { + LauncherInstance['publish'] = jest.fn(); + + LauncherInstance['onSameAccount'](); - expect(ABLY_REALTIME_MOCK.leave).toHaveBeenCalled(); - expect(EVENT_BUS_MOCK.destroy).toHaveBeenCalled(); + expect(LauncherInstance['publish']).toHaveBeenCalledWith(ParticipantEvent.SAME_ACCOUNT_ERROR); + }); }); - test('should unsubscribe from realtime events', () => { - LauncherInstance.destroy(); + describe('destroy', () => { + test('should destroy the instance', () => { + LauncherInstance.destroy(); - expect(ABLY_REALTIME_MOCK.participantJoinedObserver.unsubscribe).toHaveBeenCalled(); - expect(ABLY_REALTIME_MOCK.participantLeaveObserver.unsubscribe).toHaveBeenCalled(); - expect(ABLY_REALTIME_MOCK.participantsObserver.unsubscribe).toHaveBeenCalled(); - expect(ABLY_REALTIME_MOCK.hostObserver.unsubscribe).toHaveBeenCalled(); - expect(ABLY_REALTIME_MOCK.hostAvailabilityObserver.unsubscribe).toHaveBeenCalled(); - }); + expect(ABLY_REALTIME_MOCK.leave).toHaveBeenCalled(); + expect(EVENT_BUS_MOCK.destroy).toHaveBeenCalled(); + }); - test('should remove all components', () => { - LauncherInstance.addComponent(MOCK_COMPONENT); - LauncherInstance.destroy(); + test('should unsubscribe from realtime events', () => { + LauncherInstance.destroy(); + + expect(ABLY_REALTIME_MOCK.participantJoinedObserver.unsubscribe).toHaveBeenCalled(); + expect(ABLY_REALTIME_MOCK.participantLeaveObserver.unsubscribe).toHaveBeenCalled(); + expect(ABLY_REALTIME_MOCK.participantsObserver.unsubscribe).toHaveBeenCalled(); + expect(ABLY_REALTIME_MOCK.hostObserver.unsubscribe).toHaveBeenCalled(); + expect(ABLY_REALTIME_MOCK.hostAvailabilityObserver.unsubscribe).toHaveBeenCalled(); + }); - expect(MOCK_COMPONENT.detach).toHaveBeenCalled(); + test('should remove all components', () => { + LauncherInstance.addComponent(MOCK_COMPONENT); + LauncherInstance.destroy(); + + expect(MOCK_COMPONENT.detach).toHaveBeenCalled(); + }); + + test('should destroy the instance when same account callback is called', () => { + LauncherInstance['publish'] = jest.fn(); + LauncherInstance['destroy'] = jest.fn(); + + LauncherInstance['onSameAccount'](); + + expect(LauncherInstance['publish']).toHaveBeenCalledWith(ParticipantEvent.SAME_ACCOUNT_ERROR); + expect(LauncherInstance['destroy']).toHaveBeenCalled(); + }); }); }); @@ -449,4 +471,19 @@ describe('Launcher Facade', () => { expect(LauncherFacadeInstance).toHaveProperty('addComponent'); expect(LauncherFacadeInstance).toHaveProperty('removeComponent'); }); + + test('should return the same instance if already initialized', () => { + const instance = Facade(DEFAULT_INITIALIZATION_MOCK); + const instance2 = Facade(DEFAULT_INITIALIZATION_MOCK); + + expect(instance).toStrictEqual(instance2); + }); + + test('should return different instances if it`s destroyed', () => { + const instance = Facade(DEFAULT_INITIALIZATION_MOCK); + instance.destroy(); + const instance2 = Facade(DEFAULT_INITIALIZATION_MOCK); + + expect(instance).not.toStrictEqual(instance2); + }); }); diff --git a/src/core/launcher/index.ts b/src/core/launcher/index.ts index d641821f..5729ce0d 100644 --- a/src/core/launcher/index.ts +++ b/src/core/launcher/index.ts @@ -120,6 +120,7 @@ export class Launcher extends Observable implements DefaultLauncher { this.eventBus.destroy(); this.eventBus = undefined; + this.realtime.sameAccountObserver.unsubscribe(this.onSameAccount); this.realtime.participantJoinedObserver.unsubscribe(this.onParticipantJoined); this.realtime.participantLeaveObserver.unsubscribe(this.onParticipantLeave); this.realtime.participantsObserver.unsubscribe(this.onParticipantListUpdate); @@ -128,6 +129,9 @@ export class Launcher extends Observable implements DefaultLauncher { this.realtime.leave(); this.realtime = undefined; this.isDestroyed = true; + + // clean window object + window.SUPERVIZ = undefined; }; /** @@ -200,6 +204,7 @@ export class Launcher extends Observable implements DefaultLauncher { * @returns {void} */ private subscribeToRealtimeEvents = (): void => { + this.realtime.sameAccountObserver.subscribe(this.onSameAccount); this.realtime.participantJoinedObserver.subscribe(this.onParticipantJoined); this.realtime.participantLeaveObserver.subscribe(this.onParticipantLeave); this.realtime.participantsObserver.subscribe(this.onParticipantListUpdate); @@ -344,6 +349,11 @@ export class Launcher extends Observable implements DefaultLauncher { } this.publish(RealtimeEvent.REALTIME_NO_HOST_AVAILABLE); }; + + private onSameAccount = (): void => { + this.publish(ParticipantEvent.SAME_ACCOUNT_ERROR); + this.destroy(); + }; } /** @@ -353,8 +363,22 @@ export class Launcher extends Observable implements DefaultLauncher { * @returns {LauncherFacade} */ export default (options: LauncherOptions): LauncherFacade => { + if (window.SUPERVIZ) { + console.warn('[SUPERVIZ] Room already initialized'); + + return { + destroy: window.SUPERVIZ.destroy, + subscribe: window.SUPERVIZ.subscribe, + unsubscribe: window.SUPERVIZ.unsubscribe, + addComponent: window.SUPERVIZ.addComponent, + removeComponent: window.SUPERVIZ.removeComponent, + }; + } + const launcher = new Launcher(options); + window.SUPERVIZ = launcher; + return { destroy: launcher.destroy, subscribe: launcher.subscribe, diff --git a/src/core/launcher/types.ts b/src/core/launcher/types.ts index b40f2019..6052caf4 100644 --- a/src/core/launcher/types.ts +++ b/src/core/launcher/types.ts @@ -11,6 +11,6 @@ export interface LauncherFacade { subscribe: typeof Observable.prototype.subscribe; unsubscribe: typeof Observable.prototype.unsubscribe; destroy: () => void; - addComponent: (component: BaseComponent) => void; - removeComponent: (component: BaseComponent) => void; + addComponent: (component: Partial) => void; + removeComponent: (component: Partial) => void; } diff --git a/src/index.ts b/src/index.ts index 8a199d7f..0472b603 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ import { Realtime, Comments, CanvasPin, + HTMLPin, WhoIsOnline, } from './components'; import { RealtimeComponentEvent, RealtimeComponentState } from './components/realtime/types'; @@ -62,6 +63,7 @@ if (window) { Realtime, Comments, CanvasPin, + HTMLPin, WhoIsOnline, ParticipantType, LayoutPosition, diff --git a/src/services/api/index.test.ts b/src/services/api/index.test.ts index 975e420e..1e36c8a9 100644 --- a/src/services/api/index.test.ts +++ b/src/services/api/index.test.ts @@ -195,90 +195,4 @@ describe('ApiService', () => { expect(response).toEqual(CHECK_LIMITS_MOCK.usage); }); }); - - describe('validadeParticipantIsEnteringTwice', () => { - test('should return true if the participant is entering twice', async () => { - const participant = { - id: 'any_participant_id', - name: 'any_participant_name', - }; - const roomId = 'any_room_id'; - const apiKey = 'unit-test-valid-api-key'; - const ablyKey = 'unit-test-ably-key'; - - global.fetch = jest.fn().mockResolvedValue({ - json: jest - .fn() - .mockResolvedValue([ - { data: JSON.stringify({ id: 'any_participant_id' }) }, - { data: JSON.stringify({ id: 'another_participant_id' }) }, - ]), - }); - - const response = await ApiService.validadeParticipantIsEnteringTwice( - participant, - roomId, - apiKey, - ablyKey, - ); - - expect(response).toEqual(true); - expect(fetch).toHaveBeenCalledWith( - 'https://rest.ably.io/channels/superviz:any_room_id-unit-test-valid-api-key/presence', - { - headers: { Authorization: 'Basic dW5pdC10ZXN0LWFibHkta2V5' }, - }, - ); - }); - - test('should return false if the participant is not entering twice', async () => { - const participant = { - id: 'any_participant_id', - name: 'any_participant_name', - }; - const roomId = 'any_room_id'; - const apiKey = 'unit-test-valid-api-key'; - const ablyKey = 'unit-test-ably-key'; - - global.fetch = jest.fn().mockResolvedValue({ - json: jest - .fn() - .mockResolvedValue([ - { data: JSON.stringify({ id: 'another_participant_id' }) }, - { data: JSON.stringify({ id: 'yet_another_participant_id' }) }, - ]), - }); - - const response = await ApiService.validadeParticipantIsEnteringTwice( - participant, - roomId, - apiKey, - ablyKey, - ); - - expect(response).toEqual(false); - expect(fetch).toHaveBeenCalledWith( - 'https://rest.ably.io/channels/superviz:any_room_id-unit-test-valid-api-key/presence', - { - headers: { Authorization: 'Basic dW5pdC10ZXN0LWFibHkta2V5' }, - }, - ); - }); - - test('should throw an error if failed to fetch realtime participants', async () => { - const participant = { - id: 'any_participant_id', - name: 'any_participant_name', - }; - const roomId = 'any_room_id'; - const apiKey = 'unit-test-valid-api-key'; - const ablyKey = 'unit-test-ably-key'; - - global.fetch = jest.fn().mockRejectedValue(new Error('Failed to fetch participants')); - - await expect( - ApiService.validadeParticipantIsEnteringTwice(participant, roomId, apiKey, ablyKey), - ).rejects.toThrow('Failed to fetch realtime participants'); - }); - }); }); diff --git a/src/services/api/index.ts b/src/services/api/index.ts index 9de22c0b..d65ef829 100644 --- a/src/services/api/index.ts +++ b/src/services/api/index.ts @@ -127,33 +127,4 @@ export default class ApiService { }; return doRequest(url, 'POST', body, { apikey }); } - - static async validadeParticipantIsEnteringTwice( - participant: SuperVizSdkOptions['participant'], - roomId: string, - apiKey: string, - ablyKey: string, - ) { - const ablyKey64 = window.btoa(ablyKey); - const ablyRoom = `superviz:${roomId.toLowerCase()}-${apiKey}`; - const ablyUrl = `https://rest.ably.io/channels/${ablyRoom}/presence`; - - try { - const response = await fetch(ablyUrl, { - headers: { Authorization: `Basic ${ablyKey64}` }, - }); - - const participants = await response.json(); - - const hasParticipantWithSameId = participants.some((presence) => { - const data = JSON.parse(presence.data); - - return data.id === participant.id; - }); - - return hasParticipantWithSameId; - } catch (error) { - throw new Error('Failed to fetch realtime participants'); - } - } } diff --git a/src/services/realtime/ably/index.test.ts b/src/services/realtime/ably/index.test.ts index 97a575c9..1bf2f00f 100644 --- a/src/services/realtime/ably/index.test.ts +++ b/src/services/realtime/ably/index.test.ts @@ -63,6 +63,7 @@ const AblyRealtimeMock = { update: jest.fn(), subscribe: jest.fn(), unsubscribe: jest.fn(), + leave: jest.fn(), }, }; }), diff --git a/src/services/realtime/ably/index.ts b/src/services/realtime/ably/index.ts index a60e279e..91465430 100644 --- a/src/services/realtime/ably/index.ts +++ b/src/services/realtime/ably/index.ts @@ -46,7 +46,6 @@ export default class AblyRealtimeService extends RealtimeService implements Ably private presence3DChannel: Ably.Types.RealtimeChannelCallbacks = null; private clientRoomState: Record = {}; private clientSyncPropertiesQueue: Record = {}; - // private clientSyncPropertiesTimeOut: ReturnType = null; private isReconnecting: boolean = false; @@ -217,6 +216,10 @@ export default class AblyRealtimeService extends RealtimeService implements Ably this.supervizChannel.on(this.onAblyChannelStateChange); this.supervizChannel.subscribe('update', this.onAblyRoomUpdate); + this.supervizChannel.subscribe('same-account-error', (message) => { + console.log('same-account-error', message); + }); + // join the comments channel this.commentsChannel = this.client.channels.get(`${this.roomId}:comments`); this.commentsChannel.subscribe('update', this.onCommentsChannelUpdate); @@ -239,6 +242,7 @@ export default class AblyRealtimeService extends RealtimeService implements Ably */ public leave(): void { this.logger.log('REALTIME', 'Disconnecting from ably servers'); + this.supervizChannel.presence.leave(); this.client.close(); this.isJoinedRoom = false; this.isReconnecting = false; @@ -422,6 +426,7 @@ export default class AblyRealtimeService extends RealtimeService implements Ably public setParticipantData = (data: ParticipantDataInput): void => { this.myParticipant.data = Object.assign({}, this.myParticipant.data, data); + this.updateMyProperties(this.myParticipant.data); this.updatePresence3D(this.myParticipant.data); }; @@ -472,6 +477,11 @@ export default class AblyRealtimeService extends RealtimeService implements Ably * @returns {void} */ private onAblyPresenceEnter(presenceMessage: Ably.Types.PresenceMessage): void { + if (presenceMessage.clientId === this.myParticipant.data.participantId && this.isJoinedRoom) { + this.sameAccountObserver.publish(true); + return; + } + if (presenceMessage.clientId === this.myParticipant.data.participantId) { this.onJoinRoom(presenceMessage); } else { @@ -590,6 +600,8 @@ export default class AblyRealtimeService extends RealtimeService implements Ably * @returns {void} */ private onAblyRoomUpdate(message: Ably.Types.Message): void { + this.logger.log('REALTIME', 'Room update received', message.data); + this.updateLocalRoomState(message.data); } diff --git a/src/services/realtime/base/index.ts b/src/services/realtime/base/index.ts index 98f2d50a..c2af3a11 100644 --- a/src/services/realtime/base/index.ts +++ b/src/services/realtime/base/index.ts @@ -33,6 +33,7 @@ export class RealtimeService implements DefaultRealtimeService { public presence3dLeaveObserver: Observer; public presence3dJoinedObserver: Observer; public domainRefusedObserver: Observer; + public sameAccountObserver: Observer; constructor() { this.participantObservers = []; @@ -45,6 +46,7 @@ export class RealtimeService implements DefaultRealtimeService { this.participantLeaveObserver = new Observer({ logger: this.logger }); this.syncPropertiesObserver = new Observer({ logger: this.logger }); this.reconnectObserver = new Observer({ logger: this.logger }); + this.sameAccountObserver = new Observer({ logger: this.logger }); // Room info observers helpers this.roomInfoUpdatedObserver = new Observer({ logger: this.logger }); diff --git a/src/services/video-conference-manager/index.test.ts b/src/services/video-conference-manager/index.test.ts index 82665204..3d017eca 100644 --- a/src/services/video-conference-manager/index.test.ts +++ b/src/services/video-conference-manager/index.test.ts @@ -20,6 +20,7 @@ const createVideoConfrenceManager = (options?: VideoManagerOptions) => { browserService: new BrowserService(), camerasPosition: CamerasPosition.RIGHT, canUseTranscription: true, + canShowAudienceList: true, canUseCams: true, canUseChat: true, canUseScreenshare: true, diff --git a/src/services/video-conference-manager/index.ts b/src/services/video-conference-manager/index.ts index 68ecd596..b848a913 100644 --- a/src/services/video-conference-manager/index.ts +++ b/src/services/video-conference-manager/index.ts @@ -74,6 +74,7 @@ export default class VideoConfereceManager { canUseFollow, canUseGoTo, canUseGather, + canShowAudienceList, canUseDefaultToolbar, canUseTranscription, browserService, @@ -112,6 +113,7 @@ export default class VideoConfereceManager { canUseScreenshare, canUseDefaultAvatars, canUseTranscription, + canShowAudienceList, camerasPosition: positions.camerasPosition ?? CamerasPosition.RIGHT, canUseDefaultToolbar, devices: { diff --git a/src/services/video-conference-manager/types.ts b/src/services/video-conference-manager/types.ts index 965186a1..865368da 100644 --- a/src/services/video-conference-manager/types.ts +++ b/src/services/video-conference-manager/types.ts @@ -8,6 +8,7 @@ export interface VideoManagerOptions { language?: string; canUseChat: boolean; canUseCams: boolean; + canShowAudienceList: boolean; canUseTranscription: boolean; canUseScreenshare: boolean; canUseDefaultAvatars: boolean; @@ -57,6 +58,7 @@ export interface FrameConfig { roomId: string; debug: boolean; limits: ComponentLimits; + canShowAudienceList: boolean; canUseChat: boolean; canUseCams: boolean; canUseScreenshare: boolean; diff --git a/src/shims.d.ts b/src/shims.d.ts index fb248ebe..1743607f 100644 --- a/src/shims.d.ts +++ b/src/shims.d.ts @@ -1,7 +1,9 @@ import { SuperVizCdn } from './common/types/cdn.types'; +import { Launcher } from './core/launcher'; declare global { interface Window { SuperVizRoom: SuperVizCdn; + SUPERVIZ: Launcher; } } diff --git a/src/web-components/comments/components/annotation-pin.ts b/src/web-components/comments/components/annotation-pin.ts index 4c7ef1ec..1b324752 100644 --- a/src/web-components/comments/components/annotation-pin.ts +++ b/src/web-components/comments/components/annotation-pin.ts @@ -26,6 +26,7 @@ export class CommentsAnnotationPin extends WebComponentsBaseElement { declare localAvatar: string | undefined; declare annotationSent: boolean; declare localName: string; + declare keepPositionRatio: boolean; private originalPosition: Partial; private annotationSides: Sides; @@ -46,6 +47,7 @@ export class CommentsAnnotationPin extends WebComponentsBaseElement { localAvatar: { type: String }, annotationSent: { type: Boolean }, localName: { type: String }, + keepPositionRatio: { type: Boolean }, }; constructor() { @@ -221,25 +223,21 @@ export class CommentsAnnotationPin extends WebComponentsBaseElement { }; classes[this.horizontalSide] = true; + let style = ''; + if (this.keepPositionRatio) { + style = `top: ${this.position.y}%; left: ${this.position.x}%;`; + } else { + style = `top: ${this.position.y}px; left: ${this.position.x}px;`; + } + if (this.type === PinMode.ADD) { return html` -
- ${this.avatar()} ${this.input()} -
+
${this.avatar()} ${this.input()}
`; } return html` -
- ${this.avatar()} -
+
${this.avatar()}
`; } } diff --git a/src/web-components/comments/css/annotation-pin.style.ts b/src/web-components/comments/css/annotation-pin.style.ts index cfceb788..12b2e0ac 100644 --- a/src/web-components/comments/css/annotation-pin.style.ts +++ b/src/web-components/comments/css/annotation-pin.style.ts @@ -8,6 +8,7 @@ export const annotationPinStyles = css` justify-content: center; position: relative; pointer-events: auto; + z-index: 10; } .annotation-pin { diff --git a/src/web-components/comments/css/comment-input.style.ts b/src/web-components/comments/css/comment-input.style.ts index 2fc61361..712cd247 100644 --- a/src/web-components/comments/css/comment-input.style.ts +++ b/src/web-components/comments/css/comment-input.style.ts @@ -21,6 +21,7 @@ export const commentInputStyle = css` #comment-input--textarea { all: unset; border: 0px; + text-align: left; border-radius: 4px; outline: none; font-size: 14px; @@ -28,7 +29,6 @@ export const commentInputStyle = css` font-family: Roboto; white-space: pre-wrap; word-wrap: break-word; - overflow-y: scroll; resize: none; line-height: 1rem; max-height: 5rem; diff --git a/src/web-components/comments/css/comments.style.ts b/src/web-components/comments/css/comments.style.ts index 6dd4f7b8..b1299121 100644 --- a/src/web-components/comments/css/comments.style.ts +++ b/src/web-components/comments/css/comments.style.ts @@ -12,6 +12,8 @@ export const commentsStyle = css` bottom: 0; box-shadow: -2px 0 4px 0 rgba(0, 0, 0, 0.1); height: 100%; + + z-index: 99; } .container-close { diff --git a/src/web-components/comments/css/float-button.style.ts b/src/web-components/comments/css/float-button.style.ts index c99834f9..2a344848 100644 --- a/src/web-components/comments/css/float-button.style.ts +++ b/src/web-components/comments/css/float-button.style.ts @@ -18,6 +18,8 @@ export const floatButtonStyle = css` cursor: pointer; overflow: hidden; padding-left: 10px; + + z-index: 99; } button.float-button p { diff --git a/src/web-components/dropdown/index.ts b/src/web-components/dropdown/index.ts index fe669823..737d0c58 100644 --- a/src/web-components/dropdown/index.ts +++ b/src/web-components/dropdown/index.ts @@ -26,6 +26,7 @@ export class Dropdown extends WebComponentsBaseElement { declare name?: string; declare onHoverData: { name: string; action: string }; declare shiftTooltipLeft: boolean; + declare lastParticipant: boolean; private dropdownContent: HTMLElement; private originalPosition: Positions; @@ -56,6 +57,7 @@ export class Dropdown extends WebComponentsBaseElement { canShowTooltip: { type: Boolean }, drodpdownSizes: { type: Object }, shiftTooltipLeft: { type: Boolean }, + lastParticipant: { type: Boolean }, }; constructor() { @@ -438,9 +440,12 @@ export class Dropdown extends WebComponentsBaseElement { private tooltip = () => { if (!this.canShowTooltip) return ''; + const tooltipVerticalPosition = this.lastParticipant ? 'tooltip-top' : 'tooltip-bottom'; + return html` `; }; diff --git a/src/web-components/modal/styles/index.style.ts b/src/web-components/modal/styles/index.style.ts index 710aa162..06558f8c 100644 --- a/src/web-components/modal/styles/index.style.ts +++ b/src/web-components/modal/styles/index.style.ts @@ -1,6 +1,6 @@ import { css } from 'lit'; -export const modalStyle = css` +export const modalStyle = css` .modal--overlay { position: absolute; top: 0; @@ -9,6 +9,8 @@ export const modalStyle = css` width: 100%; height: 100%; background: rgba(var(--sv-gray-400), 0.8); + + z-index: 99; } .modal--container { @@ -22,6 +24,8 @@ export const modalStyle = css` width: 100%; height: 100%; background: transparent; + + z-index: 99; } .modal--container > .modal { diff --git a/src/web-components/tooltip/index.style.ts b/src/web-components/tooltip/index.style.ts index 59d8aab6..74106008 100644 --- a/src/web-components/tooltip/index.style.ts +++ b/src/web-components/tooltip/index.style.ts @@ -20,7 +20,6 @@ export const dropdownStyle = css` cursor: default; display: none; transition: opacity 0.2s ease-in-out display 0s; - overflow-x: clip; z-index: 100; } @@ -114,7 +113,7 @@ export const dropdownStyle = css` .shift-left { left: 0; - transform: translateX(-22%); + transform: translateX(-6%); --vertical-offset: 2px; } diff --git a/src/web-components/tooltip/index.ts b/src/web-components/tooltip/index.ts index d906f67d..d623dd93 100644 --- a/src/web-components/tooltip/index.ts +++ b/src/web-components/tooltip/index.ts @@ -50,9 +50,6 @@ export class Tooltip extends WebComponentsBaseElement { const { parentElement } = this; parentElement?.addEventListener('mouseenter', this.show); parentElement?.addEventListener('mouseleave', this.hide); - - this.tooltipVerticalPosition = PositionsEnum['TOOLTIP-BOTTOM']; - this.tooltipHorizontalPosition = PositionsEnum['TOOLTIP-CENTER']; } private hide = () => { diff --git a/src/web-components/who-is-online/components/dropdown.ts b/src/web-components/who-is-online/components/dropdown.ts index 5c66dbc3..365725af 100644 --- a/src/web-components/who-is-online/components/dropdown.ts +++ b/src/web-components/who-is-online/components/dropdown.ts @@ -7,7 +7,14 @@ import { Participant } from '../../../components/who-is-online/types'; import { WebComponentsBase } from '../../base'; import { dropdownStyle } from '../css'; -import { Following, WIODropdownOptions, PositionOptions } from './types'; +import { + Following, + WIODropdownOptions, + PositionOptions, + TooltipData, + VerticalSide, + HorizontalSide, +} from './types'; const WebComponentsBaseElement = WebComponentsBase(LitElement); const styles: CSSResultGroup[] = [WebComponentsBaseElement.styles, dropdownStyle]; @@ -17,12 +24,12 @@ export class WhoIsOnlineDropdown extends WebComponentsBaseElement { static styles = styles; declare open: boolean; - declare align: 'left' | 'right'; - declare position: 'top' | 'bottom'; + declare align: HorizontalSide; + declare position: VerticalSide; declare participants: Participant[]; private textColorValues: number[]; declare selected: string; - private originalPosition: 'top' | 'bottom'; + private originalPosition: VerticalSide; private menu: HTMLElement; private dropdownContent: HTMLElement; private host: HTMLElement; @@ -30,6 +37,7 @@ export class WhoIsOnlineDropdown extends WebComponentsBaseElement { declare showSeeMoreTooltip: boolean; declare showParticipantTooltip: boolean; declare following: Following; + declare localParticipantJoinedPresence: boolean; static properties = { open: { type: Boolean }, @@ -41,6 +49,7 @@ export class WhoIsOnlineDropdown extends WebComponentsBaseElement { following: { type: Object }, showSeeMoreTooltip: { type: Boolean }, showParticipantTooltip: { type: Boolean }, + localParticipantJoinedPresence: { type: Boolean }, }; constructor() { @@ -51,6 +60,12 @@ export class WhoIsOnlineDropdown extends WebComponentsBaseElement { this.showParticipantTooltip = true; } + protected firstUpdated( + _changedProperties: PropertyValueMap | Map, + ): void { + this.shadowRoot.querySelector('.menu').scrollTop = 0; + } + protected updated(changedProperties: PropertyValueMap | Map): void { if (!changedProperties.has('open')) return; @@ -62,7 +77,6 @@ export class WhoIsOnlineDropdown extends WebComponentsBaseElement { } document.removeEventListener('click', this.onClickOutDropdown); - // this.close(); } private onClickOutDropdown = (event: Event) => { @@ -128,11 +142,12 @@ export class WhoIsOnlineDropdown extends WebComponentsBaseElement { if (!this.participants) return; const icons = ['place', 'send']; + const numberOfParticipants = this.participants.length - 1; return repeat( this.participants, (participant) => participant.id, - (participant) => { + (participant, index) => { const { id, slotIndex, joinedPresence, isLocal, color, name } = participant; const disableDropdown = !joinedPresence || isLocal || this.disableDropdown; @@ -161,6 +176,16 @@ export class WhoIsOnlineDropdown extends WebComponentsBaseElement { })) .slice(0, 2); + const tooltipData: TooltipData = { + name, + }; + + if (this.localParticipantJoinedPresence && joinedPresence) { + tooltipData.action = 'Click to Follow'; + } + + const isLastParticipant = index === numberOfParticipants; + return html`
top ? 'bottom' : 'top'; const previousSide = this.position.split('-')[0]; - const newPosition = this.position.replace(previousSide, newSide) as 'top' | 'bottom'; + const newPosition = this.position.replace(previousSide, newSide) as VerticalSide; this.position = newPosition; }; diff --git a/src/web-components/who-is-online/components/types.ts b/src/web-components/who-is-online/components/types.ts index 9889b776..e902e91a 100644 --- a/src/web-components/who-is-online/components/types.ts +++ b/src/web-components/who-is-online/components/types.ts @@ -33,4 +33,14 @@ export enum PositionOptions { export interface LocalParticipantData { id: string; color: string; + joinedPresence: boolean; } + +export interface TooltipData { + name: string; + action?: string; +} + +export type VerticalSide = 'top' | 'bottom'; + +export type HorizontalSide = 'left' | 'right'; diff --git a/src/web-components/who-is-online/css/dropdown.style.ts b/src/web-components/who-is-online/css/dropdown.style.ts index 7524b92c..da524515 100644 --- a/src/web-components/who-is-online/css/dropdown.style.ts +++ b/src/web-components/who-is-online/css/dropdown.style.ts @@ -78,6 +78,8 @@ export const dropdownStyle = css` z-index: 1; transition: 0.2s; border-radius: 3px; + max-height: 240px; + overflow: auto; } .menu--bottom { diff --git a/src/web-components/who-is-online/css/who-is-online-style.ts b/src/web-components/who-is-online/css/who-is-online-style.ts index 7cd5adce..4d74f251 100644 --- a/src/web-components/who-is-online/css/who-is-online-style.ts +++ b/src/web-components/who-is-online/css/who-is-online-style.ts @@ -12,6 +12,7 @@ export const whoIsOnlineStyle = css` display: flex; flex-direction: column; position: fixed; + z-index: 99; } .superviz-who-is-online__participant { diff --git a/src/web-components/who-is-online/who-is-online.ts b/src/web-components/who-is-online/who-is-online.ts index b6f38baa..b278faf5 100644 --- a/src/web-components/who-is-online/who-is-online.ts +++ b/src/web-components/who-is-online/who-is-online.ts @@ -7,7 +7,7 @@ import { RealtimeEvent } from '../../common/types/events.types'; import { Participant } from '../../components/who-is-online/types'; import { WebComponentsBase } from '../base'; -import type { LocalParticipantData } from './components/types'; +import type { LocalParticipantData, TooltipData } from './components/types'; import { Following, WIODropdownOptions } from './components/types'; import { whoIsOnlineStyle } from './css/index'; @@ -113,6 +113,7 @@ export class WhoIsOnline extends WebComponentsBaseElement { ?showSeeMoreTooltip=${this.showTooltip} @toggle=${this.toggleOpen} @toggle-dropdown-state=${this.toggleShowTooltip} + ?localParticipantJoinedPresence=${this.localParticipantData?.joinedPresence} >
+${excess}
@@ -319,6 +320,18 @@ export class WhoIsOnline extends WebComponentsBaseElement { const append = isLocal ? ' (you)' : ''; const participantName = name + append; + const tooltipData: TooltipData = { + name, + }; + + if (this.localParticipantData?.joinedPresence && joinedPresence && !isLocal) { + tooltipData.action = 'Click to Follow'; + } + + if (isLocal) { + tooltipData.action = 'You'; + } + return html`
${this.getAvatar(participant)}