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:
Saul-Mirone
2025-04-07 12:34:40 +00:00
parent e1bd2047c4
commit 1f45cc5dec
893 changed files with 439 additions and 460 deletions
@@ -0,0 +1,80 @@
import { type Container, createIdentifier } from '@blocksuite/global/di';
import { BlockSuiteError } from '@blocksuite/global/exceptions';
import { type BlockStdScope, StdIdentifier } from '@blocksuite/std';
import { type BlockSnapshot, Extension, type Store } from '@blocksuite/store';
import { getSurfaceComponent } from '../utils/get-surface-block';
import { EdgelessCRUDIdentifier } from './crud-extension';
export type ClipboardConfigCreationContext = {
/**
* element old id to new id
*/
oldToNewIdMap: Map<string, string>;
/**
* element old id to new layer index
*/
originalIndexes: Map<string, string>;
/**
* frame old id to new presentation index
*/
newPresentationIndexes: Map<string, string>;
};
export const EdgelessClipboardConfigIdentifier =
createIdentifier<EdgelessClipboardConfig>('edgeless-clipboard-config');
export abstract class EdgelessClipboardConfig extends Extension {
static key: string;
constructor(readonly std: BlockStdScope) {
super();
}
get surface() {
return getSurfaceComponent(this.std);
}
get crud() {
return this.std.get(EdgelessCRUDIdentifier);
}
onBlockSnapshotPaste = async (
snapshot: BlockSnapshot,
doc: Store,
parent?: string,
index?: number
) => {
const block = await this.std.clipboard.pasteBlockSnapshot(
snapshot,
doc,
parent,
index
);
return block?.id ?? null;
};
abstract createBlock(
snapshot: BlockSnapshot,
context: ClipboardConfigCreationContext
): string | null | Promise<string | null>;
static override setup(di: Container) {
if (!this.key) {
throw new BlockSuiteError(
BlockSuiteError.ErrorCode.ValueNotExists,
'Key is not defined in the EdgelessClipboardConfig'
);
}
di.add(
this as unknown as { new (std: BlockStdScope): EdgelessClipboardConfig },
[StdIdentifier]
);
di.addImpl(EdgelessClipboardConfigIdentifier(this.key), provider =>
provider.get(this)
);
}
}
@@ -0,0 +1,181 @@
import type { SurfaceElementModelMap } from '@blocksuite/affine-model';
import { EditPropsStore } from '@blocksuite/affine-shared/services';
import { type Container, createIdentifier } from '@blocksuite/global/di';
import { type BlockStdScope, StdIdentifier } from '@blocksuite/std';
import {
GfxBlockElementModel,
GfxControllerIdentifier,
type GfxModel,
isGfxGroupCompatibleModel,
} from '@blocksuite/std/gfx';
import { type BlockModel, Extension } from '@blocksuite/store';
import type { SurfaceBlockModel } from '../surface-model';
import { getLastPropsKey } from '../utils/get-last-props-key';
import { isConnectable, isNoteBlock } from './query';
export const EdgelessCRUDIdentifier = createIdentifier<EdgelessCRUDExtension>(
'AffineEdgelessCrudService'
);
export class EdgelessCRUDExtension extends Extension {
constructor(readonly std: BlockStdScope) {
super();
}
static override setup(di: Container) {
di.add(this, [StdIdentifier]);
di.addImpl(EdgelessCRUDIdentifier, provider => provider.get(this));
}
private get _gfx() {
return this.std.get(GfxControllerIdentifier);
}
private get _surface() {
return this._gfx.surface as SurfaceBlockModel | null;
}
deleteElements = (elements: GfxModel[]) => {
const surface = this._surface;
if (!surface) {
console.error('surface is not initialized');
return;
}
const gfx = this.std.get(GfxControllerIdentifier);
const set = new Set(elements);
elements.forEach(element => {
if (isConnectable(element)) {
const connectors = surface.getConnectors(element.id);
connectors.forEach(connector => set.add(connector));
}
});
set.forEach(element => {
if (isNoteBlock(element)) {
const children = gfx.doc.root?.children ?? [];
if (children.length > 1) {
gfx.doc.deleteBlock(element);
}
} else {
gfx.deleteElement(element.id);
}
});
};
addBlock = (
flavour: string,
props: Record<string, unknown>,
parentId?: string | BlockModel,
parentIndex?: number
) => {
const gfx = this.std.get(GfxControllerIdentifier);
const key = getLastPropsKey(flavour, props);
if (key) {
props = this.std.get(EditPropsStore).applyLastProps(key, props);
}
const nProps = {
...props,
index: gfx.layer.generateIndex(),
};
return this.std.store.addBlock(
flavour as never,
nProps,
parentId,
parentIndex
);
};
addElement = <T extends Record<string, unknown>>(type: string, props: T) => {
const surface = this._surface;
if (!surface) {
console.error('surface is not initialized');
return;
}
const gfx = this.std.get(GfxControllerIdentifier);
const key = getLastPropsKey(type, props);
if (key) {
props = this.std.get(EditPropsStore).applyLastProps(key, props) as T;
}
const nProps = {
...props,
type,
index: props.index ?? gfx.layer.generateIndex(),
};
return surface.addElement(nProps);
};
updateElement = (id: string, props: Record<string, unknown>) => {
const surface = this._surface;
if (!surface) {
console.error('surface is not initialized');
return;
}
const element = this._surface.getElementById(id);
if (element) {
const key = getLastPropsKey(element.type, {
...element.yMap.toJSON(),
...props,
});
key && this.std.get(EditPropsStore).recordLastProps(key, props);
this._surface.updateElement(id, props);
return;
}
const block = this.std.store.getModelById(id);
if (block) {
const key = getLastPropsKey(block.flavour, {
...block.yBlock.toJSON(),
...props,
});
key && this.std.get(EditPropsStore).recordLastProps(key, props);
this.std.store.updateBlock(block, props);
}
};
getElementById(id: string): GfxModel | null {
const surface = this._surface;
if (!surface) {
return null;
}
const el = surface.getElementById(id) ?? this.std.store.getModelById(id);
return el as GfxModel | null;
}
getElementsByType<K extends keyof SurfaceElementModelMap>(
type: K
): SurfaceElementModelMap[K][] {
if (!this._surface) {
return [];
}
return this._surface.getElementsByType(type);
}
removeElement(id: string | GfxModel) {
id = typeof id === 'string' ? id : id.id;
const el = this.getElementById(id);
if (isGfxGroupCompatibleModel(el)) {
el.childIds.forEach(childId => {
this.removeElement(childId);
});
}
if (el instanceof GfxBlockElementModel) {
this.std.store.deleteBlock(el);
return;
}
if (this._surface?.hasElementById(id)) {
this._surface.deleteElement(id);
return;
}
}
}
@@ -0,0 +1,31 @@
import {
createIdentifier,
type ServiceIdentifier,
} from '@blocksuite/global/di';
import type {
GfxLocalElementModel,
GfxPrimitiveElementModel,
} from '@blocksuite/std/gfx';
import type { ExtensionType } from '@blocksuite/store';
import type { ElementRenderer } from '../renderer/elements';
export const ElementRendererIdentifier =
createIdentifier<unknown>('element-renderer');
export const ElementRendererExtension = <
T extends GfxPrimitiveElementModel | GfxLocalElementModel,
>(
id: string,
renderer: ElementRenderer<T>
): ExtensionType & {
identifier: ServiceIdentifier<ElementRenderer<T>>;
} => {
const identifier = ElementRendererIdentifier<ElementRenderer<T>>(id);
return {
setup: di => {
di.addImpl(identifier, () => renderer);
},
identifier,
};
};
@@ -0,0 +1,588 @@
import { ImageBlockModel, type RootBlockModel } from '@blocksuite/affine-model';
import { FetchUtils } from '@blocksuite/affine-shared/adapters';
import {
CANVAS_EXPORT_IGNORE_TAGS,
DEFAULT_IMAGE_PROXY_ENDPOINT,
} from '@blocksuite/affine-shared/consts';
import type { Viewport } from '@blocksuite/affine-shared/types';
import {
isInsidePageEditor,
matchModels,
} from '@blocksuite/affine-shared/utils';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type { IBound } from '@blocksuite/global/gfx';
import { deserializeXYWH } from '@blocksuite/global/gfx';
import {
type BlockComponent,
type BlockStdScope,
type EditorHost,
StdIdentifier,
} from '@blocksuite/std';
import {
GfxBlockElementModel,
type GfxController,
GfxControllerIdentifier,
type GfxModel,
GfxPrimitiveElementModel,
isGfxGroupCompatibleModel,
} from '@blocksuite/std/gfx';
import type { ExtensionType, Store } from '@blocksuite/store';
import type { CanvasRenderer } from '../../renderer/canvas-renderer.js';
import type { SurfaceBlockComponent } from '../../surface-block.js';
import { getBgGridGap } from '../../utils/get-bg-grip-gap.js';
import { FileExporter } from './file-exporter.js';
// oxlint-disable-next-line typescript/consistent-type-imports
type Html2CanvasFunction = typeof import('html2canvas').default;
export type ExportOptions = {
imageProxyEndpoint: string;
};
export class ExportManager {
private readonly _exportOptions: ExportOptions = {
imageProxyEndpoint: DEFAULT_IMAGE_PROXY_ENDPOINT,
};
private readonly _replaceRichTextWithSvgElement = (element: HTMLElement) => {
const richList = Array.from(element.querySelectorAll('.inline-editor'));
richList.forEach(rich => {
const svgEle = this._elementToSvgElement(
rich.cloneNode(true) as HTMLElement,
rich.clientWidth,
rich.clientHeight + 1
);
rich.parentElement?.append(svgEle);
rich.remove();
});
};
replaceImgSrcWithSvg = async (element: HTMLElement) => {
const imgList = Array.from(element.querySelectorAll('img'));
// Create an array of promises
const promises = imgList.map(img => {
return FetchUtils.fetchImage(
img.src,
undefined,
this._exportOptions.imageProxyEndpoint
)
.then(response => response && response.blob())
.then(async blob => {
if (!blob) return;
// If the file type is SVG, set svg width and height
if (blob.type === 'image/svg+xml') {
// Parse the SVG
const parser = new DOMParser();
const svgDoc = parser.parseFromString(
await blob.text(),
'image/svg+xml'
);
const svgElement =
svgDoc.documentElement as unknown as SVGSVGElement;
// Check if the SVG has width and height attributes
if (
!svgElement.hasAttribute('width') &&
!svgElement.hasAttribute('height')
) {
// Get the viewBox
const viewBox = svgElement.viewBox.baseVal;
// Set the SVG width and height
svgElement.setAttribute('width', `${viewBox.width}px`);
svgElement.setAttribute('height', `${viewBox.height}px`);
}
// Replace the img src with the modified SVG
const serializer = new XMLSerializer();
const newSvgStr = serializer.serializeToString(svgElement);
img.src =
'data:image/svg+xml;charset=utf-8,' +
encodeURIComponent(newSvgStr);
}
});
});
// Wait for all promises to resolve
await Promise.all(promises);
};
get doc(): Store {
return this.std.store;
}
get editorHost(): EditorHost {
return this.std.host;
}
constructor(readonly std: BlockStdScope) {}
private _checkCanContinueToCanvas(pathName: string, editorMode: boolean) {
if (
location.pathname !== pathName ||
isInsidePageEditor(this.editorHost) !== editorMode
) {
throw new BlockSuiteError(
ErrorCode.EdgelessExportError,
'Unable to export content to canvas'
);
}
}
private async _checkReady() {
const pathname = location.pathname;
const editorMode = isInsidePageEditor(this.editorHost);
const promise = new Promise((resolve, reject) => {
let count = 0;
const checkReactRender = setInterval(() => {
try {
this._checkCanContinueToCanvas(pathname, editorMode);
} catch (e) {
clearInterval(checkReactRender);
reject(e);
}
const rootModel = this.doc.root;
const rootComponent = this.doc.root
? rootModel
? this.editorHost.view.getBlock(rootModel.id)
: null
: null;
const imageCard = rootComponent?.querySelector(
'affine-image-fallback-card'
);
const isReady =
!imageCard || imageCard.getAttribute('imageState') === '0';
if (rootComponent && isReady) {
clearInterval(checkReactRender);
resolve(true);
}
count++;
if (count > 10 * 60) {
clearInterval(checkReactRender);
resolve(false);
}
}, 100);
});
return promise;
}
private _createCanvas(bound: IBound, fillStyle: string) {
const canvas = document.createElement('canvas');
const dpr = window.devicePixelRatio || 1;
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
canvas.width = (bound.w + 100) * dpr;
canvas.height = (bound.h + 100) * dpr;
ctx.scale(dpr, dpr);
ctx.fillStyle = fillStyle;
ctx.fillRect(0, 0, canvas.width, canvas.height);
return { canvas, ctx };
}
private _disableMediaPrint() {
document.querySelectorAll('.media-print').forEach(mediaPrint => {
mediaPrint.classList.add('hide');
});
}
private async _docToCanvas(): Promise<HTMLCanvasElement | void> {
const html2canvas = (await import('html2canvas')).default;
if (!(html2canvas instanceof Function)) return;
const pathname = location.pathname;
const editorMode = isInsidePageEditor(this.editorHost);
const rootComponent = getRootByEditorHost(this.editorHost);
if (!rootComponent) return;
const viewportElement = rootComponent.viewportElement;
if (!viewportElement) return;
const pageContainer = viewportElement.querySelector(
'.affine-page-root-block-container'
);
const rect = pageContainer?.getBoundingClientRect();
const { viewport } = rootComponent;
if (!viewport) return;
const pageWidth = rect?.width;
const pageLeft = rect?.left ?? 0;
const viewportHeight = viewportElement?.scrollHeight;
const html2canvasOption = {
ignoreElements: function (element: Element) {
if (
CANVAS_EXPORT_IGNORE_TAGS.includes(element.tagName) ||
element.classList.contains('dg')
) {
return true;
} else if (
(element.classList.contains('close') &&
element.parentElement?.classList.contains(
'meta-data-expanded-title'
)) ||
(element.classList.contains('expand') &&
element.parentElement?.classList.contains('meta-data'))
) {
// the close and expand buttons in affine-doc-meta-data is not needed to be showed
return true;
} else {
return false;
}
},
onclone: async (_documentClone: Document, element: HTMLElement) => {
element.style.height = `${viewportHeight}px`;
this._replaceRichTextWithSvgElement(element);
await this.replaceImgSrcWithSvg(element);
},
backgroundColor: window.getComputedStyle(viewportElement).backgroundColor,
x: pageLeft - viewport.left,
width: pageWidth,
height: viewportHeight,
useCORS: this._exportOptions.imageProxyEndpoint ? false : true,
proxy: this._exportOptions.imageProxyEndpoint,
};
let data: HTMLCanvasElement;
try {
this._enableMediaPrint();
data = await html2canvas(
viewportElement as HTMLElement,
html2canvasOption
);
} finally {
this._disableMediaPrint();
}
this._checkCanContinueToCanvas(pathname, editorMode);
return data;
}
private _drawEdgelessBackground(
ctx: CanvasRenderingContext2D,
{
size,
backgroundColor,
gridColor,
}: {
size: number;
backgroundColor: string;
gridColor: string;
}
) {
const svgImg = `<svg width='${ctx.canvas.width}px' height='${ctx.canvas.height}px' xmlns='http://www.w3.org/2000/svg' style='background-size:${size}px ${size}px;background-color:${backgroundColor}; background-image: radial-gradient(${gridColor} 1px, ${backgroundColor} 1px)'></svg>`;
const img = new Image();
const cleanup = () => {
img.onload = null;
img.onerror = null;
};
return new Promise<void>((resolve, reject) => {
img.onload = () => {
cleanup();
ctx.drawImage(img, 0, 0);
resolve();
};
img.onerror = e => {
cleanup();
reject(e);
};
img.src = `data:image/svg+xml,${encodeURIComponent(svgImg)}`;
});
}
private _elementToSvgElement(
node: HTMLElement,
width: number,
height: number
) {
const xmlns = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(xmlns, 'svg');
const foreignObject = document.createElementNS(xmlns, 'foreignObject');
svg.setAttribute('width', `${width}`);
svg.setAttribute('height', `${height}`);
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
foreignObject.setAttribute('width', '100%');
foreignObject.setAttribute('height', '100%');
foreignObject.setAttribute('x', '0');
foreignObject.setAttribute('y', '0');
foreignObject.setAttribute('externalResourcesRequired', 'true');
svg.append(foreignObject);
foreignObject.append(node);
return svg;
}
private _enableMediaPrint() {
document.querySelectorAll('.media-print').forEach(mediaPrint => {
mediaPrint.classList.remove('hide');
});
}
private async _html2canvas(
htmlElement: HTMLElement,
options: Parameters<Html2CanvasFunction>[1] = {}
) {
const html2canvas = (await import('html2canvas'))
.default as unknown as Html2CanvasFunction;
const html2canvasOption = {
ignoreElements: function (element: Element) {
if (
CANVAS_EXPORT_IGNORE_TAGS.includes(element.tagName) ||
element.classList.contains('dg')
) {
return true;
} else {
return false;
}
},
onclone: async (documentClone: Document, element: HTMLElement) => {
// html2canvas can't support transform feature
element.style.setProperty('transform', 'none');
const layer = element.classList.contains('.affine-edgeless-layer')
? element
: null;
if (layer instanceof HTMLElement) {
layer.style.setProperty('transform', 'none');
}
const boxShadowEles = documentClone.querySelectorAll(
"[style*='box-shadow']"
);
boxShadowEles.forEach(function (element) {
if (element instanceof HTMLElement) {
element.style.setProperty('box-shadow', 'none');
}
});
this._replaceRichTextWithSvgElement(element);
await this.replaceImgSrcWithSvg(element);
},
useCORS: this._exportOptions.imageProxyEndpoint ? false : true,
proxy: this._exportOptions.imageProxyEndpoint,
};
let data: HTMLCanvasElement;
try {
this._enableMediaPrint();
data = await html2canvas(
htmlElement,
Object.assign(html2canvasOption, options)
);
} finally {
this._disableMediaPrint();
}
return data;
}
private async _toCanvas(): Promise<HTMLCanvasElement | void> {
try {
await this._checkReady();
} catch (e: unknown) {
console.error('Failed to export to canvas');
console.error(e);
return;
}
if (isInsidePageEditor(this.editorHost)) {
return this._docToCanvas();
} else {
const rootModel = this.doc.root;
if (!rootModel) return;
const gfx = this.editorHost.std.get(GfxControllerIdentifier);
const surfaceBlock = gfx.surfaceComponent as SurfaceBlockComponent | null;
if (!surfaceBlock) return;
const bound = gfx.elementsBound;
return this.edgelessToCanvas(surfaceBlock.renderer, bound, gfx);
}
}
// TODO: refactor of this part
async edgelessToCanvas(
surfaceRenderer: CanvasRenderer,
bound: IBound,
gfx: GfxController,
blocks?: GfxBlockElementModel[],
elements?: GfxPrimitiveElementModel[],
edgelessBackground?: {
zoom: number;
}
): Promise<HTMLCanvasElement | undefined> {
const rootModel = this.doc.root;
if (!rootModel) return;
const pathname = location.pathname;
const editorMode = isInsidePageEditor(this.editorHost);
const rootComponent = getRootByEditorHost(this.editorHost);
if (!rootComponent) return;
const viewportElement = rootComponent.viewportElement;
if (!viewportElement) return;
const containerComputedStyle = window.getComputedStyle(viewportElement);
const container = rootComponent.querySelector(
'.affine-block-children-container'
);
if (!container) return;
const { ctx, canvas } = this._createCanvas(
bound,
window.getComputedStyle(container).backgroundColor
);
if (edgelessBackground) {
await this._drawEdgelessBackground(ctx, {
backgroundColor: containerComputedStyle.getPropertyValue(
'--affine-background-primary-color'
),
size: getBgGridGap(edgelessBackground.zoom),
gridColor: containerComputedStyle.getPropertyValue(
'--affine-edgeless-grid-color'
),
});
}
if (!blocks && !elements) {
blocks = gfx.getElementsByBound(bound, { type: 'block' });
elements = gfx.getElementsByBound(bound, { type: 'canvas' });
} else {
elements = elements ?? [];
blocks = blocks ?? [];
const blockSet = new Set<GfxBlockElementModel>();
const elementSet = new Set<GfxPrimitiveElementModel>();
const addFn = (item: GfxModel) => {
if (item instanceof GfxBlockElementModel) {
blockSet.add(item);
} else if (item instanceof GfxPrimitiveElementModel) {
elementSet.add(item);
}
};
[...elements, ...blocks].forEach(item => {
addFn(item);
if (isGfxGroupCompatibleModel(item)) {
item.descendantElements.forEach(descendant => {
addFn(descendant);
});
}
});
elements = [...elementSet];
blocks = [...blockSet];
}
for (const block of blocks) {
if (matchModels(block, [ImageBlockModel])) {
if (!block.props.sourceId) return;
const blob = await block.doc.blobSync.get(block.props.sourceId);
if (!blob) return;
const blobToImage = (blob: Blob) =>
new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = URL.createObjectURL(blob);
});
const blockBound = xywhArrayToObject(block);
ctx.drawImage(
await blobToImage(blob),
blockBound.x - bound.x,
blockBound.y - bound.y,
blockBound.w,
blockBound.h
);
}
const blockComponent = this.editorHost.view.getBlock(block.id);
if (blockComponent) {
const blockBound = xywhArrayToObject(block);
const canvasData = await this._html2canvas(
blockComponent as HTMLElement
);
ctx.drawImage(
canvasData,
blockBound.x - bound.x + 50,
blockBound.y - bound.y + 50,
blockBound.w,
blockBound.h
);
}
this._checkCanContinueToCanvas(pathname, editorMode);
}
const surfaceCanvas = surfaceRenderer.getCanvasByBound(bound, elements);
ctx.drawImage(surfaceCanvas, 50, 50, bound.w, bound.h);
return canvas;
}
async exportPdf() {
const rootModel = this.doc.root;
if (!rootModel) return;
const canvasImage = await this._toCanvas();
if (!canvasImage) {
return;
}
const PDFLib = await import('pdf-lib');
const pdfDoc = await PDFLib.PDFDocument.create();
const page = pdfDoc.addPage([canvasImage.width, canvasImage.height]);
const imageEmbed = await pdfDoc.embedPng(canvasImage.toDataURL('PNG'));
const { width, height } = imageEmbed.scale(1);
page.drawImage(imageEmbed, {
x: 0,
y: 0,
width,
height,
});
const pdfBase64 = await pdfDoc.saveAsBase64({ dataUri: true });
FileExporter.exportFile(
(rootModel as RootBlockModel).props.title.toString() + '.pdf',
pdfBase64
);
}
async exportPng() {
const rootModel = this.doc.root;
if (!rootModel) return;
const canvasImage = await this._toCanvas();
if (!canvasImage) {
return;
}
FileExporter.exportPng(
(this.doc.root as RootBlockModel).props.title.toString(),
canvasImage.toDataURL('image/png')
);
}
}
export const ExportManagerExtension: ExtensionType = {
setup: di => {
di.add(ExportManager, [StdIdentifier]);
},
};
function xywhArrayToObject(element: GfxBlockElementModel) {
const [x, y, w, h] = deserializeXYWH(element.xywh);
return { x, y, w, h };
}
type RootBlockComponent = BlockComponent & {
viewportElement: HTMLElement;
viewport: Viewport;
};
function getRootByEditorHost(
editorHost: EditorHost
): RootBlockComponent | null {
const model = editorHost.doc.root;
if (!model) return null;
const root = editorHost.view.getBlock(model.id);
return root as RootBlockComponent | null;
}
@@ -0,0 +1,81 @@
/* oxlint-disable no-control-regex */
// Context: Lean towards breaking out any localizable content into constants so it's
// easier to track content we may need to localize in the future. (i18n)
const UNTITLED_PAGE_NAME = 'Untitled';
/** Tools for exporting files to device. For example, via browser download. */
export const FileExporter = {
/**
* Create a download for the user's browser.
*
* @param filename
* @param text
* @param mimeType like `"text/plain"`, `"text/html"`, `"application/javascript"`, etc. See {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types mdn docs List of MIME types}.
*
* @remarks
* Only accepts data in utf-8 encoding (html files, javascript source, text files, etc).
*
* @example
* const todoMDText = `# Todo items
* [ ] Item 1
* [ ] Item 2
* `
* FileExporter.exportFile("Todo list.md", todoMDText, "text/plain")
*
* @example
* const stateJsonContent = JSON.stringify({ a: 1, b: 2, c: 3 })
* FileExporter.exportFile("state.json", jsonContent, "application/json")
*/
exportFile(filename: string, dataURL: string) {
const element = document.createElement('a');
element.setAttribute('href', dataURL);
const safeFilename = getSafeFileName(filename);
element.setAttribute('download', safeFilename);
element.style.display = 'none';
document.body.append(element);
element.click();
element.remove();
},
exportPng(docTitle: string | undefined, dataURL: string) {
const title = docTitle?.trim() || UNTITLED_PAGE_NAME;
FileExporter.exportFile(title + '.png', dataURL);
},
};
function getSafeFileName(string: string) {
const replacement = ' ';
const filenameReservedRegex = /[<>:"/\\|?*\u0000-\u001F]/g;
const windowsReservedNameRegex = /^(con|prn|aux|nul|com\d|lpt\d)$/i;
const reControlChars = /[\u0000-\u001F\u0080-\u009F]/g;
const reTrailingPeriods = /\.+$/;
const allowedLength = 50;
function trimRepeated(string: string, target: string) {
const escapeStringRegexp = target
.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
.replace(/-/g, '\\x2d');
const regex = new RegExp(`(?:${escapeStringRegexp}){2,}`, 'g');
return string.replace(regex, target);
}
string = string
.normalize('NFD')
.replace(filenameReservedRegex, replacement)
.replace(reControlChars, replacement)
.replace(reTrailingPeriods, '');
string = trimRepeated(string, replacement);
string = windowsReservedNameRegex.test(string)
? string + replacement
: string;
const extIndex = string.lastIndexOf('.');
const filename = string.slice(0, extIndex).trim();
const extension = string.slice(extIndex);
string =
filename.slice(0, Math.max(1, allowedLength - extension.length)) +
extension;
return string;
}
@@ -0,0 +1 @@
export { ExportManager, ExportManagerExtension } from './export-manager.js';
@@ -0,0 +1,6 @@
export * from './clipboard-config';
export * from './crud-extension';
export * from './element-renderer';
export * from './export-manager';
export * from './legacy-slot-extension';
export * from './query';
@@ -0,0 +1,39 @@
import type { FrameBlockModel } from '@blocksuite/affine-model';
import { createIdentifier } from '@blocksuite/global/di';
import type { ExtensionType } from '@blocksuite/store';
import { Subject } from 'rxjs';
export const EdgelessLegacySlotIdentifier = createIdentifier<{
readonlyUpdated: Subject<boolean>;
navigatorSettingUpdated: Subject<{
hideToolbar?: boolean;
blackBackground?: boolean;
fillScreen?: boolean;
}>;
navigatorFrameChanged: Subject<FrameBlockModel>;
fullScreenToggled: Subject<void>;
elementResizeStart: Subject<void>;
elementResizeEnd: Subject<void>;
toggleNoteSlicer: Subject<void>;
toolbarLocked: Subject<boolean>;
}>('AffineEdgelessLegacySlotService');
export const EdgelessLegacySlotExtension: ExtensionType = {
setup: di => {
di.addImpl(EdgelessLegacySlotIdentifier, () => ({
readonlyUpdated: new Subject<boolean>(),
navigatorSettingUpdated: new Subject<{
hideToolbar?: boolean;
blackBackground?: boolean;
fillScreen?: boolean;
}>(),
navigatorFrameChanged: new Subject<FrameBlockModel>(),
fullScreenToggled: new Subject(),
elementResizeStart: new Subject(),
elementResizeEnd: new Subject(),
toggleNoteSlicer: new Subject(),
toolbarLocked: new Subject<boolean>(),
}));
},
};
@@ -0,0 +1,17 @@
import type { NoteBlockModel } from '@blocksuite/affine-model';
import type { GfxModel } from '@blocksuite/std/gfx';
import type { BlockModel } from '@blocksuite/store';
import type { Connectable } from '../managers/connector-manager';
export function isConnectable(
element: GfxModel | null
): element is Connectable {
return !!element && element.connectable;
}
export function isNoteBlock(
element: BlockModel | GfxModel | null
): element is NoteBlockModel {
return !!element && 'flavour' in element && element.flavour === 'affine:note';
}