feat(editor): Make popped out log view window maximizable (#18223)

This commit is contained in:
Suguru Inoue
2025-08-12 13:13:32 +02:00
committed by GitHub
parent 514825bd51
commit aeef79df53
14 changed files with 188 additions and 233 deletions

View File

@@ -121,16 +121,6 @@ declare global {
getVariant: (name: string) => string | boolean | undefined; getVariant: (name: string) => string | boolean | undefined;
override: (name: string, value: string) => void; 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; Cypress: unknown;
} }
} }

View File

@@ -9,7 +9,7 @@ import { useNDVStore } from '@/stores/ndv.store';
import { useNodeHelpers } from '@/composables/useNodeHelpers'; import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { useI18n } from '@n8n/i18n'; import { useI18n } from '@n8n/i18n';
import { nonExistingJsonPath, PiPWindowSymbol } from '@/constants'; import { nonExistingJsonPath, PopOutWindowKey } from '@/constants';
import { useClipboard } from '@/composables/useClipboard'; import { useClipboard } from '@/composables/useClipboard';
import { usePinnedData } from '@/composables/usePinnedData'; import { usePinnedData } from '@/composables/usePinnedData';
import { inject, computed, ref } from 'vue'; import { inject, computed, ref } from 'vue';
@@ -39,8 +39,8 @@ const props = withDefaults(
}, },
); );
const pipWindow = inject(PiPWindowSymbol, ref<Window | undefined>()); const popOutWindow = inject(PopOutWindowKey, ref<Window | undefined>());
const isInPiPWindow = computed(() => pipWindow?.value !== undefined); const isInPopOutWindow = computed(() => popOutWindow?.value !== undefined);
const ndvStore = useNDVStore(); const ndvStore = useNDVStore();
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
@@ -195,7 +195,7 @@ function handleCopyClick(commandData: { command: string }) {
v-else v-else
trigger="click" trigger="click"
:teleported=" :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" @command="handleCopyClick"
> >

View File

@@ -1,6 +1,6 @@
import { inject, onBeforeUnmount, onMounted, ref } from 'vue'; import { inject, onBeforeUnmount, onMounted, ref } from 'vue';
import { useClipboard as useClipboardCore, useThrottleFn } from '@vueuse/core'; import { useClipboard as useClipboardCore, useThrottleFn } from '@vueuse/core';
import { PiPWindowSymbol } from '@/constants'; import { PopOutWindowKey } from '@/constants';
type ClipboardEventFn = (data: string, event?: ClipboardEvent) => void; type ClipboardEventFn = (data: string, event?: ClipboardEvent) => void;
@@ -9,9 +9,9 @@ export function useClipboard({
}: { }: {
onPaste?: ClipboardEventFn; onPaste?: ClipboardEventFn;
} = {}) { } = {}) {
const pipWindow = inject(PiPWindowSymbol, ref<Window | undefined>()); const popOutWindow = inject(PopOutWindowKey, ref<Window | undefined>());
const { copy, copied, isSupported, text } = useClipboardCore({ const { copy, copied, isSupported, text } = useClipboardCore({
navigator: pipWindow?.value?.navigator ?? window.navigator, navigator: popOutWindow?.value?.navigator ?? window.navigator,
legacy: true, legacy: true,
}); });

View File

@@ -1,9 +1,10 @@
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import type { Ref } from 'vue';
const DEFAULT_TITLE = 'n8n'; const DEFAULT_TITLE = 'n8n';
const DEFAULT_TAGLINE = 'Workflow Automation'; const DEFAULT_TAGLINE = 'Workflow Automation';
export function useDocumentTitle() { export function useDocumentTitle(windowRef?: Ref<Window | undefined>) {
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const { releaseChannel } = settingsStore.settings; const { releaseChannel } = settingsStore.settings;
const suffix = const suffix =
@@ -13,7 +14,7 @@ export function useDocumentTitle() {
const set = (title: string) => { const set = (title: string) => {
const sections = [title || DEFAULT_TAGLINE, suffix]; const sections = [title || DEFAULT_TAGLINE, suffix];
document.title = sections.join(' - '); (windowRef?.value?.document ?? document).title = sections.join(' - ');
}; };
const reset = () => { const reset = () => {

View File

@@ -1,4 +1,4 @@
import { PiPWindowSymbol } from '@/constants'; import { PopOutWindowKey } from '@/constants';
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport'; import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import { useActiveElement, useEventListener } from '@vueuse/core'; import { useActiveElement, useEventListener } from '@vueuse/core';
import type { MaybeRefOrGetter } from 'vue'; import type { MaybeRefOrGetter } from 'vue';
@@ -30,8 +30,8 @@ export const useKeybindings = (
disabled: MaybeRefOrGetter<boolean>; disabled: MaybeRefOrGetter<boolean>;
}, },
) => { ) => {
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 { isCtrlKeyPressed } = useDeviceSupport(); const { isCtrlKeyPressed } = useDeviceSupport();
const isDisabled = computed(() => toValue(options?.disabled)); 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);
}; };

View File

@@ -964,10 +964,10 @@ export const CanvasKey = 'canvas' as unknown as InjectionKey<CanvasInjectionData
export const CanvasNodeKey = 'canvasNode' as unknown as InjectionKey<CanvasNodeInjectionData>; export const CanvasNodeKey = 'canvasNode' as unknown as InjectionKey<CanvasNodeInjectionData>;
export const CanvasNodeHandleKey = export const CanvasNodeHandleKey =
'canvasNodeHandle' as unknown as InjectionKey<CanvasNodeHandleInjectionData>; 'canvasNodeHandle' as unknown as InjectionKey<CanvasNodeHandleInjectionData>;
export const PiPWindowSymbol = 'PiPWindow' as unknown as InjectionKey<Ref<Window | undefined>>; export const PopOutWindowKey: InjectionKey<Ref<Window | undefined>> = Symbol('PopOutWindow');
export const ExpressionLocalResolveContextSymbol = Symbol( export const ExpressionLocalResolveContextSymbol: InjectionKey<
'ExpressionLocalResolveContext', ComputedRef<ExpressionLocalResolveContext | undefined>
) as InjectionKey<ComputedRef<ExpressionLocalResolveContext | undefined>>; > = Symbol('ExpressionLocalResolveContext');
export const APP_MODALS_ELEMENT_ID = 'app-modals'; export const APP_MODALS_ELEMENT_ID = 'app-modals';
export const CODEMIRROR_TOOLTIP_CONTAINER_ELEMENT_ID = 'cm-tooltip-container'; export const CODEMIRROR_TOOLTIP_CONTAINER_ELEMENT_ID = 'cm-tooltip-container';

View File

@@ -16,16 +16,19 @@ import { useLogsStore } from '@/stores/logs.store';
import { useLogsPanelLayout } from '@/features/logs/composables/useLogsPanelLayout'; import { useLogsPanelLayout } from '@/features/logs/composables/useLogsPanelLayout';
import { type KeyMap } from '@/composables/useKeybindings'; import { type KeyMap } from '@/composables/useKeybindings';
import LogsViewKeyboardEventListener from './LogsViewKeyboardEventListener.vue'; import LogsViewKeyboardEventListener from './LogsViewKeyboardEventListener.vue';
import { useWorkflowsStore } from '@/stores/workflows.store';
const props = withDefaults(defineProps<{ isReadOnly?: boolean }>(), { isReadOnly: false }); const props = withDefaults(defineProps<{ isReadOnly?: boolean }>(), { isReadOnly: false });
const container = useTemplateRef('container'); const container = useTemplateRef('container');
const logsContainer = useTemplateRef('logsContainer'); const logsContainer = useTemplateRef('logsContainer');
const pipContainer = useTemplateRef('pipContainer'); const popOutContainer = useTemplateRef('popOutContainer');
const pipContent = useTemplateRef('pipContent'); const popOutContent = useTemplateRef('popOutContent');
const logsStore = useLogsStore(); const logsStore = useLogsStore();
const ndvStore = useNDVStore(); const ndvStore = useNDVStore();
const workflowsStore = useWorkflowsStore();
const workflowName = computed(() => workflowsStore.workflow.name);
const { const {
height, height,
@@ -36,7 +39,7 @@ const {
isPoppedOut, isPoppedOut,
isCollapsingDetailsPanel, isCollapsingDetailsPanel,
isOverviewPanelFullWidth, isOverviewPanelFullWidth,
pipWindow, popOutWindow,
onResize, onResize,
onResizeEnd, onResizeEnd,
onToggleOpen, onToggleOpen,
@@ -45,7 +48,7 @@ const {
onChatPanelResizeEnd, onChatPanelResizeEnd,
onOverviewPanelResize, onOverviewPanelResize,
onOverviewPanelResizeEnd, onOverviewPanelResizeEnd,
} = useLogsPanelLayout(pipContainer, pipContent, container, logsContainer); } = useLogsPanelLayout(workflowName, popOutContainer, popOutContent, container, logsContainer);
const { const {
currentSessionId, currentSessionId,
@@ -136,20 +139,20 @@ function handleChangeOutputTableColumnCollapsing(columnName: string | null) {
</script> </script>
<template> <template>
<div ref="pipContainer"> <div ref="popOutContainer">
<!-- force re-create with key for shortcuts to work in PiP window --> <!-- force re-create with key for shortcuts to work in pop-out window -->
<LogsViewKeyboardEventListener <LogsViewKeyboardEventListener
:key="String(!!pipWindow)" :key="String(!!popOutWindow)"
:key-map="keyMap" :key-map="keyMap"
:container="container" :container="container"
/> />
<div ref="pipContent" :class="$style.pipContent"> <div ref="popOutContent" :class="[$style.popOutContent, isPoppedOut ? $style.poppedOut : '']">
<N8nResizeWrapper <N8nResizeWrapper
:height="height" :height="isPoppedOut ? undefined : height"
:supported-directions="['top']" :supported-directions="['top']"
:is-resizing-enabled="!isPoppedOut" :is-resizing-enabled="!isPoppedOut"
:class="$style.resizeWrapper" :class="$style.resizeWrapper"
:style="{ height: isOpen ? `${height}px` : 'auto' }" :style="{ height: isOpen && !isPoppedOut ? `${height}px` : 'auto' }"
@resize="onResize" @resize="onResize"
@resizeend="onResizeEnd" @resizeend="onResizeEnd"
> >
@@ -161,12 +164,12 @@ function handleChangeOutputTableColumnCollapsing(columnName: string | null) {
:width="chatPanelWidth" :width="chatPanelWidth"
:style="{ width: `${chatPanelWidth}px` }" :style="{ width: `${chatPanelWidth}px` }"
:class="$style.chat" :class="$style.chat"
:window="pipWindow" :window="popOutWindow"
@resize="onChatPanelResize" @resize="onChatPanelResize"
@resizeend="onChatPanelResizeEnd" @resizeend="onChatPanelResizeEnd"
> >
<ChatMessagesPanel <ChatMessagesPanel
:key="`canvas-chat-${currentSessionId}${isPoppedOut ? '-pip' : ''}`" :key="`canvas-chat-${currentSessionId}${isPoppedOut ? '-pop-out' : ''}`"
data-test-id="canvas-chat" data-test-id="canvas-chat"
:is-open="isOpen" :is-open="isOpen"
:is-read-only="isReadOnly" :is-read-only="isReadOnly"
@@ -189,7 +192,7 @@ function handleChangeOutputTableColumnCollapsing(columnName: string | null) {
:style="{ width: isLogDetailsVisuallyOpen ? `${overviewPanelWidth}px` : '' }" :style="{ width: isLogDetailsVisuallyOpen ? `${overviewPanelWidth}px` : '' }"
:supported-directions="['right']" :supported-directions="['right']"
:is-resizing-enabled="isLogDetailsOpen" :is-resizing-enabled="isLogDetailsOpen"
:window="pipWindow" :window="popOutWindow"
@resize="onOverviewPanelResize" @resize="onOverviewPanelResize"
@resizeend="handleResizeOverviewPanelEnd" @resizeend="handleResizeOverviewPanelEnd"
> >
@@ -222,7 +225,7 @@ function handleChangeOutputTableColumnCollapsing(columnName: string | null) {
:class="$style.logDetails" :class="$style.logDetails"
:is-open="isOpen" :is-open="isOpen"
:log-entry="selected" :log-entry="selected"
:window="pipWindow" :window="popOutWindow"
:latest-info="latestNodeNameById[selected.node.id]" :latest-info="latestNodeNameById[selected.node.id]"
:panels="logsStore.detailsState" :panels="logsStore.detailsState"
:collapsing-input-table-column-name="inputCollapsingColumnName" :collapsing-input-table-column-name="inputCollapsingColumnName"
@@ -245,14 +248,7 @@ function handleChangeOutputTableColumnCollapsing(columnName: string | null) {
</template> </template>
<style lang="scss" module> <style lang="scss" module>
@media all and (display-mode: picture-in-picture) { .popOutContent {
.resizeWrapper {
height: 100% !important;
max-height: 100vh !important;
}
}
.pipContent {
height: 100%; height: 100%;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@@ -264,6 +260,10 @@ function handleChangeOutputTableColumnCollapsing(columnName: string | null) {
flex-basis: 0; flex-basis: 0;
border-top: var(--border-base); border-top: var(--border-base);
background-color: var(--color-background-light); background-color: var(--color-background-light);
.poppedOut & {
border-top: none;
}
} }
.container { .container {

View File

@@ -71,7 +71,7 @@ function handleSelectMenuItem(selected: string) {
activator-icon="ellipsis" activator-icon="ellipsis"
activator-size="small" activator-size="small"
:items="menuItems" :items="menuItems"
:teleported="false /* for PiP window */" :teleported="false /* for pop-out window */"
@select="handleSelectMenuItem" @select="handleSelectMenuItem"
/> />
<KeyboardShortcutTooltip <KeyboardShortcutTooltip

View File

@@ -1,16 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { type KeyMap, useKeybindings } from '@/composables/useKeybindings'; import { type KeyMap, useKeybindings } from '@/composables/useKeybindings';
import { PiPWindowSymbol } from '@/constants'; import { PopOutWindowKey } from '@/constants';
import { useActiveElement } from '@vueuse/core'; import { useActiveElement } from '@vueuse/core';
import { ref, computed, toRef, inject } from 'vue'; import { ref, computed, toRef, inject } from 'vue';
const { container, keyMap } = defineProps<{ keyMap: KeyMap; container: HTMLElement | null }>(); 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(() => { const isBlurred = computed(() => {
if (pipWindow?.value) { if (popOutWindow?.value) {
return pipWindow.value.document.activeElement === null; return popOutWindow.value.document.activeElement === null;
} }
return ( return (

View File

@@ -8,7 +8,7 @@ import { waitingNodeTooltip } from '@/utils/executionUtils';
import { N8nLink, N8nText } from '@n8n/design-system'; import { N8nLink, N8nText } from '@n8n/design-system';
import { computed, inject, ref } from 'vue'; import { computed, inject, ref } from 'vue';
import { I18nT } from 'vue-i18n'; import { I18nT } from 'vue-i18n';
import { PiPWindowSymbol } from '@/constants'; import { PopOutWindowKey } from '@/constants';
import { isSubNodeLog } from '../logs.utils'; import { isSubNodeLog } from '../logs.utils';
const { title, logEntry, paneType, collapsingTableColumnName } = defineProps<{ const { title, logEntry, paneType, collapsingTableColumnName } = defineProps<{
@@ -25,7 +25,7 @@ const emit = defineEmits<{
const locale = useI18n(); const locale = useI18n();
const ndvStore = useNDVStore(); 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 displayMode = ref<IRunDataDisplayMode>(paneType === 'input' ? 'schema' : 'table');
const isMultipleInput = computed( const isMultipleInput = computed(
@@ -74,7 +74,7 @@ function handleChangeDisplayMode(value: IRunDataDisplayMode) {
<RunData <RunData
v-if="runDataProps" v-if="runDataProps"
v-bind="runDataProps" v-bind="runDataProps"
:key="`run-data${pipWindow ? '-pip' : ''}`" :key="`run-data${popOutWindow ? '-pop-out' : ''}`"
:class="$style.component" :class="$style.component"
:workflow-object="logEntry.workflow" :workflow-object="logEntry.workflow"
:workflow-execution="logEntry.execution" :workflow-execution="logEntry.execution"

View File

@@ -1,9 +1,9 @@
import { computed, type ShallowRef } from 'vue'; import { computed, type ComputedRef, type ShallowRef } from 'vue';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { watch } from 'vue'; import { watch } from 'vue';
import { useLogsStore } from '@/stores/logs.store'; import { useLogsStore } from '@/stores/logs.store';
import { useResizablePanel } from '@/composables/useResizablePanel'; import { useResizablePanel } from '@/composables/useResizablePanel';
import { usePiPWindow } from '@/features/logs/composables/usePiPWindow'; import { usePopOutWindow } from '@/features/logs/composables/usePopOutWindow';
import { import {
LOGS_PANEL_STATE, LOGS_PANEL_STATE,
LOCAL_STORAGE_OVERVIEW_PANEL_WIDTH, LOCAL_STORAGE_OVERVIEW_PANEL_WIDTH,
@@ -12,8 +12,9 @@ import {
} from '@/features/logs/logs.constants'; } from '@/features/logs/logs.constants';
export function useLogsPanelLayout( export function useLogsPanelLayout(
pipContainer: Readonly<ShallowRef<HTMLElement | null>>, workflowName: ComputedRef<string>,
pipContent: Readonly<ShallowRef<HTMLElement | null>>, popOutContainer: Readonly<ShallowRef<HTMLElement | null>>,
popOutContent: Readonly<ShallowRef<HTMLElement | null>>,
container: Readonly<ShallowRef<HTMLElement | null>>, container: Readonly<ShallowRef<HTMLElement | null>>,
logsContainer: Readonly<ShallowRef<HTMLElement | null>>, logsContainer: Readonly<ShallowRef<HTMLElement | null>>,
) { ) {
@@ -51,13 +52,20 @@ export function useLogsPanelLayout(
: resizer.isResizing.value && resizer.size.value > 0, : resizer.isResizing.value && resizer.size.value > 0,
); );
const isCollapsingDetailsPanel = computed(() => overviewPanelResizer.isFullSize.value); 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, initialHeight: 400,
initialWidth: window.document.body.offsetWidth * 0.8, initialWidth: window.document.body.offsetWidth * 0.8,
container: pipContainer, container: popOutContainer,
content: pipContent, content: popOutContent,
shouldPopOut: computed(() => logsStore.state === LOGS_PANEL_STATE.FLOATING), shouldPopOut,
onRequestClose: () => { onRequestClose: () => {
if (!isOpen.value) { if (!isOpen.value) {
return; return;
@@ -123,7 +131,7 @@ export function useLogsPanelLayout(
isCollapsingDetailsPanel, isCollapsingDetailsPanel,
isPoppedOut, isPoppedOut,
isOverviewPanelFullWidth: overviewPanelResizer.isFullSize, isOverviewPanelFullWidth: overviewPanelResizer.isFullSize,
pipWindow, popOutWindow,
onToggleOpen: handleToggleOpen, onToggleOpen: handleToggleOpen,
onPopOut: handlePopOut, onPopOut: handlePopOut,
onResize: resizer.onResize, onResize: resizer.onResize,

View File

@@ -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());
});
});
});

View File

@@ -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());
});
});
});

View File

@@ -1,6 +1,5 @@
import { PiPWindowSymbol } from '@/constants'; import { useDocumentTitle } from '@/composables/useDocumentTitle';
import { useUIStore } from '@/stores/ui.store'; import { PopOutWindowKey } from '@/constants';
import { applyThemeToBody } from '@/stores/ui.utils';
import { useProvideTooltipAppendTo } from '@n8n/design-system/composables/useTooltipAppendTo'; import { useProvideTooltipAppendTo } from '@n8n/design-system/composables/useTooltipAppendTo';
import { import {
computed, computed,
@@ -14,7 +13,8 @@ import {
watch, watch,
} from 'vue'; } from 'vue';
interface UsePiPWindowOptions { interface UsePopOutWindowOptions {
title: ComputedRef<string>;
initialWidth?: number; initialWidth?: number;
initialHeight?: number; initialHeight?: number;
container: Readonly<ShallowRef<HTMLElement | null>>; container: Readonly<ShallowRef<HTMLElement | null>>;
@@ -23,10 +23,10 @@ interface UsePiPWindowOptions {
onRequestClose: () => void; onRequestClose: () => void;
} }
interface UsePiPWindowReturn { interface UsePopOutWindowReturn {
isPoppedOut: ComputedRef<boolean>; isPoppedOut: ComputedRef<boolean>;
canPopOut: ComputedRef<boolean>; canPopOut: ComputedRef<boolean>;
pipWindow?: Ref<Window | undefined>; popOutWindow?: Ref<Window | undefined>;
} }
function isStyle(node: Node): node is HTMLElement { 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, container,
content, content,
initialHeight, initialHeight,
initialWidth, initialWidth,
shouldPopOut, shouldPopOut,
onRequestClose, onRequestClose,
}: UsePiPWindowOptions): UsePiPWindowReturn { }: UsePopOutWindowOptions): UsePopOutWindowReturn {
const pipWindow = ref<Window>(); const popOutWindow = ref<Window>();
const isUnmounting = ref(false); const isUnmounting = ref(false);
const canPopOut = computed( const canPopOut = computed(() => window.parent === window /* Not in iframe */);
() => const isPoppedOut = computed(() => !!popOutWindow.value);
!!window.documentPictureInPicture /* Browser supports the API */ &&
window.parent === window /* Not in iframe */,
);
const isPoppedOut = computed(() => !!pipWindow.value);
const tooltipContainer = computed(() => const tooltipContainer = computed(() =>
isPoppedOut.value ? (content.value ?? undefined) : undefined, isPoppedOut.value ? (content.value ?? undefined) : undefined,
); );
const uiStore = useUIStore();
const observer = new MutationObserver((mutations) => { const observer = new MutationObserver((mutations) => {
if (pipWindow.value) { if (popOutWindow.value) {
syncStyleMutations(pipWindow.value, mutations); 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 }); observer.observe(document.head, { childList: true, subtree: true });
provide(PiPWindowSymbol, pipWindow); provide(PopOutWindowKey, popOutWindow);
useProvideTooltipAppendTo(tooltipContainer); useProvideTooltipAppendTo(tooltipContainer);
async function showPip() { async function showPopOut() {
if (!content.value) { if (!content.value) {
return; return;
} }
pipWindow.value = if (!popOutWindow.value) {
pipWindow.value ?? // Chrome ignores these options but effective in Firefox
(await window.documentPictureInPicture?.requestWindow({ const options = `popup=yes,width=${initialWidth},height=${initialHeight},left=100,top=100,toolbar=no,menubar=no,scrollbars=yes,resizable=yes`;
width: initialWidth,
height: initialHeight,
disallowReturnToOpener: true,
}));
// Copy style sheets over from the initial document popOutWindow.value = window.open('', '_blank', options) ?? undefined;
// so that the content looks the same. }
[...document.styleSheets].forEach((styleSheet) => {
if (!popOutWindow.value) {
return;
}
copyFavicon(window, popOutWindow.value);
for (const styleSheet of [...document.styleSheets]) {
try { try {
const cssRules = [...styleSheet.cssRules].map((rule) => rule.cssText).join(''); const cssRules = [...styleSheet.cssRules].map((rule) => rule.cssText).join('');
const style = document.createElement('style'); const style = document.createElement('style');
style.textContent = cssRules; style.textContent = cssRules;
pipWindow.value?.document.head.appendChild(style); popOutWindow.value.document.head.appendChild(style);
} catch (e) { } catch (e) {
const link = document.createElement('link'); const link = document.createElement('link');
@@ -122,18 +135,18 @@ export function usePiPWindow({
link.type = styleSheet.type; link.type = styleSheet.type;
link.media = styleSheet.media as unknown as string; link.media = styleSheet.media as unknown as string;
link.href = styleSheet.href 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. // Move the content to child window.
pipWindow.value?.document.body.append(content.value); popOutWindow.value.document.body.append(content.value);
pipWindow.value?.addEventListener('pagehide', () => !isUnmounting.value && onRequestClose()); popOutWindow.value.addEventListener('pagehide', () => !isUnmounting.value && onRequestClose());
} }
function hidePiP() { function hidePopOut() {
pipWindow.value?.close(); popOutWindow.value?.close();
pipWindow.value = undefined; popOutWindow.value = undefined;
if (content.value) { if (content.value) {
container.value?.appendChild(content.value); container.value?.appendChild(content.value);
@@ -141,17 +154,15 @@ export function usePiPWindow({
} }
// `requestAnimationFrame()` to make sure the content is already rendered // `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, 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( watch(
[() => uiStore.appliedTheme, pipWindow], [title, popOutWindow],
([theme, pip]) => { ([newTitle, win]) => {
if (pip) { if (win) {
applyThemeToBody(theme, pip); documentTitle.set(newTitle);
} }
}, },
{ immediate: true }, { immediate: true },
@@ -163,8 +174,11 @@ export function usePiPWindow({
onBeforeUnmount(() => { onBeforeUnmount(() => {
isUnmounting.value = true; isUnmounting.value = true;
pipWindow.value?.close(); if (popOutWindow.value) {
popOutWindow.value.close();
onRequestClose();
}
}); });
return { canPopOut, isPoppedOut, pipWindow }; return { canPopOut, isPoppedOut, popOutWindow };
} }