27d51303ba
Пять точечных клиентских правок (дизайн из адверсариального разбора #523): 1. Задержка hover-раскрытия при drag: `AUTO_EXPAND_MS` 500 → 2000 мс, чтобы проведение курсора через дерево не раскрывало ряды — только осознанное удержание. 2. Не раскрывать цель при drop внутрь (make-child): удалена строка `onToggle(target, true)` в `onDrop` (drop оставляет узел свёрнутым; раскрытие только по hover-hold). Восстановление раскрытости самого source не тронуто. 3. Guard от потери детей в `handleMove` (обязателен вместе с п.2): раньше эта же `onToggle` была ЕДИНСТВЕННЫМ триггером корректирующего lazy-load. `treeModel.move` материализует `target.children = [source]`; для НЕзагруженной цели (предикат gate `treeModel.isUnloadedBranch`) это скрыло бы остальных серверных детей папки (класс #159 #1). Теперь для незагруженной make-child-цели оптимистичное дерево строится БЕЗ материализации source: source удаляется из старого родителя, цели ставится `hasChildren:true`; gate остаётся взведён и раскрытие догружает полный набор. Для загруженной цели поведение прежнее (append сохраняет детей). 4. Инвалидация крошек: `updateCacheOnMovePage` инвалидирует `["breadcrumbs", pageId]` — guard делает `remove(source)`, и для текущей открытой страницы `findBreadcrumbPath` промахивается → крошки показывали бы старого родителя до refocus. 5. Сигнал «уехало сюда»: транзиентный teal-пульс на строке при make-child-drop в свёрнутую цель (узел НЕ раскрывается), отдельный `data-landed-child` + `DROP_LANDED_HIGHLIGHT_MS`, таймер чистится при анмаунте; уважает prefers-reduced-motion. Тесты: `handleMove` — незагруженная make-child-цель не материализует `[source]` (мутация guard'а краснит), загруженная цель append'ит и сохраняет детей, genuinely-empty лист материализует; инвалидация `["breadcrumbs", pageId]`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
235 lines
5.6 KiB
CSS
235 lines
5.6 KiB
CSS
.tree {
|
|
border-radius: 0;
|
|
}
|
|
|
|
.treeContainer {
|
|
height: 100%;
|
|
min-width: 0;
|
|
/* DocTree renders a vanilla <ul role="tree"> with no internal virtualizer,
|
|
so the container must own the scroll. Without this the tree grows past
|
|
its parent and the page scrolls instead. */
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
.node {
|
|
position: relative;
|
|
border-radius: 4px;
|
|
display: flex;
|
|
align-items: center;
|
|
/* Inset the highlight pill by 2px top + 2px bottom inside the full-height
|
|
row slot. rowWrapper (height: 100%, align-items: center) vertically
|
|
centers this pill in the stride, giving an even gap between rows and
|
|
symmetric padding around the row content. */
|
|
height: calc(100% - 4px);
|
|
width: 100%;
|
|
text-decoration: none;
|
|
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
|
|
|
/* Gate hover styles to mouse-capable devices. Touch browsers synthesize
|
|
:hover on the first tap (sticky hover) and only fire click on the
|
|
second tap, requiring a double-tap to navigate. */
|
|
@media (hover: hover) {
|
|
&:hover {
|
|
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
|
}
|
|
|
|
&:hover .actions {
|
|
opacity: 1;
|
|
pointer-events: auto;
|
|
}
|
|
}
|
|
|
|
.actions {
|
|
display: inline-flex;
|
|
flex-shrink: 0;
|
|
align-items: center;
|
|
margin-left: 4px;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
&:focus-within .actions {
|
|
opacity: 1;
|
|
pointer-events: auto;
|
|
}
|
|
}
|
|
|
|
|
|
.icon {
|
|
margin: 0 rem(10px);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.actionIcon {
|
|
color: light-dark(var(--mantine-color-dark-3), var(--mantine-color-gray-4));
|
|
}
|
|
|
|
.text {
|
|
flex: 1;
|
|
/* min-width: 0 lets a flex child shrink below its content size — required
|
|
for text-overflow: ellipsis on flex items. */
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
font-size: rem(14px);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.arrow {
|
|
display: flex;
|
|
}
|
|
|
|
/* Strip the browser's default <ul> bullet + indent from the DocTree
|
|
<ul role="tree"> and nested <ul role="group"> nodes. The tree's own indent
|
|
is driven by paddingLeft on .rowWrapper. */
|
|
[role="tree"],
|
|
[role="tree"] [role="group"] {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
/* ---- pragmatic-tree additions ---- */
|
|
|
|
.rowWrapper {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
border-radius: 4px;
|
|
/* Fill the full row slot (the <li> is sized to the virtualizer stride) so
|
|
align-items: center can vertically center the inset `.node` pill. */
|
|
height: 100%;
|
|
}
|
|
|
|
.node[data-dragging="true"] {
|
|
opacity: 0.4;
|
|
}
|
|
|
|
.node:focus-visible {
|
|
outline: 2px solid light-dark(
|
|
var(--mantine-color-blue-5),
|
|
var(--mantine-color-blue-4)
|
|
);
|
|
outline-offset: -2px;
|
|
}
|
|
|
|
.node :focus-visible {
|
|
outline-offset: -2px;
|
|
}
|
|
|
|
.node[data-selected="true"] {
|
|
background-color: light-dark(
|
|
var(--mantine-color-gray-3),
|
|
var(--mantine-color-dark-6)
|
|
);
|
|
}
|
|
|
|
.node[data-selected="true"] .actions {
|
|
opacity: 1;
|
|
pointer-events: auto;
|
|
}
|
|
|
|
.node[data-receiving-drop="make-child"] {
|
|
background-color: light-dark(
|
|
var(--mantine-color-blue-1),
|
|
rgba(56, 139, 253, 0.15)
|
|
);
|
|
outline: 2px solid light-dark(
|
|
var(--mantine-color-blue-5),
|
|
var(--mantine-color-blue-7)
|
|
);
|
|
outline-offset: -1px;
|
|
}
|
|
|
|
.node[data-receiving-drop="make-child-blocked"] {
|
|
outline-color: light-dark(
|
|
var(--mantine-color-red-5),
|
|
var(--mantine-color-red-7)
|
|
);
|
|
}
|
|
|
|
/* "A page just moved in here" cue (#523). A make-child drop no longer expands
|
|
the collapsed target, so the moved page is momentarily invisible; pulse the
|
|
target row in a distinct teal (NOT the blue make-child highlight, NOT the
|
|
neutral post-move flash) so the landing is noticeable while the node stays
|
|
collapsed. Two short pulses fit inside DROP_LANDED_HIGHLIGHT_MS (1.8s), after
|
|
which the row clears the attribute and the animation stops. */
|
|
@keyframes landedChildPulse {
|
|
0% {
|
|
background-color: light-dark(
|
|
var(--mantine-color-teal-2),
|
|
rgba(45, 212, 191, 0.30)
|
|
);
|
|
outline-color: light-dark(
|
|
var(--mantine-color-teal-6),
|
|
var(--mantine-color-teal-5)
|
|
);
|
|
}
|
|
100% {
|
|
background-color: transparent;
|
|
outline-color: transparent;
|
|
}
|
|
}
|
|
|
|
.node[data-landed-child="true"] {
|
|
outline: 2px solid transparent;
|
|
outline-offset: -1px;
|
|
animation: landedChildPulse 0.9s ease-out 2;
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.node[data-landed-child="true"] {
|
|
animation: none;
|
|
background-color: light-dark(
|
|
var(--mantine-color-teal-1),
|
|
rgba(45, 212, 191, 0.18)
|
|
);
|
|
}
|
|
}
|
|
|
|
.dropLine {
|
|
position: absolute;
|
|
left: var(--drop-line-indent, 0);
|
|
right: 8px;
|
|
height: 2px;
|
|
background: light-dark(
|
|
var(--mantine-color-blue-5),
|
|
var(--mantine-color-blue-4)
|
|
);
|
|
pointer-events: none;
|
|
z-index: 1;
|
|
}
|
|
|
|
.dropLine::before {
|
|
content: "";
|
|
position: absolute;
|
|
left: -4px;
|
|
top: -3px;
|
|
width: 8px;
|
|
height: 8px;
|
|
border: 2px solid currentColor;
|
|
border-radius: 50%;
|
|
color: light-dark(
|
|
var(--mantine-color-blue-5),
|
|
var(--mantine-color-blue-4)
|
|
);
|
|
background: var(--mantine-color-body);
|
|
}
|
|
|
|
.dropLine[data-blocked="true"] {
|
|
background: light-dark(
|
|
var(--mantine-color-red-5),
|
|
var(--mantine-color-red-4)
|
|
);
|
|
}
|
|
|
|
.dropLine[data-edge="top"] {
|
|
top: -1px;
|
|
}
|
|
|
|
.dropLine[data-edge="bottom"] {
|
|
bottom: -1px;
|
|
}
|