Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
8ab58d3
Extract text layout logic into reusable selection module
frouaix Apr 24, 2026
4daafd6
Implement TextSelectionManager with multi-user support
frouaix Apr 24, 2026
75b8441
Implement event handling for text selection
frouaix Apr 24, 2026
527f400
Implement caret and selection highlight rendering
frouaix Apr 24, 2026
699034c
Finalize text selection API and configuration
frouaix Apr 24, 2026
82d18cc
Add text selection demo page
frouaix Apr 24, 2026
d6de73c
Fix text-selection demo: use div container instead of canvas element …
frouaix Apr 24, 2026
e5d6346
Add CharacterBoundsProvider to text-selection demo for selection rend…
frouaix Apr 24, 2026
1e4f2b6
Increase selection highlight transparency in demo
frouaix Apr 24, 2026
b70cab3
Further reduce selection highlight opacity to 0.05
frouaix Apr 24, 2026
772f768
Add canvas context options for Safari compatibility
frouaix Apr 24, 2026
a1c8ff0
Fix selection color handling to apply transparency to user-selected c…
frouaix Apr 24, 2026
d6c1742
Increase selection highlight opacity to 20%
frouaix Apr 24, 2026
4d185ac
Fix selection range end position using accurate character bounds
frouaix Apr 24, 2026
5aadf3c
Add Phase 2 event handling: hit-testing and keyboard support
frouaix Apr 24, 2026
8017d9f
Wire pointer and keyboard events for text selection
frouaix Apr 24, 2026
17d4360
Fix coordinate transformation in VitrineComponent
frouaix Apr 24, 2026
b64e2ee
Expand block discovery to test text1-text10
frouaix Apr 24, 2026
1f75a26
Add minimal text selection test page
frouaix Apr 24, 2026
645bdbb
Fix component initialization in test pages
frouaix Apr 24, 2026
b31ed3a
Fix coordinate system mismatch in CharacterBoundsProvider
frouaix Apr 24, 2026
3ea15c2
Add debug logging to caret rendering
frouaix Apr 24, 2026
897eaf8
Stabilize text selection interaction and debug demos
frouaix Apr 25, 2026
45e87ed
Harden text selection APIs and performance
frouaix Apr 26, 2026
66f1a5e
Move text layout and selection types into core
frouaix Apr 26, 2026
cb83044
Refactor text-selection demos and checkpoint selection metrics
frouaix Apr 26, 2026
b636980
Add text-selection debug to demo ring and docs
frouaix Apr 27, 2026
1a4d34c
Update packages/gui/src/selection/character-bounds-adapter.ts
frouaix Apr 27, 2026
d2be36d
Update test-results/.last-run.json
frouaix Apr 27, 2026
2e1a8c6
Update packages/gui/src/selection/TextSelectionManager.ts
frouaix Apr 27, 2026
d752ebc
tweak
frouaix Apr 27, 2026
e0ae885
Merge branch 'feature/textselection' of https://github.com/frouaix/vi…
frouaix Apr 27, 2026
f628a6c
CR feedback
frouaix Apr 27, 2026
3f2c467
Optimize text selection bounds caching
frouaix Apr 27, 2026
20776c6
Decouple texta from core via block registry
frouaix Apr 28, 2026
6da9a86
hungarian, code review, checkpoint
frouaix Apr 28, 2026
b4e44e2
review, hungarian; checkpoint
frouaix Apr 28, 2026
db82afd
Code review up to text layout, cleanup
frouaix Apr 29, 2026
ddc4950
code review
frouaix Apr 30, 2026
8f84232
code review
frouaix Apr 30, 2026
9100c5e
more code review
frouaix Apr 30, 2026
d5b05b0
fix CI
frouaix Apr 30, 2026
c63e7e5
Merge branch 'main' into feature/textselection
frouaix Apr 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build vitrine (texta dependency)
run: pnpm --filter vitrine build

- name: Run texta tests
run: pnpm --filter texta test

Expand Down
2 changes: 2 additions & 0 deletions docs/COORDINATES.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ immediately visible:
| `dx`, `dy` | A size / dimension (delta) | `dxRect`, `dyClip` |
| `du` | A generic distance (dimensionless units) | `duRadius` |
| `pt` | A point object `{ xc, yc }` | `ptcDragStart` |
| `xf` | A `Matrix2D` transform | `xfBlock`, `xfCur`, `xfWorld` |
| `bl` | A `Block` | `blHit`, `blChild` |

Coordinate-space suffixes are appended to the prefix:

Expand Down
83 changes: 83 additions & 0 deletions docs/TEXT_SELECTION_REGULAR_TEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Text Selection for Regular `text()` Blocks

This guide explains how to enable text selection when using regular Vitrine `text()` blocks (not `texta()`).

## 1. Enable selection on the component

Create your component with `selectionConfig.enabled: true`:

```ts
import { VitrineComponent } from 'vitrine-gui';

const component = VitrineComponent.block(buildScene, {
width: 800,
height: 400,
renderMode: 'auto',
selectionConfig: {
enabled: true,
caretColor: '#dc2626',
selectionColor: 'rgba(59, 130, 246, 0.2)',
caretWidth: 2
}
});
```

