refactor(words): rail is now a real column outside the editor box

User feedback was right — the previous approach put the rail INSIDE
the editor as extra content padding, so it was glued to the editor
card with no visual separation. Reframed as a proper SIBLING column
in the grid layout, with its own grey/dotted background and a gap
to the editor mirroring the drawer's gap on the other side.

Layout (right-drawer default):
  [rail 2rem] [gap 0.5rem] [editor card] [drawer]
Left-drawer flips both rail + gap to the right side.

CSS changes:
- New tokens: `--_words-rail-px` (2rem ~32px column width),
  `--_words-rail-gap` (0.5rem ~8px gap to editor),
  `--_words-rail-bg` (subtle grey using toolbar-border tint),
  `--_words-rail-dot` (denser status-color dots at 65% opacity).
- Grid `template-columns` and `template-areas` of
  `[data-words]:has([data-words-drawer])` extended to include `rail`
  and a `.` gap column. Left-drawer mirror reverses them.
- Rail visual: `[data-words]:has(...)::before` with `grid-area:
  rail`, dotted radial-gradient on grey bg, rounded corners
  matching the editor's radius.
- Reverted content + placeholder to symmetric `padding-inline` (the
  rail no longer lives in content's padding). Deleted the obsolete
  `[data-words-content]::before` rule.

Overlay positioning (JS):
- `words-block-handle.svelte` reads `--_words-rail-px`,
  `--_words-rail-gap`, `--_words-content-px` from the editor host
  and computes the grip's `left` as the center of the rail column
  in viewport coords:
    right-drawer: `block.rect.left - content-px - rail-gap - rail-px/2`
    left-drawer:  `block.rect.right + content-px + rail-gap + rail-px/2`
  Then offset by half the grip's width (14 px) to center on the rail.

Inserter "+" button (CSS-only):
- `left` switched from a hard `-28px` to a calc that mirrors the
  grip math:
    `-(content-px + rail-gap + rail-px/2) - 12px`
  Right-edge mirror for the left-drawer case.

Grip visibility bumped:
- Color from `status-color` to `command-color` so it reads as
  active-text against the dotted rail.
- Default opacity 0.45 → 0.75 (visible without hover).
- Hover state gains a toolbar-bg fill + accent-text + accent
  border ring so the hovered grip stands out from the rail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 33b3a580b7
commit 6bcbd8a983

@ -49,6 +49,28 @@
return drawer?.getAttribute('data-side') === 'left' ? 'left' : 'right';
}
/**
* Read the editor host's CSS variables so the grip lands centered
* inside the dedicated rail column. Layout (right-drawer default):
* [rail-px] [rail-gap] [content...] [drawer]
* Block's `rect.left` is the editor card's left + content-px. The
* rail column sits to the LEFT of the editor card by
* (rail-px + rail-gap), so the rail's center in viewport coords
* is `block.rect.left - content-px - rail-gap - rail-px/2`.
*/
function rememberRailMetrics(el: HTMLElement): {
railPx: number;
railGap: number;
contentPx: number;
} {
const host = el.closest<HTMLElement>('[data-words]') ?? document.documentElement;
const cs = getComputedStyle(host);
const railPx = parseFloat(cs.getPropertyValue('--_words-rail-px')) || 32;
const railGap = parseFloat(cs.getPropertyValue('--_words-rail-gap')) || 8;
const contentPx = parseFloat(cs.getPropertyValue('--_words-content-px')) || 16;
return { railPx, railGap, contentPx };
}
function measureFromEl(el: HTMLElement | null) {
if (!el) {
position = null;
@ -56,13 +78,21 @@
}
const rect = el.getBoundingClientRect();
const side = detectDrawerSide();
// Grip is 28×28 (`1.75rem`) so the centering offset is 14.
const { railPx, railGap, contentPx } = rememberRailMetrics(el);
// Grip is 28×28 (`1.75rem`) → centering offset is 14.
const gripHalf = 14;
// Center of the rail column (relative to block's text-left):
// right-drawer: rail is BEFORE the editor box.
// rail center = block.rect.left - contentPx - railGap - railPx/2
// left-drawer: rail is AFTER the editor box.
// rail center = block.rect.right + contentPx + railGap + railPx/2
const railCenter =
side === 'left'
? rect.right + contentPx + railGap + railPx / 2
: rect.left - contentPx - railGap - railPx / 2;
position = {
top: rect.top + rect.height / 2 - 14,
// Drawer on RIGHT → grip on block LEFT (left edge − 32, small
// gap so it doesn't visually overlap text descenders).
// Drawer on LEFT → grip on block RIGHT (right edge + 4).
left: side === 'left' ? rect.right + 4 : rect.left - 32
top: rect.top + rect.height / 2 - gripHalf,
left: railCenter - gripHalf
};
}

