Files
gitmost/apps/client/src/features/page/tree/styles/tree.module.css
T
agent_coder 27d51303ba fix(page-tree/dnd): drag авто-раскрытие — задержка 2с, не раскрывать при drop внутрь + guard от потери детей и сигнал «уехало сюда»
Пять точечных клиентских правок (дизайн из адверсариального разбора #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>
2026-07-12 01:15:02 +03:00

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;
}