mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(editor): Popping logs out into a new window (#13788)
This commit is contained in:
@@ -174,7 +174,7 @@ describe('CanvasChat', () => {
|
||||
|
||||
return matchedNode;
|
||||
});
|
||||
workflowsStore.isChatPanelOpen = true;
|
||||
workflowsStore.chatPanelState = 'attached';
|
||||
workflowsStore.isLogsPanelOpen = true;
|
||||
workflowsStore.getWorkflowExecution = mockWorkflowExecution as unknown as IExecutionResponse;
|
||||
workflowsStore.getPastChatMessages = ['Previous message 1', 'Previous message 2'];
|
||||
@@ -197,7 +197,7 @@ describe('CanvasChat', () => {
|
||||
});
|
||||
|
||||
it('should not render chat when panel is closed', async () => {
|
||||
workflowsStore.isChatPanelOpen = false;
|
||||
workflowsStore.chatPanelState = 'closed';
|
||||
const { queryByTestId } = renderComponent();
|
||||
await waitFor(() => {
|
||||
expect(queryByTestId('canvas-chat')).not.toBeInTheDocument();
|
||||
@@ -338,17 +338,12 @@ describe('CanvasChat', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should refresh session with confirmation when messages exist', async () => {
|
||||
const { getByTestId, getByRole } = renderComponent();
|
||||
it('should refresh session when messages exist', async () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
|
||||
const originalSessionId = getByTestId('chat-session-id').textContent;
|
||||
await userEvent.click(getByTestId('refresh-session-button'));
|
||||
|
||||
const confirmButton = getByRole('dialog').querySelector('button.btn--confirm');
|
||||
|
||||
if (!confirmButton) throw new Error('Confirm button not found');
|
||||
await userEvent.click(confirmButton);
|
||||
|
||||
expect(getByTestId('chat-session-id').textContent).not.toEqual(originalSessionId);
|
||||
});
|
||||
});
|
||||
@@ -392,7 +387,7 @@ describe('CanvasChat', () => {
|
||||
isLoading: computed(() => false),
|
||||
});
|
||||
|
||||
workflowsStore.isChatPanelOpen = true;
|
||||
workflowsStore.chatPanelState = 'attached';
|
||||
workflowsStore.allowFileUploads = true;
|
||||
});
|
||||
|
||||
@@ -554,7 +549,7 @@ describe('CanvasChat', () => {
|
||||
});
|
||||
|
||||
// Close chat panel
|
||||
workflowsStore.isChatPanelOpen = false;
|
||||
workflowsStore.chatPanelState = 'closed';
|
||||
await waitFor(() => {
|
||||
expect(canvasStore.setPanelHeight).toHaveBeenCalledWith(0);
|
||||
});
|
||||
@@ -564,14 +559,14 @@ describe('CanvasChat', () => {
|
||||
const { unmount, rerender } = renderComponent();
|
||||
|
||||
// Set initial state
|
||||
workflowsStore.isChatPanelOpen = true;
|
||||
workflowsStore.chatPanelState = 'attached';
|
||||
workflowsStore.isLogsPanelOpen = true;
|
||||
|
||||
// Unmount and remount
|
||||
unmount();
|
||||
await rerender({});
|
||||
|
||||
expect(workflowsStore.isChatPanelOpen).toBe(true);
|
||||
expect(workflowsStore.chatPanelState).toBe('attached');
|
||||
expect(workflowsStore.isLogsPanelOpen).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -597,10 +592,10 @@ describe('CanvasChat', () => {
|
||||
getChatMessages: getChatMessagesSpy,
|
||||
});
|
||||
|
||||
workflowsStore.isChatPanelOpen = false;
|
||||
workflowsStore.chatPanelState = 'closed';
|
||||
const { rerender } = renderComponent();
|
||||
|
||||
workflowsStore.isChatPanelOpen = true;
|
||||
workflowsStore.chatPanelState = 'attached';
|
||||
await rerender({});
|
||||
|
||||
expect(getChatMessagesSpy).toHaveBeenCalled();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { Ref } from 'vue';
|
||||
import { provide, watch, computed, ref, watchEffect } from 'vue';
|
||||
import { provide, watch, computed, ref, watchEffect, useTemplateRef } from 'vue';
|
||||
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
|
||||
import type { Router } from 'vue-router';
|
||||
import { useRouter } from 'vue-router';
|
||||
@@ -26,6 +26,9 @@ import type { RunWorkflowChatPayload } from './composables/useChatMessaging';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useCanvasStore } from '@/stores/canvas.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { usePiPWindow } from '@/components/CanvasChat/composables/usePiPWindow';
|
||||
import { N8nResizeWrapper } from '@n8n/design-system';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const canvasStore = useCanvasStore();
|
||||
@@ -38,17 +41,15 @@ const messages = ref<ChatMessage[]>([]);
|
||||
const currentSessionId = ref<string>(uuid().replace(/-/g, ''));
|
||||
const isDisabled = ref(false);
|
||||
const container = ref<HTMLElement>();
|
||||
const pipContainer = useTemplateRef('pipContainer');
|
||||
const pipContent = useTemplateRef('pipContent');
|
||||
|
||||
// Computed properties
|
||||
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
|
||||
|
||||
const allConnections = computed(() => workflowsStore.allConnections);
|
||||
const isChatOpen = computed(() => {
|
||||
const result = workflowsStore.isChatPanelOpen;
|
||||
return result;
|
||||
});
|
||||
const chatPanelState = computed(() => workflowsStore.chatPanelState);
|
||||
const canvasNodes = computed(() => workflowsStore.allNodes);
|
||||
const isLogsOpen = computed(() => workflowsStore.isLogsPanelOpen);
|
||||
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
|
||||
const resultData = computed(() => workflowsStore.getWorkflowRunData);
|
||||
// Expose internal state for testing
|
||||
@@ -59,6 +60,8 @@ defineExpose({
|
||||
workflow,
|
||||
});
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const { runWorkflow } = useRunWorkflow({ router });
|
||||
|
||||
// Initialize features with injected dependencies
|
||||
@@ -97,6 +100,22 @@ const {
|
||||
onWindowResize,
|
||||
} = useResize(container);
|
||||
|
||||
const { canPopOut, isPoppedOut, pipWindow } = usePiPWindow({
|
||||
initialHeight: 400,
|
||||
initialWidth: window.document.body.offsetWidth * 0.8,
|
||||
container: pipContainer,
|
||||
content: pipContent,
|
||||
shouldPopOut: computed(() => chatPanelState.value === 'floating'),
|
||||
onRequestClose: () => {
|
||||
if (chatPanelState.value === 'closed') {
|
||||
return;
|
||||
}
|
||||
|
||||
telemetry.track('User toggled log view', { new_state: 'attached' });
|
||||
workflowsStore.setPanelState('attached');
|
||||
},
|
||||
});
|
||||
|
||||
// Extracted pure functions for better testability
|
||||
function createChatConfig(params: {
|
||||
messages: Chat['messages'];
|
||||
@@ -169,7 +188,7 @@ const handleRefreshSession = () => {
|
||||
};
|
||||
|
||||
const closePanel = () => {
|
||||
workflowsStore.setPanelOpen('chat', false);
|
||||
workflowsStore.setPanelState('closed');
|
||||
};
|
||||
|
||||
// This function creates a promise that resolves when the workflow execution completes
|
||||
@@ -211,6 +230,11 @@ async function onRunChatWorkflow(payload: RunWorkflowChatPayload) {
|
||||
return;
|
||||
}
|
||||
|
||||
function onPopOut() {
|
||||
telemetry.track('User toggled log view', { new_state: 'floating' });
|
||||
workflowsStore.setPanelState('floating');
|
||||
}
|
||||
|
||||
// Initialize chat config
|
||||
const { chatConfig, chatOptions } = createChatConfig({
|
||||
messages,
|
||||
@@ -228,9 +252,9 @@ provide(ChatOptionsSymbol, chatOptions);
|
||||
|
||||
// Watchers
|
||||
watch(
|
||||
() => isChatOpen.value,
|
||||
(isOpen) => {
|
||||
if (isOpen) {
|
||||
chatPanelState,
|
||||
(state) => {
|
||||
if (state !== 'closed') {
|
||||
setChatTriggerNode();
|
||||
setConnectedNode();
|
||||
|
||||
@@ -262,59 +286,91 @@ watch(
|
||||
);
|
||||
|
||||
watchEffect(() => {
|
||||
canvasStore.setPanelHeight(isChatOpen.value || isLogsOpen.value ? height.value : 0);
|
||||
canvasStore.setPanelHeight(chatPanelState.value === 'attached' ? height.value : 0);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nResizeWrapper
|
||||
v-if="chatTriggerNode"
|
||||
:is-resizing-enabled="isChatOpen || isLogsOpen"
|
||||
:supported-directions="['top']"
|
||||
:class="[$style.resizeWrapper, !isChatOpen && !isLogsOpen && $style.empty]"
|
||||
:height="height"
|
||||
:style="rootStyles"
|
||||
@resize="onResizeDebounced"
|
||||
>
|
||||
<div ref="container" :class="[$style.container, 'ignore-key-press-canvas']" tabindex="0">
|
||||
<div v-if="isChatOpen || isLogsOpen" :class="$style.chatResizer">
|
||||
<n8n-resize-wrapper
|
||||
v-if="isChatOpen"
|
||||
:supported-directions="['right']"
|
||||
:width="chatWidth"
|
||||
:class="$style.chat"
|
||||
@resize="onResizeChatDebounced"
|
||||
>
|
||||
<div :class="$style.inner">
|
||||
<ChatMessagesPanel
|
||||
data-test-id="canvas-chat"
|
||||
:messages="messages"
|
||||
:session-id="currentSessionId"
|
||||
:past-chat-messages="previousChatMessages"
|
||||
:show-close-button="!connectedNode"
|
||||
@close="closePanel"
|
||||
@refresh-session="handleRefreshSession"
|
||||
@display-execution="handleDisplayExecution"
|
||||
@send-message="sendMessage"
|
||||
/>
|
||||
<div ref="pipContainer">
|
||||
<div ref="pipContent" :class="$style.pipContent">
|
||||
<N8nResizeWrapper
|
||||
v-if="chatTriggerNode"
|
||||
:is-resizing-enabled="!isPoppedOut && chatPanelState === 'attached'"
|
||||
:supported-directions="['top']"
|
||||
:class="[$style.resizeWrapper, chatPanelState === 'closed' && $style.empty]"
|
||||
:height="height"
|
||||
:style="rootStyles"
|
||||
@resize="onResizeDebounced"
|
||||
>
|
||||
<div ref="container" :class="[$style.container, 'ignore-key-press-canvas']" tabindex="0">
|
||||
<div v-if="chatPanelState !== 'closed'" :class="$style.chatResizer">
|
||||
<N8nResizeWrapper
|
||||
:supported-directions="['right']"
|
||||
:width="chatWidth"
|
||||
:class="$style.chat"
|
||||
:window="pipWindow"
|
||||
@resize="onResizeChatDebounced"
|
||||
>
|
||||
<div :class="$style.inner">
|
||||
<ChatMessagesPanel
|
||||
data-test-id="canvas-chat"
|
||||
:messages="messages"
|
||||
:session-id="currentSessionId"
|
||||
:past-chat-messages="previousChatMessages"
|
||||
:show-close-button="!isPoppedOut && !connectedNode"
|
||||
@close="closePanel"
|
||||
@refresh-session="handleRefreshSession"
|
||||
@display-execution="handleDisplayExecution"
|
||||
@send-message="sendMessage"
|
||||
/>
|
||||
</div>
|
||||
</N8nResizeWrapper>
|
||||
<div v-if="connectedNode" :class="$style.logs">
|
||||
<ChatLogsPanel
|
||||
:key="`${resultData?.length ?? messages?.length}`"
|
||||
:workflow="workflow"
|
||||
data-test-id="canvas-chat-logs"
|
||||
:node="connectedNode"
|
||||
:slim="logsWidth < 700"
|
||||
>
|
||||
<template #actions>
|
||||
<n8n-icon-button
|
||||
v-if="canPopOut && !isPoppedOut"
|
||||
icon="pop-out"
|
||||
type="secondary"
|
||||
size="medium"
|
||||
@click="onPopOut"
|
||||
/>
|
||||
<n8n-icon-button
|
||||
v-if="!isPoppedOut"
|
||||
outline
|
||||
icon="times"
|
||||
type="secondary"
|
||||
size="medium"
|
||||
@click="closePanel"
|
||||
/>
|
||||
</template>
|
||||
</ChatLogsPanel>
|
||||
</div>
|
||||
</div>
|
||||
</n8n-resize-wrapper>
|
||||
<div v-if="isLogsOpen && connectedNode" :class="$style.logs">
|
||||
<ChatLogsPanel
|
||||
:key="`${resultData?.length ?? messages?.length}`"
|
||||
:workflow="workflow"
|
||||
data-test-id="canvas-chat-logs"
|
||||
:node="connectedNode"
|
||||
:slim="logsWidth < 700"
|
||||
@close="closePanel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</N8nResizeWrapper>
|
||||
</div>
|
||||
</N8nResizeWrapper>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
@media all and (display-mode: picture-in-picture) {
|
||||
.resizeWrapper {
|
||||
height: 100% !important;
|
||||
max-height: 100vh !important;
|
||||
}
|
||||
}
|
||||
|
||||
.pipContent {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.resizeWrapper {
|
||||
height: var(--panel-height);
|
||||
min-height: 4rem;
|
||||
|
||||
@@ -3,16 +3,14 @@ import type { INode, Workflow } from 'n8n-workflow';
|
||||
import RunDataAi from '@/components/RunDataAi/RunDataAi.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
defineProps<{
|
||||
node: INode | null;
|
||||
slim?: boolean;
|
||||
workflow: Workflow;
|
||||
}>();
|
||||
|
||||
defineSlots<{ actions: {} }>();
|
||||
|
||||
const locale = useI18n();
|
||||
</script>
|
||||
|
||||
@@ -27,14 +25,9 @@ const locale = useI18n();
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<n8n-icon-button
|
||||
:class="$style.close"
|
||||
outline
|
||||
icon="times"
|
||||
type="secondary"
|
||||
size="mini"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
<div :class="$style.actions">
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</header>
|
||||
<div :class="$style.logs">
|
||||
<RunDataAi
|
||||
@@ -62,10 +55,6 @@ const locale = useI18n();
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.close {
|
||||
border: none;
|
||||
}
|
||||
|
||||
span {
|
||||
font-weight: 100;
|
||||
}
|
||||
@@ -88,4 +77,13 @@ const locale = useI18n();
|
||||
flex-grow: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,8 +7,6 @@ import MessageOptionAction from './MessageOptionAction.vue';
|
||||
import { chatEventBus } from '@n8n/chat/event-buses';
|
||||
import type { ArrowKeyDownPayload } from '@n8n/chat/components/Input.vue';
|
||||
import ChatInput from '@n8n/chat/components/Input.vue';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import { MODAL_CONFIRM } from '@/constants';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useClipboard } from '@/composables/useClipboard';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
@@ -29,7 +27,6 @@ const emit = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const messageComposable = useMessage();
|
||||
const clipboard = useClipboard();
|
||||
const locale = useI18n();
|
||||
const toast = useToast();
|
||||
@@ -62,25 +59,8 @@ function sendMessage(message: string) {
|
||||
emit('sendMessage', message);
|
||||
}
|
||||
|
||||
async function onRefreshSession() {
|
||||
// If there are no messages, refresh the session without asking
|
||||
if (props.messages.length === 0) {
|
||||
emit('refreshSession');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmResult = await messageComposable.confirm(
|
||||
locale.baseText('chat.window.session.reset.warning'),
|
||||
{
|
||||
title: locale.baseText('chat.window.session.reset.title'),
|
||||
type: 'warning',
|
||||
confirmButtonText: locale.baseText('chat.window.session.reset.confirm'),
|
||||
showClose: true,
|
||||
},
|
||||
);
|
||||
if (confirmResult === MODAL_CONFIRM) {
|
||||
emit('refreshSession');
|
||||
}
|
||||
function onRefreshSession() {
|
||||
emit('refreshSession');
|
||||
}
|
||||
|
||||
function onArrowKeyDown({ currentInputValue, key }: ArrowKeyDownPayload) {
|
||||
@@ -131,8 +111,9 @@ function onArrowKeyDown({ currentInputValue, key }: ArrowKeyDownPayload) {
|
||||
previousMessageIndex.value = 0;
|
||||
}
|
||||
}
|
||||
function copySessionId() {
|
||||
void clipboard.copy(props.sessionId);
|
||||
|
||||
async function copySessionId() {
|
||||
await clipboard.copy(props.sessionId);
|
||||
toast.showMessage({
|
||||
title: locale.baseText('generic.copiedToClipboard'),
|
||||
message: '',
|
||||
@@ -151,9 +132,12 @@ function copySessionId() {
|
||||
<template #content>
|
||||
{{ sessionId }}
|
||||
</template>
|
||||
<span :class="$style.sessionId" data-test-id="chat-session-id" @click="copySessionId">{{
|
||||
sessionId
|
||||
}}</span>
|
||||
<span
|
||||
:class="[$style.sessionId, clipboard.isSupported.value ? $style.copyable : '']"
|
||||
data-test-id="chat-session-id"
|
||||
@click="clipboard.isSupported.value ? copySessionId() : null"
|
||||
>{{ sessionId }}</span
|
||||
>
|
||||
</n8n-tooltip>
|
||||
<n8n-icon-button
|
||||
:class="$style.headerButton"
|
||||
@@ -162,7 +146,7 @@ function copySessionId() {
|
||||
type="secondary"
|
||||
size="mini"
|
||||
icon="undo"
|
||||
:title="locale.baseText('chat.window.session.reset.confirm')"
|
||||
:title="locale.baseText('chat.window.session.reset')"
|
||||
@click="onRefreshSession"
|
||||
/>
|
||||
<n8n-icon-button
|
||||
@@ -293,7 +277,9 @@ function copySessionId() {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
|
||||
cursor: pointer;
|
||||
&.copyable {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.headerButton {
|
||||
max-height: 1.1rem;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/* 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';
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
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,109 @@
|
||||
import { IsInPiPWindowSymbol } from '@/constants';
|
||||
import { useProvideTooltipAppendTo } from '@n8n/design-system/composables/useTooltipAppendTo';
|
||||
import {
|
||||
computed,
|
||||
type ComputedRef,
|
||||
onBeforeUnmount,
|
||||
provide,
|
||||
type Ref,
|
||||
ref,
|
||||
type ShallowRef,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
interface UsePiPWindowOptions {
|
||||
initialWidth?: number;
|
||||
initialHeight?: number;
|
||||
container: Readonly<ShallowRef<HTMLDivElement | null>>;
|
||||
content: Readonly<ShallowRef<HTMLDivElement | null>>;
|
||||
shouldPopOut: ComputedRef<boolean>;
|
||||
onRequestClose: () => void;
|
||||
}
|
||||
|
||||
interface UsePiPWindowReturn {
|
||||
isPoppedOut: ComputedRef<boolean>;
|
||||
canPopOut: ComputedRef<boolean>;
|
||||
pipWindow?: Ref<Window | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A composable that allows to pop out given content in document PiP (picture-in-picture) window
|
||||
*/
|
||||
export function usePiPWindow({
|
||||
container,
|
||||
content,
|
||||
initialHeight,
|
||||
initialWidth,
|
||||
shouldPopOut,
|
||||
onRequestClose,
|
||||
}: UsePiPWindowOptions): UsePiPWindowReturn {
|
||||
const pipWindow = ref<Window>();
|
||||
const isUnmounting = ref(false);
|
||||
const canPopOut = computed(() => !!window.documentPictureInPicture);
|
||||
const isPoppedOut = computed(() => !!pipWindow.value);
|
||||
const tooltipContainer = computed(() =>
|
||||
isPoppedOut.value ? (content.value ?? undefined) : undefined,
|
||||
);
|
||||
|
||||
provide(IsInPiPWindowSymbol, isPoppedOut);
|
||||
useProvideTooltipAppendTo(tooltipContainer);
|
||||
|
||||
async function showPip() {
|
||||
if (!content.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
pipWindow.value =
|
||||
pipWindow.value ??
|
||||
(await window.documentPictureInPicture?.requestWindow({
|
||||
width: initialWidth,
|
||||
height: initialHeight,
|
||||
disallowReturnToOpener: true,
|
||||
}));
|
||||
|
||||
// Copy style sheets over from the initial document
|
||||
// so that the content looks the same.
|
||||
[...document.styleSheets].forEach((styleSheet) => {
|
||||
try {
|
||||
const cssRules = [...styleSheet.cssRules].map((rule) => rule.cssText).join('');
|
||||
const style = document.createElement('style');
|
||||
|
||||
style.textContent = cssRules;
|
||||
pipWindow.value?.document.head.appendChild(style);
|
||||
} catch (e) {
|
||||
const link = document.createElement('link');
|
||||
|
||||
link.rel = 'stylesheet';
|
||||
link.type = styleSheet.type;
|
||||
link.media = styleSheet.media as unknown as string;
|
||||
link.href = styleSheet.href as string;
|
||||
pipWindow.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());
|
||||
}
|
||||
|
||||
function hidePiP() {
|
||||
pipWindow.value?.close();
|
||||
pipWindow.value = undefined;
|
||||
|
||||
if (content.value) {
|
||||
container.value?.appendChild(content.value);
|
||||
}
|
||||
}
|
||||
|
||||
// `requestAnimationFrame()` to make sure the content is already rendered
|
||||
watch(shouldPopOut, (value) => (value ? requestAnimationFrame(showPip) : hidePiP()), {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
isUnmounting.value = true;
|
||||
pipWindow.value?.close();
|
||||
});
|
||||
|
||||
return { canPopOut, isPoppedOut, pipWindow };
|
||||
}
|
||||
@@ -39,7 +39,7 @@ const uiStore = useUIStore();
|
||||
const { runEntireWorkflow } = useRunWorkflow({ router });
|
||||
const { toggleChatOpen } = useCanvasOperations({ router });
|
||||
|
||||
const isChatOpen = computed(() => workflowsStore.isChatPanelOpen);
|
||||
const isChatOpen = computed(() => workflowsStore.chatPanelState !== 'closed');
|
||||
const isExecuting = computed(() => uiStore.isActionActive.workflowRunning);
|
||||
const testId = computed(() => `execute-workflow-button-${name}`);
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user