The sheet was not what it wanted to be, so writing is plain text again: about 73 characters to the line, centred, no border and no shadow. The fixed page belongs to the canvas — a stroke has to land in the same place on every screen, which needs one agreed page — so it appears exactly when something is on it or about to be: in Draw, or when the note already has a drawing. The cap lives in the stylesheet rather than on a container, so it holds on a page as well as off one. Emoji are gone from the note rows and the sidebar. They are drawn by the operating system, so they looked different on every machine and were loud enough that a row's picture caught the eye before its title did. Rows are tighter by three pixels of padding. The trash button no longer stands permanently in the danger colour beside the star, which made the most destructive control the loudest thing on screen. It sits behind a divider and turns red under the pointer, when it is about to be used. On a phone the toolbar's fourteen controls scrolled sideways, so the ones at the end could only be reached by dragging the bar and nothing said they were there. Nine remain, plus a menu holding the headings, quote, code and undo. A new `sync` suite proves what was assumed: a PDF written on one device — the document, the drawing and the pinned notes — reaches a second one. Two browser contexts are two devices. Both search suites read a row's title as the second line of its text, which only worked while the icon was itself a text emoji; they read it by name now. 218 unit tests, 249 browser checks across twelve suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
688 lines
22 KiB
React
688 lines
22 KiB
React
import { useEffect, useRef, useState } from "react";
|
||
|
||
import { I } from "../../ui/icons.jsx";
|
||
import { fmtDate } from "../../ui/format";
|
||
import { FONT_SERIF, FONT_UI } from "../../ui/theme";
|
||
import { END_ID, SORT_LABELS, mergeEntries } from "./noteSort";
|
||
|
||
const iconButton = (T) => ({
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
width: 32,
|
||
height: 32,
|
||
borderRadius: 10,
|
||
border: `1px solid ${T.border}`,
|
||
background: "transparent",
|
||
cursor: "pointer",
|
||
color: T.muted,
|
||
});
|
||
|
||
/** A note rendered outside the manual order, wrapped to look like an entry. */
|
||
const asNoteEntry = (note) => ({ kind: "note", id: note.id, note });
|
||
|
||
/**
|
||
* Where the dragged row will land.
|
||
*
|
||
* A real element rather than a border on the row above. A border that appears
|
||
* and disappears changes that row's height, so the list twitched under the
|
||
* cursor — and setting `borderTop` in the same style object as a `border`
|
||
* shorthand left a permanent 2px black line behind once it was cleared, because
|
||
* clearing the longhand resets its width and colour to the defaults while the
|
||
* shorthand's `solid` stays.
|
||
*/
|
||
const DropLine = ({ T }) => (
|
||
<div data-drop-line style={{ height: 2, borderRadius: 2, background: T.accent, margin: "2px 4px" }} />
|
||
);
|
||
|
||
/** Width the separator's label keeps in both states, so renaming moves nothing. */
|
||
const MIDDLE_W = 132;
|
||
|
||
/**
|
||
* A line drawn between notes, with an optional name.
|
||
*
|
||
* Draggable like a note, because a divider that cannot move with what it
|
||
* divides is worse than none.
|
||
*
|
||
* Both renaming and removing are right-click, which is how everything else in
|
||
* this list is acted on. A left-click on the label started renaming, so the row
|
||
* could not be clicked — to select it, to drag from it — without falling into an
|
||
* edit; and the × for removal sat a few pixels from that same target.
|
||
*/
|
||
function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContextMenu }) {
|
||
const [draft, setDraft] = useState(separator.label);
|
||
|
||
useEffect(() => {
|
||
if (editing) setDraft(separator.label);
|
||
}, [editing, separator.label]);
|
||
|
||
const commit = () => {
|
||
onStartRename(null);
|
||
if (draft !== separator.label) onRename(separator.id, draft);
|
||
};
|
||
|
||
return (
|
||
<div
|
||
data-separator={separator.id}
|
||
onContextMenu={(e) => onContextMenu(e, separator)}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
padding: "8px 8px 6px",
|
||
cursor: "grab",
|
||
}}
|
||
>
|
||
{/* The two rules always share what is left over equally, and the middle
|
||
keeps one width whether it is showing a label or an input. Sizing it to
|
||
its contents made the whole row jump sideways the moment renaming
|
||
started, which is exactly when the eye is on it. */}
|
||
<div style={{ height: 1, background: T.border, flex: 1, minWidth: 8 }} />
|
||
<div style={{ flex: "0 0 auto", width: MIDDLE_W, display: "flex" }}>
|
||
{editing ? (
|
||
<input
|
||
autoFocus
|
||
value={draft}
|
||
onChange={(e) => setDraft(e.target.value)}
|
||
onBlur={commit}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") commit();
|
||
if (e.key === "Escape") {
|
||
setDraft(separator.label);
|
||
onStartRename(null);
|
||
}
|
||
}}
|
||
aria-label="Separator label"
|
||
placeholder="Section"
|
||
style={{
|
||
width: "100%",
|
||
minWidth: 0,
|
||
border: `1px solid ${T.border}`,
|
||
borderRadius: 6,
|
||
background: "transparent",
|
||
color: T.textSec,
|
||
fontSize: 10.5,
|
||
fontWeight: 700,
|
||
letterSpacing: 0.6,
|
||
textAlign: "center",
|
||
// Matches the button's box exactly, so nothing shifts vertically
|
||
// either when the input replaces it.
|
||
padding: "1px 4px",
|
||
outline: "none",
|
||
}}
|
||
/>
|
||
) : (
|
||
<div
|
||
title="Right-click for options"
|
||
style={{
|
||
width: "100%",
|
||
border: "1px solid transparent",
|
||
background: "transparent",
|
||
color: separator.label ? T.textSec : T.faint,
|
||
fontSize: 10.5,
|
||
fontWeight: 700,
|
||
letterSpacing: 0.6,
|
||
textTransform: "uppercase",
|
||
textAlign: "center",
|
||
padding: "1px 4px",
|
||
whiteSpace: "nowrap",
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
}}
|
||
>
|
||
{separator.label || "Unnamed"}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div style={{ height: 1, background: T.border, flex: 1, minWidth: 8 }} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* How this notebook's notes are ordered.
|
||
*
|
||
* The chosen order is stored with the notebook, so it is the same on every
|
||
* device and still there next week — a reference book stays alphabetical, a
|
||
* journal stays newest-first, without either being re-chosen on each visit.
|
||
*/
|
||
function SortMenu({ T, mode, modes, onChange, disabled }) {
|
||
const [open, setOpen] = useState(false);
|
||
const box = useRef(null);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const close = (e) => {
|
||
if (!box.current?.contains(e.target)) setOpen(false);
|
||
};
|
||
document.addEventListener("pointerdown", close);
|
||
return () => document.removeEventListener("pointerdown", close);
|
||
}, [open]);
|
||
|
||
return (
|
||
<div style={{ position: "relative" }} ref={box}>
|
||
<button
|
||
onClick={() => setOpen((v) => !v)}
|
||
disabled={disabled}
|
||
title={disabled ? "Sortierung" : `Sortierung: ${SORT_LABELS[mode]}`}
|
||
aria-label="Sort notes"
|
||
data-sort-button={mode}
|
||
style={{ ...iconButton(T), opacity: disabled ? 0.4 : 1, color: open ? T.accent : T.muted }}
|
||
>
|
||
{I.sort}
|
||
</button>
|
||
{open && (
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
top: 38,
|
||
right: 0,
|
||
minWidth: 190,
|
||
background: T.card,
|
||
border: `1px solid ${T.border}`,
|
||
borderRadius: 12,
|
||
padding: 6,
|
||
zIndex: 300,
|
||
boxShadow: "0 10px 26px rgba(0,0,0,.28)",
|
||
}}
|
||
>
|
||
{modes.map((m) => (
|
||
<button
|
||
key={m}
|
||
data-sort-option={m}
|
||
onClick={() => {
|
||
onChange(m);
|
||
setOpen(false);
|
||
}}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
width: "100%",
|
||
padding: "7px 10px",
|
||
borderRadius: 8,
|
||
border: "none",
|
||
background: m === mode ? T.accentSoft : "transparent",
|
||
color: m === mode ? T.accent : T.text,
|
||
cursor: "pointer",
|
||
fontSize: 12,
|
||
textAlign: "left",
|
||
}}
|
||
>
|
||
<span style={{ width: 12 }}>{m === mode ? "✓" : ""}</span>
|
||
{SORT_LABELS[m]}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* One way into the notebook.
|
||
*
|
||
* Making a note, importing a PDF and importing Markdown are the same act —
|
||
* putting something new in here — and they had grown into three buttons side by
|
||
* side, two of which were icons you had to hover to identify.
|
||
*/
|
||
function AddMenu({ T, onCreateNote, onImportPdf, onImportMarkdown }) {
|
||
const [open, setOpen] = useState(false);
|
||
const box = useRef(null);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const close = (e) => {
|
||
if (!box.current?.contains(e.target)) setOpen(false);
|
||
};
|
||
document.addEventListener("pointerdown", close);
|
||
return () => document.removeEventListener("pointerdown", close);
|
||
}, [open]);
|
||
|
||
const item = (label, icon, action) => (
|
||
<button
|
||
data-add-option={label}
|
||
onClick={() => {
|
||
setOpen(false);
|
||
action();
|
||
}}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 9,
|
||
width: "100%",
|
||
padding: "8px 10px",
|
||
borderRadius: 8,
|
||
border: "none",
|
||
background: "transparent",
|
||
color: T.text,
|
||
cursor: "pointer",
|
||
fontSize: 12.5,
|
||
textAlign: "left",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
<span style={{ display: "flex", color: T.muted }}>{icon}</span>
|
||
{label}
|
||
</button>
|
||
);
|
||
|
||
return (
|
||
<div style={{ position: "relative" }} ref={box}>
|
||
<button
|
||
onClick={() => setOpen((v) => !v)}
|
||
title="New Note"
|
||
data-add-menu={open ? "on" : "off"}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
width: 32,
|
||
height: 32,
|
||
borderRadius: 10,
|
||
border: "none",
|
||
background: T.accent,
|
||
cursor: "pointer",
|
||
color: "#fff",
|
||
boxShadow: `0 2px 8px ${T.accent}44`,
|
||
}}
|
||
>
|
||
{I.plus}
|
||
</button>
|
||
{open && (
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
top: 38,
|
||
right: 0,
|
||
minWidth: 190,
|
||
background: T.card,
|
||
border: `1px solid ${T.border}`,
|
||
borderRadius: 12,
|
||
padding: 6,
|
||
zIndex: 300,
|
||
boxShadow: "0 10px 26px rgba(0,0,0,.28)",
|
||
}}
|
||
>
|
||
{item("New note", I.plus, onCreateNote)}
|
||
{item("Upload PDF", I.pdf, onImportPdf)}
|
||
{item("Import Markdown", I.import, onImportMarkdown)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** The middle column: every note in the selected folder, in the chosen order. */
|
||
export function NoteList({
|
||
T,
|
||
isMobile,
|
||
title,
|
||
notes,
|
||
activeNote,
|
||
isTrashView,
|
||
trashedCount,
|
||
snippetFor,
|
||
sortMode,
|
||
sortModes,
|
||
onSortModeChange,
|
||
query,
|
||
onQueryChange,
|
||
reorderable,
|
||
separators,
|
||
onAddSeparator,
|
||
renamingSeparator,
|
||
onStartRenameSeparator,
|
||
onRenameSeparator,
|
||
onSeparatorContextMenu,
|
||
dnd,
|
||
onToggleSidebar,
|
||
onSelectNote,
|
||
onNoteContextMenu,
|
||
onCreateNote,
|
||
onImportPdf,
|
||
onImportMarkdown,
|
||
onEmptyTrash,
|
||
}) {
|
||
// Opened by the button and kept open while there is something typed in it, so
|
||
// a search never disappears because the box collapsed under it.
|
||
const [searching, setSearching] = useState(false);
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
width: isMobile ? "100%" : 300,
|
||
minWidth: isMobile ? 0 : 300,
|
||
flex: isMobile ? 1 : undefined,
|
||
borderRight: isMobile ? "none" : `1px solid ${T.border}`,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
background: T.bg,
|
||
fontFamily: FONT_UI,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
padding: "14px 16px 10px",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
borderBottom: `1px solid ${T.borderLight}`,
|
||
}}
|
||
>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
||
<button
|
||
onClick={onToggleSidebar}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
width: 30,
|
||
height: 30,
|
||
borderRadius: 8,
|
||
border: "none",
|
||
background: "transparent",
|
||
cursor: "pointer",
|
||
color: T.muted,
|
||
}}
|
||
>
|
||
{I.menu}
|
||
</button>
|
||
<h2
|
||
style={{
|
||
margin: 0,
|
||
fontSize: 16,
|
||
fontWeight: 600,
|
||
color: T.text,
|
||
fontFamily: FONT_SERIF,
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{title}
|
||
</h2>
|
||
</div>
|
||
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
|
||
{!isTrashView && <SortMenu T={T} mode={sortMode} modes={sortModes} onChange={onSortModeChange} />}
|
||
{!isTrashView && (
|
||
<button
|
||
onClick={() => setSearching((v) => !v)}
|
||
data-search-toggle={searching || query ? "on" : "off"}
|
||
title="Search this notebook"
|
||
aria-label="Search notes"
|
||
style={{ ...iconButton(T), color: searching || query ? T.accent : T.muted }}
|
||
>
|
||
{I.search}
|
||
</button>
|
||
)}
|
||
{!isTrashView && (
|
||
<AddMenu
|
||
T={T}
|
||
onCreateNote={onCreateNote}
|
||
onImportPdf={onImportPdf}
|
||
onImportMarkdown={onImportMarkdown}
|
||
/>
|
||
)}
|
||
{isTrashView && trashedCount > 0 && (
|
||
<button
|
||
onClick={onEmptyTrash}
|
||
title="Empty Trash"
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
padding: "6px 12px",
|
||
borderRadius: 10,
|
||
border: "none",
|
||
background: T.danger,
|
||
cursor: "pointer",
|
||
color: "#fff",
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
Empty
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* The search box lives with the list it narrows, not in the sidebar.
|
||
Searching has always been scoped to the notebook you are in; from the
|
||
sidebar it read as searching everything, which it never did. */}
|
||
{(searching || query) && !isTrashView && (
|
||
<div style={{ padding: "8px 12px", borderBottom: `1px solid ${T.borderLight}` }}>
|
||
<input
|
||
autoFocus
|
||
value={query}
|
||
onChange={(e) => onQueryChange(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Escape") {
|
||
onQueryChange("");
|
||
setSearching(false);
|
||
}
|
||
}}
|
||
placeholder={`Search in ${title}...`}
|
||
aria-label="Search in this notebook"
|
||
style={{
|
||
width: "100%",
|
||
padding: "7px 10px",
|
||
borderRadius: 9,
|
||
border: `1px solid ${T.border}`,
|
||
background: T.inputBg,
|
||
color: T.text,
|
||
fontSize: 12.5,
|
||
outline: "none",
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Just the one control. The sentence that used to sit here explained the
|
||
order the sort menu already names, on every visit, forever. */}
|
||
{reorderable && (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
justifyContent: "flex-end",
|
||
padding: "5px 10px",
|
||
borderBottom: `1px solid ${T.borderLight}`,
|
||
}}
|
||
>
|
||
<button
|
||
onClick={onAddSeparator}
|
||
data-add-separator
|
||
title="Insert a separator line"
|
||
style={{
|
||
border: `1px solid ${T.border}`,
|
||
background: "transparent",
|
||
color: T.muted,
|
||
borderRadius: 8,
|
||
padding: "2px 8px",
|
||
fontSize: 10.5,
|
||
cursor: "pointer",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
+ Separator
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ flex: 1, overflowY: "auto", padding: 6, WebkitOverflowScrolling: "touch" }}>
|
||
{notes.length === 0 ? (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
height: "100%",
|
||
color: T.faint,
|
||
gap: 10,
|
||
padding: 30,
|
||
}}
|
||
>
|
||
<div style={{ display: "flex", opacity: 0.5, transform: "scale(2.2)", marginBottom: 12 }}>
|
||
{isTrashView ? I.trash : I.note}
|
||
</div>
|
||
<div style={{ fontSize: 13, textAlign: "center" }}>
|
||
{isTrashView ? "Trash is empty" : "No notes yet"}
|
||
</div>
|
||
{!isTrashView && (
|
||
<button
|
||
onClick={onCreateNote}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
padding: "10px 20px",
|
||
borderRadius: 12,
|
||
border: "none",
|
||
background: T.accent,
|
||
color: "#fff",
|
||
cursor: "pointer",
|
||
fontSize: 13,
|
||
fontWeight: 600,
|
||
marginTop: 8,
|
||
}}
|
||
>
|
||
{I.plus} New Note
|
||
</button>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* Separators are part of the manual arrangement and only mean
|
||
something there: in "newest first" the notes would slide past a
|
||
line on every edit. They are not shown in the other orders, and
|
||
are still in place on the way back. */}
|
||
{(reorderable ? mergeEntries(notes, separators) : notes.map(asNoteEntry)).map((entry) => {
|
||
const showDropLine = reorderable && dnd?.overId === entry.id;
|
||
/* The whole row, indicator included, is the drop target.
|
||
*
|
||
* Putting these on the inner element left the indicator itself as
|
||
* a dead strip: it pushes the row down as it appears, so the
|
||
* pointer ended up over the line rather than over the row, and
|
||
* releasing there dropped on nothing. */
|
||
const rowProps = reorderable
|
||
? {
|
||
draggable: true,
|
||
onDragStart: (e) => dnd.onDragStart(e, entry.id),
|
||
onDragOver: (e) => dnd.onDragOver(e, entry.id),
|
||
onDrop: (e) => dnd.onDrop(e, entry.id),
|
||
onDragEnd: dnd.onDragEnd,
|
||
}
|
||
: {};
|
||
|
||
if (entry.kind === "separator") {
|
||
return (
|
||
<div key={entry.id} {...rowProps}>
|
||
{showDropLine && <DropLine T={T} />}
|
||
<SeparatorRow
|
||
T={T}
|
||
separator={entry.separator}
|
||
editing={renamingSeparator === entry.id}
|
||
onStartRename={onStartRenameSeparator}
|
||
onRename={onRenameSeparator}
|
||
onContextMenu={onSeparatorContextMenu}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const note = entry.note;
|
||
const snippet = snippetFor(note.id);
|
||
return (
|
||
<div key={note.id} {...rowProps}>
|
||
{showDropLine && <DropLine T={T} />}
|
||
<button
|
||
data-note-row={note.id}
|
||
onClick={() => onSelectNote(note.id)}
|
||
onContextMenu={(e) => onNoteContextMenu(e, note)}
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
width: "100%",
|
||
padding: "9px 12px",
|
||
borderRadius: 10,
|
||
border: `1px solid ${activeNote === note.id ? T.accent + "55" : "transparent"}`,
|
||
background: activeNote === note.id ? T.accentSoft : "transparent",
|
||
cursor: reorderable ? "grab" : "pointer",
|
||
marginBottom: 2,
|
||
textAlign: "left",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 7,
|
||
marginBottom: 3,
|
||
width: "100%",
|
||
}}
|
||
>
|
||
<span style={{ display: "flex", color: T.faint, flexShrink: 0 }}>
|
||
{note.trashed ? I.trash : note.type === "pdf" ? I.pdf : I.note}
|
||
</span>
|
||
<span
|
||
data-note-title
|
||
style={{
|
||
fontSize: 13,
|
||
fontWeight: 600,
|
||
color: T.text,
|
||
flex: 1,
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{note.title}
|
||
</span>
|
||
{note.starred && !note.trashed && (
|
||
<span style={{ display: "flex", color: "#f0b429" }}>{I.starFill}</span>
|
||
)}
|
||
</div>
|
||
{snippet && (
|
||
<div
|
||
style={{
|
||
fontSize: 11,
|
||
color: T.textSec,
|
||
lineHeight: 1.45,
|
||
marginTop: 1,
|
||
display: "-webkit-box",
|
||
WebkitLineClamp: 2,
|
||
WebkitBoxOrient: "vertical",
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
{snippet}
|
||
</div>
|
||
)}
|
||
<div style={{ fontSize: 10.5, color: T.faint, marginTop: 3 }}>
|
||
{fmtDate(note.updatedAt)}
|
||
</div>
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
{/* Dropping below the last row moves something to the end, which
|
||
otherwise needs an awkward drop onto the very last note. */}
|
||
{reorderable && (
|
||
<div
|
||
data-drop-end
|
||
onDragOver={(e) => dnd.onDragOver(e, END_ID)}
|
||
onDrop={(e) => dnd.onDrop(e, END_ID)}
|
||
style={{ minHeight: 40 }}
|
||
>
|
||
{dnd?.overId === END_ID && <DropLine T={T} />}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|