mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-06 03:49:56 +08:00
refactor(editor): unify directories naming (#11516)
**Directory Structure Changes** - Renamed multiple block-related directories by removing the "block-" prefix: - `block-attachment` → `attachment` - `block-bookmark` → `bookmark` - `block-callout` → `callout` - `block-code` → `code` - `block-data-view` → `data-view` - `block-database` → `database` - `block-divider` → `divider` - `block-edgeless-text` → `edgeless-text` - `block-embed` → `embed`
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
export * from './providers';
|
||||
export * from './toolbar';
|
||||
@@ -0,0 +1,42 @@
|
||||
import { EmbedIframeConfigExtension } from '@blocksuite/affine-shared/services';
|
||||
|
||||
import {
|
||||
type EmbedIframeUrlValidationOptions,
|
||||
validateEmbedIframeUrl,
|
||||
} from '../../utils';
|
||||
|
||||
const EXCALIDRAW_DEFAULT_WIDTH_IN_SURFACE = 640;
|
||||
const EXCALIDRAW_DEFAULT_HEIGHT_IN_SURFACE = 480;
|
||||
const EXCALIDRAW_DEFAULT_HEIGHT_IN_NOTE = 480;
|
||||
const EXCALIDRAW_DEFAULT_WIDTH_PERCENT = 100;
|
||||
|
||||
const excalidrawUrlValidationOptions: EmbedIframeUrlValidationOptions = {
|
||||
protocols: ['https:'],
|
||||
hostnames: ['excalidraw.com'],
|
||||
};
|
||||
|
||||
const excalidrawConfig = {
|
||||
name: 'excalidraw',
|
||||
match: (url: string) =>
|
||||
validateEmbedIframeUrl(url, excalidrawUrlValidationOptions),
|
||||
buildOEmbedUrl: (url: string) => {
|
||||
const match = validateEmbedIframeUrl(url, excalidrawUrlValidationOptions);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
return url;
|
||||
},
|
||||
useOEmbedUrlDirectly: true,
|
||||
options: {
|
||||
widthInSurface: EXCALIDRAW_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: EXCALIDRAW_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
heightInNote: EXCALIDRAW_DEFAULT_HEIGHT_IN_NOTE,
|
||||
widthPercent: EXCALIDRAW_DEFAULT_WIDTH_PERCENT,
|
||||
allow: 'clipboard-read; clipboard-write',
|
||||
style: 'border: none; border-radius: 8px;',
|
||||
allowFullscreen: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const ExcalidrawEmbedConfig =
|
||||
EmbedIframeConfigExtension(excalidrawConfig);
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import { EmbedIframeConfigExtension } from '@blocksuite/affine-shared/services';
|
||||
|
||||
import {
|
||||
type EmbedIframeUrlValidationOptions,
|
||||
validateEmbedIframeUrl,
|
||||
} from '../../utils';
|
||||
|
||||
const GOOGLE_DOCS_DEFAULT_WIDTH_IN_SURFACE = 800;
|
||||
const GOOGLE_DOCS_DEFAULT_HEIGHT_IN_SURFACE = 600;
|
||||
const GOOGLE_DOCS_DEFAULT_WIDTH_PERCENT = 100;
|
||||
const GOOGLE_DOCS_DEFAULT_HEIGHT_IN_NOTE = 600;
|
||||
|
||||
const googleDocsUrlValidationOptions: EmbedIframeUrlValidationOptions = {
|
||||
protocols: ['https:'],
|
||||
hostnames: ['docs.google.com'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the URL has a valid sharing parameter
|
||||
* @param parsedUrl Parsed URL object
|
||||
* @returns Boolean indicating if the URL has a valid sharing parameter
|
||||
*/
|
||||
function hasValidSharingParam(parsedUrl: URL): boolean {
|
||||
const usp = parsedUrl.searchParams.get('usp');
|
||||
return usp === 'sharing';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a URL is a valid Google Docs URL
|
||||
* Valid format: https://docs.google.com/document/d/doc-id/edit?usp=sharing
|
||||
* @param url The URL to validate
|
||||
* @param strictMode Whether to strictly validate sharing parameters
|
||||
* @returns Boolean indicating if the URL is a valid Google Docs URL
|
||||
*/
|
||||
function isValidGoogleDocsUrl(url: string, strictMode = true): boolean {
|
||||
try {
|
||||
if (!validateEmbedIframeUrl(url, googleDocsUrlValidationOptions)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
if (strictMode && !hasValidSharingParam(parsedUrl)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
|
||||
return (
|
||||
pathSegments[0] === 'document' &&
|
||||
pathSegments[1] === 'd' &&
|
||||
pathSegments.length >= 3 &&
|
||||
!!pathSegments[2]
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Invalid Google Docs URL:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const googleDocsConfig = {
|
||||
name: 'google-docs',
|
||||
match: (url: string) => isValidGoogleDocsUrl(url),
|
||||
buildOEmbedUrl: (url: string) => {
|
||||
if (!isValidGoogleDocsUrl(url)) {
|
||||
return undefined;
|
||||
}
|
||||
return url;
|
||||
},
|
||||
useOEmbedUrlDirectly: true,
|
||||
options: {
|
||||
widthInSurface: GOOGLE_DOCS_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: GOOGLE_DOCS_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
widthPercent: GOOGLE_DOCS_DEFAULT_WIDTH_PERCENT,
|
||||
heightInNote: GOOGLE_DOCS_DEFAULT_HEIGHT_IN_NOTE,
|
||||
allowFullscreen: true,
|
||||
style: 'border: none; border-radius: 8px;',
|
||||
},
|
||||
};
|
||||
|
||||
export const GoogleDocsEmbedConfig =
|
||||
EmbedIframeConfigExtension(googleDocsConfig);
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
import { EmbedIframeConfigExtension } from '@blocksuite/affine-shared/services';
|
||||
|
||||
import {
|
||||
type EmbedIframeUrlValidationOptions,
|
||||
validateEmbedIframeUrl,
|
||||
} from '../../utils';
|
||||
|
||||
const GOOGLE_DRIVE_DEFAULT_WIDTH_IN_SURFACE = 640;
|
||||
const GOOGLE_DRIVE_DEFAULT_HEIGHT_IN_SURFACE = 480;
|
||||
const GOOGLE_DRIVE_DEFAULT_WIDTH_PERCENT = 100;
|
||||
const GOOGLE_DRIVE_DEFAULT_HEIGHT_IN_NOTE = 480;
|
||||
const GOOGLE_DRIVE_EMBED_FOLDER_URL =
|
||||
'https://drive.google.com/embeddedfolderview';
|
||||
const GOOGLE_DRIVE_EMBED_FILE_URL = 'https://drive.google.com/file/d/';
|
||||
|
||||
const googleDriveUrlValidationOptions: EmbedIframeUrlValidationOptions = {
|
||||
protocols: ['https:'],
|
||||
hostnames: ['drive.google.com'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the URL has a valid sharing parameter
|
||||
* @param parsedUrl Parsed URL object
|
||||
* @returns Boolean indicating if the URL has a valid sharing parameter
|
||||
*/
|
||||
function hasValidSharingParam(parsedUrl: URL): boolean {
|
||||
const usp = parsedUrl.searchParams.get('usp');
|
||||
return usp === 'sharing';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the url is a valid google drive file url
|
||||
* @param parsedUrl Parsed URL object
|
||||
* @returns Boolean indicating if the URL is a valid Google Drive file URL
|
||||
*/
|
||||
function isValidGoogleDriveFileUrl(parsedUrl: URL): boolean {
|
||||
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
|
||||
return (
|
||||
pathSegments[0] === 'file' &&
|
||||
pathSegments[1] === 'd' &&
|
||||
pathSegments.length >= 3 &&
|
||||
!!pathSegments[2]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the url is a valid google drive folder url
|
||||
* @param parsedUrl Parsed URL object
|
||||
* @returns Boolean indicating if the URL is a valid Google Drive folder URL
|
||||
*/
|
||||
function isValidGoogleDriveFolderUrl(parsedUrl: URL): boolean {
|
||||
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
|
||||
return (
|
||||
pathSegments[0] === 'drive' &&
|
||||
pathSegments[1] === 'folders' &&
|
||||
pathSegments.length >= 3 &&
|
||||
!!pathSegments[2]
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Validates if a URL is a valid Google Drive path URL
|
||||
* @param parsedUrl Parsed URL object
|
||||
* @returns Boolean indicating if the URL is valid
|
||||
*/
|
||||
function isValidGoogleDrivePathUrl(parsedUrl: URL): boolean {
|
||||
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
|
||||
|
||||
// Should have at least 2 segments
|
||||
if (pathSegments.length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for file pattern: /file/d/file-id/view
|
||||
if (isValidGoogleDriveFileUrl(parsedUrl)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for folder pattern: /drive/folders/folder-id
|
||||
if (isValidGoogleDriveFolderUrl(parsedUrl)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely validates if a URL is a valid Google Drive URL
|
||||
* https://drive.google.com/file/d/your-file-id/view?usp=sharing
|
||||
* https://drive.google.com/drive/folders/your-folder-id?usp=sharing
|
||||
* @param url The URL to validate
|
||||
* @param strictMode Whether to strictly validate sharing parameters
|
||||
* @returns Boolean indicating if the URL is a valid Google Drive URL
|
||||
*/
|
||||
function isValidGoogleDriveUrl(url: string, strictMode = true): boolean {
|
||||
try {
|
||||
if (!validateEmbedIframeUrl(url, googleDriveUrlValidationOptions)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
// Check sharing parameter if in strict mode
|
||||
if (strictMode && !hasValidSharingParam(parsedUrl)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check hostname and path structure
|
||||
return isValidGoogleDrivePathUrl(parsedUrl);
|
||||
} catch (e) {
|
||||
// URL parsing failed
|
||||
console.warn('Invalid Google Drive URL:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build embed URL for Google Drive files
|
||||
* @param fileId File ID
|
||||
* @returns Embed URL
|
||||
*/
|
||||
function buildGoogleDriveFileEmbedUrl(fileId: string): string | undefined {
|
||||
const embedUrl = new URL(
|
||||
'preview',
|
||||
`${GOOGLE_DRIVE_EMBED_FILE_URL}${fileId}/`
|
||||
);
|
||||
embedUrl.searchParams.set('usp', 'embed_googleplus');
|
||||
return embedUrl.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build embed URL for Google Drive folders
|
||||
* @param folderId Folder ID
|
||||
* @returns Embed URL
|
||||
*/
|
||||
function buildGoogleDriveFolderEmbedUrl(folderId: string): string | undefined {
|
||||
const embedUrl = new URL(GOOGLE_DRIVE_EMBED_FOLDER_URL);
|
||||
embedUrl.searchParams.set('id', folderId);
|
||||
embedUrl.hash = 'list';
|
||||
return embedUrl.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build embed URL for Google Drive paths
|
||||
* @param url The URL to embed
|
||||
* @returns The embed URL
|
||||
*/
|
||||
function buildGoogleDriveEmbedUrl(url: string): string | undefined {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
|
||||
|
||||
// Should have at least 2 segments
|
||||
if (pathSegments.length < 2) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Handle file URL: /file/d/file-id/view
|
||||
if (isValidGoogleDriveFileUrl(parsedUrl)) {
|
||||
return buildGoogleDriveFileEmbedUrl(pathSegments[2]);
|
||||
}
|
||||
|
||||
// Handle folder URL: /drive/folders/folder-id
|
||||
if (isValidGoogleDriveFolderUrl(parsedUrl)) {
|
||||
return buildGoogleDriveFolderEmbedUrl(pathSegments[2]);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse Google Drive path URL:', e);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const googleDriveConfig = {
|
||||
name: 'google-drive',
|
||||
match: (url: string) => isValidGoogleDriveUrl(url),
|
||||
buildOEmbedUrl: (url: string) => {
|
||||
if (!isValidGoogleDriveUrl(url)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If is a valid google drive url, build the embed url
|
||||
return buildGoogleDriveEmbedUrl(url);
|
||||
},
|
||||
useOEmbedUrlDirectly: true,
|
||||
options: {
|
||||
widthInSurface: GOOGLE_DRIVE_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: GOOGLE_DRIVE_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
widthPercent: GOOGLE_DRIVE_DEFAULT_WIDTH_PERCENT,
|
||||
heightInNote: GOOGLE_DRIVE_DEFAULT_HEIGHT_IN_NOTE,
|
||||
allowFullscreen: true,
|
||||
style: 'border: none; border-radius: 8px;',
|
||||
},
|
||||
};
|
||||
|
||||
export const GoogleDriveEmbedConfig =
|
||||
EmbedIframeConfigExtension(googleDriveConfig);
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ExcalidrawEmbedConfig } from './excalidraw';
|
||||
import { GoogleDocsEmbedConfig } from './google-docs';
|
||||
import { GoogleDriveEmbedConfig } from './google-drive';
|
||||
import { MiroEmbedConfig } from './miro';
|
||||
import { SpotifyEmbedConfig } from './spotify';
|
||||
|
||||
export const EmbedIframeConfigExtensions = [
|
||||
SpotifyEmbedConfig,
|
||||
GoogleDriveEmbedConfig,
|
||||
MiroEmbedConfig,
|
||||
ExcalidrawEmbedConfig,
|
||||
GoogleDocsEmbedConfig,
|
||||
];
|
||||
@@ -0,0 +1,46 @@
|
||||
import { EmbedIframeConfigExtension } from '@blocksuite/affine-shared/services';
|
||||
|
||||
import {
|
||||
type EmbedIframeUrlValidationOptions,
|
||||
validateEmbedIframeUrl,
|
||||
} from '../../utils';
|
||||
|
||||
const MIRO_DEFAULT_WIDTH_IN_SURFACE = 640;
|
||||
const MIRO_DEFAULT_HEIGHT_IN_SURFACE = 480;
|
||||
const MIRO_DEFAULT_HEIGHT_IN_NOTE = 480;
|
||||
const MIRO_DEFAULT_WIDTH_PERCENT = 100;
|
||||
|
||||
// https://developers.miro.com/reference/getembeddata
|
||||
const miroEndpoint = 'https://miro.com/api/v1/oembed';
|
||||
|
||||
const miroUrlValidationOptions: EmbedIframeUrlValidationOptions = {
|
||||
protocols: ['https:'],
|
||||
hostnames: ['miro.com'],
|
||||
};
|
||||
|
||||
const miroConfig = {
|
||||
name: 'miro',
|
||||
match: (url: string) => validateEmbedIframeUrl(url, miroUrlValidationOptions),
|
||||
buildOEmbedUrl: (url: string) => {
|
||||
const match = validateEmbedIframeUrl(url, miroUrlValidationOptions);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const encodedUrl = encodeURIComponent(url);
|
||||
const oEmbedUrl = `${miroEndpoint}?url=${encodedUrl}`;
|
||||
return oEmbedUrl;
|
||||
},
|
||||
useOEmbedUrlDirectly: false,
|
||||
options: {
|
||||
widthInSurface: MIRO_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: MIRO_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
heightInNote: MIRO_DEFAULT_HEIGHT_IN_NOTE,
|
||||
widthPercent: MIRO_DEFAULT_WIDTH_PERCENT,
|
||||
allow: 'clipboard-read; clipboard-write',
|
||||
style: 'border: none;',
|
||||
allowFullscreen: true,
|
||||
containerBorderRadius: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export const MiroEmbedConfig = EmbedIframeConfigExtension(miroConfig);
|
||||
@@ -0,0 +1,47 @@
|
||||
import { EmbedIframeConfigExtension } from '@blocksuite/affine-shared/services';
|
||||
|
||||
import {
|
||||
type EmbedIframeUrlValidationOptions,
|
||||
validateEmbedIframeUrl,
|
||||
} from '../../utils';
|
||||
|
||||
const SPOTIFY_DEFAULT_WIDTH_IN_SURFACE = 640;
|
||||
const SPOTIFY_DEFAULT_HEIGHT_IN_SURFACE = 152;
|
||||
const SPOTIFY_DEFAULT_HEIGHT_IN_NOTE = 152;
|
||||
const SPOTIFY_DEFAULT_WIDTH_PERCENT = 100;
|
||||
|
||||
// https://developer.spotify.com/documentation/embeds/reference/oembed
|
||||
const spotifyEndpoint = 'https://open.spotify.com/oembed';
|
||||
|
||||
const spotifyUrlValidationOptions: EmbedIframeUrlValidationOptions = {
|
||||
protocols: ['https:'],
|
||||
hostnames: ['open.spotify.com', 'spotify.link'],
|
||||
};
|
||||
|
||||
const spotifyConfig = {
|
||||
name: 'spotify',
|
||||
match: (url: string) =>
|
||||
validateEmbedIframeUrl(url, spotifyUrlValidationOptions),
|
||||
buildOEmbedUrl: (url: string) => {
|
||||
const match = validateEmbedIframeUrl(url, spotifyUrlValidationOptions);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const encodedUrl = encodeURIComponent(url);
|
||||
const oEmbedUrl = `${spotifyEndpoint}?url=${encodedUrl}`;
|
||||
return oEmbedUrl;
|
||||
},
|
||||
useOEmbedUrlDirectly: false,
|
||||
options: {
|
||||
widthInSurface: SPOTIFY_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: SPOTIFY_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
heightInNote: SPOTIFY_DEFAULT_HEIGHT_IN_NOTE,
|
||||
widthPercent: SPOTIFY_DEFAULT_WIDTH_PERCENT,
|
||||
allow: 'autoplay; clipboard-write; encrypted-media; picture-in-picture',
|
||||
style: 'border-radius: 8px;',
|
||||
allowFullscreen: true,
|
||||
containerBorderRadius: 12,
|
||||
},
|
||||
};
|
||||
|
||||
export const SpotifyEmbedConfig = EmbedIframeConfigExtension(spotifyConfig);
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { getSelectedModelsCommand } from '@blocksuite/affine-shared/commands';
|
||||
import type { SlashMenuConfig } from '@blocksuite/affine-widget-slash-menu';
|
||||
import { EmbedIcon } from '@blocksuite/icons/lit';
|
||||
|
||||
import { insertEmptyEmbedIframeCommand } from '../../commands/insert-empty-embed-iframe';
|
||||
import { EmbedIframeTooltip } from './tooltip';
|
||||
|
||||
export const embedIframeSlashMenuConfig: SlashMenuConfig = {
|
||||
items: [
|
||||
{
|
||||
name: 'Embed',
|
||||
description: 'For Google Drive, and more.',
|
||||
icon: EmbedIcon(),
|
||||
tooltip: {
|
||||
figure: EmbedIframeTooltip,
|
||||
caption: 'Embed',
|
||||
},
|
||||
group: '4_Content & Media@5',
|
||||
when: ({ model }) => {
|
||||
return model.doc.schema.flavourSchemaMap.has('affine:embed-iframe');
|
||||
},
|
||||
action: ({ std }) => {
|
||||
std.command
|
||||
.chain()
|
||||
.pipe(getSelectedModelsCommand)
|
||||
.pipe(insertEmptyEmbedIframeCommand, {
|
||||
place: 'after',
|
||||
removeEmptyLine: true,
|
||||
linkInputPopupOptions: {
|
||||
telemetrySegment: 'slash menu',
|
||||
},
|
||||
})
|
||||
.run();
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,485 @@
|
||||
import { reassociateConnectorsCommand } from '@blocksuite/affine-block-surface';
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import {
|
||||
BookmarkStyles,
|
||||
EmbedIframeBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
EMBED_CARD_HEIGHT,
|
||||
EMBED_CARD_WIDTH,
|
||||
} from '@blocksuite/affine-shared/consts';
|
||||
import {
|
||||
ActionPlacement,
|
||||
type ToolbarAction,
|
||||
type ToolbarActionGroup,
|
||||
type ToolbarContext,
|
||||
type ToolbarModuleConfig,
|
||||
ToolbarModuleExtension,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { getBlockProps } from '@blocksuite/affine-shared/utils';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
CaptionIcon,
|
||||
CopyIcon,
|
||||
DeleteIcon,
|
||||
DuplicateIcon,
|
||||
LinkedPageIcon,
|
||||
OpenInNewIcon,
|
||||
ResetIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { BlockFlavourIdentifier, BlockSelection } from '@blocksuite/std';
|
||||
import {
|
||||
type ExtensionType,
|
||||
Slice,
|
||||
Text,
|
||||
toDraftModel,
|
||||
} from '@blocksuite/store';
|
||||
import { computed, signal } from '@preact/signals-core';
|
||||
import { html } from 'lit';
|
||||
import { keyed } from 'lit/directives/keyed.js';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import {
|
||||
convertSelectedBlocksToLinkedDoc,
|
||||
getTitleFromSelectedModels,
|
||||
notifyDocCreated,
|
||||
promptDocTitle,
|
||||
} from '../../common/render-linked-doc';
|
||||
import { EmbedIframeBlockComponent } from '../embed-iframe-block';
|
||||
|
||||
const trackBaseProps = {
|
||||
category: 'embed iframe block',
|
||||
};
|
||||
|
||||
const showWhenUrlExists = (ctx: ToolbarContext) => {
|
||||
const model = ctx.getCurrentModelByType(EmbedIframeBlockModel);
|
||||
if (!model) return false;
|
||||
|
||||
return !!model.props.url;
|
||||
};
|
||||
|
||||
const openLinkAction = (id: string): ToolbarAction => {
|
||||
return {
|
||||
id,
|
||||
when: showWhenUrlExists,
|
||||
tooltip: 'Original',
|
||||
icon: OpenInNewIcon(),
|
||||
run(ctx) {
|
||||
const component = ctx.getCurrentBlockByType(EmbedIframeBlockComponent);
|
||||
component?.open();
|
||||
|
||||
ctx.track('OpenLink', {
|
||||
...trackBaseProps,
|
||||
control: 'open original link',
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const captionAction = (id: string): ToolbarAction => {
|
||||
return {
|
||||
id,
|
||||
when: showWhenUrlExists,
|
||||
tooltip: 'Caption',
|
||||
icon: CaptionIcon(),
|
||||
run(ctx) {
|
||||
const component = ctx.getCurrentBlockByType(EmbedIframeBlockComponent);
|
||||
component?.captionEditor?.show();
|
||||
|
||||
ctx.track('OpenedCaptionEditor', {
|
||||
...trackBaseProps,
|
||||
control: 'add caption',
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const builtinToolbarConfig = {
|
||||
actions: [
|
||||
openLinkAction('a.open-link'),
|
||||
{
|
||||
id: 'c.conversions',
|
||||
when: showWhenUrlExists,
|
||||
actions: [
|
||||
{
|
||||
id: 'inline',
|
||||
label: 'Inline view',
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedIframeBlockModel);
|
||||
if (!model) return;
|
||||
|
||||
const { title, caption, url } = model.props;
|
||||
if (!url) return;
|
||||
|
||||
const { parent } = model;
|
||||
const index = parent?.children.indexOf(model);
|
||||
|
||||
const yText = new Y.Text();
|
||||
const insert = title || caption || url;
|
||||
yText.insert(0, insert);
|
||||
yText.format(0, insert.length, { link: url });
|
||||
|
||||
const text = new Text(yText);
|
||||
|
||||
ctx.store.addBlock('affine:paragraph', { text }, parent, index);
|
||||
|
||||
ctx.store.deleteBlock(model);
|
||||
|
||||
// Clears
|
||||
ctx.reset();
|
||||
ctx.select('note');
|
||||
|
||||
ctx.track('SelectedView', {
|
||||
...trackBaseProps,
|
||||
control: 'select view',
|
||||
type: 'inline view',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'card',
|
||||
label: 'Card view',
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedIframeBlockModel);
|
||||
if (!model) return;
|
||||
|
||||
const { url, caption } = model.props;
|
||||
if (!url) return;
|
||||
|
||||
const { parent } = model;
|
||||
const index = parent?.children.indexOf(model);
|
||||
|
||||
const flavour = 'affine:bookmark';
|
||||
const style =
|
||||
BookmarkStyles.find(s => s !== 'vertical' && s !== 'cube') ??
|
||||
BookmarkStyles[1];
|
||||
|
||||
const blockId = ctx.store.addBlock(
|
||||
flavour,
|
||||
{ url, caption, style },
|
||||
parent,
|
||||
index
|
||||
);
|
||||
|
||||
ctx.store.deleteBlock(model);
|
||||
|
||||
// Selects new block
|
||||
ctx.select('note', [
|
||||
ctx.selection.create(BlockSelection, { blockId }),
|
||||
]);
|
||||
|
||||
ctx.track('SelectedView', {
|
||||
...trackBaseProps,
|
||||
control: 'select view',
|
||||
type: 'card view',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'embed',
|
||||
label: 'Embed view',
|
||||
disabled: true,
|
||||
},
|
||||
],
|
||||
content(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedIframeBlockModel);
|
||||
if (!model) return null;
|
||||
|
||||
const actions = this.actions.map(action => ({ ...action }));
|
||||
const toggle = (e: CustomEvent<boolean>) => {
|
||||
const opened = e.detail;
|
||||
if (!opened) return;
|
||||
|
||||
ctx.track('OpenedViewSelector', {
|
||||
...trackBaseProps,
|
||||
control: 'switch view',
|
||||
});
|
||||
};
|
||||
|
||||
return html`${keyed(
|
||||
model,
|
||||
html`<affine-view-dropdown-menu
|
||||
.actions=${actions}
|
||||
.context=${ctx}
|
||||
.toggle=${toggle}
|
||||
.viewType$=${signal(actions[2].label)}
|
||||
></affine-view-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||
captionAction('d.caption'),
|
||||
{
|
||||
id: 'e.convert-to-linked-doc',
|
||||
tooltip: 'Create Linked Doc',
|
||||
icon: LinkedPageIcon(),
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedIframeBlockModel);
|
||||
if (!model) return;
|
||||
|
||||
const { store, std, selection, track } = ctx;
|
||||
selection.clear();
|
||||
|
||||
const draftedModels = [model].map(toDraftModel);
|
||||
const autofill = getTitleFromSelectedModels(draftedModels);
|
||||
promptDocTitle(std, autofill)
|
||||
.then(async title => {
|
||||
if (title === null) return;
|
||||
await convertSelectedBlocksToLinkedDoc(
|
||||
std,
|
||||
store,
|
||||
draftedModels,
|
||||
title
|
||||
);
|
||||
notifyDocCreated(std, store);
|
||||
|
||||
track('DocCreated', {
|
||||
segment: 'doc',
|
||||
page: 'doc editor',
|
||||
module: 'toolbar',
|
||||
control: 'create linked doc',
|
||||
type: 'embed-linked-doc',
|
||||
});
|
||||
|
||||
track('LinkedDocCreated', {
|
||||
segment: 'doc',
|
||||
page: 'doc editor',
|
||||
module: 'toolbar',
|
||||
control: 'create linked doc',
|
||||
type: 'embed-linked-doc',
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
},
|
||||
},
|
||||
{
|
||||
placement: ActionPlacement.More,
|
||||
id: 'a.clipboard',
|
||||
actions: [
|
||||
{
|
||||
id: 'copy',
|
||||
label: 'Copy',
|
||||
icon: CopyIcon(),
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedIframeBlockModel);
|
||||
if (!model) return;
|
||||
|
||||
const slice = Slice.fromModels(ctx.store, [model]);
|
||||
ctx.clipboard
|
||||
.copySlice(slice)
|
||||
.then(() => toast(ctx.host, 'Copied to clipboard'))
|
||||
.catch(console.error);
|
||||
|
||||
ctx.track('CopiedLink', {
|
||||
...trackBaseProps,
|
||||
control: 'copy link',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'duplicate',
|
||||
label: 'Duplicate',
|
||||
icon: DuplicateIcon(),
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedIframeBlockModel);
|
||||
if (!model) return;
|
||||
|
||||
const { flavour, parent } = model;
|
||||
const props = getBlockProps(model);
|
||||
const index = parent?.children.indexOf(model);
|
||||
|
||||
ctx.store.addBlock(flavour, props, parent, index);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
placement: ActionPlacement.More,
|
||||
id: 'b.reload',
|
||||
label: 'Reload',
|
||||
icon: ResetIcon(),
|
||||
run(ctx) {
|
||||
const component = ctx.getCurrentBlockByType(EmbedIframeBlockComponent);
|
||||
component?.refreshData().catch(console.error);
|
||||
|
||||
ctx.track('ReloadLink', {
|
||||
...trackBaseProps,
|
||||
control: 'reload link',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
placement: ActionPlacement.More,
|
||||
id: 'c.delete',
|
||||
label: 'Delete',
|
||||
icon: DeleteIcon(),
|
||||
variant: 'destructive',
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedIframeBlockModel);
|
||||
if (!model) return;
|
||||
|
||||
ctx.store.deleteBlock(model);
|
||||
|
||||
// Clears
|
||||
ctx.select('note');
|
||||
ctx.reset();
|
||||
},
|
||||
},
|
||||
],
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
|
||||
export const builtinSurfaceToolbarConfig = {
|
||||
actions: [
|
||||
openLinkAction('a.open-link'),
|
||||
{
|
||||
id: 'c.conversions',
|
||||
when: showWhenUrlExists,
|
||||
actions: [
|
||||
{
|
||||
id: 'card',
|
||||
label: 'Card view',
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedIframeBlockModel);
|
||||
if (!model) return;
|
||||
|
||||
const { id: oldId, xywh, parent } = model;
|
||||
const { url, caption } = model.props;
|
||||
|
||||
if (!url) return;
|
||||
|
||||
const style =
|
||||
BookmarkStyles.find(s => s !== 'vertical' && s !== 'cube') ??
|
||||
BookmarkStyles[1];
|
||||
let flavour = 'affine:bookmark';
|
||||
|
||||
const bounds = Bound.deserialize(xywh);
|
||||
bounds.w = EMBED_CARD_WIDTH[style];
|
||||
bounds.h = EMBED_CARD_HEIGHT[style];
|
||||
|
||||
const newId = ctx.store.addBlock(
|
||||
flavour,
|
||||
{ url, caption, style, xywh: bounds.serialize() },
|
||||
parent
|
||||
);
|
||||
|
||||
ctx.command.exec(reassociateConnectorsCommand, { oldId, newId });
|
||||
|
||||
ctx.store.deleteBlock(model);
|
||||
|
||||
// Selects new block
|
||||
ctx.gfx.selection.set({ editing: false, elements: [newId] });
|
||||
|
||||
ctx.track('SelectedView', {
|
||||
...trackBaseProps,
|
||||
control: 'select view',
|
||||
type: 'card view',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'embed',
|
||||
label: 'Embed view',
|
||||
disabled: true,
|
||||
},
|
||||
],
|
||||
content(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedIframeBlockModel);
|
||||
if (!model) return null;
|
||||
|
||||
const actions = this.actions.map(action => ({ ...action }));
|
||||
const onToggle = (e: CustomEvent<boolean>) => {
|
||||
if (!e.detail) return;
|
||||
|
||||
ctx.track('OpenedViewSelector', {
|
||||
...trackBaseProps,
|
||||
control: 'switch view',
|
||||
});
|
||||
};
|
||||
|
||||
return html`${keyed(
|
||||
model,
|
||||
html`<affine-view-dropdown-menu
|
||||
@toggle=${onToggle}
|
||||
.actions=${actions}
|
||||
.context=${ctx}
|
||||
.viewType$=${signal(actions[1].label)}
|
||||
></affine-view-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||
captionAction('d.caption'),
|
||||
{
|
||||
id: 'e.scale',
|
||||
content(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedIframeBlockModel);
|
||||
if (!model) return null;
|
||||
|
||||
const scale$ = computed(() => {
|
||||
const scale = model.props.scale$.value ?? 1;
|
||||
return Math.round(100 * scale);
|
||||
});
|
||||
const onSelect = (e: CustomEvent<number>) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const scale = e.detail / 100;
|
||||
|
||||
const bounds = Bound.deserialize(model.xywh);
|
||||
const oldScale = model.props.scale ?? 1;
|
||||
const ratio = scale / oldScale;
|
||||
bounds.w *= ratio;
|
||||
bounds.h *= ratio;
|
||||
const xywh = bounds.serialize();
|
||||
|
||||
ctx.store.updateBlock(model, () => {
|
||||
model.xywh = xywh;
|
||||
model.props.scale = scale;
|
||||
});
|
||||
|
||||
ctx.track('SelectedCardScale', {
|
||||
...trackBaseProps,
|
||||
control: 'select card scale',
|
||||
});
|
||||
};
|
||||
const onToggle = (e: CustomEvent<boolean>) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const opened = e.detail;
|
||||
if (!opened) return;
|
||||
|
||||
ctx.track('OpenedCardScaleSelector', {
|
||||
...trackBaseProps,
|
||||
control: 'switch card scale',
|
||||
});
|
||||
};
|
||||
const format = (value: number) => `${value}%`;
|
||||
|
||||
return html`${keyed(
|
||||
model,
|
||||
html`<affine-size-dropdown-menu
|
||||
@select=${onSelect}
|
||||
@toggle=${onToggle}
|
||||
.format=${format}
|
||||
.size$=${scale$}
|
||||
></affine-size-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
},
|
||||
],
|
||||
when: ctx => ctx.getSurfaceModelsByType(EmbedIframeBlockModel).length > 0,
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
|
||||
export const createBuiltinToolbarConfigExtension = (
|
||||
flavour: string
|
||||
): ExtensionType[] => {
|
||||
const name = flavour.split(':').pop();
|
||||
|
||||
return [
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier(flavour),
|
||||
config: builtinToolbarConfig,
|
||||
}),
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier(`affine:surface:${name}`),
|
||||
config: builtinSurfaceToolbarConfig,
|
||||
}),
|
||||
];
|
||||
};
|
||||
Reference in New Issue
Block a user