## 2. Give selectable `text()` blocks stable `id` values

Selection hit-testing and overlays are keyed by block `id`.

```ts
import { group, text } from 'vitrine';

function buildScene() {
return group({}, [
text({
id: 'paragraph-1',
x: 40,
y: 60,
text: 'Click and drag to select this text.',
fontSize: 18,
baseline: 'top'
})
]);
}
```

If a `text()` block has no `id`, it is not selectable.

## 3. Pointer + keyboard behavior

- Pointer down/move/up drives caret placement and drag selection.
- Keyboard navigation (`ArrowLeft/Right/Up/Down`, `Shift+Arrow`, `Home`, `End`, `Ctrl/Cmd+A`) is handled by `TextSelectionManager`.
- The canvas is focusable; clicking the canvas gives it focus for keyboard navigation.

## 4. Coordinate space contract

Character bounds are handled in **scene/CSS coordinates** (the same coordinate system as your block positions), not raw canvas buffer pixels.

## 5. Automatic bounds provider (default path)

For regular `text()` blocks, `VitrineComponent` automatically builds and updates a character bounds provider from the block tree each frame.

It uses renderer-compatible layout rules (font, wrapping, alignment, baseline, transforms), so typical usage does not require manual bounds wiring.

## 6. Custom bounds provider (optional)

You can override the automatic provider when needed:

```ts
const selectionManager = component.getSelectionManager();

selectionManager?.setCharacterBoundsProvider((blockId, charIndex) => {
// Return { x, y, width, height } in scene/CSS coordinates, or null.
return null;
});
```

Use this only for custom rendering paths where automatic `text()` extraction is insufficient.

## Current scope

- Automatic adapter support is for regular `text()` blocks with `id`.
- Cross-block selections are not supported.
- `texta()` requires custom handling if you need selection behavior equivalent to `text()`.
4 changes: 1 addition & 3 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@
"files": [
"dist"
],
"dependencies": {
"texta": "workspace:*"
},
"dependencies": {},
"scripts": {
"build": "tsc",
"lint": "tsc --noEmit",
Expand Down
49 changes: 49 additions & 0 deletions packages/core/src/core/block-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Copyright (c) 2026 François Rouaix

import type { RenderContext } from './context.ts';
import type { Block, Rc } from './types.ts';

export interface CustomBlockDescriptor {
type: string;
props: Record<string, unknown>;
rgblChildren?: Block[];
}

export interface BlockLayoutCache {
mpbl_rc: WeakMap<Block, Rc>;
}

export interface CustomBlockRenderApi {
context: RenderContext;
layoutCache: BlockLayoutCache;
setLayoutBounds: (bounds: Rc) => void;
}

export interface CustomBlockHitTestApi {
layoutCache?: BlockLayoutCache;
}

export interface CustomBlockDebugApi {
context: RenderContext;
}

export interface CustomBlockHandlers {
render?: (block: CustomBlockDescriptor, api: CustomBlockRenderApi) => void;
hitTestShape?: (block: CustomBlockDescriptor, xl: number, yl: number, api: CustomBlockHitTestApi) => boolean;
rcl?: (block: CustomBlockDescriptor) => Rc | null;
getDebugOutlineBounds?: (block: CustomBlockDescriptor, api: CustomBlockDebugApi) => Rc | null;
}

const mpCustomBlockHandlers = new Map<string, CustomBlockHandlers>();

export function registerBlockType(type: string, handlers: CustomBlockHandlers): void {
mpCustomBlockHandlers.set(type, handlers);
}

export function unregisterBlockType(type: string): void {
mpCustomBlockHandlers.delete(type);
}

export function getBlockTypeHandlers(type: string): CustomBlockHandlers | undefined {
return mpCustomBlockHandlers.get(type);
}
73 changes: 41 additions & 32 deletions packages/core/src/core/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
BlockForType,
BlockPropsByType,
BlockOfType,
BaseBlockProps
} from './types.ts';
import { BlockType } from './types.ts';

Expand All @@ -15,79 +16,87 @@ type LeafBlockType = Exclude<BlockType, ContainerBlockType>;
export function block<T extends ContainerBlockType>(
type: T,
props: BlockPropsByType[T],
children: Block[]
rgblChildren: Block[]
): BlockForType<T>;
export function block<T extends LeafBlockType>(
type: T,
props: BlockPropsByType[T],
children?: Block[]
rgblChildren?: Block[]
): BlockForType<T>;
export function block<T extends BlockType>(
type: T,
props: BlockPropsByType[T],
children?: Block[]
rgblChildren?: Block[]
): BlockForType<T> {
return {
type,
props,
children
rgblChildren
};
}