@ -5,12 +5,14 @@
--_words-content-py: var(--words-content-py-md);
--_words-content-min-block-size: var(--words-content-min-block-size-md);
/* Gutter reserved for the block-handle grip + inserter "+" overlay.
Content is padded by this much on the side OPPOSITE to the drawer
so the floating overlays have a visible band to land in. 1rem
(≈16px) is the minimum: combined with the existing content-px
(16px) it gives a 32px band — wide enough for the 28px grip to
start at the editor edge and leave 4px before the text. */
--_words-rail-px: 1rem;
Rendered as a SEPARATE column outside the editor box (its own
background + dot pattern), with a small gap before the editor.
The grip + "+" are positioned by JS at viewport coordinates that
land inside this column. */
--_words-rail-px: 2rem;
--_words-rail-gap: 0.5rem;
--_words-rail-bg: color-mix(in srgb, var(--words-toolbar-border) 35%, transparent);
--_words-rail-dot: color-mix(in srgb, var(--words-status-color) 65%, transparent);
--_words-toolbar-px: var(--words-toolbar-px-md);
--_words-toolbar-py: var(--words-toolbar-py-md);
--_words-button-size: var(--words-button-size-md);
@ -132,35 +134,62 @@
opacity: var(--words-disabled-opacity);
}
/* ── Companion Drawer ────────────────────────────────────────────────────
Layout: when the editor host contains a [data-words-drawer], place
the drawer in a dedicated column that spans toolbar+content+
status rows. Default side is right; `data-side='left'` on the drawer
flips the column order. Children without a grid-area placement
(BubbleMenu / SlashMenu / HiddenInput / find-replace etc.) are
absolute-positioned or hidden inputs that don't participate in the
visual flow. */
/* ── Layout ──────────────────────────────────────────────────────────────
The editor host is a grid: rail | gap | (toolbar/content/status) |
drawer. The rail is a dedicated column OUTSIDE the editor card that
visually holds the block-handle grip + inserter "+" overlays — with
its own grey background and dotted pattern, separated from the
editor by a small gap mirroring the drawer's gap on the other side.
When `<Words.Drawer side="left">` the columns flip. Children without
a grid-area placement (BubbleMenu / SlashMenu / HiddenInput /
find-replace) are absolute-positioned and don't participate. */
[data-words]:has([data-words-drawer]) {
display: grid;
grid-template-columns: minmax(0, 1fr) max-content;
grid-template-columns:
var(--_words-rail-px)
var(--_words-rail-gap)
minmax(0, 1fr)
max-content;
grid-template-rows: auto minmax(0, 1fr) auto;
grid-template-areas:
'toolbar drawer'
'content drawer'
'status drawer';
'rail . toolbar drawer'
'rail . content drawer'
'rail . status drawer';
column-gap: 0;
}
/* Left-side drawer — flip the column order so the drawer takes the
start column and the editor flow takes the rest. The drawer's
own border-inline-start (toward the content) is what visually
separates them; we swap it to border-inline-end below. */
/* Left-side drawer — drawer first, then rail at the END mirroring
the right-side layout (rail is on the SAME side as the grip,
opposite to the drawer). */
[data-words]:has([data-words-drawer][data-side='left']) {
grid-template-columns: max-content minmax(0, 1fr);
grid-template-columns:
max-content
minmax(0, 1fr)
var(--_words-rail-gap)
var(--_words-rail-px);
grid-template-areas:
'drawer toolbar'
'drawer content'
'drawer status';
'drawer toolbar . rail'
'drawer content . rail'
'drawer status . rail';
}
/* Rail visual — pseudo-element on the editor host that fills the
`rail` grid area with the dotted grey band. The grip + "+" overlays
are JS-positioned via viewport coordinates that land here. */
[data-words]:has([data-words-drawer])::before {
content: '';
grid-area: rail;
background-color: var(--_words-rail-bg);
background-image: radial-gradient(
circle 1px,
var(--_words-rail-dot) 99%,
transparent 100%
);
background-size: 5px 5px;
background-position: 50% 50%;
background-repeat: repeat;
border-radius: var(--_words-radius);
pointer-events: none;
}
[data-words]:has([data-words-drawer]) > [data-words-toolbar] {
@ -769,9 +798,10 @@
border: 0;
border-radius: var(--words-command-radius);
background: transparent;
color: var(--words-status-color);
/* Distinct color against the dotted rail. Accent on hover. */
color: var(--words-command-color);
cursor: grab;
opacity: 0.45;
opacity: 0.75;
transition: opacity 120ms ease, background-color 120ms ease, color 120ms ease;
z-index: var(--z-index-popover);
}
@ -779,8 +809,9 @@
[data-words-block-handle]:hover,
[data-words-block-handle][data-open] {
opacity: 1;
background: var(--words-command-hover-bg);
color: var(--words-command-active-color);
background: var(--words-toolbar-bg);
color: var(--_words-accent-text);
box-shadow: 0 0 0 var(--words-border-width) var(--_words-accent-border);
}
[data-words-block-handle]:active {
@ -853,7 +884,14 @@
[data-words-block-inserter-button] {
pointer-events: auto;
position: absolute;
left: -28px;
/* Land the "+" centered in the dedicated rail column to the left
of the editor box. Same metrics as the block-handle's positioning:
block.rect.left − content-px − rail-gap − rail-px/2 − 12 (button half).
The inserter wrapper sits at block.rect.left, so we offset by
−(content-px + rail-gap + rail-px/2 + 12). */
left: calc(
-1 * (var(--_words-content-px) + var(--_words-rail-gap) + var(--_words-rail-px) / 2) - 12px
);
display: inline-flex;
align-items: center;
justify-content: center;
@ -878,12 +916,14 @@
color: var(--_words-accent-on);
}
/* When the drawer is on the LEFT side, flip the "+" button to the
block's right margin so it doesn't clash with the drawer's
gutter (mirrors the block-handle grip behaviour). */
/* When the drawer is on the LEFT side, flip the "+" button so it
lands in the rail column on the editor's RIGHT side. Same metric
math as the right-drawer case, just on the inline-end side. */
[data-words-block-inserter][data-side='left'] [data-words-block-inserter-button] {
left: auto;
right: -28px;
right: calc(
-1 * (var(--_words-content-px) + var(--_words-rail-gap) + var(--_words-rail-px) / 2) - 12px
);
}
[data-words-block-inserter-line] {
@ -1766,9 +1806,7 @@
[data-words-placeholder] {
display: block;
padding-block-start: var(--_words-content-py);
/* Default (drawer on right): rail on the LEFT for the grip. */
padding-inline-start: calc(var(--_words-content-px) + var(--_words-rail-px));
padding-inline-end: var(--_words-content-px);
padding-inline: var(--_words-content-px);
color: var(--words-placeholder-color);
pointer-events: none;
}
@ -1779,12 +1817,9 @@
}
[data-words-content] {
position: relative;
min-block-size: var(--_words-content-min-block-size);
padding-block: var(--_words-content-py);
/* Default (drawer on right): rail on the LEFT for the grip + "+". */
padding-inline-start: calc(var(--_words-content-px) + var(--_words-rail-px));
padding-inline-end: var(--_words-content-px);
padding-inline: var(--_words-content-px);
color: var(--words-content-color);
outline: none;
overflow-wrap: anywhere;
@ -1794,42 +1829,6 @@
-webkit-user-select: text;
}
/* Dotted band — fills the rail's padding zone so the user reads it as
a dedicated gutter (where grip + "+" overlays live) instead of empty
content padding. Pointer-events disabled so it doesn't intercept
clicks/selection. Pseudo-elements on a contenteditable don't affect
text selection or caret in modern browsers. */
[data-words-content]::before {
content: '';
position: absolute;
inset-block: 0;
inset-inline-start: 0;
inline-size: calc(var(--_words-rail-px) + var(--_words-content-px));
background-image: radial-gradient(
circle 1px,
color-mix(in srgb, var(--words-status-color) 40%, transparent) 99%,
transparent 100%
);
background-size: 8px 8px;
background-position: 50% 50%;
background-repeat: repeat;
pointer-events: none;
z-index: 0;
}
[data-words]:has([data-words-drawer][data-side='left']) > [data-words-content]::before {
inset-inline-start: auto;
inset-inline-end: 0;
}
/* When the drawer lives on the LEFT, mirror: rail moves to the RIGHT
so the grip + "+" don't fight the drawer's gutter. */
[data-words]:has([data-words-drawer][data-side='left']) > [data-words-content],
[data-words]:has([data-words-drawer][data-side='left']) > [data-words-placeholder] {
padding-inline-start: var(--_words-content-px);
padding-inline-end: calc(var(--_words-content-px) + var(--_words-rail-px));
}
[data-words-content] * {
user-select: text;
-webkit-user-select: text;

Loading…
Cancel
Save

Powered by TurnKey Linux.