mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 10:31:15 +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;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 { 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 };
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user