mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(editor): Make popped out log view window maximizable (#18223)
This commit is contained in:
@@ -121,16 +121,6 @@ declare global {
|
||||
getVariant: (name: string) => string | boolean | undefined;
|
||||
override: (name: string, value: string) => void;
|
||||
};
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/DocumentPictureInPicture
|
||||
documentPictureInPicture?: {
|
||||
window: Window | null;
|
||||
requestWindow: (options?: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
preferInitialWindowPlacement?: boolean;
|
||||
disallowReturnToOpener?: boolean;
|
||||
}) => Promise<Window>;
|
||||
};
|
||||
Cypress: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { nonExistingJsonPath, PiPWindowSymbol } from '@/constants';
|
||||
import { nonExistingJsonPath, PopOutWindowKey } from '@/constants';
|
||||
import { useClipboard } from '@/composables/useClipboard';
|
||||
import { usePinnedData } from '@/composables/usePinnedData';
|
||||
import { inject, computed, ref } from 'vue';
|
||||
@@ -39,8 +39,8 @@ const props = withDefaults(
|
||||
},
|
||||
);
|
||||
|
||||
const pipWindow = inject(PiPWindowSymbol, ref<Window | undefined>());
|
||||
const isInPiPWindow = computed(() => pipWindow?.value !== undefined);
|
||||
const popOutWindow = inject(PopOutWindowKey, ref<Window | undefined>());
|
||||
const isInPopOutWindow = computed(() => popOutWindow?.value !== undefined);
|
||||
|
||||
const ndvStore = useNDVStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
@@ -195,7 +195,7 @@ function handleCopyClick(commandData: { command: string }) {
|
||||
v-else
|
||||
trigger="click"
|
||||
:teleported="
|
||||
!isInPiPWindow // disabling teleport ensures the menu is rendered in PiP window
|
||||
!isInPopOutWindow // disabling teleport ensures the menu is rendered in pop-out window
|
||||
"
|
||||
@command="handleCopyClick"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { inject, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useClipboard as useClipboardCore, useThrottleFn } from '@vueuse/core';
|
||||
import { PiPWindowSymbol } from '@/constants';
|
||||
import { PopOutWindowKey } from '@/constants';
|
||||
|
||||
type ClipboardEventFn = (data: string, event?: ClipboardEvent) => void;
|
||||
|
||||
@@ -9,9 +9,9 @@ export function useClipboard({
|
||||
}: {
|
||||
onPaste?: ClipboardEventFn;
|
||||
} = {}) {
|
||||
const pipWindow = inject(PiPWindowSymbol, ref<Window | undefined>());
|
||||
const popOutWindow = inject(PopOutWindowKey, ref<Window | undefined>());
|
||||
const { copy, copied, isSupported, text } = useClipboardCore({
|
||||
navigator: pipWindow?.value?.navigator ?? window.navigator,
|
||||
navigator: popOutWindow?.value?.navigator ?? window.navigator,
|
||||
legacy: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
const DEFAULT_TITLE = 'n8n';
|
||||
const DEFAULT_TAGLINE = 'Workflow Automation';
|
||||
|
||||
export function useDocumentTitle() {
|
||||
export function useDocumentTitle(windowRef?: Ref<Window | undefined>) {
|
||||
const settingsStore = useSettingsStore();
|
||||
const { releaseChannel } = settingsStore.settings;
|
||||
const suffix =
|
||||
@@ -13,7 +14,7 @@ export function useDocumentTitle() {
|
||||
|
||||
const set = (title: string) => {
|
||||
const sections = [title || DEFAULT_TAGLINE, suffix];
|
||||
document.title = sections.join(' - ');
|
||||
(windowRef?.value?.document ?? document).title = sections.join(' - ');
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PiPWindowSymbol } from '@/constants';
|
||||
import { PopOutWindowKey } from '@/constants';
|
||||
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
|
||||
import { useActiveElement, useEventListener } from '@vueuse/core';
|
||||
import type { MaybeRefOrGetter } from 'vue';
|
||||
@@ -30,8 +30,8 @@ export const useKeybindings = (
|
||||
disabled: MaybeRefOrGetter<boolean>;
|
||||
},
|
||||
) => {
|
||||
const pipWindow = inject(PiPWindowSymbol, ref<Window | undefined>());
|
||||
const activeElement = useActiveElement({ window: pipWindow?.value });
|
||||
const popOutWindow = inject(PopOutWindowKey, ref<Window | undefined>());
|
||||
const activeElement = useActiveElement({ window: popOutWindow?.value });
|
||||
const { isCtrlKeyPressed } = useDeviceSupport();
|
||||
|
||||
const isDisabled = computed(() => toValue(options?.disabled));
|
||||
@@ -150,5 +150,5 @@ export const useKeybindings = (
|
||||
}
|
||||
}
|
||||
|
||||
useEventListener(pipWindow?.value?.document ?? document, 'keydown', onKeyDown);
|
||||
useEventListener(popOutWindow?.value?.document ?? document, 'keydown', onKeyDown);
|
||||
};
|
||||
|
||||
@@ -964,10 +964,10 @@ export const CanvasKey = 'canvas' as unknown as InjectionKey<CanvasInjectionData
|
||||
export const CanvasNodeKey = 'canvasNode' as unknown as InjectionKey<CanvasNodeInjectionData>;
|
||||
export const CanvasNodeHandleKey =
|
||||
'canvasNodeHandle' as unknown as InjectionKey<CanvasNodeHandleInjectionData>;
|
||||
export const PiPWindowSymbol = 'PiPWindow' as unknown as InjectionKey<Ref<Window | undefined>>;
|
||||
export const ExpressionLocalResolveContextSymbol = Symbol(
|
||||
'ExpressionLocalResolveContext',
|
||||
) as InjectionKey<ComputedRef<ExpressionLocalResolveContext | undefined>>;
|
||||
export const PopOutWindowKey: InjectionKey<Ref<Window | undefined>> = Symbol('PopOutWindow');
|
||||
export const ExpressionLocalResolveContextSymbol: InjectionKey<
|
||||
ComputedRef<ExpressionLocalResolveContext | undefined>
|
||||
> = Symbol('ExpressionLocalResolveContext');
|
||||
|
||||
export const APP_MODALS_ELEMENT_ID = 'app-modals';
|
||||
export const CODEMIRROR_TOOLTIP_CONTAINER_ELEMENT_ID = 'cm-tooltip-container';
|
||||
|
||||
@@ -16,16 +16,19 @@ import { useLogsStore } from '@/stores/logs.store';
|
||||
import { useLogsPanelLayout } from '@/features/logs/composables/useLogsPanelLayout';
|
||||
import { type KeyMap } from '@/composables/useKeybindings';
|
||||
import LogsViewKeyboardEventListener from './LogsViewKeyboardEventListener.vue';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
|
||||
const props = withDefaults(defineProps<{ isReadOnly?: boolean }>(), { isReadOnly: false });
|
||||
|
||||
const container = useTemplateRef('container');
|
||||
const logsContainer = useTemplateRef('logsContainer');
|
||||
const pipContainer = useTemplateRef('pipContainer');
|
||||
const pipContent = useTemplateRef('pipContent');
|
||||
const popOutContainer = useTemplateRef('popOutContainer');
|
||||
const popOutContent = useTemplateRef('popOutContent');
|
||||
|
||||
const logsStore = useLogsStore();
|
||||
const ndvStore = useNDVStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const workflowName = computed(() => workflowsStore.workflow.name);
|
||||
|
||||
const {
|
||||
height,
|
||||
@@ -36,7 +39,7 @@ const {
|
||||
isPoppedOut,
|
||||
isCollapsingDetailsPanel,
|
||||
isOverviewPanelFullWidth,
|
||||
pipWindow,
|
||||
popOutWindow,
|
||||
onResize,
|
||||
onResizeEnd,
|
||||
onToggleOpen,
|
||||
@@ -45,7 +48,7 @@ const {
|
||||
onChatPanelResizeEnd,
|
||||
onOverviewPanelResize,
|
||||
onOverviewPanelResizeEnd,
|
||||
} = useLogsPanelLayout(pipContainer, pipContent, container, logsContainer);
|
||||
} = useLogsPanelLayout(workflowName, popOutContainer, popOutContent, container, logsContainer);
|
||||
|
||||
const {
|
||||
currentSessionId,
|
||||
@@ -136,20 +139,20 @@ function handleChangeOutputTableColumnCollapsing(columnName: string | null) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="pipContainer">
|
||||
<!-- force re-create with key for shortcuts to work in PiP window -->
|
||||
<div ref="popOutContainer">
|
||||
<!-- force re-create with key for shortcuts to work in pop-out window -->
|
||||
<LogsViewKeyboardEventListener
|
||||
:key="String(!!pipWindow)"
|
||||
:key="String(!!popOutWindow)"
|
||||
:key-map="keyMap"
|
||||
:container="container"
|
||||
/>
|
||||
<div ref="pipContent" :class="$style.pipContent">
|
||||
<div ref="popOutContent" :class="[$style.popOutContent, isPoppedOut ? $style.poppedOut : '']">
|
||||
<N8nResizeWrapper
|
||||
:height="height"
|
||||
:height="isPoppedOut ? undefined : height"
|
||||
:supported-directions="['top']"
|
||||
:is-resizing-enabled="!isPoppedOut"
|
||||
:class="$style.resizeWrapper"
|
||||
:style="{ height: isOpen ? `${height}px` : 'auto' }"
|
||||
:style="{ height: isOpen && !isPoppedOut ? `${height}px` : 'auto' }"
|
||||
@resize="onResize"
|
||||
@resizeend="onResizeEnd"
|
||||
>
|
||||
@@ -161,12 +164,12 @@ function handleChangeOutputTableColumnCollapsing(columnName: string | null) {
|
||||
:width="chatPanelWidth"
|
||||
:style="{ width: `${chatPanelWidth}px` }"
|
||||
:class="$style.chat"
|
||||
:window="pipWindow"
|
||||
:window="popOutWindow"
|
||||
@resize="onChatPanelResize"
|
||||
@resizeend="onChatPanelResizeEnd"
|
||||
>
|
||||
<ChatMessagesPanel
|
||||
:key="`canvas-chat-${currentSessionId}${isPoppedOut ? '-pip' : ''}`"
|
||||
:key="`canvas-chat-${currentSessionId}${isPoppedOut ? '-pop-out' : ''}`"
|
||||
data-test-id="canvas-chat"
|
||||
:is-open="isOpen"
|
||||
:is-read-only="isReadOnly"
|
||||
@@ -189,7 +192,7 @@ function handleChangeOutputTableColumnCollapsing(columnName: string | null) {
|
||||
:style="{ width: isLogDetailsVisuallyOpen ? `${overviewPanelWidth}px` : '' }"
|
||||
:supported-directions="['right']"
|
||||
:is-resizing-enabled="isLogDetailsOpen"
|
||||
:window="pipWindow"
|
||||
:window="popOutWindow"
|
||||
@resize="onOverviewPanelResize"
|
||||
@resizeend="handleResizeOverviewPanelEnd"
|
||||
>
|
||||
@@ -222,7 +225,7 @@ function handleChangeOutputTableColumnCollapsing(columnName: string | null) {
|
||||
:class="$style.logDetails"
|
||||
:is-open="isOpen"
|
||||
:log-entry="selected"
|
||||
:window="pipWindow"
|
||||
:window="popOutWindow"
|
||||
:latest-info="latestNodeNameById[selected.node.id]"
|
||||
:panels="logsStore.detailsState"
|
||||
:collapsing-input-table-column-name="inputCollapsingColumnName"
|
||||
@@ -245,14 +248,7 @@ function handleChangeOutputTableColumnCollapsing(columnName: string | null) {
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
@media all and (display-mode: picture-in-picture) {
|
||||
.resizeWrapper {
|
||||
height: 100% !important;
|
||||
max-height: 100vh !important;
|
||||
}
|
||||
}
|
||||
|
||||
.pipContent {
|
||||
.popOutContent {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -264,6 +260,10 @@ function handleChangeOutputTableColumnCollapsing(columnName: string | null) {
|
||||
flex-basis: 0;
|
||||
border-top: var(--border-base);
|
||||
background-color: var(--color-background-light);
|
||||
|
||||
.poppedOut & {
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
|
||||
@@ -71,7 +71,7 @@ function handleSelectMenuItem(selected: string) {
|
||||
activator-icon="ellipsis"
|
||||
activator-size="small"
|
||||
:items="menuItems"
|
||||
:teleported="false /* for PiP window */"
|
||||
:teleported="false /* for pop-out window */"
|
||||
@select="handleSelectMenuItem"
|
||||
/>
|
||||
<KeyboardShortcutTooltip
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { type KeyMap, useKeybindings } from '@/composables/useKeybindings';
|
||||
import { PiPWindowSymbol } from '@/constants';
|
||||
import { PopOutWindowKey } from '@/constants';
|
||||
import { useActiveElement } from '@vueuse/core';
|
||||
import { ref, computed, toRef, inject } from 'vue';
|
||||
|
||||
const { container, keyMap } = defineProps<{ keyMap: KeyMap; container: HTMLElement | null }>();
|
||||
const pipWindow = inject(PiPWindowSymbol, ref<Window | undefined>());
|
||||
const popOutWindow = inject(PopOutWindowKey, ref<Window | undefined>());
|
||||
|
||||
const activeElement = useActiveElement({ window: pipWindow?.value });
|
||||
const activeElement = useActiveElement({ window: popOutWindow?.value });
|
||||
const isBlurred = computed(() => {
|
||||
if (pipWindow?.value) {
|
||||
return pipWindow.value.document.activeElement === null;
|
||||
if (popOutWindow?.value) {
|
||||
return popOutWindow.value.document.activeElement === null;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,7 +8,7 @@ import { waitingNodeTooltip } from '@/utils/executionUtils';
|
||||
import { N8nLink, N8nText } from '@n8n/design-system';
|
||||
import { computed, inject, ref } from 'vue';
|
||||
import { I18nT } from 'vue-i18n';
|
||||
import { PiPWindowSymbol } from '@/constants';
|
||||
import { PopOutWindowKey } from '@/constants';
|
||||
import { isSubNodeLog } from '../logs.utils';
|
||||
|
||||
const { title, logEntry, paneType, collapsingTableColumnName } = defineProps<{
|
||||
@@ -25,7 +25,7 @@ const emit = defineEmits<{
|
||||
const locale = useI18n();
|
||||
const ndvStore = useNDVStore();
|
||||
|
||||
const pipWindow = inject(PiPWindowSymbol, ref<Window | undefined>());
|
||||
const popOutWindow = inject(PopOutWindowKey, ref<Window | undefined>());
|
||||
|
||||
const displayMode = ref<IRunDataDisplayMode>(paneType === 'input' ? 'schema' : 'table');
|
||||
const isMultipleInput = computed(
|
||||
@@ -74,7 +74,7 @@ function handleChangeDisplayMode(value: IRunDataDisplayMode) {
|
||||
<RunData
|
||||
v-if="runDataProps"
|
||||
v-bind="runDataProps"
|
||||
:key="`run-data${pipWindow ? '-pip' : ''}`"
|
||||
:key="`run-data${popOutWindow ? '-pop-out' : ''}`"
|
||||
:class="$style.component"
|
||||
:workflow-object="logEntry.workflow"
|
||||
:workflow-execution="logEntry.execution"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { computed, type ShallowRef } from 'vue';
|
||||
import { computed, type ComputedRef, type ShallowRef } from 'vue';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { watch } from 'vue';
|
||||
import { useLogsStore } from '@/stores/logs.store';
|
||||
import { useResizablePanel } from '@/composables/useResizablePanel';
|
||||
import { usePiPWindow } from '@/features/logs/composables/usePiPWindow';
|
||||
import { usePopOutWindow } from '@/features/logs/composables/usePopOutWindow';
|
||||
import {
|
||||
LOGS_PANEL_STATE,
|
||||
LOCAL_STORAGE_OVERVIEW_PANEL_WIDTH,
|
||||
@@ -12,8 +12,9 @@ import {
|
||||
} from '@/features/logs/logs.constants';
|
||||
|
||||
export function useLogsPanelLayout(
|
||||
pipContainer: Readonly<ShallowRef<HTMLElement | null>>,
|
||||
pipContent: Readonly<ShallowRef<HTMLElement | null>>,
|
||||
workflowName: ComputedRef<string>,
|
||||
popOutContainer: Readonly<ShallowRef<HTMLElement | null>>,
|
||||
popOutContent: Readonly<ShallowRef<HTMLElement | null>>,
|
||||
container: Readonly<ShallowRef<HTMLElement | null>>,
|
||||
logsContainer: Readonly<ShallowRef<HTMLElement | null>>,
|
||||
) {
|
||||
@@ -51,13 +52,20 @@ export function useLogsPanelLayout(
|
||||
: resizer.isResizing.value && resizer.size.value > 0,
|
||||
);
|
||||
const isCollapsingDetailsPanel = computed(() => overviewPanelResizer.isFullSize.value);
|
||||
const popOutWindowTitle = computed(() => `Logs - ${workflowName.value}`);
|
||||
const shouldPopOut = computed(() => logsStore.state === LOGS_PANEL_STATE.FLOATING);
|
||||
|
||||
const { canPopOut, isPoppedOut, pipWindow } = usePiPWindow({
|
||||
const {
|
||||
canPopOut,
|
||||
isPoppedOut,
|
||||
popOutWindow: popOutWindow,
|
||||
} = usePopOutWindow({
|
||||
title: popOutWindowTitle,
|
||||
initialHeight: 400,
|
||||
initialWidth: window.document.body.offsetWidth * 0.8,
|
||||
container: pipContainer,
|
||||
content: pipContent,
|
||||
shouldPopOut: computed(() => logsStore.state === LOGS_PANEL_STATE.FLOATING),
|
||||
container: popOutContainer,
|
||||
content: popOutContent,
|
||||
shouldPopOut,
|
||||
onRequestClose: () => {
|
||||
if (!isOpen.value) {
|
||||
return;
|
||||
@@ -123,7 +131,7 @@ export function useLogsPanelLayout(
|
||||
isCollapsingDetailsPanel,
|
||||
isPoppedOut,
|
||||
isOverviewPanelFullWidth: overviewPanelResizer.isFullSize,
|
||||
pipWindow,
|
||||
popOutWindow,
|
||||
onToggleOpen: handleToggleOpen,
|
||||
onPopOut: handlePopOut,
|
||||
onResize: resizer.onResize,
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
import { computed, defineComponent, h, ref } from 'vue';
|
||||
import { usePiPWindow } from './usePiPWindow';
|
||||
import { waitFor } from '@testing-library/vue';
|
||||
import { renderComponent } from '@/__tests__/render';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
|
||||
describe(usePiPWindow, () => {
|
||||
const documentPictureInPicture: NonNullable<Window['documentPictureInPicture']> = {
|
||||
window: null,
|
||||
requestWindow: async () =>
|
||||
({
|
||||
document: { body: { append: vi.fn(), removeChild: vi.fn() } },
|
||||
addEventListener: vi.fn(),
|
||||
close: vi.fn(),
|
||||
}) as unknown as Window,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }));
|
||||
});
|
||||
|
||||
describe('canPopOut', () => {
|
||||
it('should return false if window.documentPictureInPicture is not available', () => {
|
||||
const MyComponent = defineComponent({
|
||||
setup() {
|
||||
const container = ref<HTMLDivElement | null>(null);
|
||||
const content = ref<HTMLDivElement | null>(null);
|
||||
const pipWindow = usePiPWindow({
|
||||
container,
|
||||
content,
|
||||
shouldPopOut: computed(() => true),
|
||||
onRequestClose: vi.fn(),
|
||||
});
|
||||
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{ ref: container },
|
||||
h('div', { ref: content }, String(pipWindow.canPopOut.value)),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { queryByText } = renderComponent(MyComponent);
|
||||
|
||||
expect(queryByText('false')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should return true if window.documentPictureInPicture is available', () => {
|
||||
Object.assign(window, { documentPictureInPicture });
|
||||
|
||||
const MyComponent = defineComponent({
|
||||
setup() {
|
||||
const container = ref<HTMLDivElement | null>(null);
|
||||
const content = ref<HTMLDivElement | null>(null);
|
||||
const pipWindow = usePiPWindow({
|
||||
container,
|
||||
content,
|
||||
shouldPopOut: computed(() => true),
|
||||
onRequestClose: vi.fn(),
|
||||
});
|
||||
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{ ref: container },
|
||||
h('div', { ref: content }, String(pipWindow.canPopOut.value)),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { queryByText } = renderComponent(MyComponent);
|
||||
|
||||
expect(queryByText('true')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPoppedOut', () => {
|
||||
beforeEach(() => {
|
||||
Object.assign(window, { documentPictureInPicture });
|
||||
});
|
||||
|
||||
it('should be set to true when popped out', async () => {
|
||||
const shouldPopOut = ref(false);
|
||||
const MyComponent = defineComponent({
|
||||
setup() {
|
||||
const container = ref<HTMLDivElement | null>(null);
|
||||
const content = ref<HTMLDivElement | null>(null);
|
||||
const pipWindow = usePiPWindow({
|
||||
container,
|
||||
content,
|
||||
shouldPopOut: computed(() => shouldPopOut.value),
|
||||
onRequestClose: vi.fn(),
|
||||
});
|
||||
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{ ref: container },
|
||||
h('div', { ref: content }, String(pipWindow.isPoppedOut.value)),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { queryByText } = renderComponent(MyComponent);
|
||||
|
||||
expect(queryByText('false')).toBeInTheDocument();
|
||||
|
||||
shouldPopOut.value = true;
|
||||
|
||||
await waitFor(() => expect(queryByText('true')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
import { computed, defineComponent, h, ref } from 'vue';
|
||||
import { usePopOutWindow } from './usePopOutWindow';
|
||||
import { waitFor } from '@testing-library/vue';
|
||||
import { renderComponent } from '@/__tests__/render';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
|
||||
describe(usePopOutWindow, () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }));
|
||||
});
|
||||
|
||||
describe('isPoppedOut', () => {
|
||||
beforeEach(() => {
|
||||
Object.assign(window, {
|
||||
open: () =>
|
||||
({
|
||||
document: { body: { append: vi.fn() } },
|
||||
addEventListener: vi.fn(),
|
||||
close: vi.fn(),
|
||||
}) as unknown as Window,
|
||||
});
|
||||
});
|
||||
|
||||
it('should be set to true when popped out', async () => {
|
||||
const shouldPopOut = ref(false);
|
||||
const MyComponent = defineComponent({
|
||||
setup() {
|
||||
const container = ref<HTMLDivElement | null>(null);
|
||||
const content = ref<HTMLDivElement | null>(null);
|
||||
const popOutWindow = usePopOutWindow({
|
||||
title: computed(() => ''),
|
||||
container,
|
||||
content,
|
||||
shouldPopOut: computed(() => shouldPopOut.value),
|
||||
onRequestClose: vi.fn(),
|
||||
});
|
||||
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{ ref: container },
|
||||
h('div', { ref: content }, String(popOutWindow.isPoppedOut.value)),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { queryByText } = renderComponent(MyComponent);
|
||||
|
||||
expect(queryByText('false')).toBeInTheDocument();
|
||||
|
||||
shouldPopOut.value = true;
|
||||
|
||||
await waitFor(() => expect(queryByText('true')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import { PiPWindowSymbol } from '@/constants';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { applyThemeToBody } from '@/stores/ui.utils';
|
||||
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||
import { PopOutWindowKey } from '@/constants';
|
||||
import { useProvideTooltipAppendTo } from '@n8n/design-system/composables/useTooltipAppendTo';
|
||||
import {
|
||||
computed,
|
||||
@@ -14,7 +13,8 @@ import {
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
interface UsePiPWindowOptions {
|
||||
interface UsePopOutWindowOptions {
|
||||
title: ComputedRef<string>;
|
||||
initialWidth?: number;
|
||||
initialHeight?: number;
|
||||
container: Readonly<ShallowRef<HTMLElement | null>>;
|
||||
@@ -23,10 +23,10 @@ interface UsePiPWindowOptions {
|
||||
onRequestClose: () => void;
|
||||
}
|
||||
|
||||
interface UsePiPWindowReturn {
|
||||
interface UsePopOutWindowReturn {
|
||||
isPoppedOut: ComputedRef<boolean>;
|
||||
canPopOut: ComputedRef<boolean>;
|
||||
pipWindow?: Ref<Window | undefined>;
|
||||
popOutWindow?: Ref<Window | undefined>;
|
||||
}
|
||||
|
||||
function isStyle(node: Node): node is HTMLElement {
|
||||
@@ -58,63 +58,76 @@ function syncStyleMutations(destination: Window, mutations: MutationRecord[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function copyFavicon(source: Window, target: Window) {
|
||||
const iconUrl = source.document.querySelector('link[rel=icon]')?.getAttribute('href');
|
||||
|
||||
if (iconUrl) {
|
||||
const link = target.document.createElement('link');
|
||||
|
||||
link.setAttribute('rel', 'icon');
|
||||
link.setAttribute('href', iconUrl);
|
||||
|
||||
target.document.head.appendChild(link);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A composable that allows to pop out given content in document PiP (picture-in-picture) window
|
||||
* A composable that allows to pop out given content in child window
|
||||
*/
|
||||
export function usePiPWindow({
|
||||
export function usePopOutWindow({
|
||||
title,
|
||||
container,
|
||||
content,
|
||||
initialHeight,
|
||||
initialWidth,
|
||||
shouldPopOut,
|
||||
onRequestClose,
|
||||
}: UsePiPWindowOptions): UsePiPWindowReturn {
|
||||
const pipWindow = ref<Window>();
|
||||
}: UsePopOutWindowOptions): UsePopOutWindowReturn {
|
||||
const popOutWindow = ref<Window>();
|
||||
const isUnmounting = ref(false);
|
||||
const canPopOut = computed(
|
||||
() =>
|
||||
!!window.documentPictureInPicture /* Browser supports the API */ &&
|
||||
window.parent === window /* Not in iframe */,
|
||||
);
|
||||
const isPoppedOut = computed(() => !!pipWindow.value);
|
||||
const canPopOut = computed(() => window.parent === window /* Not in iframe */);
|
||||
const isPoppedOut = computed(() => !!popOutWindow.value);
|
||||
const tooltipContainer = computed(() =>
|
||||
isPoppedOut.value ? (content.value ?? undefined) : undefined,
|
||||
);
|
||||
const uiStore = useUIStore();
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
if (pipWindow.value) {
|
||||
syncStyleMutations(pipWindow.value, mutations);
|
||||
if (popOutWindow.value) {
|
||||
syncStyleMutations(popOutWindow.value, mutations);
|
||||
}
|
||||
});
|
||||
const documentTitle = useDocumentTitle(popOutWindow);
|
||||
|
||||
// Copy over dynamic styles to PiP window to support lazily imported modules
|
||||
// Copy over dynamic styles to child window to support lazily imported modules
|
||||
observer.observe(document.head, { childList: true, subtree: true });
|
||||
|
||||
provide(PiPWindowSymbol, pipWindow);
|
||||
provide(PopOutWindowKey, popOutWindow);
|
||||
useProvideTooltipAppendTo(tooltipContainer);
|
||||
|
||||
async function showPip() {
|
||||
async function showPopOut() {
|
||||
if (!content.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
pipWindow.value =
|
||||
pipWindow.value ??
|
||||
(await window.documentPictureInPicture?.requestWindow({
|
||||
width: initialWidth,
|
||||
height: initialHeight,
|
||||
disallowReturnToOpener: true,
|
||||
}));
|
||||
if (!popOutWindow.value) {
|
||||
// Chrome ignores these options but effective in Firefox
|
||||
const options = `popup=yes,width=${initialWidth},height=${initialHeight},left=100,top=100,toolbar=no,menubar=no,scrollbars=yes,resizable=yes`;
|
||||
|
||||
// Copy style sheets over from the initial document
|
||||
// so that the content looks the same.
|
||||
[...document.styleSheets].forEach((styleSheet) => {
|
||||
popOutWindow.value = window.open('', '_blank', options) ?? undefined;
|
||||
}
|
||||
|
||||
if (!popOutWindow.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
copyFavicon(window, popOutWindow.value);
|
||||
|
||||
for (const styleSheet of [...document.styleSheets]) {
|
||||
try {
|
||||
const cssRules = [...styleSheet.cssRules].map((rule) => rule.cssText).join('');
|
||||
const style = document.createElement('style');
|
||||
|
||||
style.textContent = cssRules;
|
||||
pipWindow.value?.document.head.appendChild(style);
|
||||
popOutWindow.value.document.head.appendChild(style);
|
||||
} catch (e) {
|
||||
const link = document.createElement('link');
|
||||
|
||||
@@ -122,18 +135,18 @@ export function usePiPWindow({
|
||||
link.type = styleSheet.type;
|
||||
link.media = styleSheet.media as unknown as string;
|
||||
link.href = styleSheet.href as string;
|
||||
pipWindow.value?.document.head.appendChild(link);
|
||||
popOutWindow.value.document.head.appendChild(link);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Move the content to the Picture-in-Picture window.
|
||||
pipWindow.value?.document.body.append(content.value);
|
||||
pipWindow.value?.addEventListener('pagehide', () => !isUnmounting.value && onRequestClose());
|
||||
// Move the content to child window.
|
||||
popOutWindow.value.document.body.append(content.value);
|
||||
popOutWindow.value.addEventListener('pagehide', () => !isUnmounting.value && onRequestClose());
|
||||
}
|
||||
|
||||
function hidePiP() {
|
||||
pipWindow.value?.close();
|
||||
pipWindow.value = undefined;
|
||||
function hidePopOut() {
|
||||
popOutWindow.value?.close();
|
||||
popOutWindow.value = undefined;
|
||||
|
||||
if (content.value) {
|
||||
container.value?.appendChild(content.value);
|
||||
@@ -141,17 +154,15 @@ export function usePiPWindow({
|
||||
}
|
||||
|
||||
// `requestAnimationFrame()` to make sure the content is already rendered
|
||||
watch(shouldPopOut, (value) => (value ? requestAnimationFrame(showPip) : hidePiP()), {
|
||||
watch(shouldPopOut, (value) => (value ? requestAnimationFrame(showPopOut) : hidePopOut()), {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
// It seems "prefers-color-scheme: dark" media query matches in PiP window by default
|
||||
// So we're enforcing currently applied theme in the main window by setting data-theme in PiP's body element
|
||||
watch(
|
||||
[() => uiStore.appliedTheme, pipWindow],
|
||||
([theme, pip]) => {
|
||||
if (pip) {
|
||||
applyThemeToBody(theme, pip);
|
||||
[title, popOutWindow],
|
||||
([newTitle, win]) => {
|
||||
if (win) {
|
||||
documentTitle.set(newTitle);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -163,8 +174,11 @@ export function usePiPWindow({
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
isUnmounting.value = true;
|
||||
pipWindow.value?.close();
|
||||
if (popOutWindow.value) {
|
||||
popOutWindow.value.close();
|
||||
onRequestClose();
|
||||
}
|
||||
});
|
||||
|
||||
return { canPopOut, isPoppedOut, pipWindow };
|
||||
return { canPopOut, isPoppedOut, popOutWindow };
|
||||
}
|
||||
Reference in New Issue
Block a user