/*! * Matomo - free/libre analytics platform * * @link https://matomo.org * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { defineComponent, ref, } from 'vue'; import { IDirectiveFactory, IDirectivePrePost, Injectable } from 'angular'; import Matomo from './Matomo/Matomo'; import createVueApp from './createVueApp'; interface SingleScopeVarInfo { vue?: string; default?: any; transform?: ( v: unknown, vm: any, scope: any, element: ng.IAugmentedJQuery, attrs: ng.IAttributes, otherController?: ng.IController, ...injected: InjectTypes ) => unknown; angularJsBind?: string; deepWatch?: boolean; } type ScopeMapping = { [scopeVarName: string]: SingleScopeVarInfo, }; type AdapterFunction = ( scope: any, element: ng.IAugmentedJQuery, attrs: ng.IAttributes, ...injected: InjectTypes ) => R; type EventAdapterFunction = ( $event: any, vm: any, scope: any, element: ng.IAugmentedJQuery, attrs: ng.IAttributes, otherController?: ng.IController, ...injected: InjectTypes ) => R; type PostCreateFunction = ( vm: any, scope: any, element: ng.IAugmentedJQuery, attrs: ng.IAttributes, otherController?: ng.IController, ...injected: InjectTypes ) => R; type EventMapping = { [vueEventName: string]: EventAdapterFunction, }; type ComponentType = ReturnType; let transcludeCounter = 0; function toKebabCase(arg: string): string { return arg.substring(0, 1).toLowerCase() + arg.substring(1) .replace(/[A-Z]/g, (s) => `-${s.toLowerCase()}`); } function toAngularJsCamelCase(arg: string): string { return arg.substring(0, 1).toLowerCase() + arg.substring(1) .replace(/-([a-z])/g, (s, p) => p.toUpperCase()); } export function removeAngularJsSpecificProperties(newValue: T): T { if (typeof newValue === 'object' && newValue !== null && Object.getPrototypeOf(newValue) === Object.prototype ) { return Object.fromEntries(Object.entries(newValue).filter((pair) => !/^\$/.test(pair[0]))) as T; } return newValue; } export default function createAngularJsAdapter(options: { component: ComponentType, require?: string, scope?: ScopeMapping, directiveName: string, events?: EventMapping, $inject?: string[], transclude?: boolean, mountPointFactory?: AdapterFunction, postCreate?: PostCreateFunction, noScope?: boolean, restrict?: string, priority?: number, replace?: boolean, }): Injectable { const { component, require, scope = {}, events = {}, $inject, directiveName, transclude, mountPointFactory, postCreate, noScope, restrict = 'A', priority, replace, } = options; const currentTranscludeCounter = transcludeCounter; if (transclude) { transcludeCounter += 1; } const vueToAngular: Record = {}; const angularJsScope: Record = {}; Object.entries(scope).forEach(([scopeVarName, info]) => { if (!info.vue) { info.vue = scopeVarName; } if (info.angularJsBind) { angularJsScope[scopeVarName] = info.angularJsBind; } vueToAngular[info.vue] = scopeVarName; }); function angularJsAdapter(...injectedServices: InjectTypes): ng.IDirective { const adapter: ng.IDirective = { restrict, require, priority, scope: noScope ? undefined : angularJsScope, compile: function angularJsAdapterCompile(): IDirectivePrePost { return { post: function angularJsAdapterLink( ngScope: any, ngElement: ng.IAugmentedJQuery, ngAttrs: ng.IAttributes, ngController?: ng.IController, ) { const transcludeClone = transclude ? ngElement.find(`[ng-transclude][counter=${currentTranscludeCounter}]`) : null; // build the root vue template let rootVueTemplate = ' { const [eventName] = info; rootVueTemplate += ` @${toKebabCase(eventName)}="onEventHandler('${eventName}', $event)"`; }); Object.entries(scope).forEach(([, info]) => { if (info.angularJsBind === '&' || info.angularJsBind === '&?') { const eventName = toKebabCase(info.vue!); if (!events[info.vue!]) { // pass through scope & w/o a custom event handler rootVueTemplate += ` @${eventName}="onEventHandler('${info.vue!}', $event)"`; } } else { rootVueTemplate += ` :${toKebabCase(info.vue!)}="${info.vue}"`; } }); rootVueTemplate += '>'; if (transclude) { rootVueTemplate += '
'; } rootVueTemplate += ''; // build the vue app const app = createVueApp({ template: rootVueTemplate, data() { const initialData: Record = {}; Object.entries(scope).forEach(([scopeVarName, info]) => { let value = removeAngularJsSpecificProperties(ngScope[scopeVarName]); if (typeof value === 'undefined' && typeof info.default !== 'undefined') { value = info.default instanceof Function ? info.default(ngScope, ngElement, ngAttrs, ...injectedServices) : info.default; } if (info.transform) { value = info.transform( value, this, ngScope, ngElement, ngAttrs, ngController, ...injectedServices, ); } initialData[info.vue!] = value; }); return initialData; }, setup() { if (transclude) { const transcludeTarget = ref(null); return { transcludeTarget, }; } return undefined; }, methods: { onEventHandler(name: string, $event: any) { let scopePropertyName = toAngularJsCamelCase(name); scopePropertyName = vueToAngular[scopePropertyName] || scopePropertyName; if (ngScope[scopePropertyName]) { ngScope[scopePropertyName]($event); } if (events[name]) { events[name]( $event, this, ngScope, ngElement, ngAttrs, ngController, ...injectedServices, ); } }, }, }); app.component('root-component', component); // mount the app const mountPoint = mountPointFactory ? mountPointFactory(ngScope, ngElement, ngAttrs, ...injectedServices) : ngElement[0]; const vm: any = app.mount(mountPoint); // setup watches to bind between angularjs + vue Object.entries(scope).forEach(([scopeVarName, info]) => { if (!info.angularJsBind || info.angularJsBind === '&' || info.angularJsBind === '&?') { return; } ngScope.$watch(scopeVarName, (newValue: any, oldValue: any) => { if (newValue === oldValue && JSON.stringify(vm[info.vue!]) === JSON.stringify(newValue) ) { return; // initial } let newValueFinal = removeAngularJsSpecificProperties(newValue); if (typeof info.default !== 'undefined' && typeof newValue === 'undefined') { newValueFinal = info.default instanceof Function ? info.default(ngScope, ngElement, ngAttrs, ...injectedServices) : info.default; } if (info.transform) { newValueFinal = info.transform( newValueFinal, vm, ngScope, ngElement, ngAttrs, ngController, ...injectedServices, ); } vm[info.vue!] = newValueFinal; }, info.deepWatch); }); if (transclude && transcludeClone) { $(vm.transcludeTarget).append(transcludeClone); } if (postCreate) { postCreate(vm, ngScope, ngElement, ngAttrs, ngController, ...injectedServices); } // specifying replace: true on the directive does nothing w/ vue inside, so // handle it here. if (replace) { // transfer attributes from angularjs element that are not in scope to // mount point element Array.from(ngElement[0].attributes).forEach((attr) => { if (scope[attr.nodeName]) { return; } if (mountPoint.firstElementChild) { mountPoint.firstElementChild.setAttribute(attr.nodeName, attr.nodeValue!); } }); ngElement.replaceWith(window.$(mountPoint).children()); } ngElement.on('$destroy', () => { app.unmount(); }); }, }; }, }; if (transclude) { adapter.transclude = true; adapter.template = `
`; } return adapter; } angularJsAdapter.$inject = $inject || []; window.angular.module('piwikApp').directive( directiveName, angularJsAdapter as unknown as Injectable, ); return angularJsAdapter as unknown as Injectable; } export function transformAngularJsBoolAttr(v: unknown): boolean|undefined { if (typeof v === 'undefined') { return undefined; } if (v === 'true') { return true; } return !!v && v as number > 0 && v !== '0'; } export function transformAngularJsIntAttr(v: unknown): number|undefined|null { if (typeof v === 'undefined') { return undefined; } if (v === null) { return null; } return parseInt(v as string, 10); } // utility function for service adapters export function clone(p: T): T { if (typeof p === 'undefined') { return p; } return JSON.parse(JSON.stringify(p)) as T; } export function cloneThenApply(p: T): T { const result = clone(p); Matomo.helper.getAngularDependency('$rootScope').$applyAsync(); return result; }