@mirasen/chessboard 1.2.4 → 1.3.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 (74) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +72 -1
  3. package/dist/animation/planner.js +2 -1
  4. package/dist/extensions/build/index.d.ts +1 -1
  5. package/dist/extensions/first-party/annotations/update.js +1 -1
  6. package/dist/extensions/first-party/board-events/factory.js +2 -2
  7. package/dist/extensions/first-party/main-renderer/animation/factory.d.ts +2 -1
  8. package/dist/extensions/first-party/main-renderer/animation/factory.js +3 -2
  9. package/dist/extensions/first-party/main-renderer/animation/types.d.ts +2 -0
  10. package/dist/extensions/first-party/main-renderer/animation/update.js +4 -2
  11. package/dist/extensions/first-party/main-renderer/board/factory.d.ts +1 -1
  12. package/dist/extensions/first-party/main-renderer/board/factory.js +4 -4
  13. package/dist/extensions/first-party/main-renderer/board/render.js +1 -1
  14. package/dist/extensions/first-party/main-renderer/board/types.d.ts +1 -1
  15. package/dist/extensions/first-party/main-renderer/coordinates/factory.d.ts +1 -1
  16. package/dist/extensions/first-party/main-renderer/coordinates/factory.js +2 -2
  17. package/dist/extensions/first-party/main-renderer/coordinates/render.js +1 -1
  18. package/dist/extensions/first-party/main-renderer/coordinates/types.d.ts +1 -1
  19. package/dist/extensions/first-party/main-renderer/denormalize.d.ts +3 -0
  20. package/dist/extensions/first-party/main-renderer/denormalize.js +18 -0
  21. package/dist/extensions/first-party/main-renderer/drag/factory.d.ts +2 -1
  22. package/dist/extensions/first-party/main-renderer/drag/factory.js +2 -1
  23. package/dist/extensions/first-party/main-renderer/drag/render.js +18 -11
  24. package/dist/extensions/first-party/main-renderer/drag/types.d.ts +2 -0
  25. package/dist/extensions/first-party/main-renderer/drag/update.js +4 -2
  26. package/dist/extensions/first-party/main-renderer/factory.d.ts +2 -1
  27. package/dist/extensions/first-party/main-renderer/factory.js +46 -12
  28. package/dist/extensions/first-party/main-renderer/normalize.d.ts +3 -3
  29. package/dist/extensions/first-party/main-renderer/normalize.js +19 -7
  30. package/dist/extensions/first-party/main-renderer/pieces/suppress.js +2 -1
  31. package/dist/extensions/first-party/main-renderer/public-defaults.d.ts +3 -0
  32. package/dist/extensions/first-party/main-renderer/public-defaults.js +4 -0
  33. package/dist/extensions/first-party/main-renderer/types/extension.d.ts +8 -5
  34. package/dist/extensions/first-party/main-renderer/types/instance.d.ts +10 -8
  35. package/dist/extensions/first-party/main-renderer/types/internal.d.ts +6 -7
  36. package/dist/extensions/first-party/main-renderer/types/internal.js +24 -1
  37. package/dist/extensions/first-party/main-renderer/types/public.d.ts +9 -0
  38. package/dist/extensions/first-party/main-renderer/types/template.d.ts +12 -1
  39. package/dist/extensions/first-party/promotion/types/input.d.ts +2 -2
  40. package/dist/extensions/index.d.ts +1 -0
  41. package/dist/extensions/index.js +1 -0
  42. package/dist/extensions/types/basic/events.d.ts +30 -16
  43. package/dist/extensions/types/basic/interaction.d.ts +12 -2
  44. package/dist/extensions/types/surface/commands.d.ts +4 -2
  45. package/dist/index.d.ts +10 -0
  46. package/dist/index.js +11 -0
  47. package/dist/runtime/factory/input.js +50 -24
  48. package/dist/runtime/factory/main.js +11 -0
  49. package/dist/runtime/input/adapter/types.js +2 -1
  50. package/dist/runtime/input/controller/factory.js +36 -9
  51. package/dist/runtime/input/controller/helpers.d.ts +5 -0
  52. package/dist/runtime/input/controller/helpers.js +11 -0
  53. package/dist/runtime/input/controller/pointer.js +48 -21
  54. package/dist/runtime/input/controller/types.d.ts +13 -4
  55. package/dist/runtime/mutation/interaction.js +1 -1
  56. package/dist/runtime/mutation/types.d.ts +2 -2
  57. package/dist/state/interaction/config.d.ts +6 -0
  58. package/dist/state/interaction/config.js +30 -0
  59. package/dist/state/interaction/factory.js +15 -3
  60. package/dist/state/interaction/helpers.d.ts +18 -2
  61. package/dist/state/interaction/helpers.js +12 -0
  62. package/dist/state/interaction/mutation.d.ts +2 -0
  63. package/dist/state/interaction/reducers.d.ts +3 -0
  64. package/dist/state/interaction/reducers.js +22 -7
  65. package/dist/state/interaction/types/config.d.ts +9 -0
  66. package/dist/state/interaction/types/config.js +1 -0
  67. package/dist/state/interaction/types/input.d.ts +3 -0
  68. package/dist/state/interaction/types/internal.d.ts +14 -4
  69. package/dist/state/interaction/types/main.d.ts +11 -5
  70. package/dist/wrapper/factory.js +2 -0
  71. package/dist/wrapper/types.d.ts +1 -1
  72. package/package.json +5 -6
  73. package/dist/extensions/first-party/main-renderer/types/input.d.ts +0 -4
  74. /package/dist/extensions/first-party/main-renderer/types/{input.js → public.js} +0 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # @mirasen/chessboard
