refactor(editor): separate the element renders (#11461)

This commit is contained in:
Saul-Mirone
2025-04-04 13:09:46 +00:00
parent 5a1106fb88
commit 2a1306c58c
70 changed files with 390 additions and 330 deletions

View File

@@ -10,7 +10,6 @@
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-block-edgeless-text": "workspace:*",
"@blocksuite/affine-block-surface": "workspace:*",
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-model": "workspace:*",

View File

@@ -0,0 +1 @@
export { insertEdgelessTextCommand } from './insert-edgeless-text.js';

View 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.doc;
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 });
};

View File

@@ -26,6 +26,8 @@ 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,
@@ -34,7 +36,7 @@ export function mountTextElementEditor(
let cursorIndex = textElement.text.length;
if (focusCoord) {
cursorIndex = Math.min(
TextUtils.getCursorByCoord(textElement, focusCoord),
getCursorByCoord(textElement, focusCoord),
cursorIndex
);
}
@@ -413,11 +415,7 @@ export class EdgelessTextEditor extends WithDisposable(ShadowlessElement) {
hasMaxWidth,
w,
} = this.element;
const lineHeight = TextUtils.getLineHeight(
fontFamily,
fontSize,
fontWeight
);
const lineHeight = getLineHeight(fontFamily, fontSize, fontWeight);
const rect = getSelectedRect([this.element]);
const { translateX, translateY, zoom } = this.gfx.viewport;

View 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';

View File

@@ -0,0 +1,555 @@
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';
// eslint-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: Record<string, Array<number>> = {};
const calculate = (char: string, font: string) => {
const ascii = char.charCodeAt(0);
if (!cachedCharWidth[font]) {
cachedCharWidth[font] = [];
}
if (!cachedCharWidth[font][ascii]) {
const width = getLineWidth(char, font);
cachedCharWidth[font][ascii] = width;
}
return cachedCharWidth[font][ascii];
};
const getCache = (font: string) => {
return cachedCharWidth[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');
}

View File

@@ -1,4 +1,6 @@
export * from './commands';
export * from './edgeless-text-editor';
export * from './element-renderer';
export * from './tool';
export * from './toolbar';
export * from './view';

View File

@@ -2,7 +2,6 @@ import {
CanvasElementType,
EdgelessCRUDIdentifier,
type IModelCoord,
TextUtils,
} from '@blocksuite/affine-block-surface';
import { TextElementModel } from '@blocksuite/affine-model';
import { Bound } from '@blocksuite/global/gfx';
@@ -11,6 +10,7 @@ import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import * as Y from 'yjs';
import { EdgelessTextEditor } from './edgeless-text-editor';
import { getCursorByCoord } from './element-renderer/utils';
export function mountTextElementEditor(
textElement: TextElementModel,
@@ -20,7 +20,7 @@ export function mountTextElementEditor(
let cursorIndex = textElement.text.length;
if (focusCoord) {
cursorIndex = Math.min(
TextUtils.getCursorByCoord(textElement, focusCoord),
getCursorByCoord(textElement, focusCoord),
cursorIndex
);
}

View File

@@ -1,4 +1,3 @@
import { insertEdgelessTextCommand } from '@blocksuite/affine-block-edgeless-text';
import type { TextElementModel } from '@blocksuite/affine-model';
import {
FeatureFlagService,
@@ -9,6 +8,7 @@ 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 './mount-text-editor';
function addText(gfx: GfxController, event: PointerEventState) {

View File

@@ -0,0 +1,382 @@
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,
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 fontFamily =
getMostCommonValue(models.map(mapInto), 'fontFamily') ??
FontFamily.Inter;
const styleInfo = { fontFamily: TextUtils.wrapFontFamily(fontFamily) };
const onPick = (fontFamily: FontFamily) => {
let fontWeight =
getMostCommonValue(models.map(mapInto), 'fontWeight') ??
FontWeight.Regular;
let fontStyle =
getMostCommonValue(models.map(mapInto), '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 field = 'color';
const firstModel = models[0];
const originalColor = mapInto(firstModel)[field];
const color =
getMostCommonResolvedValue(models, field, color =>
resolveColor(color, theme)
) ?? resolveColor(defaultColor, theme);
const onPick = (e: PickColorEvent) => {
if (e.type === 'pick') {
const color = e.detail.value;
for (const model of models) {
const props = packColor(field, color);
update(ctx, model, props);
}
return;
}
for (const model of models) {
stash(model, e.type === 'start' ? 'stash' : 'pop', field);
}
};
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}
.size$=${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);
}

View File

@@ -1,16 +1,15 @@
import {
EdgelessCRUDIdentifier,
TextUtils,
} from '@blocksuite/affine-block-surface';
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
import { TextElementModel } from '@blocksuite/affine-model';
import {
type ToolbarModuleConfig,
ToolbarModuleExtension,
} from '@blocksuite/affine-shared/services';
import { createTextActions } from '@blocksuite/affine-widget-edgeless-toolbar';
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
@@ -30,7 +29,7 @@ export const textToolbarConfig = {
const { fontFamily, fontStyle, fontSize, fontWeight } = textStyle;
const bounds = TextUtils.normalizeTextBound(
const bounds = normalizeTextBound(
{
yText,
fontFamily,

View File

@@ -1 +1,2 @@
export * from './actions';
export * from './config';

View File

@@ -7,7 +7,6 @@
},
"include": ["./src"],
"references": [
{ "path": "../../blocks/block-edgeless-text" },
{ "path": "../../blocks/block-surface" },
{ "path": "../../components" },
{ "path": "../../model" },