Skip to content

Commit 7848360

Browse files
committed
feat(theme): straight arrow callouts by default, with calloutStyle: elbow
Callout connectors were L-shaped elbows with a small head on the code, and shelf-stacked boxes beside the same block could make them cross. - `arrow` (default) draws a straight line from the highlight (or a slide callout's anchor) to the box border, aimed at the box centre, with a larger arrowhead pointing into the box. - On `arrow` slides, auto-placed boxes on one side of a code block or element are stacked in their anchors' order (`stackCallouts`), so arrows don't cross; pinned boxes stay put and callouts never visible together don't constrain each other. Boxes keep 36px from the block so the head has room, and a connector under 30px is drawn as a plain line. - `calloutStyle: elbow` per slide, or `defaults: { calloutStyle: elbow }` in the headmatter, keeps the previous connectors and placement exactly. The style is read from `$frontmatter`, where Slidev merges `defaults:`. - Bare arrows (no text) keep their short arrow pointing at the anchor, and a box over its own anchor keeps no connector, in both styles. - fix: arrowheads never rendered on most slides. Slidev keeps neighbouring slides mounted but hidden, every slide used the same marker ids, and `url(#id)` resolved to a hidden slide's marker. Marker ids are now unique per layout instance (`useId()`), and the e2e tests resolve markers the way the browser does. Docs, tutorial (both styles), README note and specs are updated; the change is archived.
1 parent 7e0108d commit 7848360

17 files changed

Lines changed: 978 additions & 49 deletions

File tree

‎AGENTS.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,10 @@ Image fractions resolve against the picture, not its `geometry.images` box: an i
117117
### What gets drawn
118118

119119
- **A connector is drawn only when the box does not contain its anchor** (`boxContainsAnchor`). That one rule yields all three shapes: a box beside its anchor is connected, an entry with no `text` is a bare arrow with no box, and a box sitting over its own anchor is a label with no arrow.
120-
- Every connector -- slide callouts *and* code callouts -- ends in an arrowhead at the thing it points at. `elbowPath` returns `[anchor, bend, boxEdge]`, so the head goes on `marker-start` with `orient="auto-start-reverse"`; the `<marker>` carries its own fill because `.code-callout-connector` sets `fill: none`.
120+
- **Connector style** (`calloutStyle` in a slide's frontmatter, or deck-wide through the headmatter's `defaults: { calloutStyle: elbow }`), for slide callouts *and* code callouts. It is read from `$frontmatter`, the only frontmatter Slidev merges `defaults:` into; the editor never writes it.
121+
- `arrow` (the default): `arrowPath(anchorPoint(highlightRect, side), box)`, a straight segment from the highlight edge facing the box (or the anchor point) to where the line from the box's centre towards it leaves the box. The larger box-end head sits on `marker-end`, pointing into the box. Placement keeps boxes `ARROW_OBSTACLE_GAP` (36px) from the block or element instead of 12px, so there's room for the head, and a connector shorter than `MIN_ARROWHEAD_CONNECTOR` (30px, e.g. a box dragged next to its anchor) is drawn as a plain line.
122+
- `elbow`: `elbowPath` returns `[anchor, bend, boxEdge]`, with the small anchor-end head on `marker-start` (`orient="auto-start-reverse"`), pointing at the anchor.
123+
- A bare arrow keeps its short stub with the head on the anchor in both styles. The `<marker>`s carry their own fill because `.code-callout-connector` sets `fill: none`, and their ids are unique per layout instance (`useId()`): Slidev keeps neighbouring slides mounted but hidden, and `url(#id)` resolves to the first element with that id, so a shared id pointed connectors at a hidden slide's marker, which the browser doesn't render.
121124
- **Click steps.** `step: N`, or a step range written as a YAML string (`step: 2-4`, `step: -1`; see `stepRange.ts`), counts toward the slide's clicks the same way code-highlight steps do: a `post` markdown transformer (`slideCalloutStepBlock` in `setup/transformers.ts`) appends a hidden block of `v-click="N"` placeholders, one per distinct click at which the slide's valid callouts change (each start, and the click after a range's end), to every `default`-layout slide's markdown. It has to be compiled into the slide, since the layout only reads callouts after mount, too late for Slidev to count them. An editor write that changes a step only reaches the placeholders once the slide's markdown is re-rendered.
122125
- Placement, shelf-stacking, `{N}`-style click steps (`step: N`) and drag-to-move are the existing code-callout machinery: the obstacle is simply the anchor's own element (the `<img>`, the matched node) instead of always a `<pre>`, and nothing at all for a bare point.
123126
- Callouts are ignored on any layout other than `default`, with a console warning from `global-top.vue` -- the layout that would report it is precisely the one that never mounts for those slides.
@@ -167,6 +170,7 @@ public GestorNotas(DBAlumno alumnos) { // [!mark] Injects the DB dependency
167170
- Callouts auto-place around the code block (right → left → below → above, first side that fits), sized to their comment text (capped at a max width, wrapping/growing taller for longer comments) rather than a fixed box.
168171
- The obstacle used for placement is the *actual code lines'* bounding box, not the `<pre>` element's full container width — a `<pre>` typically stretches wider than its longest line, and that leftover space is still fair game for a callout.
169172
- When a side is already occupied by another callout on the same code block, a new callout shelf-stacks along that side (closest open slot to its own highlight) instead of jumping to a worse side.
173+
- On `arrow` slides, `stackCallouts` then reorders each side's auto-placed boxes (per code block, or per slide callout's obstacle) by their anchors, top to bottom beside a block and left to right above or below it. Each box sits level with its anchor or just past the previous one, and the stack is pulled back inside the slide. Pinned boxes (`@x,y`, `box:`) stay put, and callouts whose step ranges never overlap don't constrain each other. So straight connectors don't cross in the common case (anchors far apart horizontally can still cross). `elbow` slides keep the greedy result exactly.
170174
- In editor mode (Layout tab), drag a callout to override its position; the dragged position is written back into that callout's own marker as `@x,y` (e.g. `[!mark@120,40]`) via the `/api/save-code-highlight-position` endpoint, so it persists across reloads and survives further edits to the code above it. On a line carrying several markers only the dragged one is rewritten, and a line repeated on another slide is disambiguated by the slide's own line range.
171175
- Multiple highlights per code block are supported; callouts avoid overlapping the code block, each other, and (best-effort, via a bounds-clamped fallback) the edges of the slide itself.
172176
- **Click steps** (`{<step>}`):

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ pnpm dev tutorial.md
4747
- ODP import: `create-codeurjc-slidev --from-odp` turns a LibreOffice Impress deck into a project, with a side-by-side comparison deck for whatever couldn't be converted
4848
- Layout editor integrated into Slidev's built-in SideEditor panel: drag/resize slide elements (red bar, logo, title, content) with undo support, and save layouts as new `.vue` files or overwrite the current one
4949

50+
**Behaviour change (callout arrows).** Callouts (on code and slides) are now drawn as straight arrows pointing into their box, and boxes on one side of a block follow their highlights' order. `calloutStyle: elbow` in a slide's frontmatter, or `defaults: { calloutStyle: elbow }` in the deck's first slide, restores the previous L-shaped connectors and placement.
51+
5052
**Behaviour change (image positioning).** Images now stay in the normal flow of the slide unless the slide's `geometry.images` positions them. The paste presets (Below/Right) write that frontmatter instead of creating a `layouts/layout-<timestamp>.vue` copy, and the layout editor no longer has a layout-level "Image" element. Slides already pointing at such a copy keep working as before. To position an image on any other slide, paste it again and pick a preset, or add a `geometry.images` entry (`{src, x, y, w, h}`).
5153

5254
See `CLAUDE.md` for the full grammar/behavior reference (marker syntax, snippet-import selectors, title carry-over rules).
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-09-17
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
## Context
2+
3+
`layouts/default.vue`'s `computeCallouts` places every callout of a slide in one pass:
4+
- **Code callouts** (grouped highlight spans per `<pre>`) and **slide callouts** (from `callouts` frontmatter) share one `placed: PlacedRect[]` list.
5+
- **Placement:** `placeCallout` (in `useHighlightLayout.ts`) tries right → left → below → above, with shelf candidates past already-placed boxes on the same side, and skips boxes whose step range can't overlap.
6+
- **Connectors:** each item gets `elbowPath(highlightRect, rect, side)` = `[anchor, bend, boxEdge]` as an SVG path. The small `#callout-arrowhead` marker goes on `marker-start` (the anchor end).
7+
- **Other cases:** bare arrows (no text) get a two-point stub `[point, box corner]`. A box containing its anchor gets no path.
8+
- **Remeasure:** `remeasureCallouts` recomputes paths after render against the boxes' real sizes, from `item.highlightRect` and `item.side`.
9+
10+
Geometry and callouts are read from the slide-info ref (`liveFrontmatter`), which is Slidev's raw slide frontmatter. Slidev merges the headmatter's `defaults:` only into the compiled per-slide frontmatter module (`getFrontmatter` in `@slidev/cli`), which is what `$frontmatter` reflects.
11+
12+
## Goals / Non-Goals
13+
14+
**Goals:**
15+
- An `arrow` connector: straight, from the anchor to the box border, with a larger head into the box. It is the default.
16+
- `calloutStyle: elbow` per slide, or deck-wide through `defaults:`, reproducing today's connectors and placement exactly.
17+
- On `arrow` slides, boxes on one side of one obstacle stacked in their anchors' order, so the lines don't cross in the common case.
18+
- Pure, unit-tested geometry for both the arrow path and the stacking.
19+
20+
**Non-Goals:**
21+
- A per-callout style.
22+
- A marker at the anchor end of `arrow` connectors.
23+
- Reordering on `elbow` slides.
24+
- Guaranteeing zero crossings (diagonals from anchors at very different x can still cross).
25+
- Changes to the ODP importer or the VS Code extension.
26+
27+
## Decisions
28+
29+
### 1. The style comes from `$frontmatter`
30+
31+
`calloutStyle` is read as `$frontmatter.calloutStyle`, which includes the headmatter's `defaults:` merged under the slide's own keys.
32+
- `'elbow'` → elbow.
33+
- Absent or `'arrow'` → arrow.
34+
- Anything else → arrow, with a one-time console warning naming the slide.
35+
36+
The editor never writes `calloutStyle`, so the frontmatter-only-patch staleness that made geometry and callouts read from the slide-info ref doesn't apply: a hand edit re-renders the slide. An e2e test covers `defaults:` so the assumption about Slidev's merge is pinned.
37+
38+
*Alternative considered:* reading `data.headmatter.defaults` through the slide-info ref. Rejected: it isn't exposed there, and `$frontmatter` already has the merged value.
39+
40+
### 2. `arrowPath(anchor, box)` in `useHighlightLayout.ts`
41+
42+
`arrowPath(anchor: Point, box: Rect): Point[]` returns `[anchor, borderPoint]`, where `borderPoint` is the point on the ray from the box's centre towards `anchor` where it leaves the box. It uses the smaller of the x and y scale factors to reach the border (`t = min(hw / |dx|, hh / |dy|)`).
43+
- **Anchor on the centre:** returns `[]` (no connector).
44+
- **Anchor for code callouts:** the same highlight edge `elbowPath` uses, facing `side`. It moves into a small `anchorPoint(highlightRect, side)` helper that both paths share.
45+
- **Anchor for slide callouts:** the anchor point itself.
46+
47+
The path keeps starting at the anchor, so existing e2e helpers that read a connector's start keep working.
48+
49+
### 3. Two markers
50+
51+
- **Elbow:** the existing `#callout-arrowhead` (7 × stroke width) stays on `marker-start` for `elbow` connectors and for bare arrows in both styles.
52+
- **Arrow:** a new `#callout-arrowhead-box` (10 × stroke width, `orient="auto"`, `refX` at the tip) goes on `marker-end` for `arrow` connectors.
53+
54+
The slide's style (a computed `calloutStyle`) and each item's `boxless` flag decide the marker attribute (`headOnBox`) and the path (`connectorPath`, shared by `computeCallouts` and `remeasureCallouts`).
55+
56+
### 4. Anchor-ordered stacking: `stackCallouts`
57+
58+
`stackCallouts(entries, slideRect)` in `useHighlightLayout.ts` is pure. Each entry has:
59+
- `rect` (the result of `placeCallout`);
60+
- `side`;
61+
- `group` (a key identifying the obstacle: the code block's `<pre>` for code callouts, the anchor's element for slide callouts, a unique key for bare points);
62+
- `anchor` (the anchor's coordinate along the side: y for right/left, x for above/below);
63+
- `range` (step range);
64+
- `fixed` (a position override or `box:`).
65+
66+
It returns the adjusted rects. For each group and side:
67+
68+
1. **Order.** Sort the non-fixed entries by `anchor`.
69+
2. **Forward pass.** Each entry's main-axis coordinate starts at its level position: the anchor aligned with the box's top, clamped to the slide, as `candidateRect` computes it. It is then raised to just past (`GAP`) any earlier entry in the group whose range overlaps it. It is also pushed past any fixed or other-group box on the same column that it would overlap with an overlapping range.
70+
3. **Backward pass.** If the last entry runs past the slide's far edge, walk back from the end, pulling each entry before the next one (again only against range-overlapping entries) and clamping to the slide. A stack that doesn't fit at all degrades to clamped overlap, like `placeCallout`'s fallback.
71+
72+
The cross-axis coordinate (x for right/left) is left as placed, so a column stays a column.
73+
74+
In `computeCallouts`, placement is split into gathering (greedy placement plus metadata for every item) and finishing (stacking on `arrow` slides, then `editor.ensurePosition`, paths and `items`). Code and slide callouts are stacked together, because they share `placed`. On `elbow` slides the stacking step is skipped, so the result is identical to today.
75+
76+
*Alternative considered:* re-running `placeCallout` in anchor order. Rejected: greedy side choice with shelf candidates can still invert neighbours, and it would change which side boxes land on.
77+
78+
### 5. What stays the same
79+
80+
- **No connector when needed:** `boxContainsAnchor` (label) and boxless callouts keep their current behaviour, with the bare-arrow stub and small head at the anchor.
81+
- **Dragging:** a dragged box is `fixed`. Its connector follows it in the new style as the box moves.
82+
- **Printing and export:** they render the same SVG, so no change is needed.
83+
84+
## Risks / Trade-offs
85+
86+
- **[Existing decks change look]** Every deck without `calloutStyle` switches to arrows, and some boxes move to follow their anchors' order. → A release note, plus `defaults: { calloutStyle: elbow }` restores the old look deck-wide in one line.
87+
- **[Diagonals crossing code text]** A box stacked far from its highlight draws a long diagonal over other lines. → Stacking keeps boxes as close to level with their anchors as possible, and `elbow` exists for dense code.
88+
- **[Stacking pushes a box into another group's space]** → The forward pass also avoids overlapping boxes from other groups (with range overlap). Genuine overcrowding degrades to clamped overlap, as today's fallback does.
89+
- **[Existing e2e expectations of the L shape]** → Specs that assert elbow geometry get `calloutStyle: elbow` in their fixture; specs that only read a connector's start are unaffected.
90+
91+
## Open Questions
92+
93+
None.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
## Why
2+
3+
A callout's connector is an L-shaped elbow with a small head at the highlighted code. On a slide with several callouts beside the same code, the L-shapes and shelf-stacked boxes can cross each other, and the small head is easy to miss. A straight arrow from the highlight into its box reads more directly, and stacking boxes in the same order as their highlights keeps the lines from crossing.
4+
5+
## What Changes
6+
7+
- **New default connector, `arrow`.** A straight line from the anchor into the callout box: from the highlight's edge that faces the box (or the slide callout's exact point), ending where the line from the box's centre towards the anchor meets the box's border. It ends in a larger arrowhead that points into the box. The anchor end has no marker.
8+
- **`elbow` keeps today's look.** `calloutStyle: elbow` in a slide's frontmatter restores the L-shaped connector with its head at the code, and today's placement, exactly. A deck-wide choice goes in the headmatter's `defaults:` (`defaults: { calloutStyle: elbow }`).
9+
- **Ordered placement on `arrow` slides.** Callouts still pick a side (right → left → below → above). Then, on each side of each obstacle (a code block, or a slide callout's image or element), the auto-placed boxes are stacked in the order of their anchors: top to bottom beside it, left to right above or below it. A box goes level with its anchor, or just past the previous box when they'd overlap, and the stack moves back inside the slide if it runs past an edge.
10+
- Callouts never visible at the same click don't constrain each other.
11+
- Pinned boxes (`@x,y`, a slide callout's `box:`) stay put and are avoided.
12+
- **Bare arrows and labels are unchanged** in both styles: a slide callout without text keeps its short arrow with the head on the anchor, and a box covering its own anchor keeps no connector.
13+
- **BREAKING (visual):** existing decks get arrows and ordered placement by default. Box positions on `arrow` slides can change where the greedy placement had put boxes out of order; `calloutStyle: elbow` restores the old look.
14+
15+
## Capabilities
16+
17+
### New Capabilities
18+
19+
None.
20+
21+
### Modified Capabilities
22+
23+
- `code-highlight-callouts`: connectors follow the slide's callout style (`arrow` by default, `elbow` opt-in); the elbow routing requirement applies to `elbow` slides; placement on `arrow` slides stacks boxes in their highlights' order.
24+
- `slide-callouts`: the connector's arrowhead follows the slide's style (into the box for `arrow`, at the anchor for `elbow` and bare arrows); auto-placement on `arrow` slides stacks boxes in their anchors' order.
25+
26+
## Impact
27+
28+
- **Theme** (`packages/codeurjc-slidev-theme/`):
29+
- `composables/useHighlightLayout.ts`: a pure arrow path (anchor → clipped box border) and the per-side anchor-ordered stacking.
30+
- `layouts/default.vue`: reading `calloutStyle`, choosing path, arrowhead and placement per style; a second, larger arrowhead marker for `marker-end`.
31+
- **Tests:**
32+
- unit tests for the arrow path and stacking;
33+
- e2e tests: an `arrow` connector ends at the box border with the head there; boxes follow their highlights' order; `calloutStyle: elbow` keeps the L; the headmatter `defaults:` applies.
34+
35+
Existing e2e tests only read where a connector starts, and they may need an `elbow` fixture where they check the L.
36+
- **Docs:** `AGENTS.md` (code-highlight callouts, slide callouts), the tutorial (both styles), and a release note for the visual change.
37+
- **Not affected:** the ODP importer and the VS Code extension.

0 commit comments

Comments
 (0)