2
2
 
3
+ ## 1.3.1
4
+
5
+ ### Patch Changes
6
+
7
+ - be0386f: Calibrate the default mobile drag piece scale and add board-level desktop/mobile config presets.
8
+
9
+ The mobile main-renderer drag preset now uses a larger lifted piece scale while preserving the existing bottom anchor and offset.
10
+
11
+ The root package now also exports `DefaultChessboardDesktopConfig` and `DefaultChessboardMobileConfig`, which combine the matching interaction defaults with the matching main-renderer defaults for easier desktop/mobile preset usage.
12
+
13
+ - be0386f: Calibrate the default mobile drag piece scale.
14
+
15
+ The mobile main-renderer drag preset now uses a larger lifted piece scale, making touch dragging feel closer to common mobile chessboard behavior while keeping the existing bottom anchor and offset.
16
+
17
+ ## 1.3.0
18
+
19
+ ### Minor Changes
20
+
21
+ - eaed694: Add configurable desktop and mobile drag behavior.
22
+
23
+ The board now exposes interaction config APIs through `setInteractionConfig()` and `getInteractionConfig()`, including `drag.liftedActivation.thresholdPx`. This enables desktop-style immediate drag activation and mobile-style delayed drag activation from the same interaction model.
24
+
25
+ The first-party main renderer now exposes runtime config APIs through `renderer.setConfig()` and `renderer.getConfig()`. Renderer config now includes dragged-piece visual settings and animation duration:
26
+ - `drag.pieceScale`
27
+ - `drag.pieceAnchor`
28
+ - `drag.pieceAnchorOffsetY`
29
+ - `animation.durationMs`
30
+
31
+ The package also exports public desktop/mobile main-renderer defaults from `@mirasen/chessboard/extensions` as `DefaultMainRendererDesktopConfig` and `DefaultMainRendererMobileConfig`.
32
+
33
+ Set `animation.durationMs` to `0` to disable piece movement animation by skipping animation creation.
34
+
35
+ Custom extension authors may need to update runtime interaction action names and payload fields to the new drag-session naming.
36
+
37
+ ### Patch Changes
38
+
39
+ - dde200f: dependabot: directory '/', update eslint
40
+ - dfe6882: dependabot: directory '/', update typescript from 5.9.3 to 6.0.3
41
+ - eb1fcb5: dependabot: directory '/', update @types/node
42
+ - eb1fcb5: dependabot: directory '/', update @vitest/browser-playwright
43
+ - eb1fcb5: dependabot: directory '/', update @vitest/coverage-istanbul
44
+ - eb1fcb5: dependabot: directory '/', update svelte
45
+ - eb1fcb5: dependabot: directory '/', update typescript-eslint
46
+ - eb1fcb5: dependabot: directory '/', update vitest
47
+ - d1da3c1: Prevent fast mouse dragging from triggering native browser drag-and-drop on SVG piece images.
48
+
49
+ Browsers can treat SVG images inside pieces as draggable content during quick mouse movement. When that native drag fallback starts, it can interrupt the board's pointer-driven drag flow by causing `pointercancel` / `lostpointercapture` and, in some cases, preventing the board from receiving the expected `pointerup`.
50
+
51
+ The board now suppresses native `dragstart` by default when no extension consumes the event, reducing one common source of interrupted fast mouse drags while keeping the existing `lostpointercapture` recovery path for other platform edge cases. `dragstart` remains observable by extensions and does not produce a core interaction action.
52
+
3
53
  ## 1.2.4
4
54
 
5
55
  ### Patch Changes
package/README.md CHANGED
@@ -11,6 +11,7 @@ A framework-agnostic TypeScript chessboard platform with real chess interaction
11
11
  ## Try it live (including annotations)
12
12
 