export function rectangle(props: BlockPropsByType[BlockType.Rectangle], children?: Block[]): BlockOfType<BlockType.Rectangle> {
return block(BlockType.Rectangle, props, children);
export function rectangle(props: BlockPropsByType[BlockType.Rectangle], rgblChildren?: Block[]): BlockOfType<BlockType.Rectangle> {
return block(BlockType.Rectangle, props, rgblChildren);
}

export function circle(props: BlockPropsByType[BlockType.Circle], children?: Block[]): BlockOfType<BlockType.Circle> {
return block(BlockType.Circle, props, children);
export function circle(props: BlockPropsByType[BlockType.Circle], rgblChildren?: Block[]): BlockOfType<BlockType.Circle> {
return block(BlockType.Circle, props, rgblChildren);
}

export function ellipse(props: BlockPropsByType[BlockType.Ellipse], children?: Block[]): BlockOfType<BlockType.Ellipse> {
return block(BlockType.Ellipse, props, children);
export function ellipse(props: BlockPropsByType[BlockType.Ellipse], rgblChildren?: Block[]): BlockOfType<BlockType.Ellipse> {
return block(BlockType.Ellipse, props, rgblChildren);
}

export function line(props: BlockPropsByType[BlockType.Line], children?: Block[]): BlockOfType<BlockType.Line> {
return block(BlockType.Line, props, children);
export function line(props: BlockPropsByType[BlockType.Line], rgblChildren?: Block[]): BlockOfType<BlockType.Line> {
return block(BlockType.Line, props, rgblChildren);
}

export function text(props: BlockPropsByType[BlockType.Text], children?: Block[]): BlockOfType<BlockType.Text> {
return block(BlockType.Text, props, children);
export function text(props: BlockPropsByType[BlockType.Text], rgblChildren?: Block[]): BlockOfType<BlockType.Text> {
return block(BlockType.Text, props, rgblChildren);
}

export function texta(props: BlockPropsByType[BlockType.Texta], children?: Block[]): BlockOfType<BlockType.Texta> {
return block(BlockType.Texta, props, children);
export function path(props: BlockPropsByType[BlockType.Path], rgblChildren?: Block[]): BlockOfType<BlockType.Path> {
return block(BlockType.Path, props, rgblChildren);
}

export function path(props: BlockPropsByType[BlockType.Path], children?: Block[]): BlockOfType<BlockType.Path> {
return block(BlockType.Path, props, children);
export function arc(props: BlockPropsByType[BlockType.Arc], rgblChildren?: Block[]): BlockOfType<BlockType.Arc> {
return block(BlockType.Arc, props, rgblChildren);
}

export function arc(props: BlockPropsByType[BlockType.Arc], children?: Block[]): BlockOfType<BlockType.Arc> {
return block(BlockType.Arc, props, children);
export function image(props: BlockPropsByType[BlockType.Image], rgblChildren?: Block[]): BlockOfType<BlockType.Image> {
return block(BlockType.Image, props, rgblChildren);
}

export function image(props: BlockPropsByType[BlockType.Image], children?: Block[]): BlockOfType<BlockType.Image> {
return block(BlockType.Image, props, children);
export function group(props: BlockPropsByType[BlockType.Group], rgblChildren: Block[]): BlockOfType<BlockType.Group> {
return block(BlockType.Group, props, rgblChildren);
}

export function group(props: BlockPropsByType[BlockType.Group], children: Block[]): BlockOfType<BlockType.Group> {
return block(BlockType.Group, props, children);
export function layer(props: BlockPropsByType[BlockType.Layer], rgblChildren: Block[]): BlockOfType<BlockType.Layer> {
return block(BlockType.Layer, props, rgblChildren);
}

export function layer(props: BlockPropsByType[BlockType.Layer], children: Block[]): BlockOfType<BlockType.Layer> {
return block(BlockType.Layer, props, children);
export function portal(props: BlockPropsByType[BlockType.Portal], rgblChildren: Block[]): BlockOfType<BlockType.Portal> {
return block(BlockType.Portal, props, rgblChildren);
}

export function portal(props: BlockPropsByType[BlockType.Portal], children: Block[]): BlockOfType<BlockType.Portal> {
return block(BlockType.Portal, props, children);
export function contentSized(props: BlockPropsByType[BlockType.ContentSized], rgblChildren: Block[]): BlockOfType<BlockType.ContentSized> {
return block(BlockType.ContentSized, props, rgblChildren);
}

export function contentSized(props: BlockPropsByType[BlockType.ContentSized], children: Block[]): BlockOfType<BlockType.ContentSized> {
return block(BlockType.ContentSized, props, children);
export function customBlock<TProps extends BaseBlockProps>(
type: string,
props: TProps,
rgblChildren?: Block[]
): Block {
return {
type,
props,
rgblChildren
} as unknown as Block;
}

/** Convenience factory: wraps children in a group that opens `href` in a new tab on click. */
export function link(props: { href: string } & BlockPropsByType[BlockType.Group], children: Block[]): BlockOfType<BlockType.Group> {
export function link(props: { href: string } & BlockPropsByType[BlockType.Group], rgblChildren: Block[]): BlockOfType<BlockType.Group> {
const { href, onClick: userOnClick, tooltip: userTooltip, ...rest } = props;
return group(
{
Expand All @@ -98,6 +107,6 @@ export function link(props: { href: string } & BlockPropsByType[BlockType.Group]
if (userOnClick) userOnClick(event);
}
},
children
rgblChildren
);
}
Loading
Loading