snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
This commit is contained in:
40
blocksuite/affine/gfx/text/package.json
Normal file
40
blocksuite/affine/gfx/text/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@blocksuite/affine-gfx-text",
|
||||
"description": "Gfx text for BlockSuite.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "yarn run -T tsc"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-block-surface": "workspace:*",
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-ext-loader": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-rich-text": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"lit": "^3.2.0",
|
||||
"yjs": "^13.6.27"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./view": "./src/view.ts",
|
||||
"./store": "./src/store.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.27.5"
|
||||
}
|
||||
2
blocksuite/affine/gfx/text/src/adapter/index.ts
Normal file
2
blocksuite/affine/gfx/text/src/adapter/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './markdown';
|
||||
export * from './plain-text';
|
||||
24
blocksuite/affine/gfx/text/src/adapter/markdown/index.ts
Normal file
24
blocksuite/affine/gfx/text/src/adapter/markdown/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ElementToMarkdownAdapterExtension } from '@blocksuite/affine-block-surface';
|
||||
|
||||
import { getTextElementText } from '../text';
|
||||
|
||||
export const textToMarkdownAdapterMatcher = ElementToMarkdownAdapterExtension({
|
||||
name: 'text',
|
||||
match: elementModel => elementModel.type === 'text',
|
||||
toAST: elementModel => {
|
||||
const content = getTextElementText(elementModel);
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: content,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
14
blocksuite/affine/gfx/text/src/adapter/plain-text/index.ts
Normal file
14
blocksuite/affine/gfx/text/src/adapter/plain-text/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ElementToPlainTextAdapterExtension } from '@blocksuite/affine-block-surface';
|
||||
|
||||
import { getTextElementText } from '../text';
|
||||
|
||||
export const textToPlainTextAdapterMatcher = ElementToPlainTextAdapterExtension(
|
||||
{
|
||||
name: 'text',
|
||||
match: elementModel => elementModel.type === 'text',
|
||||
toAST: elementModel => {
|
||||
const content = getTextElementText(elementModel);
|
||||
return { content };
|
||||
},
|
||||
}
|
||||
);
|
||||
22
blocksuite/affine/gfx/text/src/adapter/text.ts
Normal file
22
blocksuite/affine/gfx/text/src/adapter/text.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { DeltaInsert } from '@blocksuite/affine/store';
|
||||
|
||||
export function getTextElementText(
|
||||
elementModel: Record<string, unknown>
|
||||
): string {
|
||||
let text = '';
|
||||
if (elementModel.type !== 'text') {
|
||||
return text;
|
||||
}
|
||||
if (
|
||||
'text' in elementModel &&
|
||||
typeof elementModel.text === 'object' &&
|
||||
elementModel.text
|
||||
) {
|
||||
let delta: DeltaInsert[] = [];
|
||||
if ('delta' in elementModel.text) {
|
||||
delta = elementModel.text.delta as DeltaInsert[];
|
||||
}
|
||||
text = delta.map(d => d.insert).join('');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
1
blocksuite/affine/gfx/text/src/commands/index.ts
Normal file
1
blocksuite/affine/gfx/text/src/commands/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { insertEdgelessTextCommand } from './insert-edgeless-text.js';
|
||||
114
blocksuite/affine/gfx/text/src/commands/insert-edgeless-text.ts
Normal file
114
blocksuite/affine/gfx/text/src/commands/insert-edgeless-text.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
EdgelessCRUDIdentifier,
|
||||
getSurfaceBlock,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
EDGELESS_TEXT_BLOCK_MIN_HEIGHT,
|
||||
EDGELESS_TEXT_BLOCK_MIN_WIDTH,
|
||||
EdgelessTextBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { focusTextModel } from '@blocksuite/affine-rich-text';
|
||||
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import type { Command } from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
|
||||
export const insertEdgelessTextCommand: Command<
|
||||
{
|
||||
x: number;
|
||||
y: number;
|
||||
},
|
||||
{
|
||||
textId: string;
|
||||
}
|
||||
> = (ctx, next) => {
|
||||
const { std, x, y } = ctx;
|
||||
const host = std.host;
|
||||
const doc = host.store;
|
||||
const surface = getSurfaceBlock(doc);
|
||||
if (!surface) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const gfx = std.get(GfxControllerIdentifier);
|
||||
const zoom = gfx.viewport.zoom;
|
||||
const selection = gfx.selection;
|
||||
|
||||
const textId = std.get(EdgelessCRUDIdentifier).addBlock(
|
||||
'affine:edgeless-text',
|
||||
{
|
||||
xywh: new Bound(
|
||||
x - (EDGELESS_TEXT_BLOCK_MIN_WIDTH * zoom) / 2,
|
||||
y - (EDGELESS_TEXT_BLOCK_MIN_HEIGHT * zoom) / 2,
|
||||
EDGELESS_TEXT_BLOCK_MIN_WIDTH * zoom,
|
||||
EDGELESS_TEXT_BLOCK_MIN_HEIGHT * zoom
|
||||
).serialize(),
|
||||
},
|
||||
surface.id
|
||||
);
|
||||
|
||||
const blockId = doc.addBlock('affine:paragraph', { type: 'text' }, textId);
|
||||
host.updateComplete
|
||||
.then(() => {
|
||||
selection.set({
|
||||
elements: [textId],
|
||||
editing: true,
|
||||
});
|
||||
const disposable = selection.slots.updated.subscribe(() => {
|
||||
const editing = selection.editing;
|
||||
const id = selection.selectedIds[0];
|
||||
if (!editing || id !== textId) {
|
||||
const textBlock = host.view.getBlock(textId);
|
||||
const model = textBlock?.model;
|
||||
if (matchModels(model, [EdgelessTextBlockModel])) {
|
||||
model.props.hasMaxWidth = true;
|
||||
}
|
||||
|
||||
disposable.unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
focusTextModel(std, blockId);
|
||||
host.updateComplete
|
||||
.then(() => {
|
||||
const edgelessText = host.view.getBlock(textId);
|
||||
const paragraph = host.view.getBlock(blockId);
|
||||
if (!edgelessText || !paragraph) return;
|
||||
|
||||
const abortController = new AbortController();
|
||||
edgelessText.addEventListener(
|
||||
'focusout',
|
||||
e => {
|
||||
if (edgelessText.model.children.length > 1) return;
|
||||
if (
|
||||
!paragraph.model.text ||
|
||||
(paragraph.model.text.length === 0 && e.relatedTarget !== null)
|
||||
) {
|
||||
doc.deleteBlock(edgelessText.model);
|
||||
}
|
||||
},
|
||||
{
|
||||
once: true,
|
||||
signal: abortController.signal,
|
||||
}
|
||||
);
|
||||
const subscription = paragraph.model.deleted.subscribe(() => {
|
||||
subscription.unsubscribe();
|
||||
abortController.abort();
|
||||
});
|
||||
edgelessText.addEventListener(
|
||||
'beforeinput',
|
||||
() => {
|
||||
abortController.abort();
|
||||
},
|
||||
{
|
||||
once: true,
|
||||
}
|
||||
);
|
||||
})
|
||||
.catch(console.error);
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
next({ textId });
|
||||
};
|
||||
52
blocksuite/affine/gfx/text/src/dblclick-add-edgeless-text.ts
Normal file
52
blocksuite/affine/gfx/text/src/dblclick-add-edgeless-text.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
FeatureFlagService,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
type GfxInteractivityContext,
|
||||
InteractivityExtension,
|
||||
} from '@blocksuite/std/gfx';
|
||||
|
||||
import { insertEdgelessTextCommand } from './commands';
|
||||
import { addText } from './edgeless-text-editor';
|
||||
|
||||
export class DblClickAddEdgelessText extends InteractivityExtension {
|
||||
static override key = 'dbl-click-add-edgeless-text';
|
||||
|
||||
override mounted() {
|
||||
this.event.on('dblclick', (ctx: GfxInteractivityContext) => {
|
||||
const { event: e } = ctx;
|
||||
const textFlag = this.std.store
|
||||
.get(FeatureFlagService)
|
||||
.getFlag('enable_edgeless_text');
|
||||
const picked = this.gfx.getElementByPoint(
|
||||
...this.gfx.viewport.toModelCoord(e.x, e.y)
|
||||
);
|
||||
|
||||
if (picked) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (textFlag) {
|
||||
const [x, y] = this.gfx.viewport.toModelCoord(e.x, e.y);
|
||||
this.std.command.exec(insertEdgelessTextCommand, { x, y });
|
||||
} else {
|
||||
const edgelessView = this.std.view.getBlock(
|
||||
this.std.store.root?.id || ''
|
||||
);
|
||||
|
||||
if (edgelessView) {
|
||||
addText(edgelessView, e);
|
||||
}
|
||||
}
|
||||
|
||||
this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
|
||||
control: 'canvas:dbclick',
|
||||
page: 'whiteboard editor',
|
||||
module: 'toolbar',
|
||||
segment: 'toolbar',
|
||||
type: 'text',
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
492
blocksuite/affine/gfx/text/src/edgeless-text-editor.ts
Normal file
492
blocksuite/affine/gfx/text/src/edgeless-text-editor.ts
Normal file
@@ -0,0 +1,492 @@
|
||||
import {
|
||||
CanvasElementType,
|
||||
DefaultTool,
|
||||
EdgelessCRUDIdentifier,
|
||||
getSurfaceBlock,
|
||||
type IModelCoord,
|
||||
TextUtils,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { DefaultTheme, TextElementModel } from '@blocksuite/affine-model';
|
||||
import type { RichText } from '@blocksuite/affine-rich-text';
|
||||
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { getSelectedRect } from '@blocksuite/affine-shared/utils';
|
||||
import { Bound, toRadian, Vec } from '@blocksuite/global/gfx';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import {
|
||||
type BlockComponent,
|
||||
type BlockStdScope,
|
||||
type PointerEventState,
|
||||
ShadowlessElement,
|
||||
stdContext,
|
||||
} from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/std/inline';
|
||||
import { consume } from '@lit/context';
|
||||
import { css, html, nothing } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { getCursorByCoord, getLineHeight } from './element-renderer/utils';
|
||||
|
||||
export function mountTextElementEditor(
|
||||
textElement: TextElementModel,
|
||||
edgeless: BlockComponent,
|
||||
focusCoord?: IModelCoord
|
||||
) {
|
||||
let cursorIndex = textElement.text.length;
|
||||
if (focusCoord) {
|
||||
cursorIndex = Math.min(
|
||||
getCursorByCoord(textElement, focusCoord),
|
||||
cursorIndex
|
||||
);
|
||||
}
|
||||
|
||||
const textEditor = new EdgelessTextEditor();
|
||||
textEditor.element = textElement;
|
||||
|
||||
edgeless.append(textEditor);
|
||||
textEditor.updateComplete
|
||||
.then(() => {
|
||||
textEditor.inlineEditor?.focusIndex(cursorIndex);
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
const gfx = edgeless.std.get(GfxControllerIdentifier);
|
||||
|
||||
gfx.tool.setTool(DefaultTool);
|
||||
gfx.selection.set({
|
||||
elements: [textElement.id],
|
||||
editing: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*
|
||||
* Canvas Text has been deprecated
|
||||
*/
|
||||
export function addText(edgeless: BlockComponent, event: PointerEventState) {
|
||||
const gfx = edgeless.std.get(GfxControllerIdentifier);
|
||||
const crud = edgeless.std.get(EdgelessCRUDIdentifier);
|
||||
const [x, y] = gfx.viewport.toModelCoord(event.x, event.y);
|
||||
const selected = gfx.getElementByPoint(x, y);
|
||||
|
||||
if (!selected) {
|
||||
const [modelX, modelY] = gfx.viewport.toModelCoord(event.x, event.y);
|
||||
|
||||
const id = edgeless.std
|
||||
.get(EdgelessCRUDIdentifier)
|
||||
.addElement(CanvasElementType.TEXT, {
|
||||
xywh: new Bound(modelX, modelY, 32, 32).serialize(),
|
||||
text: new Y.Text(),
|
||||
});
|
||||
if (!id) return;
|
||||
|
||||
edgeless.store.captureSync();
|
||||
const textElement = crud.getElementById(id);
|
||||
if (!textElement) return;
|
||||
if (textElement instanceof TextElementModel) {
|
||||
mountTextElementEditor(textElement, edgeless);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class EdgelessTextEditor extends WithDisposable(ShadowlessElement) {
|
||||
get crud() {
|
||||
return this.std.get(EdgelessCRUDIdentifier);
|
||||
}
|
||||
|
||||
get gfx() {
|
||||
return this.std.get(GfxControllerIdentifier);
|
||||
}
|
||||
|
||||
static BORDER_WIDTH = 1;
|
||||
|
||||
static PADDING_HORIZONTAL = 10;
|
||||
|
||||
static PADDING_VERTICAL = 6;
|
||||
|
||||
static PLACEHOLDER_TEXT = 'Type from here';
|
||||
|
||||
static override styles = css`
|
||||
.edgeless-text-editor {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
transform-origin: left top;
|
||||
font-kerning: none;
|
||||
border: ${EdgelessTextEditor.BORDER_WIDTH}px solid
|
||||
var(--affine-primary-color, #1e96eb);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0px 0px 0px 2px rgba(30, 150, 235, 0.3);
|
||||
padding: ${EdgelessTextEditor.PADDING_VERTICAL}px
|
||||
${EdgelessTextEditor.PADDING_HORIZONTAL}px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.edgeless-text-editor .inline-editor {
|
||||
white-space: pre-wrap !important;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.edgeless-text-editor .inline-editor span {
|
||||
word-break: normal !important;
|
||||
overflow-wrap: anywhere !important;
|
||||
}
|
||||
|
||||
.edgeless-text-editor-placeholder {
|
||||
pointer-events: none;
|
||||
color: var(--affine-text-disable-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
`;
|
||||
|
||||
private _isComposition = false;
|
||||
|
||||
private _keeping = false;
|
||||
|
||||
private readonly _updateRect = () => {
|
||||
const element = this.element;
|
||||
|
||||
if (!element || !this.inlineEditorContainer) return;
|
||||
|
||||
const newWidth = this.inlineEditorContainer.scrollWidth;
|
||||
const newHeight = this.inlineEditorContainer.scrollHeight;
|
||||
const bound = new Bound(element.x, element.y, newWidth, newHeight);
|
||||
const { x, y, w, h, rotate } = element;
|
||||
|
||||
switch (element.textAlign) {
|
||||
case 'left':
|
||||
{
|
||||
const newPos = this.getCoordsOnLeftAlign(
|
||||
{
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
r: toRadian(rotate),
|
||||
},
|
||||
newWidth,
|
||||
newHeight
|
||||
);
|
||||
|
||||
bound.x = newPos.x;
|
||||
bound.y = newPos.y;
|
||||
}
|
||||
break;
|
||||
case 'center':
|
||||
{
|
||||
const newPos = this.getCoordsOnCenterAlign(
|
||||
{
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
r: toRadian(rotate),
|
||||
},
|
||||
newWidth,
|
||||
newHeight
|
||||
);
|
||||
|
||||
bound.x = newPos.x;
|
||||
bound.y = newPos.y;
|
||||
}
|
||||
break;
|
||||
case 'right':
|
||||
{
|
||||
const newPos = this.getCoordsOnRightAlign(
|
||||
{
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
r: toRadian(rotate),
|
||||
},
|
||||
newWidth,
|
||||
newHeight
|
||||
);
|
||||
|
||||
bound.x = newPos.x;
|
||||
bound.y = newPos.y;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
this.crud.updateElement(element.id, {
|
||||
xywh: bound.serialize(),
|
||||
});
|
||||
};
|
||||
|
||||
get inlineEditor() {
|
||||
return this.richText.inlineEditor;
|
||||
}
|
||||
|
||||
get inlineEditorContainer() {
|
||||
return this.inlineEditor?.rootElement;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (!this.element) {
|
||||
console.error('text element is not set.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
|
||||
}
|
||||
|
||||
override firstUpdated(): void {
|
||||
const element = this.element;
|
||||
const dispatcher = this.std.event;
|
||||
const surface = getSurfaceBlock(this.std.store);
|
||||
if (!surface) {
|
||||
console.error('surface block is not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateComplete
|
||||
.then(() => {
|
||||
if (!this.inlineEditor) return;
|
||||
this.inlineEditor.slots.renderComplete.subscribe(() => {
|
||||
this._updateRect();
|
||||
this.requestUpdate();
|
||||
});
|
||||
|
||||
this.disposables.add(
|
||||
surface.elementUpdated.subscribe(({ id }) => {
|
||||
if (id === element.id) this.requestUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
this.disposables.add(
|
||||
this.gfx.viewport.viewportUpdated.subscribe(() => {
|
||||
this.requestUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
this.disposables.add(dispatcher.add('click', () => true));
|
||||
this.disposables.add(dispatcher.add('doubleClick', () => true));
|
||||
|
||||
this.disposables.add(() => {
|
||||
element.display = true;
|
||||
|
||||
if (element.text.length === 0) {
|
||||
this.crud.deleteElements([element]);
|
||||
}
|
||||
|
||||
this.gfx.selection.set({
|
||||
elements: [],
|
||||
editing: false,
|
||||
});
|
||||
});
|
||||
|
||||
if (!this.inlineEditorContainer) return;
|
||||
this.disposables.addFromEvent(
|
||||
this.inlineEditorContainer,
|
||||
'blur',
|
||||
() => !this._keeping && this.remove()
|
||||
);
|
||||
|
||||
this.disposables.addFromEvent(
|
||||
this.inlineEditorContainer,
|
||||
'compositionstart',
|
||||
() => {
|
||||
this._isComposition = true;
|
||||
this.requestUpdate();
|
||||
}
|
||||
);
|
||||
this.disposables.addFromEvent(
|
||||
this.inlineEditorContainer,
|
||||
'compositionend',
|
||||
() => {
|
||||
this._isComposition = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
);
|
||||
|
||||
element.display = false;
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
getContainerOffset() {
|
||||
const { PADDING_VERTICAL, PADDING_HORIZONTAL, BORDER_WIDTH } =
|
||||
EdgelessTextEditor;
|
||||
return `-${PADDING_HORIZONTAL + BORDER_WIDTH}px, -${
|
||||
PADDING_VERTICAL + BORDER_WIDTH
|
||||
}px`;
|
||||
}
|
||||
|
||||
getCoordsOnCenterAlign(
|
||||
rect: { w: number; h: number; r: number; x: number; y: number },
|
||||
w1: number,
|
||||
h1: number
|
||||
): { x: number; y: number } {
|
||||
const centerX = rect.x + rect.w / 2;
|
||||
const centerY = rect.y + rect.h / 2;
|
||||
|
||||
let deltaXPrime = 0;
|
||||
let deltaYPrime = (-rect.h / 2) * Math.cos(rect.r);
|
||||
|
||||
const vX = centerX + deltaXPrime;
|
||||
const vY = centerY + deltaYPrime;
|
||||
|
||||
deltaXPrime = 0;
|
||||
deltaYPrime = (-h1 / 2) * Math.cos(rect.r);
|
||||
|
||||
const newCenterX = vX - deltaXPrime;
|
||||
const newCenterY = vY - deltaYPrime;
|
||||
|
||||
return { x: newCenterX - w1 / 2, y: newCenterY - h1 / 2 };
|
||||
}
|
||||
|
||||
getCoordsOnLeftAlign(
|
||||
rect: { w: number; h: number; r: number; x: number; y: number },
|
||||
w1: number,
|
||||
h1: number
|
||||
): { x: number; y: number } {
|
||||
const cX = rect.x + rect.w / 2;
|
||||
const cY = rect.y + rect.h / 2;
|
||||
|
||||
let deltaXPrime =
|
||||
(-rect.w / 2) * Math.cos(rect.r) + (rect.h / 2) * Math.sin(rect.r);
|
||||
let deltaYPrime =
|
||||
(-rect.w / 2) * Math.sin(rect.r) - (rect.h / 2) * Math.cos(rect.r);
|
||||
|
||||
const vX = cX + deltaXPrime;
|
||||
const vY = cY + deltaYPrime;
|
||||
|
||||
deltaXPrime = (-w1 / 2) * Math.cos(rect.r) + (h1 / 2) * Math.sin(rect.r);
|
||||
deltaYPrime = (-w1 / 2) * Math.sin(rect.r) - (h1 / 2) * Math.cos(rect.r);
|
||||
|
||||
const newCenterX = vX - deltaXPrime;
|
||||
const newCenterY = vY - deltaYPrime;
|
||||
|
||||
return { x: newCenterX - w1 / 2, y: newCenterY - h1 / 2 };
|
||||
}
|
||||
|
||||
getCoordsOnRightAlign(
|
||||
rect: { w: number; h: number; r: number; x: number; y: number },
|
||||
w1: number,
|
||||
h1: number
|
||||
): { x: number; y: number } {
|
||||
const centerX = rect.x + rect.w / 2;
|
||||
const centerY = rect.y + rect.h / 2;
|
||||
|
||||
let deltaXPrime =
|
||||
(rect.w / 2) * Math.cos(rect.r) - (-rect.h / 2) * Math.sin(rect.r);
|
||||
let deltaYPrime =
|
||||
(rect.w / 2) * Math.sin(rect.r) + (-rect.h / 2) * Math.cos(rect.r);
|
||||
|
||||
const vX = centerX + deltaXPrime;
|
||||
const vY = centerY + deltaYPrime;
|
||||
|
||||
deltaXPrime = (w1 / 2) * Math.cos(rect.r) - (-h1 / 2) * Math.sin(rect.r);
|
||||
deltaYPrime = (w1 / 2) * Math.sin(rect.r) + (-h1 / 2) * Math.cos(rect.r);
|
||||
|
||||
const newCenterX = vX - deltaXPrime;
|
||||
const newCenterY = vY - deltaYPrime;
|
||||
|
||||
return { x: newCenterX - w1 / 2, y: newCenterY - h1 / 2 };
|
||||
}
|
||||
|
||||
override async getUpdateComplete(): Promise<boolean> {
|
||||
const result = await super.getUpdateComplete();
|
||||
await this.richText?.updateComplete;
|
||||
return result;
|
||||
}
|
||||
|
||||
getVisualPosition(element: TextElementModel) {
|
||||
const { x, y, w, h, rotate } = element;
|
||||
return Vec.rotWith([x, y], [x + w / 2, y + h / 2], toRadian(rotate));
|
||||
}
|
||||
|
||||
override render() {
|
||||
const {
|
||||
text,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
fontWeight,
|
||||
fontStyle,
|
||||
textAlign,
|
||||
rotate,
|
||||
hasMaxWidth,
|
||||
w,
|
||||
} = this.element;
|
||||
const lineHeight = getLineHeight(fontFamily, fontSize, fontWeight);
|
||||
const rect = getSelectedRect([this.element]);
|
||||
|
||||
const { translateX, translateY, zoom, viewScale } = this.gfx.viewport;
|
||||
const [visualX, visualY] = this.getVisualPosition(this.element);
|
||||
const containerOffset = this.getContainerOffset();
|
||||
// Compensate for outer CSS scale, matching GfxBlockComponent.getCSSTransform.
|
||||
const transformOperation = [
|
||||
`translate(${translateX / viewScale}px, ${translateY / viewScale}px)`,
|
||||
`translate(${(visualX * zoom) / viewScale}px, ${(visualY * zoom) / viewScale}px)`,
|
||||
`scale(${zoom / viewScale})`,
|
||||
`rotate(${rotate}deg)`,
|
||||
`translate(${containerOffset})`,
|
||||
];
|
||||
|
||||
const isEmpty = !text.length && !this._isComposition;
|
||||
const color = this.std
|
||||
.get(ThemeProvider)
|
||||
.generateColorProperty(this.element.color, DefaultTheme.textColor);
|
||||
|
||||
return html`<div
|
||||
style=${styleMap({
|
||||
transform: transformOperation.join(' '),
|
||||
minWidth: hasMaxWidth ? `${rect.width}px` : 'none',
|
||||
maxWidth: hasMaxWidth ? `${w}px` : 'none',
|
||||
fontFamily: TextUtils.wrapFontFamily(fontFamily),
|
||||
fontSize: `${fontSize}px`,
|
||||
fontWeight,
|
||||
fontStyle,
|
||||
color,
|
||||
textAlign,
|
||||
lineHeight: `${lineHeight}px`,
|
||||
boxSizing: 'content-box',
|
||||
})}
|
||||
class="edgeless-text-editor"
|
||||
>
|
||||
<rich-text
|
||||
.yText=${text}
|
||||
.enableFormat=${false}
|
||||
.enableAutoScrollHorizontally=${false}
|
||||
style=${
|
||||
isEmpty
|
||||
? styleMap({
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
padding: `${EdgelessTextEditor.PADDING_VERTICAL}px
|
||||
${EdgelessTextEditor.PADDING_HORIZONTAL}px`,
|
||||
})
|
||||
: nothing
|
||||
}
|
||||
></rich-text>
|
||||
${
|
||||
isEmpty
|
||||
? html`<span class="edgeless-text-editor-placeholder">
|
||||
Type from here
|
||||
</span>`
|
||||
: nothing
|
||||
}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
setKeeping(keeping: boolean) {
|
||||
this._keeping = keeping;
|
||||
}
|
||||
|
||||
@consume({ context: stdContext })
|
||||
accessor std!: BlockStdScope;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor element!: TextElementModel;
|
||||
|
||||
@query('rich-text')
|
||||
accessor richText!: RichText;
|
||||
}
|
||||
14
blocksuite/affine/gfx/text/src/effects.ts
Normal file
14
blocksuite/affine/gfx/text/src/effects.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { EdgelessTextEditor } from './edgeless-text-editor';
|
||||
import { EdgelessTextMenu } from './toolbar/text-menu';
|
||||
|
||||
export function effects() {
|
||||
customElements.define('edgeless-text-editor', EdgelessTextEditor);
|
||||
customElements.define('edgeless-text-menu', EdgelessTextMenu);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-text-editor': EdgelessTextEditor;
|
||||
'edgeless-text-menu': EdgelessTextMenu;
|
||||
}
|
||||
}
|
||||
94
blocksuite/affine/gfx/text/src/element-renderer/index.ts
Normal file
94
blocksuite/affine/gfx/text/src/element-renderer/index.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
type ElementRenderer,
|
||||
ElementRendererExtension,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { DefaultTheme, type TextElementModel } from '@blocksuite/affine-model';
|
||||
import { deltaInsertsToChunks } from '@blocksuite/std/inline';
|
||||
|
||||
import {
|
||||
getFontString,
|
||||
getLineHeight,
|
||||
getTextWidth,
|
||||
isRTL,
|
||||
wrapTextDeltas,
|
||||
} from './utils.js';
|
||||
|
||||
export const text: ElementRenderer<TextElementModel> = (
|
||||
model,
|
||||
ctx,
|
||||
matrix,
|
||||
renderer
|
||||
) => {
|
||||
const { fontSize, fontWeight, fontStyle, fontFamily, textAlign, rotate } =
|
||||
model;
|
||||
const [, , w, h] = model.deserializedXYWH;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
|
||||
ctx.setTransform(
|
||||
matrix.translateSelf(cx, cy).rotateSelf(rotate).translateSelf(-cx, -cy)
|
||||
);
|
||||
|
||||
// const deltas: ITextDelta[] = yText.toDelta() as ITextDelta[];
|
||||
const font = getFontString({
|
||||
fontStyle,
|
||||
fontWeight,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
});
|
||||
const deltas = wrapTextDeltas(model.text, font, w);
|
||||
const lines = deltaInsertsToChunks(deltas);
|
||||
const lineHeightPx = getLineHeight(fontFamily, fontSize, fontWeight);
|
||||
const horizontalOffset =
|
||||
textAlign === 'center' ? w / 2 : textAlign === 'right' ? w : 0;
|
||||
|
||||
const color = renderer.getColorValue(
|
||||
model.color,
|
||||
DefaultTheme.textColor,
|
||||
true
|
||||
);
|
||||
|
||||
ctx.font = font;
|
||||
ctx.fillStyle = color;
|
||||
ctx.textAlign = textAlign;
|
||||
ctx.textBaseline = 'ideographic';
|
||||
|
||||
for (const [lineIndex, line] of lines.entries()) {
|
||||
let beforeTextWidth = 0;
|
||||
|
||||
for (const delta of line) {
|
||||
const str = delta.insert;
|
||||
const rtl = isRTL(str);
|
||||
const shouldTemporarilyAttach = rtl && !ctx.canvas.isConnected;
|
||||
if (shouldTemporarilyAttach) {
|
||||
// to correctly render RTL text mixed with LTR, we have to append it
|
||||
// to the DOM
|
||||
document.body.append(ctx.canvas);
|
||||
}
|
||||
|
||||
ctx.canvas.setAttribute('dir', rtl ? 'rtl' : 'ltr');
|
||||
|
||||
// 0.5 comes from v-line padding
|
||||
const offset =
|
||||
textAlign === 'center' ? 0 : textAlign === 'right' ? -0.5 : 0.5;
|
||||
ctx.fillText(
|
||||
str,
|
||||
horizontalOffset + beforeTextWidth + offset,
|
||||
(lineIndex + 1) * lineHeightPx
|
||||
);
|
||||
|
||||
beforeTextWidth += getTextWidth(str, font);
|
||||
|
||||
if (shouldTemporarilyAttach) {
|
||||
ctx.canvas.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const TextElementRendererExtension = ElementRendererExtension(
|
||||
'text',
|
||||
text
|
||||
);
|
||||
|
||||
export * from './utils';
|
||||
559
blocksuite/affine/gfx/text/src/element-renderer/utils.ts
Normal file
559
blocksuite/affine/gfx/text/src/element-renderer/utils.ts
Normal file
@@ -0,0 +1,559 @@
|
||||
import { TextUtils } from '@blocksuite/affine-block-surface';
|
||||
import type {
|
||||
FontFamily,
|
||||
FontStyle,
|
||||
FontWeight,
|
||||
TextElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import type { Bound } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
getPointsFromBoundWithRotation,
|
||||
rotatePoints,
|
||||
} from '@blocksuite/global/gfx';
|
||||
import { deltaInsertsToChunks } from '@blocksuite/std/inline';
|
||||
import type * as Y from 'yjs';
|
||||
|
||||
const { getFontFacesByFontFamily, wrapFontFamily } = TextUtils;
|
||||
|
||||
export type TextDelta = {
|
||||
insert: string;
|
||||
attributes?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const getMeasureCtx = (function initMeasureContext() {
|
||||
let ctx: CanvasRenderingContext2D | null = null;
|
||||
let canvas: HTMLCanvasElement | null = null;
|
||||
|
||||
return () => {
|
||||
if (!canvas) {
|
||||
canvas = document.createElement('canvas');
|
||||
ctx = canvas.getContext('2d')!;
|
||||
}
|
||||
|
||||
return ctx!;
|
||||
};
|
||||
})();
|
||||
|
||||
const textMeasureCache = new Map<
|
||||
string,
|
||||
{
|
||||
lineHeight: number;
|
||||
lineGap: number;
|
||||
fontSize: number;
|
||||
}
|
||||
>();
|
||||
|
||||
export function measureTextInDOM(
|
||||
fontFamily: string,
|
||||
fontSize: number,
|
||||
fontWeight: string
|
||||
) {
|
||||
const cacheKey = `${wrapFontFamily(fontFamily)}-${fontWeight}`;
|
||||
|
||||
if (textMeasureCache.has(cacheKey)) {
|
||||
const {
|
||||
fontSize: cacheFontSize,
|
||||
lineGap,
|
||||
lineHeight,
|
||||
} = textMeasureCache.get(cacheKey)!;
|
||||
|
||||
return {
|
||||
lineHeight: lineHeight * (fontSize / cacheFontSize),
|
||||
lineGap: lineGap * (fontSize / cacheFontSize),
|
||||
};
|
||||
}
|
||||
|
||||
const div = document.createElement('div');
|
||||
const span = document.createElement('span');
|
||||
|
||||
div.append(span);
|
||||
|
||||
span.innerText = 'x';
|
||||
|
||||
div.style.position = 'absolute';
|
||||
div.style.top = '0px';
|
||||
div.style.left = '0px';
|
||||
div.style.visibility = 'hidden';
|
||||
div.style.fontFamily = wrapFontFamily(fontFamily);
|
||||
div.style.fontWeight = fontWeight;
|
||||
div.style.fontSize = `${fontSize}px`;
|
||||
|
||||
div.style.pointerEvents = 'none';
|
||||
|
||||
document.body.append(div);
|
||||
|
||||
const lineHeight = span.getBoundingClientRect().height;
|
||||
const height = div.getBoundingClientRect().height;
|
||||
const result = {
|
||||
lineHeight,
|
||||
lineGap: height - lineHeight,
|
||||
};
|
||||
|
||||
div.remove();
|
||||
|
||||
textMeasureCache.set(cacheKey, {
|
||||
...result,
|
||||
fontSize,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getFontString({
|
||||
fontStyle,
|
||||
fontWeight,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
}: {
|
||||
fontStyle: string;
|
||||
fontWeight: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
}): string {
|
||||
const lineHeight = getLineHeight(fontFamily, fontSize, fontWeight);
|
||||
return `${fontStyle} ${fontWeight} ${fontSize}px/${lineHeight}px ${wrapFontFamily(
|
||||
fontFamily
|
||||
)}, sans-serif`.trim();
|
||||
}
|
||||
|
||||
export function getLineHeight(
|
||||
fontFamily: string,
|
||||
fontSize: number,
|
||||
fontWeight: string
|
||||
): number {
|
||||
const { lineHeight } = measureTextInDOM(fontFamily, fontSize, fontWeight);
|
||||
return lineHeight;
|
||||
}
|
||||
|
||||
type Writeable<T> = { -readonly [P in keyof T]: T[P] };
|
||||
|
||||
type TextMetricsLike = Writeable<TextMetrics>;
|
||||
|
||||
const metricsCache = new Map<
|
||||
string,
|
||||
{
|
||||
fontSize: number;
|
||||
metrics: TextMetrics;
|
||||
}
|
||||
>();
|
||||
export function getFontMetrics(
|
||||
fontFamily: string,
|
||||
fontSize: number,
|
||||
fontWeight: string
|
||||
) {
|
||||
const ctx = getMeasureCtx();
|
||||
const cacheKey = `${wrapFontFamily(fontFamily)}-${fontWeight}`;
|
||||
|
||||
if (metricsCache.has(cacheKey)) {
|
||||
const { fontSize: cacheFontSize, metrics } = metricsCache.get(cacheKey)!;
|
||||
|
||||
return Object.keys(Object.getPrototypeOf(metrics)).reduce((acc, key) => {
|
||||
acc[key as keyof TextMetrics] =
|
||||
metrics[key as keyof TextMetrics] * (fontSize / cacheFontSize);
|
||||
return acc;
|
||||
}, {} as TextMetricsLike);
|
||||
}
|
||||
|
||||
const font = `${fontWeight} ${fontSize}px ${wrapFontFamily(fontFamily)}`;
|
||||
ctx.font = font;
|
||||
const metrics = ctx.measureText('x');
|
||||
|
||||
// check if font does not fallback
|
||||
if (ctx.font === font) {
|
||||
metricsCache.set(cacheKey, {
|
||||
fontSize,
|
||||
metrics,
|
||||
});
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
const RS_LTR_CHARS =
|
||||
'A-Za-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF' +
|
||||
'\u2C00-\uFB1C\uFDFE-\uFE6F\uFEFD-\uFFFF';
|
||||
const RS_RTL_CHARS = '\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC';
|
||||
// oxlint-disable-next-line no-misleading-character-class
|
||||
const RE_RTL_CHECK = new RegExp(`^[^${RS_LTR_CHARS}]*[${RS_RTL_CHARS}]`);
|
||||
export function isRTL(text: string) {
|
||||
return RE_RTL_CHECK.test(text);
|
||||
}
|
||||
|
||||
export function splitIntoLines(text: string): string[] {
|
||||
return normalizeText(text).split('\n');
|
||||
}
|
||||
|
||||
export function getLineWidth(text: string, font: string): number {
|
||||
const ctx = getMeasureCtx();
|
||||
if (font !== ctx.font) ctx.font = font;
|
||||
const width = ctx.measureText(text).width;
|
||||
|
||||
return width;
|
||||
}
|
||||
|
||||
export function getTextWidth(text: string, font: string): number {
|
||||
const lines = splitIntoLines(text);
|
||||
let width = 0;
|
||||
lines.forEach(line => {
|
||||
width = Math.max(width, getLineWidth(line, font));
|
||||
});
|
||||
return width;
|
||||
}
|
||||
|
||||
export function wrapTextDeltas(text: Y.Text, font: string, w: number) {
|
||||
if (!text) return [];
|
||||
|
||||
const deltas: TextDelta[] = (text.toDelta() as TextDelta[]).flatMap(
|
||||
delta => ({
|
||||
insert: wrapText(delta.insert, font, w),
|
||||
attributes: delta.attributes,
|
||||
})
|
||||
) as TextDelta[];
|
||||
|
||||
return deltas;
|
||||
}
|
||||
|
||||
export const truncateTextByWidth = (
|
||||
text: string,
|
||||
font: string,
|
||||
width: number
|
||||
) => {
|
||||
let totalWidth = 0;
|
||||
let i = 0;
|
||||
for (; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
totalWidth += charWidth.calculate(char, font);
|
||||
if (totalWidth > width) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return text.slice(0, i);
|
||||
};
|
||||
|
||||
export function getTextCursorPosition(
|
||||
model: TextElementModel,
|
||||
coord: { x: number; y: number }
|
||||
) {
|
||||
const leftTop = getPointsFromBoundWithRotation(model)[0];
|
||||
const mousePos = rotatePoints(
|
||||
[[coord.x, coord.y]],
|
||||
leftTop,
|
||||
-model.rotate
|
||||
)[0];
|
||||
|
||||
return [
|
||||
Math.floor(
|
||||
(mousePos[1] - leftTop[1]) /
|
||||
getLineHeight(model.fontFamily, model.fontSize, model.fontWeight)
|
||||
),
|
||||
mousePos[0] - leftTop[0],
|
||||
];
|
||||
}
|
||||
|
||||
export function getCursorByCoord(
|
||||
model: TextElementModel,
|
||||
coord: { x: number; y: number }
|
||||
) {
|
||||
const [lineIndex, offsetX] = getTextCursorPosition(model, coord);
|
||||
|
||||
const font = getFontString(model);
|
||||
const deltas = wrapTextDeltas(model.text, font, model.w);
|
||||
const lines = deltaInsertsToChunks(deltas).map(line =>
|
||||
line.map(iTextDelta => iTextDelta.insert).join('')
|
||||
);
|
||||
|
||||
if (lineIndex < 0 || lineIndex >= lines.length) {
|
||||
return model.text.length;
|
||||
}
|
||||
|
||||
const string = lines[lineIndex];
|
||||
|
||||
let index = lines.slice(0, lineIndex).join('').length - 1;
|
||||
let currentStringWidth = 0;
|
||||
let charIndex = 0;
|
||||
while (currentStringWidth < offsetX) {
|
||||
index += 1;
|
||||
if (charIndex === string.length) {
|
||||
break;
|
||||
}
|
||||
currentStringWidth += charWidth.calculate(string[charIndex], font);
|
||||
charIndex += 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
export function normalizeTextBound(
|
||||
{
|
||||
yText,
|
||||
fontStyle,
|
||||
fontWeight,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
hasMaxWidth,
|
||||
maxWidth,
|
||||
}: {
|
||||
yText: Y.Text;
|
||||
fontStyle: FontStyle;
|
||||
fontWeight: FontWeight;
|
||||
fontSize: number;
|
||||
fontFamily: FontFamily;
|
||||
hasMaxWidth?: boolean;
|
||||
maxWidth?: number;
|
||||
},
|
||||
bound: Bound,
|
||||
dragging: boolean = false
|
||||
): Bound {
|
||||
if (!yText) return bound;
|
||||
|
||||
const lineHeightPx = getLineHeight(fontFamily, fontSize, fontWeight);
|
||||
const font = getFontString({
|
||||
fontStyle,
|
||||
fontWeight,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
});
|
||||
|
||||
let lines: TextDelta[][] = [];
|
||||
const deltas: TextDelta[] = yText.toDelta() as TextDelta[];
|
||||
const text = yText.toString();
|
||||
const widestCharWidth =
|
||||
[...text]
|
||||
.map(char => getTextWidth(char, font))
|
||||
.sort((a, b) => a - b)
|
||||
.pop() ?? getTextWidth('W', font);
|
||||
|
||||
if (bound.w < widestCharWidth) {
|
||||
bound.w = widestCharWidth;
|
||||
}
|
||||
|
||||
const width = bound.w;
|
||||
const insertDeltas = deltas.flatMap(delta => ({
|
||||
insert: wrapText(delta.insert, font, width),
|
||||
attributes: delta.attributes,
|
||||
})) as TextDelta[];
|
||||
lines = deltaInsertsToChunks(insertDeltas);
|
||||
if (!dragging) {
|
||||
lines = deltaInsertsToChunks(deltas);
|
||||
const widestLineWidth = Math.max(
|
||||
...text.split('\n').map(line => getTextWidth(line, font))
|
||||
);
|
||||
bound.w = widestLineWidth;
|
||||
if (hasMaxWidth && maxWidth && maxWidth > 0) {
|
||||
bound.w = Math.min(bound.w, maxWidth);
|
||||
}
|
||||
}
|
||||
bound.h = lineHeightPx * lines.length;
|
||||
|
||||
return bound;
|
||||
}
|
||||
|
||||
export function isFontWeightSupported(
|
||||
fontFamily: FontFamily | string,
|
||||
weight: FontWeight
|
||||
) {
|
||||
const fontFaces = getFontFacesByFontFamily(fontFamily);
|
||||
const fontFace = fontFaces.find(fontFace => fontFace.weight === weight);
|
||||
return !!fontFace;
|
||||
}
|
||||
|
||||
export function isFontStyleSupported(
|
||||
fontFamily: FontFamily | string,
|
||||
style: FontStyle
|
||||
) {
|
||||
const fontFaces = getFontFacesByFontFamily(fontFamily);
|
||||
const fontFace = fontFaces.find(fontFace => fontFace.style === style);
|
||||
return !!fontFace;
|
||||
}
|
||||
|
||||
export function normalizeText(text: string): string {
|
||||
return (
|
||||
text
|
||||
// replace tabs with spaces so they render and measure correctly
|
||||
.replace(/\t/g, ' ')
|
||||
// normalize newlines
|
||||
.replace(/\r?\n|\r/g, '\n')
|
||||
);
|
||||
}
|
||||
|
||||
export const getTextHeight = (text: string, lineHeight: number) => {
|
||||
const lineCount = splitIntoLines(text).length;
|
||||
return lineHeight * lineCount;
|
||||
};
|
||||
|
||||
export function parseTokens(text: string): string[] {
|
||||
// Splitting words containing "-" as those are treated as separate words
|
||||
// by css wrapping algorithm eg non-profit => non-, profit
|
||||
const words = text.split('-');
|
||||
if (words.length > 1) {
|
||||
// non-proft org => ['non-', 'profit org']
|
||||
words.forEach((word, index) => {
|
||||
if (index !== words.length - 1) {
|
||||
words[index] = word += '-';
|
||||
}
|
||||
});
|
||||
}
|
||||
// Joining the words with space and splitting them again with space to get the
|
||||
// final list of tokens
|
||||
// ['non-', 'profit org'] =>,'non- profit org' => ['non-','profit','org']
|
||||
return words.join(' ').split(' ');
|
||||
}
|
||||
|
||||
export const charWidth = (() => {
|
||||
const cachedCharWidth = new Map<string, Array<number>>();
|
||||
|
||||
const calculate = (char: string, font: string) => {
|
||||
const ascii = char.charCodeAt(0);
|
||||
|
||||
let fontCache = cachedCharWidth.get(font);
|
||||
if (!fontCache) {
|
||||
fontCache = [];
|
||||
cachedCharWidth.set(font, fontCache);
|
||||
}
|
||||
|
||||
if (fontCache[ascii] === undefined) {
|
||||
const width = getLineWidth(char, font);
|
||||
fontCache[ascii] = width;
|
||||
}
|
||||
|
||||
return fontCache[ascii];
|
||||
};
|
||||
|
||||
const getCache = (font: string) => {
|
||||
return cachedCharWidth.get(font);
|
||||
};
|
||||
return {
|
||||
calculate,
|
||||
getCache,
|
||||
};
|
||||
})();
|
||||
|
||||
export function wrapText(text: string, font: string, maxWidth: number): string {
|
||||
// if maxWidth is not finite or NaN which can happen in case of bugs in
|
||||
// computation, we need to make sure we don't continue as we'll end up
|
||||
// in an infinite loop
|
||||
if (!Number.isFinite(maxWidth) || maxWidth < 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const lines: Array<string> = [];
|
||||
const originalLines = text.split('\n');
|
||||
const spaceWidth = getLineWidth(' ', font);
|
||||
|
||||
let currentLine = '';
|
||||
let currentLineWidthTillNow = 0;
|
||||
|
||||
const push = (str: string) => {
|
||||
if (str.trim()) {
|
||||
lines.push(str);
|
||||
}
|
||||
};
|
||||
|
||||
const resetParams = () => {
|
||||
currentLine = '';
|
||||
currentLineWidthTillNow = 0;
|
||||
};
|
||||
originalLines.forEach(originalLine => {
|
||||
const currentLineWidth = getTextWidth(originalLine, font);
|
||||
|
||||
// Push the line if its <= maxWidth
|
||||
if (currentLineWidth <= maxWidth) {
|
||||
lines.push(originalLine);
|
||||
return; // continue
|
||||
}
|
||||
|
||||
const words = parseTokens(originalLine);
|
||||
resetParams();
|
||||
|
||||
let index = 0;
|
||||
|
||||
while (index < words.length) {
|
||||
const currentWordWidth = getLineWidth(words[index], font);
|
||||
|
||||
// This will only happen when single word takes entire width
|
||||
if (currentWordWidth === maxWidth) {
|
||||
push(words[index]);
|
||||
index++;
|
||||
}
|
||||
|
||||
// Start breaking longer words exceeding max width
|
||||
else if (currentWordWidth > maxWidth) {
|
||||
// push current line since the current word exceeds the max width
|
||||
// so will be appended in next line
|
||||
|
||||
push(currentLine);
|
||||
|
||||
resetParams();
|
||||
|
||||
while (words[index].length > 0) {
|
||||
const currentChar = String.fromCodePoint(
|
||||
words[index].codePointAt(0)!
|
||||
);
|
||||
const width = charWidth.calculate(currentChar, font);
|
||||
currentLineWidthTillNow += width;
|
||||
words[index] = words[index].slice(currentChar.length);
|
||||
|
||||
if (currentLineWidthTillNow >= maxWidth) {
|
||||
push(currentLine);
|
||||
currentLine = currentChar;
|
||||
currentLineWidthTillNow = width;
|
||||
} else {
|
||||
currentLine += currentChar;
|
||||
}
|
||||
}
|
||||
// push current line if appending space exceeds max width
|
||||
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
|
||||
push(currentLine);
|
||||
resetParams();
|
||||
// space needs to be appended before next word
|
||||
// as currentLine contains chars which couldn't be appended
|
||||
// to previous line unless the line ends with hyphen to sync
|
||||
// with css word-wrap
|
||||
} else if (!currentLine.endsWith('-')) {
|
||||
currentLine += ' ';
|
||||
currentLineWidthTillNow += spaceWidth;
|
||||
}
|
||||
index++;
|
||||
} else {
|
||||
// Start appending words in a line till max width reached
|
||||
while (currentLineWidthTillNow < maxWidth && index < words.length) {
|
||||
const word = words[index];
|
||||
currentLineWidthTillNow = getLineWidth(currentLine + word, font);
|
||||
|
||||
if (currentLineWidthTillNow > maxWidth) {
|
||||
push(currentLine);
|
||||
resetParams();
|
||||
|
||||
break;
|
||||
}
|
||||
index++;
|
||||
|
||||
// if word ends with "-" then we don't need to add space
|
||||
// to sync with css word-wrap
|
||||
const shouldAppendSpace = !word.endsWith('-');
|
||||
currentLine += word;
|
||||
|
||||
if (shouldAppendSpace) {
|
||||
currentLine += ' ';
|
||||
}
|
||||
|
||||
// Push the word if appending space exceeds max width
|
||||
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
|
||||
if (shouldAppendSpace) {
|
||||
lines.push(currentLine.slice(0, -1));
|
||||
} else {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
resetParams();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentLine.slice(-1) === ' ') {
|
||||
// only remove last trailing space which we have added when joining words
|
||||
currentLine = currentLine.slice(0, -1);
|
||||
push(currentLine);
|
||||
}
|
||||
});
|
||||
return lines.join('\n');
|
||||
}
|
||||
95
blocksuite/affine/gfx/text/src/element-view.ts
Normal file
95
blocksuite/affine/gfx/text/src/element-view.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { TextElementModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
GfxElementModelView,
|
||||
GfxViewInteractionExtension,
|
||||
} from '@blocksuite/std/gfx';
|
||||
|
||||
import { mountTextElementEditor } from './edgeless-text-editor';
|
||||
import { normalizeTextBound } from './element-renderer';
|
||||
|
||||
export class TextElementView extends GfxElementModelView<TextElementModel> {
|
||||
static override type: string = 'text';
|
||||
|
||||
override onCreated(): void {
|
||||
super.onCreated();
|
||||
|
||||
this._initDblClickToEdit();
|
||||
}
|
||||
|
||||
private _initDblClickToEdit(): void {
|
||||
this.on('dblclick', evt => {
|
||||
const edgeless = this.std.view.getBlock(this.std.store.root!.id);
|
||||
const [x, y] = this.gfx.viewport.toModelCoord(evt.x, evt.y);
|
||||
|
||||
if (edgeless && !this.model.isLocked()) {
|
||||
mountTextElementEditor(this.model, edgeless, {
|
||||
x,
|
||||
y,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const TextInteraction = GfxViewInteractionExtension<TextElementView>(
|
||||
TextElementView.type,
|
||||
{
|
||||
resizeConstraint: {
|
||||
lockRatio: ['top-left', 'top-right', 'bottom-left', 'bottom-right'],
|
||||
},
|
||||
handleResize({ model }) {
|
||||
let initialFontSize = model.fontSize;
|
||||
return {
|
||||
onResizeStart(context) {
|
||||
const { handle } = context;
|
||||
|
||||
context.default(context);
|
||||
|
||||
if (handle === 'left' || handle === 'right') {
|
||||
model.stash('hasMaxWidth');
|
||||
}
|
||||
model.stash('fontSize');
|
||||
},
|
||||
onResizeMove(context) {
|
||||
const { handle, newBound, originalBound } = context;
|
||||
if (handle === 'left' || handle === 'right') {
|
||||
const {
|
||||
text: yText,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
fontStyle,
|
||||
fontWeight,
|
||||
hasMaxWidth,
|
||||
} = model;
|
||||
// If the width of the text element has been changed by dragging,
|
||||
// We need to set hasMaxWidth to true for wrapping the text
|
||||
const normalizedBound = normalizeTextBound(
|
||||
{
|
||||
yText,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
fontStyle,
|
||||
fontWeight,
|
||||
hasMaxWidth,
|
||||
},
|
||||
newBound,
|
||||
true
|
||||
);
|
||||
|
||||
model.xywh = normalizedBound.serialize();
|
||||
model.hasMaxWidth = true;
|
||||
} else {
|
||||
model.xywh = newBound.serialize();
|
||||
model.fontSize = initialFontSize * (newBound.w / originalBound.w);
|
||||
}
|
||||
},
|
||||
onResizeEnd(context) {
|
||||
context.default(context);
|
||||
|
||||
model.pop('fontSize');
|
||||
model.pop('hasMaxWidth');
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
);
|
||||
7
blocksuite/affine/gfx/text/src/index.ts
Normal file
7
blocksuite/affine/gfx/text/src/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './adapter';
|
||||
export * from './commands';
|
||||
export * from './edgeless-text-editor';
|
||||
export * from './element-renderer';
|
||||
export * from './element-view';
|
||||
export * from './tool';
|
||||
export * from './toolbar';
|
||||
19
blocksuite/affine/gfx/text/src/store.ts
Normal file
19
blocksuite/affine/gfx/text/src/store.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
type StoreExtensionContext,
|
||||
StoreExtensionProvider,
|
||||
} from '@blocksuite/affine-ext-loader';
|
||||
|
||||
import {
|
||||
textToMarkdownAdapterMatcher,
|
||||
textToPlainTextAdapterMatcher,
|
||||
} from './adapter';
|
||||
|
||||
export class TextStoreExtension extends StoreExtensionProvider {
|
||||
override name = 'affine-text-gfx';
|
||||
|
||||
override setup(context: StoreExtensionContext) {
|
||||
super.setup(context);
|
||||
context.register(textToMarkdownAdapterMatcher);
|
||||
context.register(textToPlainTextAdapterMatcher);
|
||||
}
|
||||
}
|
||||
66
blocksuite/affine/gfx/text/src/tool.ts
Normal file
66
blocksuite/affine/gfx/text/src/tool.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { DefaultTool } from '@blocksuite/affine-block-surface';
|
||||
import type { TextElementModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
FeatureFlagService,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import type { PointerEventState } from '@blocksuite/std';
|
||||
import { BaseTool, type GfxController } from '@blocksuite/std/gfx';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { insertEdgelessTextCommand } from './commands/insert-edgeless-text';
|
||||
import { mountTextElementEditor } from './edgeless-text-editor';
|
||||
|
||||
function addText(gfx: GfxController, event: PointerEventState) {
|
||||
const [x, y] = gfx.viewport.toModelCoord(event.x, event.y);
|
||||
const selected = gfx.getElementByPoint(x, y);
|
||||
|
||||
if (!selected) {
|
||||
const [modelX, modelY] = gfx.viewport.toModelCoord(event.x, event.y);
|
||||
|
||||
if (!gfx.surface) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = gfx.surface.addElement({
|
||||
type: 'text',
|
||||
xywh: new Bound(modelX, modelY, 32, 32).serialize(),
|
||||
text: new Y.Text(),
|
||||
});
|
||||
gfx.doc.captureSync();
|
||||
const textElement = gfx.getElementById(id) as TextElementModel;
|
||||
const edgelessView = gfx.std.view.getBlock(gfx.std.store.root!.id);
|
||||
if (!edgelessView) {
|
||||
console.error('edgeless view is not found.');
|
||||
return;
|
||||
}
|
||||
mountTextElementEditor(textElement, edgelessView);
|
||||
}
|
||||
}
|
||||
|
||||
export class TextTool extends BaseTool {
|
||||
static override toolName: string = 'text';
|
||||
|
||||
override click(e: PointerEventState): void {
|
||||
const textFlag = this.gfx.doc
|
||||
.get(FeatureFlagService)
|
||||
.getFlag('enable_edgeless_text');
|
||||
|
||||
if (textFlag) {
|
||||
const [x, y] = this.gfx.viewport.toModelCoord(e.x, e.y);
|
||||
this.gfx.std.command.exec(insertEdgelessTextCommand, { x, y });
|
||||
this.gfx.tool.setTool(DefaultTool);
|
||||
} else {
|
||||
addText(this.gfx, e);
|
||||
}
|
||||
|
||||
this.gfx.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
|
||||
control: 'canvas:draw',
|
||||
page: 'whiteboard editor',
|
||||
module: 'toolbar',
|
||||
segment: 'toolbar',
|
||||
type: 'text',
|
||||
});
|
||||
}
|
||||
}
|
||||
396
blocksuite/affine/gfx/text/src/toolbar/actions.ts
Normal file
396
blocksuite/affine/gfx/text/src/toolbar/actions.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import {
|
||||
EdgelessCRUDIdentifier,
|
||||
TextUtils,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
packColor,
|
||||
type PickColorEvent,
|
||||
} from '@blocksuite/affine-components/color-picker';
|
||||
import { EditorChevronDown } from '@blocksuite/affine-components/toolbar';
|
||||
import {
|
||||
DefaultTheme,
|
||||
FontFamily,
|
||||
FontStyle,
|
||||
FontWeight,
|
||||
resolveColor,
|
||||
type SurfaceTextModelMap,
|
||||
TextAlign,
|
||||
type TextStyleProps,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
type ToolbarActions,
|
||||
type ToolbarContext,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
getMostCommonResolvedValue,
|
||||
getMostCommonValue,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
type MenuItem,
|
||||
renderCurrentMenuItemWith,
|
||||
renderMenu,
|
||||
} from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import {
|
||||
TextAlignCenterIcon,
|
||||
TextAlignLeftIcon,
|
||||
TextAlignRightIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import type { GfxModel } from '@blocksuite/std/gfx';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { html } from 'lit';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import {
|
||||
isFontStyleSupported,
|
||||
isFontWeightSupported,
|
||||
} from '../element-renderer/utils';
|
||||
|
||||
const FONT_WEIGHT_LIST = [
|
||||
{
|
||||
key: 'Light',
|
||||
value: FontWeight.Light,
|
||||
},
|
||||
{
|
||||
key: 'Regular',
|
||||
value: FontWeight.Regular,
|
||||
},
|
||||
{
|
||||
key: 'Semibold',
|
||||
value: FontWeight.SemiBold,
|
||||
},
|
||||
] as const satisfies MenuItem<FontWeight>[];
|
||||
|
||||
const FONT_STYLE_LIST = [
|
||||
{
|
||||
value: FontStyle.Normal,
|
||||
},
|
||||
{
|
||||
key: 'Italic',
|
||||
value: FontStyle.Italic,
|
||||
},
|
||||
] as const satisfies MenuItem<FontStyle>[];
|
||||
|
||||
const FONT_SIZE_LIST = [
|
||||
{ value: 16 },
|
||||
{ value: 24 },
|
||||
{ value: 32 },
|
||||
{ value: 40 },
|
||||
{ value: 64 },
|
||||
{ value: 128 },
|
||||
] as const satisfies MenuItem<number>[];
|
||||
|
||||
const TEXT_ALIGN_LIST = [
|
||||
{
|
||||
key: 'Left',
|
||||
value: TextAlign.Left,
|
||||
icon: TextAlignLeftIcon(),
|
||||
},
|
||||
{
|
||||
key: 'Center',
|
||||
value: TextAlign.Center,
|
||||
icon: TextAlignCenterIcon(),
|
||||
},
|
||||
{
|
||||
key: 'Right',
|
||||
value: TextAlign.Right,
|
||||
icon: TextAlignRightIcon(),
|
||||
},
|
||||
] as const satisfies MenuItem<TextAlign>[];
|
||||
|
||||
export function createTextActions<
|
||||
K extends abstract new (...args: any) => any,
|
||||
T extends keyof SurfaceTextModelMap,
|
||||
>(
|
||||
klass: K,
|
||||
type: T,
|
||||
update: (
|
||||
ctx: ToolbarContext,
|
||||
model: InstanceType<K>,
|
||||
props: Partial<TextStyleProps>
|
||||
) => void = (ctx, model, props) =>
|
||||
ctx.std.get(EdgelessCRUDIdentifier).updateElement(model.id, props),
|
||||
mapInto: (model: InstanceType<K>) => TextStyleProps = model => model,
|
||||
stash: <P extends keyof TextStyleProps>(
|
||||
model: InstanceType<K>,
|
||||
type: 'stash' | 'pop',
|
||||
field: P
|
||||
) => void = (model, type, field) => model[type](field)
|
||||
) {
|
||||
return [
|
||||
{
|
||||
id: 'a.font',
|
||||
content(ctx) {
|
||||
const models = ctx.getSurfaceModelsByType(klass);
|
||||
if (!models.length) return null;
|
||||
const allowed = models.every(model =>
|
||||
isSurfaceTextModel(model, klass, type)
|
||||
);
|
||||
if (!allowed) return null;
|
||||
|
||||
const mappedModels = models.map(mapInto);
|
||||
|
||||
const fontFamily =
|
||||
getMostCommonValue(mappedModels, 'fontFamily') ?? FontFamily.Inter;
|
||||
const styleInfo = { fontFamily: TextUtils.wrapFontFamily(fontFamily) };
|
||||
|
||||
const onPick = (fontFamily: FontFamily) => {
|
||||
let fontWeight =
|
||||
getMostCommonValue(mappedModels, 'fontWeight') ??
|
||||
FontWeight.Regular;
|
||||
let fontStyle =
|
||||
getMostCommonValue(mappedModels, 'fontStyle') ?? FontStyle.Normal;
|
||||
|
||||
if (!isFontWeightSupported(fontFamily, fontWeight)) {
|
||||
fontWeight = FontWeight.Regular;
|
||||
}
|
||||
if (!isFontStyleSupported(fontFamily, fontStyle)) {
|
||||
fontStyle = FontStyle.Normal;
|
||||
}
|
||||
|
||||
for (const model of models) {
|
||||
update(ctx, model, { fontFamily, fontWeight, fontStyle });
|
||||
}
|
||||
};
|
||||
|
||||
return html`
|
||||
<editor-menu-button
|
||||
.contentPadding="${'8px'}"
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Font"
|
||||
.tooltip="${'Font'}"
|
||||
.justify="${'space-between'}"
|
||||
.iconContainerWidth="${'40px'}"
|
||||
>
|
||||
<span class="label padding0" style=${styleMap(styleInfo)}
|
||||
>Aa</span
|
||||
>
|
||||
${EditorChevronDown}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<edgeless-font-family-panel
|
||||
.value=${fontFamily}
|
||||
.onSelect=${onPick}
|
||||
></edgeless-font-family-panel>
|
||||
</editor-menu-button>
|
||||
`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'b.text-color',
|
||||
content(ctx) {
|
||||
const models = ctx.getSurfaceModelsByType(klass);
|
||||
if (!models.length) return null;
|
||||
const allowed = models.every(model =>
|
||||
isSurfaceTextModel(model, klass, type)
|
||||
);
|
||||
if (!allowed) return null;
|
||||
|
||||
const enableCustomColor = ctx.features.getFlag('enable_color_picker');
|
||||
const theme = ctx.theme.edgeless$.value;
|
||||
|
||||
const palettes =
|
||||
type === 'shape'
|
||||
? DefaultTheme.ShapeTextColorPalettes
|
||||
: DefaultTheme.Palettes;
|
||||
const defaultColor =
|
||||
type === 'shape'
|
||||
? DefaultTheme.shapeTextColor
|
||||
: DefaultTheme.textColor;
|
||||
|
||||
const mappedModels = models.map(mapInto);
|
||||
|
||||
const field = 'color';
|
||||
const firstModel = mappedModels[0];
|
||||
const originalColor = firstModel[field];
|
||||
const color =
|
||||
getMostCommonResolvedValue(mappedModels, field, color =>
|
||||
resolveColor(color, theme)
|
||||
) ?? resolveColor(defaultColor, theme);
|
||||
|
||||
const onPick = (e: PickColorEvent) => {
|
||||
switch (e.type) {
|
||||
case 'pick':
|
||||
{
|
||||
const color = e.detail.value;
|
||||
const props = packColor(field, color);
|
||||
models.forEach(model => {
|
||||
update(ctx, model, props);
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'start':
|
||||
ctx.store.captureSync();
|
||||
models.forEach(model => {
|
||||
stash(model, 'stash', field);
|
||||
});
|
||||
break;
|
||||
case 'end':
|
||||
ctx.store.transact(() => {
|
||||
models.forEach(model => {
|
||||
stash(model, 'pop', field);
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return html`
|
||||
<edgeless-color-picker-button
|
||||
class="text-color"
|
||||
.label="${'Text color'}"
|
||||
.pick=${onPick}
|
||||
.color=${color}
|
||||
.theme=${theme}
|
||||
.isText=${true}
|
||||
.hollowCircle=${true}
|
||||
.originalColor=${originalColor}
|
||||
.palettes=${palettes}
|
||||
.enableCustomColor=${enableCustomColor}
|
||||
>
|
||||
</edgeless-color-picker-button>
|
||||
`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c.font-style',
|
||||
content(ctx) {
|
||||
const models = ctx.getSurfaceModelsByType(klass);
|
||||
if (!models.length) return null;
|
||||
const allowed = models.every(model =>
|
||||
isSurfaceTextModel(model, klass, type)
|
||||
);
|
||||
if (!allowed) return null;
|
||||
|
||||
const fontFamily =
|
||||
getMostCommonValue(models.map(mapInto), 'fontFamily') ??
|
||||
FontFamily.Inter;
|
||||
const fontWeight =
|
||||
getMostCommonValue(models.map(mapInto), 'fontWeight') ??
|
||||
FontWeight.Regular;
|
||||
const fontStyle =
|
||||
getMostCommonValue(models.map(mapInto), 'fontStyle') ??
|
||||
FontStyle.Normal;
|
||||
const matchFontFaces = TextUtils.getFontFacesByFontFamily(fontFamily);
|
||||
const disabled =
|
||||
matchFontFaces.length === 1 &&
|
||||
matchFontFaces[0].style === fontStyle &&
|
||||
matchFontFaces[0].weight === fontWeight;
|
||||
|
||||
const onPick = (fontWeight: FontWeight, fontStyle: FontStyle) => {
|
||||
for (const model of models) {
|
||||
update(ctx, model, { fontWeight, fontStyle });
|
||||
}
|
||||
};
|
||||
|
||||
return html`
|
||||
<editor-menu-button
|
||||
.contentPadding="${'8px'}"
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Font style"
|
||||
.tooltip="${'Font style'}"
|
||||
.justify="${'space-between'}"
|
||||
.iconContainerWidth="${'90px'}"
|
||||
.disabled=${disabled}
|
||||
>
|
||||
<span class="label ellipsis">
|
||||
${renderCurrentMenuItemWith(
|
||||
FONT_WEIGHT_LIST,
|
||||
fontWeight,
|
||||
'key'
|
||||
)}
|
||||
${renderCurrentMenuItemWith(
|
||||
FONT_STYLE_LIST,
|
||||
fontStyle,
|
||||
'key'
|
||||
)}
|
||||
</span>
|
||||
${EditorChevronDown}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<edgeless-font-weight-and-style-panel
|
||||
.fontFamily=${fontFamily}
|
||||
.fontWeight=${fontWeight}
|
||||
.fontStyle=${fontStyle}
|
||||
.onSelect=${onPick}
|
||||
></edgeless-font-weight-and-style-panel>
|
||||
</editor-menu-button>
|
||||
`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'd.font-size',
|
||||
when: type !== 'edgeless-text',
|
||||
content(ctx) {
|
||||
const models = ctx.getSurfaceModelsByType(klass);
|
||||
if (!models.length) return null;
|
||||
const allowed = models.every(model =>
|
||||
isSurfaceTextModel(model, klass, type)
|
||||
);
|
||||
if (!allowed) return null;
|
||||
|
||||
const fontSize$ = signal(
|
||||
Math.trunc(
|
||||
getMostCommonValue(models.map(mapInto), 'fontSize') ??
|
||||
FONT_SIZE_LIST[0].value
|
||||
)
|
||||
);
|
||||
|
||||
const onPick = (e: CustomEvent<number>) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const fontSize = e.detail;
|
||||
|
||||
for (const model of models) {
|
||||
update(ctx, model, { fontSize });
|
||||
}
|
||||
};
|
||||
|
||||
return html`<affine-size-dropdown-menu
|
||||
@select=${onPick}
|
||||
.label="${'Font size'}"
|
||||
.sizes=${FONT_SIZE_LIST}
|
||||
.sizeSignal=${fontSize$}
|
||||
></affine-size-dropdown-menu>`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'e.alignment',
|
||||
content(ctx) {
|
||||
const models = ctx.getSurfaceModelsByType(klass);
|
||||
if (!models.length) return null;
|
||||
const allowed = models.every(model =>
|
||||
isSurfaceTextModel(model, klass, type)
|
||||
);
|
||||
if (!allowed) return null;
|
||||
|
||||
const textAlign =
|
||||
getMostCommonValue(models.map(mapInto), 'textAlign') ??
|
||||
TextAlign.Left;
|
||||
|
||||
const onPick = (textAlign: TextAlign) => {
|
||||
for (const model of models) {
|
||||
update(ctx, model, { textAlign });
|
||||
}
|
||||
};
|
||||
|
||||
return renderMenu({
|
||||
label: 'Alignment',
|
||||
items: TEXT_ALIGN_LIST,
|
||||
currentValue: textAlign,
|
||||
onPick,
|
||||
});
|
||||
},
|
||||
},
|
||||
] as const satisfies ToolbarActions;
|
||||
}
|
||||
|
||||
function isSurfaceTextModel<
|
||||
K extends abstract new (...args: any) => any,
|
||||
T extends keyof SurfaceTextModelMap,
|
||||
>(model: GfxModel, klass: K, type: T): model is InstanceType<K> {
|
||||
return model instanceof klass || ('type' in model && model.type === type);
|
||||
}
|
||||
56
blocksuite/affine/gfx/text/src/toolbar/config.ts
Normal file
56
blocksuite/affine/gfx/text/src/toolbar/config.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import { TextElementModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
type ToolbarModuleConfig,
|
||||
ToolbarModuleExtension,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import { BlockFlavourIdentifier } from '@blocksuite/std';
|
||||
|
||||
import { normalizeTextBound } from '../element-renderer/utils';
|
||||
import { createTextActions } from './actions';
|
||||
|
||||
export const textToolbarConfig = {
|
||||
actions: createTextActions(TextElementModel, 'text', (ctx, model, props) => {
|
||||
// No need to adjust element bounds
|
||||
if (props['textAlign']) {
|
||||
ctx.std.get(EdgelessCRUDIdentifier).updateElement(model.id, props);
|
||||
return;
|
||||
}
|
||||
|
||||
const { text: yText, hasMaxWidth } = model;
|
||||
const textStyle = {
|
||||
fontFamily: model.fontFamily,
|
||||
fontStyle: model.fontStyle,
|
||||
fontSize: model.fontSize,
|
||||
fontWeight: model.fontWeight,
|
||||
...props,
|
||||
};
|
||||
|
||||
const { fontFamily, fontStyle, fontSize, fontWeight } = textStyle;
|
||||
|
||||
const bounds = normalizeTextBound(
|
||||
{
|
||||
yText,
|
||||
fontFamily,
|
||||
fontStyle,
|
||||
fontSize,
|
||||
fontWeight,
|
||||
hasMaxWidth,
|
||||
},
|
||||
Bound.fromXYWH(model.deserializedXYWH)
|
||||
);
|
||||
|
||||
ctx.std.get(EdgelessCRUDIdentifier).updateElement(model.id, {
|
||||
...textStyle,
|
||||
xywh: bounds.serialize(),
|
||||
});
|
||||
}),
|
||||
|
||||
when: ctx => ctx.getSurfaceModelsByType(TextElementModel).length > 0,
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
|
||||
export const textToolbarExtension = ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier('affine:surface:text'),
|
||||
config: textToolbarConfig,
|
||||
});
|
||||
2
blocksuite/affine/gfx/text/src/toolbar/index.ts
Normal file
2
blocksuite/affine/gfx/text/src/toolbar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './actions';
|
||||
export * from './config';
|
||||
49
blocksuite/affine/gfx/text/src/toolbar/text-menu.ts
Normal file
49
blocksuite/affine/gfx/text/src/toolbar/text-menu.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { DefaultTheme } from '@blocksuite/affine-model';
|
||||
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
||||
import type { ColorEvent } from '@blocksuite/affine-shared/utils';
|
||||
import { EdgelessToolbarToolMixin } from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { css, html, LitElement, nothing } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import { TextTool } from '../tool';
|
||||
|
||||
export class EdgelessTextMenu extends EdgelessToolbarToolMixin(LitElement) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _theme$ = computed(() => {
|
||||
return this.edgeless.std.get(ThemeProvider).theme$.value;
|
||||
});
|
||||
|
||||
override type = TextTool;
|
||||
|
||||
override render() {
|
||||
if (this.edgelessTool.toolType !== TextTool) return nothing;
|
||||
|
||||
return html`
|
||||
<edgeless-slide-menu>
|
||||
<div class="menu-content">
|
||||
<edgeless-color-panel
|
||||
class="one-way"
|
||||
.value=${this.color}
|
||||
.theme=${this._theme$.value}
|
||||
.palettes=${DefaultTheme.StrokeColorShortPalettes}
|
||||
@select=${(e: ColorEvent) => this.onChange({ color: e.detail })}
|
||||
></edgeless-color-panel>
|
||||
</div>
|
||||
</edgeless-slide-menu>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor color!: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onChange!: (props: Record<string, unknown>) => void;
|
||||
}
|
||||
32
blocksuite/affine/gfx/text/src/view.ts
Normal file
32
blocksuite/affine/gfx/text/src/view.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
type ViewExtensionContext,
|
||||
ViewExtensionProvider,
|
||||
} from '@blocksuite/affine-ext-loader';
|
||||
|
||||
import { DblClickAddEdgelessText } from './dblclick-add-edgeless-text';
|
||||
import { effects } from './effects';
|
||||
import { TextElementRendererExtension } from './element-renderer';
|
||||
import { TextElementView, TextInteraction } from './element-view';
|
||||
import { TextTool } from './tool';
|
||||
import { textToolbarExtension } from './toolbar';
|
||||
|
||||
export class TextViewExtension extends ViewExtensionProvider {
|
||||
override name = 'affine-text-gfx';
|
||||
|
||||
override effect(): void {
|
||||
super.effect();
|
||||
effects();
|
||||
}
|
||||
|
||||
override setup(context: ViewExtensionContext) {
|
||||
super.setup(context);
|
||||
context.register(TextElementView);
|
||||
context.register(TextElementRendererExtension);
|
||||
if (this.isEdgeless(context.scope)) {
|
||||
context.register(TextTool);
|
||||
context.register(textToolbarExtension);
|
||||
context.register(DblClickAddEdgelessText);
|
||||
context.register(TextInteraction);
|
||||
}
|
||||
}
|
||||
}
|
||||
20
blocksuite/affine/gfx/text/tsconfig.json
Normal file
20
blocksuite/affine/gfx/text/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../../blocks/surface" },
|
||||
{ "path": "../../components" },
|
||||
{ "path": "../../ext-loader" },
|
||||
{ "path": "../../model" },
|
||||
{ "path": "../../rich-text" },
|
||||
{ "path": "../../shared" },
|
||||
{ "path": "../../widgets/edgeless-toolbar" },
|
||||
{ "path": "../../../framework/global" },
|
||||
{ "path": "../../../framework/std" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user