13
13
  - [Play a chess.js game](https://mirasen.io/chessboard/examples/chessjs.html)
14
+ - [Watch 12 live boards](https://mirasen.io/chessboard/examples/live-games-grid.html)
14
15
  - [Try the promotion flow](https://mirasen.io/chessboard/examples/promotion.html)
15
16
  - [Try the minimal example](https://mirasen.io/chessboard/examples/minimal.html)
16
17
 
@@ -18,6 +19,14 @@ A framework-agnostic TypeScript chessboard platform with real chess interaction
18
19
 
19
20
  Use [`@mirasen/react-chessboard`](https://www.npmjs.com/package/@mirasen/react-chessboard) for a React component with the same built-in interaction, annotations, promotion, animation, and chess.js integration.
20
21
 
22
+ ## Native mobile apps
23
+
24
+ `@mirasen/chessboard` is not tied to React or any other web framework. It is plain TypeScript/JavaScript on top of DOM and SVG, so it should fit WebView-based native iOS or Android integrations.
25
+
26
+ A common integration pattern is to bundle a WebView host page, mount the board there, and communicate with the native app through the platform bridge.
27
+
28
+ This keeps the integration model lightweight while avoiding a separate native chessboard UI implementation.
29
+
21
30
  ## Why this exists
22
31
 
23
32
  Most chessboards fall into one of two buckets:
@@ -65,7 +74,7 @@ Rules and legality still belong to your game layer. Use the built-in `chess.js`
65
74
  - Real built-in chess interaction
66
75
  - Built-in circles and arrows with live add/remove previews
67
76
  - **Easy game/rules integration** — connect your own game model, or use the built-in `chess.js` adapter
68
- - Works on desktop and mobile out of the box, with pointer-based mouse and touch interaction
77
+ - Works on desktop and mobile out of the box, with pointer-based mouse/touch interaction and tunable drag behavior
69
78
  - State-diff animation beyond simple move events
70
79
  - Extension-driven architecture
71
80
  - First-party baseline extensions
@@ -257,6 +266,68 @@ Movability controls how the board accepts user-initiated moves.
257
266
  - `free` — user moves are accepted without legal destination filtering
258
267
  - `strict` — user moves are accepted only when the target is allowed by the destinations provided by your app/rules layer
259
268
 
269
+ ### Desktop and mobile drag tuning
270
+
271
+ The board exposes interaction-level and renderer-level drag configuration so you can tune mouse-first and touch-first surfaces without replacing the built-in interaction model.
272
+
273
+ Interaction config controls when lifted-piece drag becomes active:
274
+
275
+ ```ts
276
+ const board = createBoard({
277
+ element
278
+ });
279
+
280
+ board.setInteractionConfig({
281
+ drag: {
282
+ liftedActivation: {
283
+ thresholdPx: 5
284
+ }
285
+ }
286
+ });
287
+ ```
288
+
289
+ Use `thresholdPx: 0` for immediate desktop-style drag activation, or a small threshold for mobile/touch UIs where the piece should not jump on the first tiny pointer movement.
290
+
291
+ The first-party renderer controls how the lifted piece looks while dragging:
292
+
293
+ ```ts
294
+ board.extensions.renderer.setConfig({
295
+ drag: {
296
+ pieceScale: 2,
297
+ pieceAnchor: 'bottom',
298
+ pieceAnchorOffsetY: 0.14
299
+ }
300
+ });
301
+ ```
302
+
303
+ For convenience, the root package ships board-level presets that combine the interaction feel and renderer visuals for a desktop- or mobile-style board. Apply both parts together:
304
+
305
+ ```ts
306
+ import { DefaultChessboardMobileConfig } from '@mirasen/chessboard';
307
+
308
+ board.setInteractionConfig(DefaultChessboardMobileConfig.interaction);
309
+
310
+ board.extensions.renderer.setConfig({
311
+ drag: DefaultChessboardMobileConfig.renderer.drag
312
+ });
313
+ ```
314
+
315
+ `DefaultChessboardDesktopConfig` is the matching desktop preset. If you only want the renderer half, the lower-level `DefaultMainRendererDesktopConfig` and `DefaultMainRendererMobileConfig` building blocks are still exported from `@mirasen/chessboard/extensions`.
316
+
317
+ ### Animation tuning
318
+
319
+ The first-party renderer exposes animation duration config:
320
+
321
+ ```ts
322
+ board.extensions.renderer.setConfig({
323
+ animation: {
324
+ durationMs: 0
325
+ }
326
+ });
327
+ ```
328
+
329
+ Set `animation.durationMs` to `0` when you want position updates to render instantly without creating movement animations.
330
+
260
331
  ### Advanced: explicit extension lists
261
332
 
262
333
  Most users can rely on the default first-party extension baseline. Use an explicit extension list only when you want to control which built-in or custom extensions are installed.
@@ -3,6 +3,7 @@ import { isNonEmptyPieceCode } from '../state/board/check.js';
3
3
  import { fileOf, rankOf } from '../state/board/coords.js';
4
4
  import { SQUARE_COUNT } from '../state/board/types/internal.js';
5
5
  import { baseMovesEqual } from '../state/change/helpers.js';
6
+ import { isDragSessionActiveLiftedPiece } from '../state/interaction/helpers.js';
6
7
  // ---------------------------------------------------------------------------
7
8
  // Precomputed squared distance: (dFile² + dRank²) for all square pairs.
8
9
  // Max value = 7² + 7² = 98, fits in Uint8. Index: a * 64 + b.
@@ -31,7 +32,7 @@ const EMPTY_EXCLUDE = {
31
32
  */
32
33
  function detectLiftedPieceDrop(previous, current) {
33
34
  const prevDrag = previous.interaction.dragSession;
34
- if (!prevDrag || prevDrag.type !== 'lifted-piece-drag')
35
+ if (!prevDrag || !isDragSessionActiveLiftedPiece(prevDrag))
35
36
  return null;
36
37
  if (current.interaction.dragSession !== null)
37
38
  return null;
@@ -10,8 +10,8 @@ export type { ColorCode, EmptyPieceCode, NonEmptyPieceCode, PieceCode, RoleCode,
10
10
  export type { MovabilityModeCode } from '../../state/interaction/types/internal.js';
11
11
  export { extensionCreateInternalBase, extensionDestroyBase, extensionIsDestroyedBase, extensionIsMountedBase, extensionMountBase, extensionUnmountBase } from '../first-party/common/helpers.js';
12
12
  export type { ExtensionInternalBase, OpaqueColor } from '../first-party/common/types.js';
13
- export type { PieceUrlsInput } from '../first-party/main-renderer/types/input.js';
14
13
  export type { PieceUrls } from '../first-party/main-renderer/types/internal.js';
14
+ export type { PieceUrlsPublic } from '../first-party/main-renderer/types/public.js';
15
15
  export type { PromotionPieceCode } from '../first-party/promotion/types/internal.js';
16
16
  export type { ExtensionSlotName, ExtensionSlotSvgRoots } from '../types/basic/mount.js';
17
17
  export { isFrameMounted, isFrameRenderable } from '../types/basic/update.js';
@@ -5,7 +5,7 @@ export function annotationsOnUpdate(state, context) {
5
5
  if (state.config.clearOnCoreInteraction &&
6
6
  hasCommittedAnnotations(state) &&
7
7
  context.mutation.hasMutation({
8
- causes: ['runtime.interaction.completeCoreDragTo']
8
+ causes: ['runtime.interaction.completeCoreDragSessionTo']
9
9
  })) {
10
10
  clearCommittedAnnotations(state);
11
11
  context.invalidation.markDirty(1 /* DirtyLayer.COMMITTED */);
@@ -45,8 +45,8 @@ function createBoardEventsInstance(options) {
45
45
  });
46
46
  const interactionCompleted = context.mutation.hasMutation({
47
47
  causes: [
48
- 'runtime.interaction.completeCoreDragTo',
49
- 'runtime.interaction.completeExtensionDragTo',
48
+ 'runtime.interaction.completeCoreDragSessionTo',
49
+ 'runtime.interaction.completeExtensionDragSession',
50
50
  'runtime.interaction.resolveDeferredUIMoveRequest'
51
51
  ]
52
52
  });
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionRuntimeSurface } from '../../../types/surface/main.js';
2
2
  import type { PieceSymbolResolver } from '../piece-symbols.js';
3
+ import type { MainRendererConfigAnimation } from '../types/template.js';
3
4
  import type { MainRendererAnimation } from './types.js';
4
- export declare function createMainRendererAnimation(runtimeSurface: ExtensionRuntimeSurface, resolver: PieceSymbolResolver): MainRendererAnimation;
5
+ export declare function createMainRendererAnimation(runtimeSurface: ExtensionRuntimeSurface, resolver: PieceSymbolResolver, getAnimationConfig: () => MainRendererConfigAnimation): MainRendererAnimation;
@@ -1,11 +1,12 @@
1
1
  import { cleanAnimationPlan } from '../../../../animation/render/plan.js';
2
2
  import { getAnimationSuppressedSquares, rendererAnimationClean, rendererAnimationPrepare, rendererAnimationRender } from './render.js';
3
3
  import { rendererAnimationOnUpdate } from './update.js';
4
- export function createMainRendererAnimation(runtimeSurface, resolver) {
4
+ export function createMainRendererAnimation(runtimeSurface, resolver, getAnimationConfig) {
5
5
  const state = {
6
6
  runtimeSurface,
7
7
  resolver,
8
- entries: new Map()
8
+ entries: new Map(),
9
+ getAnimationConfig
9
10
  };
10
11
  return {
11
12
  onUpdate(context) {
@@ -5,6 +5,7 @@ import { ExtensionCleanAnimationContext, ExtensionPrepareAnimationContext, Exten
5
5
  import { ExtensionUpdateContext } from '../../../types/context/update.js';
6
6
  import { ExtensionRuntimeSurface } from '../../../types/surface/main.js';
7
7
  import type { PieceSymbolResolver } from '../piece-symbols.js';
8
+ import type { MainRendererConfigAnimation } from '../types/template.js';
8
9
  export interface MainRendererAnimationEntry {
9
10
  plan: AnimationPlan;
10
11
  nodes: PreparedNodeMap | null;
@@ -13,6 +14,7 @@ export interface MainRendererAnimationInternal {
13
14
  readonly runtimeSurface: ExtensionRuntimeSurface;
14
15
  readonly resolver: PieceSymbolResolver;
15
16
  readonly entries: Map<number, MainRendererAnimationEntry>;
17
+ readonly getAnimationConfig: () => MainRendererConfigAnimation;
16
18
  }
17
19
  export interface MainRendererAnimation {
18
20
  onUpdate(context: ExtensionUpdateContext): void;
@@ -2,7 +2,6 @@ import { calculateAnimationPlan } from '../../../../animation/planner.js';
2
2
  import { piecePositionsEqual } from '../../../../state/board/check.js';
3
3
  import { changeStatesEqual } from '../../../../state/change/helpers.js';
4
4
  import { isUpdateContextRenderable } from '../../../types/context/update.js';
5
- const DEFAULT_ANIMATION_DURATION_MS = 180;
6
5
  export function rendererAnimationOnUpdate(state, context) {
7
6
  if (!isUpdateContextRenderable(context) ||
8
7
  !context.mutation.hasMutation({ prefixes: ['state.board.', 'state.change.'] }) ||
@@ -12,6 +11,9 @@ export function rendererAnimationOnUpdate(state, context) {
12
11
  if (piecePositionsEqual(context.previousFrame.state.board, context.currentFrame.state.board) &&
13
12
  changeStatesEqual(context.previousFrame.state.change, context.currentFrame.state.change))
14
13
  return;
14
+ const durationMs = state.getAnimationConfig().durationMs;
15
+ if (durationMs === 0)
16
+ return;
15
17
  // Pass raw snapshots to the planner — it normalizes internally
16
18
  const plan = calculateAnimationPlan({
17
19
  previous: context.previousFrame.state,
@@ -20,7 +22,7 @@ export function rendererAnimationOnUpdate(state, context) {
20
22
  if (plan.tracks.length === 0)
21
23
  return;
22
24
  const session = state.runtimeSurface.animation.submit({
23
- duration: DEFAULT_ANIMATION_DURATION_MS
25
+ duration: durationMs
24
26
  });
25
27
  state.entries.set(session.id, { plan, nodes: null });
26
28
  }
@@ -1,3 +1,3 @@
1
1
  import { ConfigColorPair } from '../types/internal.js';
2
2
  import { MainRendererBoard } from './types.js';
3
- export declare function createMainRendererBoard(config: ConfigColorPair): MainRendererBoard;
3
+ export declare function createMainRendererBoard(getColorConfig: () => ConfigColorPair): MainRendererBoard;
@@ -1,10 +1,10 @@
1
1
  import { rendererBoardRender } from './render.js';
2
2
  import { rendererBoardOnUpdate } from './update.js';
3
- function createMainRendererBoardInternal(config) {
4
- return { config };
3
+ function createMainRendererBoardInternal(getColorConfig) {
4
+ return { getColorConfig };
5
5
  }
6
- export function createMainRendererBoard(config) {
7
- const internalState = createMainRendererBoardInternal(config);
6
+ export function createMainRendererBoard(getColorConfig) {
7
+ const internalState = createMainRendererBoardInternal(getColorConfig);
8
8
  return {
9
9
  onUpdate(context) {
10
10
  rendererBoardOnUpdate(internalState, context);
@@ -8,7 +8,7 @@ export function rendererBoardRender(state, context, slot) {
8
8
  }
9
9
  const geometry = context.currentFrame.layout.geometry;
10
10
  clearSvgElementChildren(slot);
11
- const { light, dark } = state.config;
11
+ const { light, dark } = state.getColorConfig();
12
12
  for (let sq = 0; sq < SQUARE_COUNT; sq++) {
13
13
  const r = geometry.getSquareRect(sq);
14
14
  createSvgElement(slot, 'rect', {
@@ -2,7 +2,7 @@ import { ExtensionRenderContext } from '../../../types/context/render.js';
2
2
  import { ExtensionUpdateContext } from '../../../types/context/update.js';
3
3
  import { ConfigColorPair } from '../types/internal.js';
4
4
  export interface MainRendererBoardInternal {
5
- readonly config: ConfigColorPair;
5
+ readonly getColorConfig: () => ConfigColorPair;
6
6
  }
7
7
  export interface MainRendererBoard {
8
8
  onUpdate(context: ExtensionUpdateContext): void;
@@ -1,3 +1,3 @@
1
1
  import { ConfigColorPair } from '../types/internal.js';
2
2
  import { MainRendererCoordinates } from './types.js';
3
- export declare function createMainRendererCoordinates(config: ConfigColorPair): MainRendererCoordinates;
3
+ export declare function createMainRendererCoordinates(getColorConfig: () => ConfigColorPair): MainRendererCoordinates;
@@ -1,6 +1,6 @@
1
1
  import { rendererCoordinatesRender } from './render.js';
2
- export function createMainRendererCoordinates(config) {
3
- const internalState = { config };
2
+ export function createMainRendererCoordinates(getColorConfig) {
3
+ const internalState = { getColorConfig };
4
4
  return {
5
5
  render(context, slot) {
6
6
  rendererCoordinatesRender(internalState, context, slot);
@@ -10,7 +10,7 @@ export function rendererCoordinatesRender(state, context, slot) {
10
10
  }
11
11
  const geometry = context.currentFrame.layout.geometry;
12
12
  clearSvgElementChildren(slot);
13
- const coords = state.config;
13
+ const coords = state.getColorConfig();
14
14
  const fontSize = geometry.squareSize * 0.12;
15
15
  const offset = 3;
16
16
  // Rank labels on the visual left edge
@@ -1,7 +1,7 @@
1
1
  import { ExtensionRenderContext } from '../../../types/context/render.js';
2
2
  import { ConfigColorPair } from '../types/internal.js';
3
3
  export interface MainRendererCoordinatesInternal {
4
- readonly config: ConfigColorPair;
4
+ readonly getColorConfig: () => ConfigColorPair;
5
5
  }
6
6
  export interface MainRendererCoordinates {
7
7
  render(context: ExtensionRenderContext, slot: SVGGElement): void;
@@ -0,0 +1,3 @@
1
+ import type { MainRendererConfig } from './types/internal.js';
2
+ import type { MainRendererConfigPublic } from './types/public.js';
3
+ export declare function denormalizeMainRendererConfig(config: MainRendererConfig): MainRendererConfigPublic;
@@ -0,0 +1,18 @@
1
+ import { cloneDeep } from 'es-toolkit';
2
+ import { denormalizePieceString } from '../../../state/board/denormalize.js';
3
+ function denormalizePieceUrls(pieceUrls) {
4
+ const result = {};
5
+ for (const [codeKey, url] of Object.entries(pieceUrls)) {
6
+ const code = Number(codeKey);
7
+ result[denormalizePieceString(code)] = url;
8
+ }
9
+ return result;
10
+ }
11
+ export function denormalizeMainRendererConfig(config) {
12
+ return {
13
+ colors: cloneDeep(config.colors),
14
+ drag: cloneDeep(config.drag),
15
+ animation: cloneDeep(config.animation),
16
+ pieceUrls: denormalizePieceUrls(config.pieceUrls)
17
+ };
18
+ }
@@ -1,4 +1,5 @@
1
1
  import { ExtensionRuntimeSurface } from '../../../types/surface/main.js';
2
2
  import type { PieceSymbolResolver } from '../piece-symbols.js';
3
+ import type { MainRendererConfigDrag } from '../types/template.js';
3
4
  import { MainRendererDrag } from './types.js';
4
- export declare function createMainRendererDrag(runtimeSurface: ExtensionRuntimeSurface, resolver: PieceSymbolResolver): MainRendererDrag;
5
+ export declare function createMainRendererDrag(runtimeSurface: ExtensionRuntimeSurface, resolver: PieceSymbolResolver, getDragConfig: () => MainRendererConfigDrag): MainRendererDrag;
@@ -1,9 +1,10 @@
1
1
  import { rendererDragRenderTransientVisuals } from './render.js';
2
2
  import { rendererDragOnUpdate } from './update.js';
3
- export function createMainRendererDrag(runtimeSurface, resolver) {
3
+ export function createMainRendererDrag(runtimeSurface, resolver, getDragConfig) {
4
4
  const internalState = {
5
5
  runtimeSurface,
6
6
  resolver,
7
+ getDragConfig,
7
8
  isDragActive: false,
8
9
  pieceCode: null,
9
10
  pieceNode: null
@@ -1,28 +1,35 @@
1
1
  import { createSvgElement, updateSvgElementAttributes } from '../../../../render/svg/helpers.js';
2
2
  export function rendererDragRenderTransientVisuals(state, context, slot) {
3
+ const config = state.getDragConfig();
3
4
  const geometry = context.currentFrame.layout.geometry;
4
5
  const point = context.transientInput.boardClampedPoint;
5
- const squareSize = geometry.squareSize.toString();
6
- const x = (point.x - geometry.squareSize / 2).toString();
7
- const y = (point.y - geometry.squareSize / 2).toString();
6
+ const renderedSize = geometry.squareSize * config.pieceScale;
7
+ const anchorOffsetY = geometry.squareSize * config.pieceAnchorOffsetY;
8
+ const x = point.x - renderedSize / 2;
9
+ const y = config.pieceAnchor === 'bottom'
10
+ ? point.y - renderedSize + anchorOffsetY
11
+ : point.y - renderedSize / 2 + anchorOffsetY;
12
+ const renderedSizeStr = renderedSize.toString();
13
+ const xStr = x.toString();
14
+ const yStr = y.toString();
8
15
  if (state.isDragActive && state.pieceCode && !state.pieceNode) {
9
16
  // Ok this is first render pass, let's create the piece node
10
17
  const href = state.resolver.getHref(state.pieceCode);
11
18
  state.pieceNode = createSvgElement(slot, 'use', {
12
19
  'data-chessboard-id': 'dragged-piece',
13
- width: squareSize,
14
- height: squareSize,
15
- x,
16
- y,
20
+ width: renderedSizeStr,
21
+ height: renderedSizeStr,
22
+ x: xStr,
23
+ y: yStr,
17
24
  href
18
25
  });
19
26
  }
20
27
  else if (state.isDragActive && state.pieceNode) {
21
28
  updateSvgElementAttributes(state.pieceNode, {
22
- width: squareSize,
23
- height: squareSize,
24
- x,
25
- y
29
+ width: renderedSizeStr,
30
+ height: renderedSizeStr,
31
+ x: xStr,
32
+ y: yStr
26
33
  });
27
34
  }
28
35
  }
@@ -3,9 +3,11 @@ import { ExtensionRenderTransientVisualsContext } from '../../../types/context/t
3
3
  import { ExtensionUpdateContext } from '../../../types/context/update.js';
4
4
  import { ExtensionRuntimeSurface } from '../../../types/surface/main.js';
5
5
  import type { PieceSymbolResolver } from '../piece-symbols.js';
6
+ import type { MainRendererConfigDrag } from '../types/template.js';
6
7
  export interface MainRendererDragInternal {
7
8
  readonly runtimeSurface: ExtensionRuntimeSurface;
8
9
  readonly resolver: PieceSymbolResolver;
10
+ readonly getDragConfig: () => MainRendererConfigDrag;
9
11
  isDragActive: boolean;
10
12
  pieceCode: NonEmptyPieceCode | null;
11
13
  pieceNode: SVGUseElement | null;
@@ -1,10 +1,12 @@
1
1
  import assert from '@ktarmyshov/assert';
2
2
  import { isNonEmptyPieceCode } from '../../../../state/board/check.js';
3
+ import { isDragSessionActiveLiftedPiece } from '../../../../state/interaction/helpers.js';
3
4
  export function rendererDragOnUpdate(state, context) {
4
- const isLiftedDragActive = context.currentFrame.state.interaction.dragSession?.type === 'lifted-piece-drag';
5
+ const dragSession = context.currentFrame.state.interaction.dragSession;
6
+ const isLiftedDragActive = dragSession !== null && isDragSessionActiveLiftedPiece(dragSession);
5
7
  if (isLiftedDragActive) {
6
8
  if (!state.isDragActive) {
7
- const pieceCode = context.currentFrame.state.interaction.dragSession.sourcePieceCode;
9
+ const pieceCode = dragSession.sourcePieceCode;
8
10
  assert(isNonEmptyPieceCode(pieceCode), 'Invalid piece code in drag session');
9
11
  state.pieceCode = pieceCode;
10
12
  state.runtimeSurface.transientVisuals.subscribe();
@@ -1,2 +1,3 @@
1
- import { MainRendererDefinition, MainRendererInitOptions } from './types/extension.js';
1
+ import { MainRendererDefinition } from './types/extension.js';
2
+ import type { MainRendererInitOptions } from './types/public.js';
2
3
  export declare function createMainRenderer(options?: MainRendererInitOptions): MainRendererDefinition;
@@ -1,16 +1,16 @@
1
- import { toMerged } from 'es-toolkit';
2
1
  import { extensionCreateInternalBase, extensionDestroyBase, extensionMountBase, extensionUnmountBase } from '../common/helpers.js';
3
2
  import { createMainRendererAnimation } from './animation/factory.js';
4
3
  import { createMainRendererBoard } from './board/factory.js';
5
4
  import { createMainRendererCoordinates } from './coordinates/factory.js';
5
+ import { denormalizeMainRendererConfig } from './denormalize.js';
6
6
  import { createMainRendererDrag } from './drag/factory.js';
7
7
  import { normalizeMainRendererConfig } from './normalize.js';
8
8
  import { createPieceSymbolResolver, ensurePieceSymbolsDefined } from './piece-symbols.js';
9
9
  import { createMainRendererPieces } from './pieces/factory.js';
10
- import { EXTENSION_ID, EXTENSION_SLOTS } from './types/extension.js';
11
- import { DEFAULT_MAIN_RENDERER_CONFIG } from './types/internal.js';
12
- export function createMainRenderer(options = {}) {
13
- const config = toMerged(DEFAULT_MAIN_RENDERER_CONFIG, normalizeMainRendererConfig(options));
10
+ import { DirtyLayer, EXTENSION_ID, EXTENSION_SLOTS } from './types/extension.js';
11
+ import { DefaultMainRendererDesktopConfig } from './types/internal.js';
12
+ export function createMainRenderer(options) {
13
+ const config = normalizeMainRendererConfig(options, DefaultMainRendererDesktopConfig);
14
14
  return {
15
15
  id: EXTENSION_ID,
16
16
  slots: EXTENSION_SLOTS,
@@ -21,12 +21,15 @@ export function createMainRenderer(options = {}) {
21
21
  }
22
22
  function createMainRendererInternal(options, config) {
23
23
  const pieceSymbolResolver = createPieceSymbolResolver(options.svgIds);
24
- const board = createMainRendererBoard(config.colors.board);
25
- const coordinates = createMainRendererCoordinates(config.colors.coordinates);
24
+ // Forward reference: subsystems read color config through getters that close over the
25
+ // internal state, so a runtime setConfig({ colors }) update is observed on the next render
26
+ // without re-creating the subsystems.
27
+ const board = createMainRendererBoard(() => internalState.config.colors.board);
28
+ const coordinates = createMainRendererCoordinates(() => internalState.config.colors.coordinates);
26
29
  const pieces = createMainRendererPieces(pieceSymbolResolver);
27
- const drag = createMainRendererDrag(options.runtimeSurface, pieceSymbolResolver);
28
- const animation = createMainRendererAnimation(options.runtimeSurface, pieceSymbolResolver);
29
- return {
30
+ const drag = createMainRendererDrag(options.runtimeSurface, pieceSymbolResolver, () => internalState.config.drag);
31
+ const animation = createMainRendererAnimation(options.runtimeSurface, pieceSymbolResolver, () => internalState.config.animation);
32
+ const internalState = {
30
33
  ...extensionCreateInternalBase(options),
31
34
  board,
32
35
  coordinates,
@@ -34,7 +37,34 @@ function createMainRendererInternal(options, config) {
34
37
  drag,
35
38
  animation,
36
39
  runtimeSurface: options.runtimeSurface,
37
- pieceSymbolResolver
40
+ pieceSymbolResolver,
41
+ config
42
+ };
43
+ return internalState;
44
+ }
45
+ function createMainRendererInstancePublic(state) {
46
+ return {
47
+ getConfig() {
48
+ return denormalizeMainRendererConfig(state.config);
49
+ },
50
+ setConfig(options) {
51
+ // Defensively strip pieceUrls so a non-TS caller cannot bypass the lifecycle.
52
+ const { pieceUrls: _ignored, ...safeInput } = (options ?? {});
53
+ void _ignored;
54
+ const previous = state.config;
55
+ state.config = normalizeMainRendererConfig(safeInput, state.config);
56
+ let dirty = 0;
57
+ if (state.config.colors.board !== previous.colors.board) {
58
+ dirty |= DirtyLayer.Board;
59
+ }
60
+ if (state.config.colors.coordinates !== previous.colors.coordinates) {
61
+ dirty |= DirtyLayer.Coordinates;
62
+ }
63
+ if (dirty !== 0) {
64
+ state.runtimeSurface.invalidation.markDirty(dirty);
65
+ state.runtimeSurface.commands.requestRender({ state: true });
66
+ }
67
+ }
38
68
  };
39
69
  }
40
70
  function extensionUnmountLocal(state) {
@@ -51,11 +81,12 @@ function validateIsMounted(state) {
51
81
  }
52
82
  function createMainRendererInstance(options, config) {
53
83
  const internalState = createMainRendererInternal(options, config);
84
+ const publicInterface = createMainRendererInstancePublic(internalState);
54
85
  return {
55
86
  id: EXTENSION_ID,
56
87
  mount(env) {
57
88
  extensionMountBase(internalState, env.slotRoots);
58
- ensurePieceSymbolsDefined(env.slotRoots.defs, config.pieceUrls, internalState.pieceSymbolResolver);
89
+ ensurePieceSymbolsDefined(env.slotRoots.defs, internalState.config.pieceUrls, internalState.pieceSymbolResolver);
59
90
  },
60
91
  onUpdate(context) {
61
92
  internalState.board.onUpdate(context);
@@ -94,6 +125,9 @@ function createMainRendererInstance(options, config) {
94
125
  destroy() {
95
126
  extensionUnmountLocal(internalState);
96
127
  extensionDestroyBase(internalState);
128
+ },
129
+ getPublic() {
130
+ return publicInterface;
97
131
  }
98
132
  };
99
133
  }
@@ -1,3 +1,3 @@
1
- import { MainRendererConfigInput } from './types/input.js';
2
- import { MainRendererConfig } from './types/internal.js';
3
- export declare function normalizeMainRendererConfig(input: Partial<MainRendererConfigInput>): MainRendererConfig;
1
+ import type { MainRendererConfig } from './types/internal.js';
2
+ import type { MainRendererInitOptions } from './types/public.js';
3
+ export declare function normalizeMainRendererConfig(input: MainRendererInitOptions | undefined, base: MainRendererConfig): MainRendererConfig;
@@ -1,7 +1,7 @@
1
1
  import assert from '@ktarmyshov/assert';
2
+ import { toMerged } from 'es-toolkit/object';
2
3
  import { normalizePiece } from '../../../state/board/normalize.js';
3
4
  import { ALL_NON_EMPTY_PIECE_CODES } from '../../../state/board/types/internal.js';
4
- import { DEFAULT_MAIN_RENDERER_CONFIG } from './types/internal.js';
5
5
  function assertPieceUrlsComplete(pieceUrls) {
6
6
  for (const pieceCode of ALL_NON_EMPTY_PIECE_CODES) {
7
7
  assert(pieceCode in pieceUrls, `Missing URL for piece code: ${pieceCode}`);
@@ -17,11 +17,23 @@ function normalizePieceUrls(input) {
17
17
  assertPieceUrlsComplete(normalized);
18
18
  return normalized;
19
19
  }
20
- export function normalizeMainRendererConfig(input) {
21
- return {
22
- colors: input.colors ?? DEFAULT_MAIN_RENDERER_CONFIG.colors,
23
- pieceUrls: input.pieceUrls
24
- ? normalizePieceUrls(input.pieceUrls)
25
- : DEFAULT_MAIN_RENDERER_CONFIG.pieceUrls
20
+ function validateMainRendererConfig(config) {
21
+ const { pieceScale, pieceAnchor, pieceAnchorOffsetY } = config.drag;
22
+ assert(Number.isFinite(pieceScale), 'drag.pieceScale must be a finite number');
23
+ assert(pieceScale > 0, 'drag.pieceScale must be > 0');
24
+ assert(pieceAnchor === 'center' || pieceAnchor === 'bottom', `drag.pieceAnchor must be 'center' or 'bottom', received: ${String(pieceAnchor)}`);
25
+ assert(Number.isFinite(pieceAnchorOffsetY), 'drag.pieceAnchorOffsetY must be a finite number');
26
+ const { durationMs } = config.animation;
27
+ assert(Number.isFinite(durationMs), 'animation.durationMs must be a finite number');
28
+ assert(durationMs >= 0, 'animation.durationMs must be >= 0');
29
+ }
30
+ export function normalizeMainRendererConfig(input, base) {
31
+ const { pieceUrls: inputPieceUrls, ...rest } = input ?? {};
32
+ const merged = toMerged(base, rest);
33
+ const result = {
34
+ ...merged,
35
+ pieceUrls: inputPieceUrls !== undefined ? normalizePieceUrls(inputPieceUrls) : merged.pieceUrls
26
36
  };
37
+ validateMainRendererConfig(result);
38
+ return result;
27
39
  }