@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.
Files changed (29) hide show
  1. package/dist/_pkgs/affine-block-frame/frame-manager.d.ts +13 -0
  2. package/dist/_pkgs/affine-block-frame/frame-manager.js +42 -3
  3. package/dist/_pkgs/affine-block-root/keyboard/shortcuts.js +3 -3
  4. package/dist/_pkgs/affine-block-root/view.js +5 -0
  5. package/dist/_pkgs/affine-gfx-shape/element-renderer/shape/index.js +10 -2
  6. package/dist/_pkgs/affine-gfx-shape/element-renderer/shape/utils.d.ts +12 -1
  7. package/dist/_pkgs/affine-gfx-shape/element-renderer/shape/utils.js +40 -1
  8. package/dist/_pkgs/affine-gfx-shape/index.d.ts +2 -0
  9. package/dist/_pkgs/affine-gfx-shape/index.js +2 -0
  10. package/dist/_pkgs/affine-gfx-shape/shortcuts.d.ts +6 -0
  11. package/dist/_pkgs/affine-gfx-shape/shortcuts.js +28 -0
  12. package/dist/_pkgs/affine-gfx-shape/text/edgeless-shape-text-editor.js +21 -13
  13. package/dist/_pkgs/affine-gfx-shape/text-fit.d.ts +10 -0
  14. package/dist/_pkgs/affine-gfx-shape/text-fit.js +29 -0
  15. package/dist/_pkgs/affine-gfx-shape/toolbar/config.d.ts +4 -0
  16. package/dist/_pkgs/affine-gfx-shape/toolbar/config.js +34 -2
  17. package/dist/_pkgs/affine-gfx-shape/view.js +4 -0
  18. package/dist/_pkgs/affine-model/consts/text.d.ts +17 -0
  19. package/dist/_pkgs/affine-model/consts/text.js +18 -0
  20. package/dist/_pkgs/affine-model/elements/shape/shape.d.ts +8 -1
  21. package/dist/_pkgs/affine-model/elements/shape/shape.js +16 -2
  22. package/dist/_pkgs/std/event/control/keyboard.js +16 -3
  23. package/dist/_pkgs/std/event/dispatcher.js +8 -1
  24. package/dist/_pkgs/std/event/keymap.d.ts +41 -1
  25. package/dist/_pkgs/std/event/keymap.js +137 -21
  26. package/dist/_pkgs/std/extension/shortcut.d.ts +30 -8
  27. package/dist/_pkgs/std/extension/shortcut.js +58 -24
  28. package/dist/shortcuts.js +11 -3
  29. 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.addElementsToFrame(frame, [element]);
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
- this.addElementsToFrame(frame, [payload.model]);
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', 'z'], other: ['Mod', 'z'] },
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', 'Mod', 'z'], other: ['Shift', 'Mod', 'z'] },
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', 'y'] },
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, fontSize, fontFamily, fontWeight, textAlign, w, h, textVerticalAlign, padding, } = model;
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(model);
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 { LocalShapeElementModel, ShapeElementModel, TextAlign, TextVerticalAlign } from '../../../affine-model/index.js';
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
- if (!shape.text)
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;
@@ -6,5 +6,7 @@ export * from './element-view';
6
6
  export * from './overlay';
7
7
  export * from './polygon-tool';
8
8
  export * from './shape-tool';
9
+ export * from './shortcuts';
9
10
  export * from './text';
11
+ export * from './text-fit';
10
12
  export * from './toolbar';
@@ -6,5 +6,7 @@ export * from './element-view';
6
6
  export * from './overlay';
7
7
  export * from './polygon-tool';
8
8
  export * from './shape-tool';
9
+ export * from './shortcuts';
9
10
  export * from './text';
11
+ export * from './text-fit';
10
12
  export * from './toolbar';
@@ -0,0 +1,6 @@
1
+ import type { ShortcutDescriptor } from '../std/index.js';
2
+ /**
3
+ * Shape shortcuts (edgeless scope, always-on — shapes are core canvas).
4
+ * Host-rebindable via `ShortcutOverrides`.
5
+ */
6
+ export declare const shapeShortcuts: ShortcutDescriptor[];
@@ -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 autoWidth = textResizing === TextResizing.AUTO_WIDTH_AND_HEIGHT;
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: textResizing === TextResizing.AUTO_HEIGHT
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: textResizing === TextResizing.AUTO_WIDTH_AND_HEIGHT
302
- ? '1em'
303
- : `${rect.height}px`,
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.fontSize + 'px',
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.#textVerticalAlign_accessor_storage = (__runInitializers(this, _textResizing_extraInitializers), __runInitializers(this, _textVerticalAlign_initializers, void 0));
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
- const binding = androidBindKeymapPatch(keymap);
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
- this.bindHotkey(getter(std), options);
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
- export declare function bindKeymap(bindings: Record<string, UIEventHandler>): UIEventHandler;
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
- function normalizeKeyName(name) {
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).forEach(mod => {
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
- throw new BlockSuiteError(ErrorCode.EventDispatcherError, 'Unrecognized modifier name: ' + mod);
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
- function normalize(map) {
61
- const copy = Object.create(null);
62
- for (const prop in map)
63
- copy[normalizeKeyName(prop)] = map[prop];
64
- return copy;
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 = normalize(bindings);
68
- return ctx => {
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
- /** Default key combos per platform, each as an array, e.g. `['Mod', 'z']`. */
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
- /** Host-provided rebinding table: combo array per id, or `'disabled'`. */
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 combo (array of keys) so equivalent spellings compare equal:
46
- * modifiers are lowercased/sorted and `Mod` resolves to the platform modifier;
47
- * the final key is lowercased (Shift is a separate modifier).
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 canonicalCombo(keys: string[]): string;
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
- * Only `'global'` scope is wired today; `'page'`/`'edgeless'` scoping is a
67
- * follow-up.
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 a combo (array of keys) so equivalent spellings compare equal:
21
- * modifiers are lowercased/sorted and `Mod` resolves to the platform modifier;
22
- * the final key is lowercased (Shift is a separate modifier).
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
- export function canonicalCombo(keys) {
25
- const key = (keys.at(-1) ?? '').toLowerCase();
26
- const mods = keys
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
- keymap[keys.join('-')] = d.handler(std);
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
- * Only `'global'` scope is wired today; `'page'`/`'edgeless'` scoping is a
89
- * follow-up.
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. No framework ships shortcuts
7
- * yet — the seam is ready and the manifest currently returns core only.
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(coreShortcuts, FRAMEWORK_SHORTCUT_GROUPS, flags);
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
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@formicoidea/labre-core",
3
3
  "description": "Labre editor — the full BlockSuite-derived editor as one package.",
4
- "version": "0.29.0",
4
+ "version": "0.30.1",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "author": "lajola",