Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions playwright/e2e/page/simple-table.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,18 @@ test.describe('SimpleTable', () => {
expect(metrics.cardWidth).toBeLessThanOrEqual(metrics.cellContentWidth + 1);
expect(metrics.cellWidth).toBeLessThanOrEqual(initialCellWidth + 1);

const layout = await card.evaluate((cardEl) => {
const title = cardEl.querySelector('.link-preview-title');

return {
flexDirection: getComputedStyle(cardEl).flexDirection,
titleWhiteSpace: title ? getComputedStyle(title).whiteSpace : '',
};
});

expect(layout.flexDirection).toBe('column');
expect(layout.titleWhiteSpace).toBe('normal');

const urlCell = getCell(page, 0, 1);
const plainUrl = 'https://appflowy.io/simple-table-url-layout';

Expand Down
10 changes: 9 additions & 1 deletion src/application/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1402,7 +1402,7 @@ export interface ViewComponentProps {
updatePage?: (viewId: string, data: UpdatePagePayload) => Promise<void>;
addPage?: (parentId: string, payload: CreatePagePayload) => Promise<CreatePageResponse>;
deletePage?: (viewId: string) => Promise<void>;
duplicatePage?: (viewId: string, options?: DuplicatePageOptions) => Promise<void>;
duplicatePage?: (viewId: string, options?: DuplicatePageOperationOptions) => Promise<void>;
openPageModal?: (viewId: string) => void;
variant?: UIVariant;
isTemplateThumb?: boolean;
Expand Down Expand Up @@ -1452,6 +1452,14 @@ export interface DuplicatePageOptions {
source?: number;
}

export interface DuplicatePageOperationOptions extends DuplicatePageOptions {
/**
* Client-only lifecycle hook. Runs after the pre-duplicate collab sync and
* before the duplicate API request; it is not sent to the server.
*/
afterPreSync?: () => Promise<void>;
}

export interface CreateDatabaseViewPayload {
parent_view_id: string;
/** Insert the new database view after this sibling. When omitted the backend prepends. */
Expand Down
4 changes: 2 additions & 2 deletions src/components/app/contexts/AppOperationsContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { SyncContext } from '@/application/services/js-services/sync-protocol';
import {
CreateDatabaseViewPayload,
CreateDatabaseViewResponse,
DuplicatePageOptions,
DuplicatePageOperationOptions,
CreatePagePayload,
CreatePageResponse,
CreateRow,
Expand Down Expand Up @@ -69,7 +69,7 @@ export interface AppOperationsContextType {
/** Soft-delete a page (move to trash). */
deletePage?: (viewId: string) => Promise<void>;
/** Duplicate a page, optionally refreshing its parent children in the outline. */
duplicatePage?: (viewId: string, options?: DuplicatePageOptions) => Promise<void>;
duplicatePage?: (viewId: string, options?: DuplicatePageOperationOptions) => Promise<void>;
/** Update page properties (name, cover, etc.). */
updatePage?: (viewId: string, payload: UpdatePagePayload) => Promise<void>;
/** Update just the page icon. */
Expand Down
16 changes: 10 additions & 6 deletions src/components/app/hooks/usePageOperations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
} from '@/application/services/js-services/http/publish-api';
import {
CreateDatabaseViewPayload,
DuplicatePageOptions,
DuplicatePageOperationOptions,
CreatePagePayload,
CreateSpacePayload,
Role,
Expand Down Expand Up @@ -154,11 +154,13 @@ export function usePageOperations({
);

const duplicatePage = useCallback(
async (viewId: string, options: DuplicatePageOptions = {}) => {
async (viewId: string, options: DuplicatePageOperationOptions = {}) => {
if (!currentWorkspaceId) {
throw new Error('No workspace or service found');
}

const { afterPreSync, ...duplicateOptions } = options;

try {
// Sync all collab documents to the server via HTTP API before duplicating.
// This ensures the server has the latest data (including unregistered row
Expand All @@ -174,12 +176,14 @@ export function usePageOperations({
await flushAllSync?.();
}

await PageService.duplicate(currentWorkspaceId, viewId, options);
await afterPreSync?.();

await PageService.duplicate(currentWorkspaceId, viewId, duplicateOptions);
await loadOutline?.(currentWorkspaceId, false);

if (options.parentViewId) {
ViewService.invalidateCache(currentWorkspaceId, options.parentViewId);
await loadViewChildren?.(options.parentViewId);
if (duplicateOptions.parentViewId) {
ViewService.invalidateCache(currentWorkspaceId, duplicateOptions.parentViewId);
await loadViewChildren?.(duplicateOptions.parentViewId);
}
} catch (e) {
return Promise.reject(e);
Expand Down
4 changes: 2 additions & 2 deletions src/components/editor/EditorContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
CreatePageResponse,
CreateDatabaseViewPayload,
CreateDatabaseViewResponse,
DuplicatePageOptions,
DuplicatePageOperationOptions,
TextCount,
LoadDatabasePrompts,
TestDatabasePromptConfig,
Expand Down Expand Up @@ -85,7 +85,7 @@ export interface EditorContextState {
onRendered?: () => void;
addPage?: (parentId: string, payload: CreatePagePayload) => Promise<CreatePageResponse>;
deletePage?: (viewId: string) => Promise<void>;
duplicatePage?: (viewId: string, options?: DuplicatePageOptions) => Promise<void>;
duplicatePage?: (viewId: string, options?: DuplicatePageOperationOptions) => Promise<void>;
openPageModal?: (viewId: string) => void;
loadViews?: (variant?: UIVariant) => Promise<View[] | undefined>;
createDatabaseView?: (viewId: string, payload: CreateDatabaseViewPayload) => Promise<CreateDatabaseViewResponse>;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,27 @@
import axios from 'axios';
import { forwardRef, memo, useCallback, useEffect, useRef, useState } from 'react';
import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Element } from 'slate';
import { useReadOnly, useSlateStatic } from 'slate-react';

import { YjsEditor } from '@/application/slate-yjs';
import { BlockType, LinkPreviewType } from '@/application/types';
import { ReactComponent as LinkIcon } from '@/assets/icons/link.svg';
import emptyImageSrc from '@/assets/images/empty.png';
import { usePopoverContext } from '@/components/editor/components/block-popover/BlockPopoverContext';
import { EditorElementProps, LinkPreviewNode } from '@/components/editor/editor.type';
import { buildFallbackLinkPreviewData, fetchLinkPreviewData, LinkPreviewData } from '@/utils/link-preview';
import { openUrl } from '@/utils/url';

interface RemoteLinkPreviewData {
data: LinkPreviewData;
url: string;
}

export const LinkPreview = memo(
forwardRef<HTMLDivElement, EditorElementProps<LinkPreviewNode>>(({ node, children, ...attributes }, ref) => {
const [data, setData] = useState<{
image?: { url: string };
title: string;
description: string;
} | null>(null);
const [notFound, setNotFound] = useState<boolean>(false);
const [remotePreview, setRemotePreview] = useState<RemoteLinkPreviewData | null>(null);
const url = node.data.url;
const fallbackData = useMemo(() => (url ? buildFallbackLinkPreviewData(url) : null), [url]);
const remoteData = remotePreview && remotePreview.url === url ? remotePreview.data : null;
const data = remoteData ?? fallbackData;
const previewType = node.data.preview_type ?? LinkPreviewType.Bookmark;
const isEmbed = previewType === LinkPreviewType.Embed;
const editor = useSlateStatic() as YjsEditor;
Expand All @@ -28,28 +30,31 @@ export const LinkPreview = memo(
const { openPopover } = usePopoverContext();

useEffect(() => {
if (!url) return;
if (!url) {
setRemotePreview(null);
return;
}

const controller = new AbortController();

setData(null);
setRemotePreview(null);
void (async () => {
try {
setNotFound(false);
const response = await axios.get(`https://api.microlink.io/?url=${url}`);
const data = await fetchLinkPreviewData(url, controller.signal);

if (response.data.statusCode !== 200) {
setNotFound(true);
return;
if (!controller.signal.aborted) {
setRemotePreview({ url, data });
}

const data = response.data.data;

setData(data);
} catch (_) {
setNotFound(true);
if (!controller.signal.aborted) {
setRemotePreview(null);
}
}
})();

return () => controller.abort();
}, [url]);
const imageUrl = data?.image?.url;
const imageUrl = data?.image?.url || data?.logo?.url;
const handleClick = useCallback(() => {
if (!url) {
if (!readOnly && emptyRef.current) {
Expand Down Expand Up @@ -81,28 +86,6 @@ export const LinkPreview = memo(
<LinkIcon className={'h-6 w-6 flex-none'} />
<div className={'truncate'}>{isEmbed ? 'Paste a link to embed' : 'Paste a link to create a bookmark'}</div>
</div>
) : notFound ? (
<div className={`link-preview-not-found flex w-full min-w-0 ${isEmbed ? 'flex-col' : 'items-center'}`}>
{!isEmbed && (
<div
className={
'link-preview-empty-thumb mr-2 flex h-[80px] w-[120px] min-w-[80px] items-center justify-center rounded border text-text-primary'
}
>
<img
src={emptyImageSrc}
alt={'Empty state'}
className={'link-preview-empty-image h-full object-cover object-center'}
/>
</div>
)}
<div className={`link-preview-content flex min-w-0 flex-1 flex-col ${isEmbed ? 'p-4' : ''}`}>
<div className={'link-preview-title text-function-error'}>
The link cannot be previewed. Click to open in a new tab.
</div>
<div className={'link-preview-url text-sm text-text-secondary'}>{url}</div>
</div>
</div>
) : (
<>
{imageUrl && (
Expand All @@ -128,11 +111,15 @@ export const LinkPreview = memo(
>
{data?.title}
</div>
<div
className={'link-preview-description max-h-[64px] overflow-hidden truncate text-sm text-text-primary'}
>
{data?.description}
</div>
{data?.description && (
<div
className={
'link-preview-description max-h-[64px] overflow-hidden truncate text-sm text-text-primary'
}
>
{data.description}
</div>
)}
<div className={'link-preview-url truncate whitespace-nowrap text-xs text-text-secondary'}>{url}</div>
</div>
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,41 +1,61 @@
import axios from 'axios';
import React, { useEffect } from 'react';

import { buildFallbackLinkPreviewData, fetchLinkPreviewData, LinkPreviewData } from '@/utils/link-preview';

interface RemoteLinkPreviewData {
data: LinkPreviewData;
url: string;
}

function MentionExternalLink ({
url,
}: {
url: string;
}) {
const [data, setData] = React.useState<{ title?: string; logo?: string } | undefined>(undefined);
const fallbackData = React.useMemo(() => buildFallbackLinkPreviewData(url), [url]);
const [remotePreview, setRemotePreview] = React.useState<RemoteLinkPreviewData | null>(null);
const data = remotePreview && remotePreview.url === url ? remotePreview.data : fallbackData;

useEffect(() => {
void axios.get(`https://api.microlink.io/?url=${url}`).then((data) => {
setData({
title: data.data.data.title,
logo: data.data.data.logo.url,
});
},
);
const controller = new AbortController();

setRemotePreview(null);
void fetchLinkPreviewData(url, controller.signal)
.then((data) => {
if (!controller.signal.aborted) {
setRemotePreview({ url, data });
}
})
.catch(() => {
if (!controller.signal.aborted) {
setRemotePreview(null);
}
});

return () => controller.abort();
}, [url]);

const imageUrl = data.logo?.url || data.image?.url;

return (
<span
onClick={() => {
window.open(url, '_blank');
}}
className={'cursor-pointer inline-flex gap-1.5 text-text-primary hover:underline'}
>
{data?.logo && (
{imageUrl && (
<span className={'mt-0.5'}>
<img
className={'object-cover w-5 h-5'}
src={data.logo}
alt={data.logo}
src={imageUrl}
alt={data.title}
/>
</span>
)}
<span className={'leading-[24px]'}>{data?.title || url}</span>
<span className={'leading-[24px]'}>{data.title || url}</span>
</span>
);
}

export default MentionExternalLink;
export default MentionExternalLink;
Loading
Loading