@formicoidea/labre-core 0.29.0 → 0.30.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_pkgs/affine-block-frame/frame-manager.d.ts +13 -0
- package/dist/_pkgs/affine-block-frame/frame-manager.js +42 -3
- package/dist/_pkgs/affine-block-root/keyboard/shortcuts.js +3 -3
- package/dist/_pkgs/affine-block-root/view.js +5 -0
- package/dist/_pkgs/affine-gfx-shape/element-renderer/shape/index.js +10 -2
- package/dist/_pkgs/affine-gfx-shape/element-renderer/shape/utils.d.ts +12 -1
- package/dist/_pkgs/affine-gfx-shape/element-renderer/shape/utils.js +40 -1
- package/dist/_pkgs/affine-gfx-shape/index.d.ts +2 -0
- package/dist/_pkgs/affine-gfx-shape/index.js +2 -0
- package/dist/_pkgs/affine-gfx-shape/shortcuts.d.ts +6 -0
- package/dist/_pkgs/affine-gfx-shape/shortcuts.js +28 -0
- package/dist/_pkgs/affine-gfx-shape/text/edgeless-shape-text-editor.js +21 -13
- package/dist/_pkgs/affine-gfx-shape/text-fit.d.ts +10 -0
- package/dist/_pkgs/affine-gfx-shape/text-fit.js +29 -0
- package/dist/_pkgs/affine-gfx-shape/toolbar/config.d.ts +4 -0
- package/dist/_pkgs/affine-gfx-shape/toolbar/config.js +34 -2
- package/dist/_pkgs/affine-gfx-shape/view.js +4 -0
- package/dist/_pkgs/affine-model/consts/text.d.ts +17 -0
- package/dist/_pkgs/affine-model/consts/text.js +18 -0
- package/dist/_pkgs/affine-model/elements/shape/shape.d.ts +8 -1
- package/dist/_pkgs/affine-model/elements/shape/shape.js +16 -2
- package/dist/_pkgs/std/event/control/keyboard.js +16 -3
- package/dist/_pkgs/std/event/dispatcher.js +8 -1
- package/dist/_pkgs/std/event/keymap.d.ts +41 -1
- package/dist/_pkgs/std/event/keymap.js +137 -21
- package/dist/_pkgs/std/extension/shortcut.d.ts +30 -8
- package/dist/_pkgs/std/extension/shortcut.js +58 -24
- package/dist/shortcuts.js +11 -3
- package/package.json +1 -1
|
@@ -36,6 +36,19 @@ export declare class EdgelessFrameManager extends GfxExtension {
|
|
|
36
36
|
private _addChildrenToLegacyFrame;
|
|
37
37
|
private _addFrameBlock;
|
|
38
38
|
private _watchElementAdded;
|
|
39
|
+
/**
|
|
40
|
+
* Adopt an element that was just created inside the frame's bounds.
|
|
41
|
+
*
|
|
42
|
+
* The element's index was generated in the top-level band; once it is
|
|
43
|
+
* prefixed by the frame's back-of-stack index, it can tie with or sort
|
|
44
|
+
* below its new siblings' indexes, making the render order degenerate
|
|
45
|
+
* (broken by map iteration order on every layer rebuild). Reassign the
|
|
46
|
+
* element an index strictly above all its siblings so a newly created
|
|
47
|
+
* element always lands on top inside the frame — mirroring the behavior
|
|
48
|
+
* outside frames. Frames themselves are skipped: a frame's index is
|
|
49
|
+
* intentionally at the back so it renders behind its content.
|
|
50
|
+
*/
|
|
51
|
+
private _adoptNewlyCreatedElement;
|
|
39
52
|
/**
|
|
40
53
|
* Reset parent of elements to the frame
|
|
41
54
|
*/
|
|
@@ -3,7 +3,7 @@ import { EditPropsStore } from '../affine-shared/services/index.js';
|
|
|
3
3
|
import { DisposableGroup } from '../global/disposable/index.js';
|
|
4
4
|
import { BlockSuiteError, ErrorCode } from '../global/exceptions/index.js';
|
|
5
5
|
import { Bound, deserializeXYWH, } from '../global/gfx/index.js';
|
|
6
|
-
import { generateKeyBetweenV2, getTopElements, GfxBlockElementModel, GfxExtension, GfxExtensionIdentifier, isGfxGroupCompatibleModel, renderableInEdgeless, } from '../std/gfx/index.js';
|
|
6
|
+
import { generateKeyBetween, generateKeyBetweenV2, getTopElements, GfxBlockElementModel, GfxExtension, GfxExtensionIdentifier, isGfxGroupCompatibleModel, renderableInEdgeless, } from '../std/gfx/index.js';
|
|
7
7
|
import { Text } from '../store/index.js';
|
|
8
8
|
import * as Y from 'yjs';
|
|
9
9
|
const FRAME_PADDING = 40;
|
|
@@ -188,7 +188,7 @@ export class EdgelessFrameManager extends GfxExtension {
|
|
|
188
188
|
}
|
|
189
189
|
// Only add elements that aren't already grouped and have a valid frame
|
|
190
190
|
if (!element.group && frame) {
|
|
191
|
-
this.
|
|
191
|
+
this._adoptNewlyCreatedElement(frame, element);
|
|
192
192
|
}
|
|
193
193
|
});
|
|
194
194
|
}
|
|
@@ -204,10 +204,49 @@ export class EdgelessFrameManager extends GfxExtension {
|
|
|
204
204
|
payload.model.containsBound(frame.elementBound)) {
|
|
205
205
|
return;
|
|
206
206
|
}
|
|
207
|
-
|
|
207
|
+
if (payload.isLocal) {
|
|
208
|
+
this._adoptNewlyCreatedElement(frame, payload.model);
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
// Remote adds: adopt only. The originating client reassigns the
|
|
212
|
+
// index and syncs it — reindexing here too would produce
|
|
213
|
+
// competing concurrent writes for the same element.
|
|
214
|
+
this.addElementsToFrame(frame, [payload.model]);
|
|
215
|
+
}
|
|
208
216
|
}
|
|
209
217
|
}));
|
|
210
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* Adopt an element that was just created inside the frame's bounds.
|
|
221
|
+
*
|
|
222
|
+
* The element's index was generated in the top-level band; once it is
|
|
223
|
+
* prefixed by the frame's back-of-stack index, it can tie with or sort
|
|
224
|
+
* below its new siblings' indexes, making the render order degenerate
|
|
225
|
+
* (broken by map iteration order on every layer rebuild). Reassign the
|
|
226
|
+
* element an index strictly above all its siblings so a newly created
|
|
227
|
+
* element always lands on top inside the frame — mirroring the behavior
|
|
228
|
+
* outside frames. Frames themselves are skipped: a frame's index is
|
|
229
|
+
* intentionally at the back so it renders behind its content.
|
|
230
|
+
*/
|
|
231
|
+
_adoptNewlyCreatedElement(frame, element) {
|
|
232
|
+
// An element that is ALREADY a child (undo re-add: childElementIds are
|
|
233
|
+
// restored in the same transaction, observers fire after) keeps its
|
|
234
|
+
// restored index — reindexing would corrupt undo and push a fresh op
|
|
235
|
+
// onto the history stack. Only a genuinely new adoption is reindexed.
|
|
236
|
+
const alreadyChild = frame.hasChild(element);
|
|
237
|
+
this.addElementsToFrame(frame, [element]);
|
|
238
|
+
if (alreadyChild || isFrameBlock(element) || !frame.hasChild(element)) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const highestSibling = frame.childElements.reduce((max, child) => child !== element && (max === null || child.index > max)
|
|
242
|
+
? child.index
|
|
243
|
+
: max, null);
|
|
244
|
+
if (highestSibling !== null && element.index <= highestSibling) {
|
|
245
|
+
this.gfx.updateElement(element, {
|
|
246
|
+
index: generateKeyBetween(highestSibling, null),
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
211
250
|
/**
|
|
212
251
|
* Reset parent of elements to the frame
|
|
213
252
|
*/
|
|
@@ -9,7 +9,7 @@ export const coreShortcuts = [
|
|
|
9
9
|
{
|
|
10
10
|
id: 'undo',
|
|
11
11
|
labelKey: 'com.affine.keyboardShortcuts.undo',
|
|
12
|
-
defaultKeys: { mac: ['Mod
|
|
12
|
+
defaultKeys: { mac: ['Mod-z'], other: ['Mod-z'] },
|
|
13
13
|
scope: 'global',
|
|
14
14
|
owner: 'core',
|
|
15
15
|
handler: std => ctx => {
|
|
@@ -21,7 +21,7 @@ export const coreShortcuts = [
|
|
|
21
21
|
{
|
|
22
22
|
id: 'redo',
|
|
23
23
|
labelKey: 'com.affine.keyboardShortcuts.redo',
|
|
24
|
-
defaultKeys: { mac: ['Shift
|
|
24
|
+
defaultKeys: { mac: ['Shift-Mod-z'], other: ['Shift-Mod-z'] },
|
|
25
25
|
scope: 'global',
|
|
26
26
|
owner: 'core',
|
|
27
27
|
handler: std => ctx => {
|
|
@@ -34,7 +34,7 @@ export const coreShortcuts = [
|
|
|
34
34
|
// Windows-only redo alias (Ctrl+Y); not bound on mac (empty combo).
|
|
35
35
|
id: 'redo-windows',
|
|
36
36
|
labelKey: 'com.affine.keyboardShortcuts.redo',
|
|
37
|
-
defaultKeys: { mac: [], other: ['Control
|
|
37
|
+
defaultKeys: { mac: [], other: ['Control-y'] },
|
|
38
38
|
scope: 'global',
|
|
39
39
|
owner: 'core',
|
|
40
40
|
handler: std => ctx => {
|
|
@@ -24,6 +24,8 @@ export class RootViewExtension extends ViewExtensionProvider {
|
|
|
24
24
|
}
|
|
25
25
|
context.register(BlockViewExtension('affine:page', literal `affine-page-root`));
|
|
26
26
|
context.register(PageClipboard);
|
|
27
|
+
// Page-scoped shortcuts only exist while a page editor is mounted.
|
|
28
|
+
context.register(ShortcutKeymapExtension('page'));
|
|
27
29
|
};
|
|
28
30
|
this._setupEdgeless = (context) => {
|
|
29
31
|
context.register([
|
|
@@ -43,6 +45,9 @@ export class RootViewExtension extends ViewExtensionProvider {
|
|
|
43
45
|
AltCloneExtension,
|
|
44
46
|
]);
|
|
45
47
|
context.register(EdgelessElementToolbarExtension);
|
|
48
|
+
// Edgeless-scoped shortcuts (e.g. framework chords) only exist while an
|
|
49
|
+
// edgeless editor is mounted.
|
|
50
|
+
context.register(ShortcutKeymapExtension('edgeless'));
|
|
46
51
|
};
|
|
47
52
|
}
|
|
48
53
|
effect() {
|
|
@@ -8,6 +8,7 @@ import { ellipse } from './ellipse.js';
|
|
|
8
8
|
import { polygon } from './polygon.js';
|
|
9
9
|
import { rect } from './rect.js';
|
|
10
10
|
import { triangle } from './triangle.js';
|
|
11
|
+
import { effectiveShapeFontSize } from './utils.js';
|
|
11
12
|
import { horizontalOffset, verticalOffset } from './utils.js';
|
|
12
13
|
const shapeRenderers = {
|
|
13
14
|
diamond,
|
|
@@ -29,11 +30,18 @@ export const shape = (model, ctx, matrix, renderer, rc) => {
|
|
|
29
30
|
export const ShapeElementRendererExtension = ElementRendererExtension('shape', shape);
|
|
30
31
|
export * from './utils';
|
|
31
32
|
function renderText(model, ctx, { color }) {
|
|
32
|
-
const { x, y, text,
|
|
33
|
+
const { x, y, text, fontFamily, fontWeight, textAlign, w, h, textVerticalAlign, padding, } = model;
|
|
33
34
|
if (!text)
|
|
34
35
|
return;
|
|
36
|
+
// Contained mode shrinks the layout font size so the text fits the bounds.
|
|
37
|
+
const fontSize = effectiveShapeFontSize(model);
|
|
35
38
|
const [verticalPadding, horPadding] = padding;
|
|
36
|
-
const font = getFontString(
|
|
39
|
+
const font = getFontString({
|
|
40
|
+
fontStyle: model.fontStyle,
|
|
41
|
+
fontWeight,
|
|
42
|
+
fontSize,
|
|
43
|
+
fontFamily,
|
|
44
|
+
});
|
|
37
45
|
const { lineGap, lineHeight } = measureTextInDOM(fontFamily, fontSize, fontWeight);
|
|
38
46
|
const metrics = getFontMetrics(fontFamily, fontSize, fontWeight);
|
|
39
47
|
const lines = typeof text === 'string'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { CanvasRenderer } from '../../../affine-block-surface/index.js';
|
|
2
2
|
import { type TextDelta } from '../../../affine-gfx-text/index.js';
|
|
3
|
-
import type
|
|
3
|
+
import { type LocalShapeElementModel, type ShapeElementModel, type TextAlign, type TextVerticalAlign } from '../../../affine-model/index.js';
|
|
4
4
|
import type { Bound } from '../../../global/gfx/index.js';
|
|
5
5
|
export type Colors = {
|
|
6
6
|
color: string;
|
|
@@ -10,5 +10,16 @@ export type Colors = {
|
|
|
10
10
|
export declare function drawGeneralShape(ctx: CanvasRenderingContext2D, shapeModel: ShapeElementModel | LocalShapeElementModel, renderer: CanvasRenderer, filled: boolean, fillColor: string, strokeColor: string): void;
|
|
11
11
|
export declare function horizontalOffset(width: number, textAlign: TextAlign, horiPadding: number): number;
|
|
12
12
|
export declare function verticalOffset(lines: TextDelta[][], lineHeight: number, height: number, textVerticalAlign: TextVerticalAlign, verticalPadding: number): number;
|
|
13
|
+
/** The floor for `TextFitMode.Contained` font shrinking. */
|
|
14
|
+
export declare const MIN_CONTAINED_FONT_SIZE = 8;
|
|
15
|
+
/**
|
|
16
|
+
* The font size actually used to lay out a shape's text. In
|
|
17
|
+
* {@link TextFitMode.Contained} it is the largest size (at most the
|
|
18
|
+
* configured `fontSize`, at least {@link MIN_CONTAINED_FONT_SIZE}) whose
|
|
19
|
+
* wrapped text fits the shape's fixed bounds; every other mode uses the
|
|
20
|
+
* configured size as-is. Local shape models have no `textFitMode` and always
|
|
21
|
+
* use the configured size.
|
|
22
|
+
*/
|
|
23
|
+
export declare function effectiveShapeFontSize(model: ShapeElementModel | LocalShapeElementModel): number;
|
|
13
24
|
export declare function normalizeShapeBound(shape: ShapeElementModel, bound: Bound): Bound;
|
|
14
25
|
export declare function fitContent(shape: ShapeElementModel): void;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getFontString, getLineHeight, getLineWidth, getTextWidth, measureTextInDOM, wrapText, wrapTextDeltas, } from '../../../affine-gfx-text/index.js';
|
|
2
|
+
import { TextFitMode, } from '../../../affine-model/index.js';
|
|
2
3
|
import { FeatureFlagService } from '../../../affine-shared/services/index.js';
|
|
3
4
|
import { deltaInsertsToChunks } from '../../../std/inline/index.js';
|
|
4
5
|
export function drawGeneralShape(ctx, shapeModel, renderer, filled, fillColor, strokeColor) {
|
|
@@ -182,8 +183,46 @@ export function verticalOffset(lines, lineHeight, height, textVerticalAlign, ver
|
|
|
182
183
|
? verticalPadding
|
|
183
184
|
: height - lineHeight * lines.length - verticalPadding;
|
|
184
185
|
}
|
|
186
|
+
/** The floor for `TextFitMode.Contained` font shrinking. */
|
|
187
|
+
export const MIN_CONTAINED_FONT_SIZE = 8;
|
|
188
|
+
/**
|
|
189
|
+
* The font size actually used to lay out a shape's text. In
|
|
190
|
+
* {@link TextFitMode.Contained} it is the largest size (at most the
|
|
191
|
+
* configured `fontSize`, at least {@link MIN_CONTAINED_FONT_SIZE}) whose
|
|
192
|
+
* wrapped text fits the shape's fixed bounds; every other mode uses the
|
|
193
|
+
* configured size as-is. Local shape models have no `textFitMode` and always
|
|
194
|
+
* use the configured size.
|
|
195
|
+
*/
|
|
196
|
+
export function effectiveShapeFontSize(model) {
|
|
197
|
+
const mode = 'textFitMode' in model ? model.textFitMode : TextFitMode.Grow;
|
|
198
|
+
const text = model.text;
|
|
199
|
+
if (mode !== TextFitMode.Contained || !text || typeof text === 'string') {
|
|
200
|
+
return model.fontSize;
|
|
201
|
+
}
|
|
202
|
+
const [verticalPadding, horiPadding] = model.padding;
|
|
203
|
+
const availableW = model.w - horiPadding * 2;
|
|
204
|
+
const availableH = model.h - verticalPadding * 2;
|
|
205
|
+
// Linear scan from the configured size down: shape text is short, so the
|
|
206
|
+
// handful of measure passes is cheap and keeps the logic obvious.
|
|
207
|
+
for (let size = model.fontSize; size > MIN_CONTAINED_FONT_SIZE; size--) {
|
|
208
|
+
const font = getFontString({
|
|
209
|
+
fontStyle: model.fontStyle,
|
|
210
|
+
fontWeight: model.fontWeight,
|
|
211
|
+
fontSize: size,
|
|
212
|
+
fontFamily: model.fontFamily,
|
|
213
|
+
});
|
|
214
|
+
const lines = deltaInsertsToChunks(wrapTextDeltas(text, font, availableW));
|
|
215
|
+
const lineHeight = getLineHeight(model.fontFamily, size, model.fontWeight);
|
|
216
|
+
if (lineHeight * lines.length <= availableH) {
|
|
217
|
+
return size;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return MIN_CONTAINED_FONT_SIZE;
|
|
221
|
+
}
|
|
185
222
|
export function normalizeShapeBound(shape, bound) {
|
|
186
|
-
|
|
223
|
+
// Contained / overflow: the shape's bounds are user-controlled and never
|
|
224
|
+
// grown (or clamped) to fit the text.
|
|
225
|
+
if (!shape.text || shape.textFitMode !== TextFitMode.Grow)
|
|
187
226
|
return bound;
|
|
188
227
|
const [verticalPadding, horiPadding] = shape.padding;
|
|
189
228
|
const yText = shape.text;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { ShapeElementModel, TextFitMode } from '../affine-model/index.js';
|
|
2
|
+
import { GfxControllerIdentifier } from '../std/gfx/index.js';
|
|
3
|
+
import { applyTextFitMode, nextTextFitMode } from './text-fit.js';
|
|
4
|
+
/**
|
|
5
|
+
* Shape shortcuts (edgeless scope, always-on — shapes are core canvas).
|
|
6
|
+
* Host-rebindable via `ShortcutOverrides`.
|
|
7
|
+
*/
|
|
8
|
+
export const shapeShortcuts = [
|
|
9
|
+
{
|
|
10
|
+
id: 'shape.cycleTextFit',
|
|
11
|
+
labelKey: 'com.labre.keyboardShortcuts.shape.cycleTextFit',
|
|
12
|
+
defaultKeys: { mac: ['Mod-Shift-f'], other: ['Mod-Shift-f'] },
|
|
13
|
+
scope: 'edgeless',
|
|
14
|
+
owner: 'core',
|
|
15
|
+
handler: std => ctx => {
|
|
16
|
+
const gfx = std.get(GfxControllerIdentifier);
|
|
17
|
+
if (gfx.selection.editing)
|
|
18
|
+
return false;
|
|
19
|
+
const models = gfx.selection.selectedElements.filter((el) => el instanceof ShapeElementModel && !!el.text);
|
|
20
|
+
if (!models.length)
|
|
21
|
+
return false;
|
|
22
|
+
ctx.get('defaultState').event.preventDefault();
|
|
23
|
+
const mode = models[0].textFitMode ?? TextFitMode.Grow;
|
|
24
|
+
applyTextFitMode(std, models, nextTextFitMode(mode));
|
|
25
|
+
return true;
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
];
|
|
@@ -33,7 +33,7 @@ var __runInitializers = (this && this.__runInitializers) || function (thisArg, i
|
|
|
33
33
|
return useValue ? value : void 0;
|
|
34
34
|
};
|
|
35
35
|
import { DefaultTool, EdgelessCRUDIdentifier, TextUtils, } from '../../affine-block-surface/index.js';
|
|
36
|
-
import { MindmapElementModel, ShapeElementModel, TextResizing, } from '../../affine-model/index.js';
|
|
36
|
+
import { MindmapElementModel, ShapeElementModel, TextFitMode, TextResizing, } from '../../affine-model/index.js';
|
|
37
37
|
import { ThemeProvider } from '../../affine-shared/services/index.js';
|
|
38
38
|
import { getSelectedRect } from '../../affine-shared/utils/index.js';
|
|
39
39
|
import { BlockSuiteError, ErrorCode } from '../../global/exceptions/index.js';
|
|
@@ -47,6 +47,7 @@ import { html, nothing } from 'lit';
|
|
|
47
47
|
import { property, query } from 'lit/decorators.js';
|
|
48
48
|
import { styleMap } from 'lit/directives/style-map.js';
|
|
49
49
|
import * as Y from 'yjs';
|
|
50
|
+
import { effectiveShapeFontSize } from '../element-renderer/shape/utils.js';
|
|
50
51
|
export function mountShapeTextEditor(shapeElement, edgeless) {
|
|
51
52
|
const mountElm = edgeless.querySelector('.edgeless-mount-point');
|
|
52
53
|
if (!mountElm) {
|
|
@@ -180,6 +181,18 @@ let EdgelessShapeTextEditor = (() => {
|
|
|
180
181
|
});
|
|
181
182
|
}
|
|
182
183
|
_updateElementWH() {
|
|
184
|
+
if (this.element.textFitMode !== TextFitMode.Grow) {
|
|
185
|
+
// Fixed shape bounds: typing never resizes the shape. Contained mode
|
|
186
|
+
// re-renders so the derived font size tracks the new content.
|
|
187
|
+
if (this.element.textFitMode === TextFitMode.Contained) {
|
|
188
|
+
this.requestUpdate();
|
|
189
|
+
}
|
|
190
|
+
this.selection.set({
|
|
191
|
+
elements: [this.element.id],
|
|
192
|
+
editing: true,
|
|
193
|
+
});
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
183
196
|
const bcr = this.richText.getBoundingClientRect();
|
|
184
197
|
const containerHeight = this.richText.offsetHeight;
|
|
185
198
|
const containerWidth = this.richText.offsetWidth;
|
|
@@ -285,7 +298,8 @@ let EdgelessShapeTextEditor = (() => {
|
|
|
285
298
|
const rotate = this.element.rotate;
|
|
286
299
|
const [leftTopX, leftTopY] = Vec.rotWith([rect.left, rect.top], [rect.left + rect.width / 2, rect.top + rect.height / 2], toRadian(rotate));
|
|
287
300
|
const [x, y] = this.gfx.viewport.toViewCoord(leftTopX, leftTopY);
|
|
288
|
-
const
|
|
301
|
+
const fixedBounds = this.element.textFitMode !== TextFitMode.Grow;
|
|
302
|
+
const autoWidth = !fixedBounds && textResizing === TextResizing.AUTO_WIDTH_AND_HEIGHT;
|
|
289
303
|
const color = this.std
|
|
290
304
|
.get(ThemeProvider)
|
|
291
305
|
.generateColorProperty(this.element.color, '#000000');
|
|
@@ -293,21 +307,15 @@ let EdgelessShapeTextEditor = (() => {
|
|
|
293
307
|
position: 'absolute',
|
|
294
308
|
left: x + 'px',
|
|
295
309
|
top: y + 'px',
|
|
296
|
-
width:
|
|
297
|
-
? rect.width + 'px'
|
|
298
|
-
: 'fit-content',
|
|
310
|
+
width: autoWidth ? 'fit-content' : rect.width + 'px',
|
|
299
311
|
// override rich-text style (height: 100%)
|
|
300
312
|
height: 'initial',
|
|
301
|
-
minHeight:
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
maxWidth: textResizing === TextResizing.AUTO_WIDTH_AND_HEIGHT
|
|
305
|
-
? this.element.maxWidth
|
|
306
|
-
? `${this.element.maxWidth}px`
|
|
307
|
-
: undefined
|
|
313
|
+
minHeight: autoWidth ? '1em' : `${rect.height}px`,
|
|
314
|
+
maxWidth: autoWidth && this.element.maxWidth
|
|
315
|
+
? `${this.element.maxWidth}px`
|
|
308
316
|
: undefined,
|
|
309
317
|
boxSizing: 'border-box',
|
|
310
|
-
fontSize: this.element
|
|
318
|
+
fontSize: effectiveShapeFontSize(this.element) + 'px',
|
|
311
319
|
fontFamily: TextUtils.wrapFontFamily(this.element.fontFamily),
|
|
312
320
|
fontWeight: this.element.fontWeight,
|
|
313
321
|
lineHeight: 'normal',
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type ShapeElementModel, TextFitMode } from '../affine-model/index.js';
|
|
2
|
+
import type { BlockStdScope } from '../std/index.js';
|
|
3
|
+
export declare const nextTextFitMode: (mode: TextFitMode) => TextFitMode;
|
|
4
|
+
/**
|
|
5
|
+
* Apply a text fit mode to shapes. Entering `grow` re-clamps each shape's
|
|
6
|
+
* bounds to its text (the mode's invariant); the two fixed-bounds modes
|
|
7
|
+
* leave the bounds as the user set them. Both writes land in the same
|
|
8
|
+
* history batch (one undo step).
|
|
9
|
+
*/
|
|
10
|
+
export declare function applyTextFitMode(std: BlockStdScope, models: ShapeElementModel[], mode: TextFitMode): void;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { EdgelessCRUDIdentifier } from '../affine-block-surface/index.js';
|
|
2
|
+
import { TextFitMode } from '../affine-model/index.js';
|
|
3
|
+
import { Bound } from '../global/gfx/index.js';
|
|
4
|
+
import { normalizeShapeBound } from './element-renderer/shape/utils.js';
|
|
5
|
+
const CYCLE = {
|
|
6
|
+
[TextFitMode.Grow]: TextFitMode.Contained,
|
|
7
|
+
[TextFitMode.Contained]: TextFitMode.Overflow,
|
|
8
|
+
[TextFitMode.Overflow]: TextFitMode.Grow,
|
|
9
|
+
};
|
|
10
|
+
export const nextTextFitMode = (mode) => CYCLE[mode];
|
|
11
|
+
/**
|
|
12
|
+
* Apply a text fit mode to shapes. Entering `grow` re-clamps each shape's
|
|
13
|
+
* bounds to its text (the mode's invariant); the two fixed-bounds modes
|
|
14
|
+
* leave the bounds as the user set them. Both writes land in the same
|
|
15
|
+
* history batch (one undo step).
|
|
16
|
+
*/
|
|
17
|
+
export function applyTextFitMode(std, models, mode) {
|
|
18
|
+
const crud = std.get(EdgelessCRUDIdentifier);
|
|
19
|
+
std.store.captureSync();
|
|
20
|
+
for (const model of models) {
|
|
21
|
+
crud.updateElement(model.id, { textFitMode: mode });
|
|
22
|
+
// The model now reads the new mode, so the grow clamp applies.
|
|
23
|
+
if (mode === TextFitMode.Grow && model.text) {
|
|
24
|
+
crud.updateElement(model.id, {
|
|
25
|
+
xywh: normalizeShapeBound(model, Bound.fromXYWH(model.deserializedXYWH)).serialize(),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -25,6 +25,10 @@ export declare const shapeToolbarConfig: {
|
|
|
25
25
|
readonly icon: import("lit-html").TemplateResult<1>;
|
|
26
26
|
readonly when: (ctx: import("../../affine-shared/services/index.js").ToolbarContext) => boolean;
|
|
27
27
|
readonly run: (ctx: import("../../affine-shared/services/index.js").ToolbarContext) => void;
|
|
28
|
+
}, {
|
|
29
|
+
readonly id: "f2.text-fit";
|
|
30
|
+
readonly when: (ctx: import("../../affine-shared/services/index.js").ToolbarContext) => boolean;
|
|
31
|
+
readonly content: (ctx: import("../../affine-shared/services/index.js").ToolbarContext) => import("lit-html").TemplateResult<1> | null;
|
|
28
32
|
}, ...ToolbarGenericAction[]];
|
|
29
33
|
readonly when: (ctx: import("../../affine-shared/services/index.js").ToolbarContext) => boolean;
|
|
30
34
|
};
|
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
import { EdgelessCRUDIdentifier } from '../../affine-block-surface/index.js';
|
|
2
2
|
import { packColor, } from '../../affine-components/color-picker/index.js';
|
|
3
3
|
import { createTextActions } from '../../affine-gfx-text/index.js';
|
|
4
|
-
import { DefaultTheme, FontFamily, getShapeName, getShapeRadius, getShapeType, isTransparent, LineWidth, MindmapElementModel, resolveColor, ShapeElementModel, ShapeStyle, ShapeType, StrokeStyle, } from '../../affine-model/index.js';
|
|
4
|
+
import { DefaultTheme, FontFamily, getShapeName, getShapeRadius, getShapeType, isTransparent, LineWidth, MindmapElementModel, resolveColor, ShapeElementModel, ShapeStyle, ShapeType, StrokeStyle, TextFitMode, } from '../../affine-model/index.js';
|
|
5
5
|
import { ToolbarModuleExtension, } from '../../affine-shared/services/index.js';
|
|
6
6
|
import { getMostCommonValue } from '../../affine-shared/utils/index.js';
|
|
7
7
|
import { getRootBlock, LINE_STYLE_LIST, renderMenu, } from '../../affine-widget-edgeless-toolbar/index.js';
|
|
8
8
|
import { Bound } from '../../global/gfx/index.js';
|
|
9
|
-
import { AddTextIcon, EditIcon, ShapeIcon } from '@blocksuite/icons/lit';
|
|
9
|
+
import { AddTextIcon, AutoHeightIcon, AutoSizeIcon, EditIcon, ScaleAltIcon, ShapeIcon, } from '@blocksuite/icons/lit';
|
|
10
10
|
import { BlockFlavourIdentifier } from '../../std/index.js';
|
|
11
11
|
import { html } from 'lit';
|
|
12
12
|
import isEqual from 'lodash-es/isEqual';
|
|
13
13
|
import { normalizeShapeBound } from '../element-renderer';
|
|
14
14
|
import { ShapeElementView } from '../element-view';
|
|
15
|
+
import { applyTextFitMode, nextTextFitMode } from '../text-fit';
|
|
15
16
|
import { mountShapeTextEditor } from '../text/edgeless-shape-text-editor';
|
|
16
17
|
import { ShapeComponentConfig } from './shape-menu-config';
|
|
18
|
+
/** Icon + label per text fit mode for the cycling toolbar button. */
|
|
19
|
+
const TEXT_FIT_UI = {
|
|
20
|
+
[TextFitMode.Grow]: { icon: AutoHeightIcon(), label: 'Grow shape' },
|
|
21
|
+
[TextFitMode.Contained]: { icon: AutoSizeIcon(), label: 'Contained text' },
|
|
22
|
+
[TextFitMode.Overflow]: { icon: ScaleAltIcon(), label: 'Overflow text' },
|
|
23
|
+
};
|
|
17
24
|
export const shapeToolbarConfig = {
|
|
18
25
|
actions: [
|
|
19
26
|
{
|
|
@@ -231,6 +238,31 @@ export const shapeToolbarConfig = {
|
|
|
231
238
|
}
|
|
232
239
|
},
|
|
233
240
|
},
|
|
241
|
+
{
|
|
242
|
+
id: 'f2.text-fit',
|
|
243
|
+
when(ctx) {
|
|
244
|
+
const models = ctx.getSurfaceModelsByType(ShapeElementModel);
|
|
245
|
+
return (models.length > 0 &&
|
|
246
|
+
models.every(model => !hasGrouped(model) && model.text));
|
|
247
|
+
},
|
|
248
|
+
content(ctx) {
|
|
249
|
+
const models = ctx.getSurfaceModelsByType(ShapeElementModel);
|
|
250
|
+
if (!models.length)
|
|
251
|
+
return null;
|
|
252
|
+
const mode = getMostCommonValue(models, 'textFitMode') ?? TextFitMode.Grow;
|
|
253
|
+
const next = nextTextFitMode(mode);
|
|
254
|
+
const { icon, label } = TEXT_FIT_UI[mode];
|
|
255
|
+
return html `
|
|
256
|
+
<editor-icon-button
|
|
257
|
+
aria-label="Text fit: ${label}"
|
|
258
|
+
.tooltip=${`Text fit: ${label} — click for ${TEXT_FIT_UI[next].label}`}
|
|
259
|
+
@click=${() => applyTextFitMode(ctx.std, models, next)}
|
|
260
|
+
>
|
|
261
|
+
${icon}
|
|
262
|
+
</editor-icon-button>
|
|
263
|
+
`;
|
|
264
|
+
},
|
|
265
|
+
},
|
|
234
266
|
// id: `g.text`
|
|
235
267
|
...createTextActions(ShapeElementModel, 'shape', (ctx, model, props) => {
|
|
236
268
|
// No need to adjust element bounds
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { ViewExtensionProvider, } from '../affine-ext-loader/index.js';
|
|
2
|
+
import { ShortcutExtension } from '../std/index.js';
|
|
2
3
|
import { effects } from './effects';
|
|
4
|
+
import { shapeShortcuts } from './shortcuts';
|
|
3
5
|
import { HighlighterElementRendererExtension, ShapeElementRendererExtension, } from './element-renderer';
|
|
4
6
|
import { ShapeDomRendererExtension } from './element-renderer/shape-dom';
|
|
5
7
|
import { ShapeElementView, ShapeViewInteraction } from './element-view';
|
|
@@ -27,6 +29,8 @@ export class ShapeViewExtension extends ViewExtensionProvider {
|
|
|
27
29
|
context.register(shapeSeniorTool);
|
|
28
30
|
context.register(shapeToolbarExtension);
|
|
29
31
|
context.register(ShapeViewInteraction);
|
|
32
|
+
// Edgeless-scoped, enumerable/rebindable (installer: root view.ts).
|
|
33
|
+
context.register(ShortcutExtension(shapeShortcuts));
|
|
30
34
|
}
|
|
31
35
|
}
|
|
32
36
|
}
|
|
@@ -46,6 +46,23 @@ export declare enum TextResizing {
|
|
|
46
46
|
AUTO_WIDTH_AND_HEIGHT = 0,
|
|
47
47
|
AUTO_HEIGHT = 1
|
|
48
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* How a shape reconciles its text with its bounds. Orthogonal to
|
|
51
|
+
* {@link TextResizing} (which only applies in `Grow` mode). A string enum on
|
|
52
|
+
* purpose: the value is persisted in documents and must never depend on
|
|
53
|
+
* member order.
|
|
54
|
+
*/
|
|
55
|
+
export declare enum TextFitMode {
|
|
56
|
+
/** Fixed font size, the shape grows to fit the text (default). */
|
|
57
|
+
Grow = "grow",
|
|
58
|
+
/**
|
|
59
|
+
* Fixed shape size, the font size shrinks so the text fits
|
|
60
|
+
* (post-it behavior).
|
|
61
|
+
*/
|
|
62
|
+
Contained = "contained",
|
|
63
|
+
/** Fixed shape size and font size; the text may paint past the bounds. */
|
|
64
|
+
Overflow = "overflow"
|
|
65
|
+
}
|
|
49
66
|
export declare const FontFamilySchema: z.ZodNativeEnum<typeof FontFamily>;
|
|
50
67
|
export declare const FontWeightSchema: z.ZodNativeEnum<typeof FontWeight>;
|
|
51
68
|
export declare const FontStyleSchema: z.ZodNativeEnum<typeof FontStyle>;
|
|
@@ -44,6 +44,24 @@ export var TextResizing;
|
|
|
44
44
|
TextResizing[TextResizing["AUTO_WIDTH_AND_HEIGHT"] = 0] = "AUTO_WIDTH_AND_HEIGHT";
|
|
45
45
|
TextResizing[TextResizing["AUTO_HEIGHT"] = 1] = "AUTO_HEIGHT";
|
|
46
46
|
})(TextResizing || (TextResizing = {}));
|
|
47
|
+
/**
|
|
48
|
+
* How a shape reconciles its text with its bounds. Orthogonal to
|
|
49
|
+
* {@link TextResizing} (which only applies in `Grow` mode). A string enum on
|
|
50
|
+
* purpose: the value is persisted in documents and must never depend on
|
|
51
|
+
* member order.
|
|
52
|
+
*/
|
|
53
|
+
export var TextFitMode;
|
|
54
|
+
(function (TextFitMode) {
|
|
55
|
+
/** Fixed font size, the shape grows to fit the text (default). */
|
|
56
|
+
TextFitMode["Grow"] = "grow";
|
|
57
|
+
/**
|
|
58
|
+
* Fixed shape size, the font size shrinks so the text fits
|
|
59
|
+
* (post-it behavior).
|
|
60
|
+
*/
|
|
61
|
+
TextFitMode["Contained"] = "contained";
|
|
62
|
+
/** Fixed shape size and font size; the text may paint past the bounds. */
|
|
63
|
+
TextFitMode["Overflow"] = "overflow";
|
|
64
|
+
})(TextFitMode || (TextFitMode = {}));
|
|
47
65
|
export const FontFamilySchema = z.nativeEnum(FontFamily);
|
|
48
66
|
export const FontWeightSchema = z.nativeEnum(FontWeight);
|
|
49
67
|
export const FontStyleSchema = z.nativeEnum(FontStyle);
|
|
@@ -3,7 +3,7 @@ import { Bound } from '../../../global/gfx/index.js';
|
|
|
3
3
|
import type { BaseElementProps, PointTestOptions } from '../../../std/gfx/index.js';
|
|
4
4
|
import { GfxLocalElementModel, GfxPrimitiveElementModel } from '../../../std/gfx/index.js';
|
|
5
5
|
import * as Y from 'yjs';
|
|
6
|
-
import { FontStyle, FontWeight, ShapeStyle, ShapeType, StrokeStyle, TextAlign, TextResizing, type TextStyleProps, TextVerticalAlign } from '../../consts/index.js';
|
|
6
|
+
import { FontStyle, FontWeight, ShapeStyle, ShapeType, StrokeStyle, TextAlign, TextFitMode, TextResizing, type TextStyleProps, TextVerticalAlign } from '../../consts/index.js';
|
|
7
7
|
import { type Color } from '../../themes/index.js';
|
|
8
8
|
export type ShapeProps = BaseElementProps & {
|
|
9
9
|
shapeType: ShapeType;
|
|
@@ -44,6 +44,7 @@ export type ShapeProps = BaseElementProps & {
|
|
|
44
44
|
textHorizontalAlign?: TextAlign;
|
|
45
45
|
textVerticalAlign?: TextVerticalAlign;
|
|
46
46
|
textResizing?: TextResizing;
|
|
47
|
+
textFitMode?: TextFitMode;
|
|
47
48
|
maxWidth?: false | number;
|
|
48
49
|
} & Partial<TextStyleProps>;
|
|
49
50
|
export declare const SHAPE_TEXT_PADDING = 20;
|
|
@@ -100,6 +101,12 @@ export declare class ShapeElementModel extends GfxPrimitiveElementModel<ShapePro
|
|
|
100
101
|
accessor textDisplay: boolean;
|
|
101
102
|
accessor textHorizontalAlign: TextAlign;
|
|
102
103
|
accessor textResizing: TextResizing;
|
|
104
|
+
/**
|
|
105
|
+
* How the text and the shape bounds reconcile (grow / contained /
|
|
106
|
+
* overflow). Documents created before this prop existed read the `Grow`
|
|
107
|
+
* fallback — the pre-existing behavior.
|
|
108
|
+
*/
|
|
109
|
+
accessor textFitMode: TextFitMode;
|
|
103
110
|
accessor textVerticalAlign: TextVerticalAlign;
|
|
104
111
|
/**
|
|
105
112
|
* Vertices for polygon shapes, stored as normalized [0-1] coordinates
|
|
@@ -35,7 +35,7 @@ var __runInitializers = (this && this.__runInitializers) || function (thisArg, i
|
|
|
35
35
|
import { Bound, getBoundWithRotation } from '../../../global/gfx/index.js';
|
|
36
36
|
import { field, GfxLocalElementModel, GfxPrimitiveElementModel, local, prop, } from '../../../std/gfx/index.js';
|
|
37
37
|
import * as Y from 'yjs';
|
|
38
|
-
import { DEFAULT_ROUGHNESS, FontFamily, FontStyle, FontWeight, ShapeStyle, ShapeTextFontSize, ShapeType, StrokeStyle, TextAlign, TextResizing, TextVerticalAlign, } from '../../consts/index.js';
|
|
38
|
+
import { DEFAULT_ROUGHNESS, FontFamily, FontStyle, FontWeight, ShapeStyle, ShapeTextFontSize, ShapeType, StrokeStyle, TextAlign, TextFitMode, TextResizing, TextVerticalAlign, } from '../../consts/index.js';
|
|
39
39
|
import { DefaultTheme } from '../../themes/index.js';
|
|
40
40
|
import { shapeMethods } from './api/index.js';
|
|
41
41
|
export const SHAPE_TEXT_PADDING = 20;
|
|
@@ -111,6 +111,9 @@ let ShapeElementModel = (() => {
|
|
|
111
111
|
let _textResizing_decorators;
|
|
112
112
|
let _textResizing_initializers = [];
|
|
113
113
|
let _textResizing_extraInitializers = [];
|
|
114
|
+
let _textFitMode_decorators;
|
|
115
|
+
let _textFitMode_initializers = [];
|
|
116
|
+
let _textFitMode_extraInitializers = [];
|
|
114
117
|
let _textVerticalAlign_decorators;
|
|
115
118
|
let _textVerticalAlign_initializers = [];
|
|
116
119
|
let _textVerticalAlign_extraInitializers = [];
|
|
@@ -155,6 +158,7 @@ let ShapeElementModel = (() => {
|
|
|
155
158
|
_textDisplay_decorators = [local()];
|
|
156
159
|
_textHorizontalAlign_decorators = [field(TextAlign.Center)];
|
|
157
160
|
_textResizing_decorators = [field(TextResizing.AUTO_HEIGHT)];
|
|
161
|
+
_textFitMode_decorators = [field(TextFitMode.Grow)];
|
|
158
162
|
_textVerticalAlign_decorators = [field(TextVerticalAlign.Center)];
|
|
159
163
|
_vertices_decorators = [field()];
|
|
160
164
|
_isClosed_decorators = [field()];
|
|
@@ -184,6 +188,7 @@ let ShapeElementModel = (() => {
|
|
|
184
188
|
__esDecorate(this, null, _textDisplay_decorators, { kind: "accessor", name: "textDisplay", static: false, private: false, access: { has: obj => "textDisplay" in obj, get: obj => obj.textDisplay, set: (obj, value) => { obj.textDisplay = value; } }, metadata: _metadata }, _textDisplay_initializers, _textDisplay_extraInitializers);
|
|
185
189
|
__esDecorate(this, null, _textHorizontalAlign_decorators, { kind: "accessor", name: "textHorizontalAlign", static: false, private: false, access: { has: obj => "textHorizontalAlign" in obj, get: obj => obj.textHorizontalAlign, set: (obj, value) => { obj.textHorizontalAlign = value; } }, metadata: _metadata }, _textHorizontalAlign_initializers, _textHorizontalAlign_extraInitializers);
|
|
186
190
|
__esDecorate(this, null, _textResizing_decorators, { kind: "accessor", name: "textResizing", static: false, private: false, access: { has: obj => "textResizing" in obj, get: obj => obj.textResizing, set: (obj, value) => { obj.textResizing = value; } }, metadata: _metadata }, _textResizing_initializers, _textResizing_extraInitializers);
|
|
191
|
+
__esDecorate(this, null, _textFitMode_decorators, { kind: "accessor", name: "textFitMode", static: false, private: false, access: { has: obj => "textFitMode" in obj, get: obj => obj.textFitMode, set: (obj, value) => { obj.textFitMode = value; } }, metadata: _metadata }, _textFitMode_initializers, _textFitMode_extraInitializers);
|
|
187
192
|
__esDecorate(this, null, _textVerticalAlign_decorators, { kind: "accessor", name: "textVerticalAlign", static: false, private: false, access: { has: obj => "textVerticalAlign" in obj, get: obj => obj.textVerticalAlign, set: (obj, value) => { obj.textVerticalAlign = value; } }, metadata: _metadata }, _textVerticalAlign_initializers, _textVerticalAlign_extraInitializers);
|
|
188
193
|
__esDecorate(this, null, _vertices_decorators, { kind: "accessor", name: "vertices", static: false, private: false, access: { has: obj => "vertices" in obj, get: obj => obj.vertices, set: (obj, value) => { obj.vertices = value; } }, metadata: _metadata }, _vertices_initializers, _vertices_extraInitializers);
|
|
189
194
|
__esDecorate(this, null, _isClosed_decorators, { kind: "accessor", name: "isClosed", static: false, private: false, access: { has: obj => "isClosed" in obj, get: obj => obj.isClosed, set: (obj, value) => { obj.isClosed = value; } }, metadata: _metadata }, _isClosed_initializers, _isClosed_extraInitializers);
|
|
@@ -310,6 +315,14 @@ let ShapeElementModel = (() => {
|
|
|
310
315
|
#textResizing_accessor_storage;
|
|
311
316
|
get textResizing() { return this.#textResizing_accessor_storage; }
|
|
312
317
|
set textResizing(value) { this.#textResizing_accessor_storage = value; }
|
|
318
|
+
#textFitMode_accessor_storage;
|
|
319
|
+
/**
|
|
320
|
+
* How the text and the shape bounds reconcile (grow / contained /
|
|
321
|
+
* overflow). Documents created before this prop existed read the `Grow`
|
|
322
|
+
* fallback — the pre-existing behavior.
|
|
323
|
+
*/
|
|
324
|
+
get textFitMode() { return this.#textFitMode_accessor_storage; }
|
|
325
|
+
set textFitMode(value) { this.#textFitMode_accessor_storage = value; }
|
|
313
326
|
#textVerticalAlign_accessor_storage;
|
|
314
327
|
get textVerticalAlign() { return this.#textVerticalAlign_accessor_storage; }
|
|
315
328
|
set textVerticalAlign(value) { this.#textVerticalAlign_accessor_storage = value; }
|
|
@@ -387,7 +400,8 @@ let ShapeElementModel = (() => {
|
|
|
387
400
|
this.#textDisplay_accessor_storage = (__runInitializers(this, _textAlign_extraInitializers), __runInitializers(this, _textDisplay_initializers, true));
|
|
388
401
|
this.#textHorizontalAlign_accessor_storage = (__runInitializers(this, _textDisplay_extraInitializers), __runInitializers(this, _textHorizontalAlign_initializers, void 0));
|
|
389
402
|
this.#textResizing_accessor_storage = (__runInitializers(this, _textHorizontalAlign_extraInitializers), __runInitializers(this, _textResizing_initializers, TextResizing.AUTO_HEIGHT));
|
|
390
|
-
this.#
|
|
403
|
+
this.#textFitMode_accessor_storage = (__runInitializers(this, _textResizing_extraInitializers), __runInitializers(this, _textFitMode_initializers, TextFitMode.Grow));
|
|
404
|
+
this.#textVerticalAlign_accessor_storage = (__runInitializers(this, _textFitMode_extraInitializers), __runInitializers(this, _textVerticalAlign_initializers, void 0));
|
|
391
405
|
this.#vertices_accessor_storage = (__runInitializers(this, _textVerticalAlign_extraInitializers), __runInitializers(this, _vertices_initializers, null));
|
|
392
406
|
this.#isClosed_accessor_storage = (__runInitializers(this, _vertices_extraInitializers), __runInitializers(this, _isClosed_initializers, true));
|
|
393
407
|
this.#smoothFlags_accessor_storage = (__runInitializers(this, _isClosed_extraInitializers), __runInitializers(this, _smoothFlags_initializers, null));
|
|
@@ -61,19 +61,32 @@ export class KeyboardControl {
|
|
|
61
61
|
bindHotkey(keymap, options) {
|
|
62
62
|
const disposables = new DisposableGroup();
|
|
63
63
|
if (IS_ANDROID) {
|
|
64
|
+
const androidBinding = androidBindKeymapPatch(keymap);
|
|
64
65
|
disposables.add(this._dispatcher.add('beforeInput', ctx => {
|
|
65
66
|
if (this.composition)
|
|
66
67
|
return false;
|
|
67
|
-
|
|
68
|
-
return binding(ctx);
|
|
68
|
+
return androidBinding(ctx);
|
|
69
69
|
}, options));
|
|
70
70
|
}
|
|
71
|
+
// Build the binding once: it holds the chord (multi-keystroke) pending
|
|
72
|
+
// state, which must survive between keystrokes. The interceptor registry
|
|
73
|
+
// lets an armed chord consume its continuation before earlier-registered
|
|
74
|
+
// handlers (the dispatcher runs the most recently added handler first).
|
|
75
|
+
const binding = bindKeymap(keymap, {
|
|
76
|
+
register: handler => this._dispatcher.add('keyDown', ctx => {
|
|
77
|
+
if (this.composition)
|
|
78
|
+
return false;
|
|
79
|
+
return handler(ctx);
|
|
80
|
+
}, options),
|
|
81
|
+
});
|
|
71
82
|
disposables.add(this._dispatcher.add('keyDown', ctx => {
|
|
72
83
|
if (this.composition)
|
|
73
84
|
return false;
|
|
74
|
-
const binding = bindKeymap(keymap);
|
|
75
85
|
return binding(ctx);
|
|
76
86
|
}, options));
|
|
87
|
+
// Tear down any armed chord (pending timer + arm-time interceptor) with
|
|
88
|
+
// the binding, so a chord can never outlive its keymap.
|
|
89
|
+
disposables.add(() => binding.dispose());
|
|
77
90
|
return () => disposables.dispose();
|
|
78
91
|
}
|
|
79
92
|
listen() {
|
|
@@ -297,7 +297,14 @@ export class UIEventDispatcher extends LifeCycleWatcher {
|
|
|
297
297
|
this.std.provider
|
|
298
298
|
.getAll(KeymapIdentifier)
|
|
299
299
|
.forEach(({ getter, options }) => {
|
|
300
|
-
|
|
300
|
+
// One broken keymap (e.g. an invalid binding key) must not prevent
|
|
301
|
+
// every later keymap from being installed.
|
|
302
|
+
try {
|
|
303
|
+
this.bindHotkey(getter(std), options);
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
console.error('[keymap] failed to install a keymap:', error);
|
|
307
|
+
}
|
|
301
308
|
});
|
|
302
309
|
}
|
|
303
310
|
run(name, context, runners) {
|
|
@@ -1,3 +1,43 @@
|
|
|
1
1
|
import type { UIEventHandler } from './base.js';
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Parse one keystroke name (`'Mod-z'`) into the runtime form used for event
|
|
4
|
+
* matching (`'Ctrl-z'` / `'Meta-z'`, `'Space'` → `' '`), or return `null`
|
|
5
|
+
* when a modifier is not recognized. Shared by the binding path (which
|
|
6
|
+
* throws on `null`) and the shortcut canonicalizer (which must validate
|
|
7
|
+
* host-provided overrides without throwing).
|
|
8
|
+
*/
|
|
9
|
+
export declare function tryNormalizeKeyName(name: string): string | null;
|
|
10
|
+
/**
|
|
11
|
+
* Lets an armed chord preempt the rest of the dispatcher chain: the
|
|
12
|
+
* continuation handler registered here must run BEFORE every existing keyDown
|
|
13
|
+
* handler (the dispatcher unshifts new handlers, so registering at arm time
|
|
14
|
+
* achieves this). Without it, a chord like `w c` would lose its second
|
|
15
|
+
* keystroke to an earlier single-key binding (e.g. `c` = connector tool).
|
|
16
|
+
*/
|
|
17
|
+
export interface ChordInterceptorRegistry {
|
|
18
|
+
/** Register a keyDown handler that runs first; returns a dispose fn. */
|
|
19
|
+
register(handler: UIEventHandler): () => void;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Bind a keymap. A binding key is one keystroke (`'Mod-z'`) or a
|
|
23
|
+
* space-separated sequence of keystrokes (`'w c'`): pressing the first
|
|
24
|
+
* keystroke arms the sequence, and the next keystroke (within a short
|
|
25
|
+
* timeout) resolves it. The prefix scopes the continuation to its namespace:
|
|
26
|
+
* an unknown continuation is swallowed (no such shortcut), it never falls
|
|
27
|
+
* through to the generic single-keystroke bindings.
|
|
28
|
+
*
|
|
29
|
+
* Pass `interceptors` (the dispatcher does) so an armed chord consumes its
|
|
30
|
+
* continuation before earlier-registered handlers; without it, continuations
|
|
31
|
+
* are only matched when this handler is reached in the chain.
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* A bound keymap handler. `dispose` tears down any armed chord state (pending
|
|
35
|
+
* timer + arm-time interceptor) — the owner of the binding MUST call it when
|
|
36
|
+
* the binding is disposed, otherwise an armed chord outlives its keymap for
|
|
37
|
+
* up to the chord timeout.
|
|
38
|
+
*/
|
|
39
|
+
export type KeymapHandler = UIEventHandler & {
|
|
40
|
+
dispose: () => void;
|
|
41
|
+
};
|
|
42
|
+
export declare function bindKeymap(bindings: Record<string, UIEventHandler>, interceptors?: ChordInterceptorRegistry): KeymapHandler;
|
|
3
43
|
export declare function androidBindKeymapPatch(bindings: Record<string, UIEventHandler>): UIEventHandler;
|
|
@@ -1,41 +1,45 @@
|
|
|
1
1
|
import { IS_MAC } from '../../global/env/index.js';
|
|
2
2
|
import { BlockSuiteError, ErrorCode } from '../../global/exceptions/index.js';
|
|
3
3
|
import { base, keyName } from 'w3c-keyname';
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Parse one keystroke name (`'Mod-z'`) into the runtime form used for event
|
|
6
|
+
* matching (`'Ctrl-z'` / `'Meta-z'`, `'Space'` → `' '`), or return `null`
|
|
7
|
+
* when a modifier is not recognized. Shared by the binding path (which
|
|
8
|
+
* throws on `null`) and the shortcut canonicalizer (which must validate
|
|
9
|
+
* host-provided overrides without throwing).
|
|
10
|
+
*/
|
|
11
|
+
export function tryNormalizeKeyName(name) {
|
|
5
12
|
const parts = name.split(/-(?!$)/);
|
|
6
13
|
let result = parts.at(-1);
|
|
7
14
|
if (result === 'Space') {
|
|
8
15
|
result = ' ';
|
|
9
16
|
}
|
|
10
17
|
let alt, ctrl, shift, meta;
|
|
11
|
-
parts.slice(0, -1)
|
|
18
|
+
for (const mod of parts.slice(0, -1)) {
|
|
12
19
|
if (/^(cmd|meta|m)$/i.test(mod)) {
|
|
13
20
|
meta = true;
|
|
14
|
-
return;
|
|
15
21
|
}
|
|
16
|
-
if (/^a(lt)?$/i.test(mod)) {
|
|
22
|
+
else if (/^a(lt)?$/i.test(mod)) {
|
|
17
23
|
alt = true;
|
|
18
|
-
return;
|
|
19
24
|
}
|
|
20
|
-
if (/^(c|ctrl|control)$/i.test(mod)) {
|
|
25
|
+
else if (/^(c|ctrl|control)$/i.test(mod)) {
|
|
21
26
|
ctrl = true;
|
|
22
|
-
return;
|
|
23
27
|
}
|
|
24
|
-
if (/^s(hift)?$/i.test(mod)) {
|
|
28
|
+
else if (/^s(hift)?$/i.test(mod)) {
|
|
25
29
|
shift = true;
|
|
26
|
-
return;
|
|
27
30
|
}
|
|
28
|
-
if (/^mod$/i.test(mod)) {
|
|
31
|
+
else if (/^mod$/i.test(mod)) {
|
|
29
32
|
if (IS_MAC) {
|
|
30
33
|
meta = true;
|
|
31
34
|
}
|
|
32
35
|
else {
|
|
33
36
|
ctrl = true;
|
|
34
37
|
}
|
|
35
|
-
return;
|
|
36
38
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
else {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
39
43
|
if (alt)
|
|
40
44
|
result = 'Alt-' + result;
|
|
41
45
|
if (ctrl)
|
|
@@ -46,6 +50,13 @@ function normalizeKeyName(name) {
|
|
|
46
50
|
result = 'Shift-' + result;
|
|
47
51
|
return result;
|
|
48
52
|
}
|
|
53
|
+
function normalizeKeyName(name) {
|
|
54
|
+
const normalized = tryNormalizeKeyName(name);
|
|
55
|
+
if (normalized === null) {
|
|
56
|
+
throw new BlockSuiteError(ErrorCode.EventDispatcherError, 'Unrecognized modifier name: ' + name);
|
|
57
|
+
}
|
|
58
|
+
return normalized;
|
|
59
|
+
}
|
|
49
60
|
function modifiers(name, event, shift = true) {
|
|
50
61
|
if (event.altKey)
|
|
51
62
|
name = 'Alt-' + name;
|
|
@@ -57,18 +68,122 @@ function modifiers(name, event, shift = true) {
|
|
|
57
68
|
name = 'Shift-' + name;
|
|
58
69
|
return name;
|
|
59
70
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
71
|
+
/** How long a chord prefix stays armed before it is forgotten. */
|
|
72
|
+
const CHORD_TIMEOUT_MS = 1200;
|
|
73
|
+
const MODIFIER_KEYS = new Set(['Shift', 'Control', 'Alt', 'Meta']);
|
|
74
|
+
/**
|
|
75
|
+
* Chords must never swallow keystrokes the user is typing into an editable
|
|
76
|
+
* (rich text, inputs) — a chord prefix like `w` is a plain letter there.
|
|
77
|
+
*/
|
|
78
|
+
function isTypingTarget(event) {
|
|
79
|
+
const target = event.composedPath?.()[0] ?? event.target;
|
|
80
|
+
if (!(target instanceof HTMLElement))
|
|
81
|
+
return false;
|
|
82
|
+
return (target.isContentEditable ||
|
|
83
|
+
target.tagName === 'INPUT' ||
|
|
84
|
+
target.tagName === 'TEXTAREA');
|
|
65
85
|
}
|
|
66
|
-
export function bindKeymap(bindings) {
|
|
67
|
-
const map =
|
|
68
|
-
|
|
86
|
+
export function bindKeymap(bindings, interceptors) {
|
|
87
|
+
const map = Object.create(null);
|
|
88
|
+
const sequences = [];
|
|
89
|
+
for (const prop in bindings) {
|
|
90
|
+
const steps = prop.split(' ').filter(Boolean);
|
|
91
|
+
if (steps.length > 1) {
|
|
92
|
+
sequences.push({
|
|
93
|
+
steps: steps.map(normalizeKeyName),
|
|
94
|
+
handler: bindings[prop],
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
map[normalizeKeyName(prop)] = bindings[prop];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
let armed = [];
|
|
102
|
+
let armedDepth = 0;
|
|
103
|
+
let armedTimer = null;
|
|
104
|
+
let disposeInterceptor = null;
|
|
105
|
+
const disarm = () => {
|
|
106
|
+
armed = [];
|
|
107
|
+
armedDepth = 0;
|
|
108
|
+
if (armedTimer !== null) {
|
|
109
|
+
clearTimeout(armedTimer);
|
|
110
|
+
armedTimer = null;
|
|
111
|
+
}
|
|
112
|
+
if (disposeInterceptor !== null) {
|
|
113
|
+
disposeInterceptor();
|
|
114
|
+
disposeInterceptor = null;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const rearmTimeout = () => {
|
|
118
|
+
if (armedTimer !== null)
|
|
119
|
+
clearTimeout(armedTimer);
|
|
120
|
+
armedTimer = setTimeout(disarm, CHORD_TIMEOUT_MS);
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* Match one keystroke against the armed sequences. Returns true when the
|
|
124
|
+
* keystroke was consumed (advanced or completed a chord), false when it
|
|
125
|
+
* should fall through to the normal bindings (and the chord is disarmed).
|
|
126
|
+
*/
|
|
127
|
+
const continueChord = ctx => {
|
|
128
|
+
const event = ctx.get('keyboardState').raw;
|
|
129
|
+
const name = keyName(event);
|
|
130
|
+
// A lone modifier press must not break an armed chord.
|
|
131
|
+
if (MODIFIER_KEYS.has(name))
|
|
132
|
+
return false;
|
|
133
|
+
// Focus moved into an editable mid-chord: give the keystroke back.
|
|
134
|
+
if (isTypingTarget(event)) {
|
|
135
|
+
disarm();
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
const stroke = modifiers(name, event);
|
|
139
|
+
const matches = armed.filter(s => s.steps[armedDepth] === stroke);
|
|
140
|
+
if (!matches.length) {
|
|
141
|
+
// No continuation matched. The prefix scopes the next keystroke to
|
|
142
|
+
// its namespace ("in wardley, shortcut X"): an unknown key there
|
|
143
|
+
// means "no such shortcut" and is swallowed, never handed to the
|
|
144
|
+
// generic single-key bindings (w+e must not trigger the eraser).
|
|
145
|
+
disarm();
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
const complete = matches.find(s => s.steps.length === armedDepth + 1);
|
|
149
|
+
if (complete) {
|
|
150
|
+
disarm();
|
|
151
|
+
complete.handler(ctx);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
armed = matches;
|
|
155
|
+
armedDepth += 1;
|
|
156
|
+
rearmTimeout();
|
|
157
|
+
return true;
|
|
158
|
+
};
|
|
159
|
+
const run = ctx => {
|
|
69
160
|
const state = ctx.get('keyboardState');
|
|
70
161
|
const event = state.raw;
|
|
71
162
|
const name = keyName(event);
|
|
163
|
+
if (sequences.length && !isTypingTarget(event)) {
|
|
164
|
+
// Without an interceptor registry, continuations are handled here.
|
|
165
|
+
if (armedDepth > 0 && !interceptors) {
|
|
166
|
+
if (MODIFIER_KEYS.has(name))
|
|
167
|
+
return false;
|
|
168
|
+
if (continueChord(ctx))
|
|
169
|
+
return true;
|
|
170
|
+
// fall through: the keystroke starts fresh below
|
|
171
|
+
}
|
|
172
|
+
if (armedDepth === 0) {
|
|
173
|
+
const stroke = modifiers(name, event);
|
|
174
|
+
const starting = sequences.filter(s => s.steps[0] === stroke);
|
|
175
|
+
// An existing single binding on the prefix key wins over the chord.
|
|
176
|
+
if (starting.length && !map[stroke]) {
|
|
177
|
+
armed = starting;
|
|
178
|
+
armedDepth = 1;
|
|
179
|
+
rearmTimeout();
|
|
180
|
+
if (interceptors) {
|
|
181
|
+
disposeInterceptor = interceptors.register(continueChord);
|
|
182
|
+
}
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
72
187
|
const direct = map[modifiers(name, event)];
|
|
73
188
|
if (direct && direct(ctx)) {
|
|
74
189
|
return true;
|
|
@@ -96,6 +211,7 @@ export function bindKeymap(bindings) {
|
|
|
96
211
|
}
|
|
97
212
|
return false;
|
|
98
213
|
};
|
|
214
|
+
return Object.assign(run, { dispose: disarm });
|
|
99
215
|
}
|
|
100
216
|
// In Android, the keypress event dose not contain
|
|
101
217
|
// the information about what key is pressed. See
|
|
@@ -14,7 +14,11 @@ export interface ShortcutDescriptor {
|
|
|
14
14
|
id: string;
|
|
15
15
|
/** i18n key; the label is resolved by the host. */
|
|
16
16
|
labelKey: string;
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Default keys per platform, as a **sequence of keystrokes**. Each
|
|
19
|
+
* keystroke is a dash-joined combo, e.g. `['Mod-z']` (single keystroke)
|
|
20
|
+
* or `['w', 'c']` (chord: press `w`, then `c`).
|
|
21
|
+
*/
|
|
18
22
|
defaultKeys: {
|
|
19
23
|
mac: string[];
|
|
20
24
|
other: string[];
|
|
@@ -26,7 +30,12 @@ export interface ShortcutDescriptor {
|
|
|
26
30
|
when?: string;
|
|
27
31
|
handler: (std: BlockStdScope) => UIEventHandler;
|
|
28
32
|
}
|
|
29
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Host-provided rebinding table: keystroke sequence per id (same shape as
|
|
35
|
+
* {@link ShortcutDescriptor.defaultKeys}), or `'disabled'`. Tables persisted
|
|
36
|
+
* in the pre-chord v0.29 format (one combo as a modifiers array, e.g.
|
|
37
|
+
* `['Ctrl', 'Shift', 'Z']`) keep working — see {@link normalizeLegacyCombo}.
|
|
38
|
+
*/
|
|
30
39
|
export type ShortcutOverrides = Record<string, string[] | 'disabled'>;
|
|
31
40
|
export interface ShortcutConflict {
|
|
32
41
|
/** Canonical combo that more than one shortcut resolved to. */
|
|
@@ -42,11 +51,22 @@ export type ShortcutConflictReporter = (conflicts: ShortcutConflict[]) => void;
|
|
|
42
51
|
*/
|
|
43
52
|
export declare function ShortcutExtension(descriptors: ShortcutDescriptor[]): ExtensionType;
|
|
44
53
|
/**
|
|
45
|
-
* Canonicalize a
|
|
46
|
-
*
|
|
47
|
-
*
|
|
54
|
+
* Canonicalize a keystroke sequence (see {@link ShortcutDescriptor.defaultKeys})
|
|
55
|
+
* so equivalent spellings compare equal, e.g. `['Cmd-z']` ≡ `['Meta-z']` and
|
|
56
|
+
* `['w', 'Shift-C']` ≡ `['w', 'shift-c']`. Returns `null` when any keystroke
|
|
57
|
+
* is invalid (unknown modifier) — callers must not bind such a sequence.
|
|
58
|
+
*/
|
|
59
|
+
export declare function canonicalCombo(keys: string[]): string | null;
|
|
60
|
+
/**
|
|
61
|
+
* v0.29 compat: the previous released format expressed ONE combo as a
|
|
62
|
+
* modifiers array (`['Ctrl', 'Shift', 'Z']`). A bare multi-letter modifier
|
|
63
|
+
* name can never be a chord step (lone modifier presses are filtered by the
|
|
64
|
+
* dispatcher), so an array whose leading elements are all bare modifier
|
|
65
|
+
* names is unambiguously the legacy format — fold it into a single
|
|
66
|
+
* keystroke. Single-letter aliases (`c`, `m`, ...) are deliberately NOT
|
|
67
|
+
* folded: they are valid chord prefixes.
|
|
48
68
|
*/
|
|
49
|
-
export declare function
|
|
69
|
+
export declare function normalizeLegacyCombo(keys: string[]): string[];
|
|
50
70
|
/**
|
|
51
71
|
* Build the effective keymap for one scope from the registered descriptors and
|
|
52
72
|
* the override table. `'disabled'` ids are dropped; combo conflicts within the
|
|
@@ -63,7 +83,9 @@ export declare function resolveKeymap(descriptors: ShortcutDescriptor[], overrid
|
|
|
63
83
|
* and binds via the normal dispatcher keymap mechanism. Re-runs whenever the
|
|
64
84
|
* specs are rebuilt (e.g. a framework is toggled on/off).
|
|
65
85
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
86
|
+
* Scoping works by registration site: `'global'` is installed by the root
|
|
87
|
+
* view extension for every editor, while `'page'`/`'edgeless'` installers are
|
|
88
|
+
* registered only in the matching view-extension branch, so their shortcuts
|
|
89
|
+
* exist only when that editor mode is active.
|
|
68
90
|
*/
|
|
69
91
|
export declare function ShortcutKeymapExtension(scope?: ShortcutScope): ExtensionType;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { IS_MAC } from '../../global/env/index.js';
|
|
2
|
+
import { tryNormalizeKeyName } from '../event/keymap.js';
|
|
2
3
|
import { ShortcutConflictReporterIdentifier, ShortcutIdentifier, ShortcutOverrideIdentifier, } from '../identifier.js';
|
|
3
4
|
import { KeymapExtension } from './keymap.js';
|
|
4
5
|
let _shortcutId = 1;
|
|
@@ -17,31 +18,53 @@ export function ShortcutExtension(descriptors) {
|
|
|
17
18
|
}
|
|
18
19
|
const platformKeys = (d) => IS_MAC ? d.defaultKeys.mac : d.defaultKeys.other;
|
|
19
20
|
/**
|
|
20
|
-
* Canonicalize
|
|
21
|
-
* modifiers are lowercased/sorted and `Mod` resolves
|
|
22
|
-
* the final key is lowercased (Shift is a separate
|
|
21
|
+
* Canonicalize one keystroke (dash-joined combo like `'Mod-z'`) so equivalent
|
|
22
|
+
* spellings compare equal: modifiers are lowercased/sorted and `Mod` resolves
|
|
23
|
+
* to the platform modifier; the final key is lowercased (Shift is a separate
|
|
24
|
+
* modifier). Delegates parsing to the runtime keymap parser so canonical
|
|
25
|
+
* equality always matches runtime binding equality (`'Space'` ≡ `' '`, same
|
|
26
|
+
* alias table). Returns `null` for an unparseable keystroke.
|
|
23
27
|
*/
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
28
|
+
function canonicalKeystroke(keystroke) {
|
|
29
|
+
const normalized = tryNormalizeKeyName(keystroke);
|
|
30
|
+
if (normalized === null)
|
|
31
|
+
return null;
|
|
32
|
+
const parts = normalized.split(/-(?!$)/);
|
|
33
|
+
const key = (parts.at(-1) ?? '').toLowerCase();
|
|
34
|
+
const mods = parts
|
|
27
35
|
.slice(0, -1)
|
|
28
|
-
.map(m =>
|
|
29
|
-
const l = m.toLowerCase();
|
|
30
|
-
if (l === 'mod')
|
|
31
|
-
return IS_MAC ? 'meta' : 'ctrl';
|
|
32
|
-
if (l === 'cmd' || l === 'm' || l === 'meta')
|
|
33
|
-
return 'meta';
|
|
34
|
-
if (l === 'control' || l === 'c' || l === 'ctrl')
|
|
35
|
-
return 'ctrl';
|
|
36
|
-
if (l === 'a' || l === 'alt')
|
|
37
|
-
return 'alt';
|
|
38
|
-
if (l === 's' || l === 'shift')
|
|
39
|
-
return 'shift';
|
|
40
|
-
return l;
|
|
41
|
-
})
|
|
36
|
+
.map(m => m.toLowerCase())
|
|
42
37
|
.sort();
|
|
43
38
|
return [...mods, key].join('-');
|
|
44
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Canonicalize a keystroke sequence (see {@link ShortcutDescriptor.defaultKeys})
|
|
42
|
+
* so equivalent spellings compare equal, e.g. `['Cmd-z']` ≡ `['Meta-z']` and
|
|
43
|
+
* `['w', 'Shift-C']` ≡ `['w', 'shift-c']`. Returns `null` when any keystroke
|
|
44
|
+
* is invalid (unknown modifier) — callers must not bind such a sequence.
|
|
45
|
+
*/
|
|
46
|
+
export function canonicalCombo(keys) {
|
|
47
|
+
const strokes = keys.map(canonicalKeystroke);
|
|
48
|
+
if (strokes.some(s => s === null))
|
|
49
|
+
return null;
|
|
50
|
+
return strokes.join(' ');
|
|
51
|
+
}
|
|
52
|
+
const LEGACY_MODIFIER = /^(mod|cmd|meta|ctrl|control|alt|shift)$/i;
|
|
53
|
+
/**
|
|
54
|
+
* v0.29 compat: the previous released format expressed ONE combo as a
|
|
55
|
+
* modifiers array (`['Ctrl', 'Shift', 'Z']`). A bare multi-letter modifier
|
|
56
|
+
* name can never be a chord step (lone modifier presses are filtered by the
|
|
57
|
+
* dispatcher), so an array whose leading elements are all bare modifier
|
|
58
|
+
* names is unambiguously the legacy format — fold it into a single
|
|
59
|
+
* keystroke. Single-letter aliases (`c`, `m`, ...) are deliberately NOT
|
|
60
|
+
* folded: they are valid chord prefixes.
|
|
61
|
+
*/
|
|
62
|
+
export function normalizeLegacyCombo(keys) {
|
|
63
|
+
if (keys.length > 1 && keys.slice(0, -1).every(k => LEGACY_MODIFIER.test(k))) {
|
|
64
|
+
return [keys.join('-')];
|
|
65
|
+
}
|
|
66
|
+
return keys;
|
|
67
|
+
}
|
|
45
68
|
/**
|
|
46
69
|
* Build the effective keymap for one scope from the registered descriptors and
|
|
47
70
|
* the override table. `'disabled'` ids are dropped; combo conflicts within the
|
|
@@ -58,10 +81,17 @@ export function resolveKeymap(descriptors, overrides, scope, std) {
|
|
|
58
81
|
const override = overrides[d.id];
|
|
59
82
|
if (override === 'disabled')
|
|
60
83
|
continue;
|
|
61
|
-
const keys = override ?? platformKeys(d);
|
|
84
|
+
const keys = normalizeLegacyCombo(override ?? platformKeys(d));
|
|
62
85
|
if (!keys.length)
|
|
63
86
|
continue;
|
|
64
87
|
const canonical = canonicalCombo(keys);
|
|
88
|
+
if (canonical === null) {
|
|
89
|
+
// Invalid keystroke (host override typo, unknown modifier): skip the
|
|
90
|
+
// binding instead of letting the keymap installer throw and take the
|
|
91
|
+
// whole scope down.
|
|
92
|
+
console.warn(`[shortcut] invalid keys for "${d.id}" — not bound:`, keys);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
65
95
|
const existing = boundBy.get(canonical);
|
|
66
96
|
if (existing) {
|
|
67
97
|
const conflict = conflicts.find(c => c.combo === canonical);
|
|
@@ -75,7 +105,9 @@ export function resolveKeymap(descriptors, overrides, scope, std) {
|
|
|
75
105
|
continue; // do not silently bind a second action to the same combo
|
|
76
106
|
}
|
|
77
107
|
boundBy.set(canonical, d.id);
|
|
78
|
-
|
|
108
|
+
// A multi-keystroke sequence becomes a space-separated chord binding,
|
|
109
|
+
// resolved by the dispatcher's keymap (see `bindKeymap`).
|
|
110
|
+
keymap[keys.join(' ')] = d.handler(std);
|
|
79
111
|
}
|
|
80
112
|
return { keymap, conflicts };
|
|
81
113
|
}
|
|
@@ -85,8 +117,10 @@ export function resolveKeymap(descriptors, overrides, scope, std) {
|
|
|
85
117
|
* and binds via the normal dispatcher keymap mechanism. Re-runs whenever the
|
|
86
118
|
* specs are rebuilt (e.g. a framework is toggled on/off).
|
|
87
119
|
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
120
|
+
* Scoping works by registration site: `'global'` is installed by the root
|
|
121
|
+
* view extension for every editor, while `'page'`/`'edgeless'` installers are
|
|
122
|
+
* registered only in the matching view-extension branch, so their shortcuts
|
|
123
|
+
* exist only when that editor mode is active.
|
|
90
124
|
*/
|
|
91
125
|
export function ShortcutKeymapExtension(scope = 'global') {
|
|
92
126
|
// No flavour/blockId option → a global keymap (like `fallbackKeymap`).
|
package/dist/shortcuts.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { coreShortcuts } from './_pkgs/affine-block-root/index.js';
|
|
2
|
+
import { shapeShortcuts } from './_pkgs/affine-gfx-shape/index.js';
|
|
2
3
|
import { isBlockEnabled } from './flags.js';
|
|
3
4
|
/**
|
|
4
5
|
* Per-framework shortcut contributions. A framework that adds shortcuts lists
|
|
5
6
|
* them here (manifest) AND registers `ShortcutExtension(...)` in its view
|
|
6
|
-
* (runtime binding); both are gated by its flag.
|
|
7
|
-
*
|
|
7
|
+
* (runtime binding); both are gated by its flag.
|
|
8
|
+
*
|
|
9
|
+
* Bundled distribution note: these groups are STRIPPED from
|
|
10
|
+
* `@formicoidea/labre-core` by `scripts/build-bundles.mjs` (`shortcuts: true`
|
|
11
|
+
* on the framework's entry) — each framework bundle exports its own
|
|
12
|
+
* descriptors (e.g. `wardleyShortcuts`) and the host appends them to
|
|
13
|
+
* `getShortcutManifest()` for the frameworks it enables.
|
|
8
14
|
*/
|
|
9
15
|
const FRAMEWORK_SHORTCUT_GROUPS = [];
|
|
10
16
|
const toEntry = (d) => ({
|
|
@@ -35,5 +41,7 @@ export function buildShortcutManifest(core, groups, flags) {
|
|
|
35
41
|
* `getInternalViewExtensions`.
|
|
36
42
|
*/
|
|
37
43
|
export function getShortcutManifest(flags) {
|
|
38
|
-
return buildShortcutManifest(
|
|
44
|
+
return buildShortcutManifest(
|
|
45
|
+
// Shapes are core canvas: their shortcuts are always-on, like root's.
|
|
46
|
+
[...coreShortcuts, ...shapeShortcuts], FRAMEWORK_SHORTCUT_GROUPS, flags);
|
|
39
47
|
}
|