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)}