diff --git a/app/src/assets/scss/business/_av.scss b/app/src/assets/scss/business/_av.scss index 4423bfc38..7fac56d8d 100644 --- a/app/src/assets/scss/business/_av.scss +++ b/app/src/assets/scss/business/_av.scss @@ -72,6 +72,10 @@ opacity: 0; display: flex; + &[draggable=true] { + cursor: grab; + } + svg { height: 25px; } @@ -90,6 +94,10 @@ cursor: pointer; } + &__body { + float: left; + } + &__row { display: flex; border-bottom: 1px solid var(--b3-theme-surface-lighter); @@ -113,6 +121,10 @@ .av__gutters { opacity: 1; } + + .av__firstcol svg { + opacity: 1; + } } &--select { @@ -136,11 +148,23 @@ } } + &--add { + .av__firstcol svg { + opacity: 1; + } + + &:hover { + background-color: var(--b3-list-icon-hover); + } + } + &--header, &--footer { background-color: var(--b3-theme-background); + position: relative; } + &--add, &--footer { display: flex; border-top: 1px solid var(--b3-theme-surface-lighter); @@ -184,27 +208,6 @@ } } } - - &--add { - color: var(--b3-theme-on-surface); - padding: 5px 5px 5px 7px; - display: flex; - align-items: center; - transition: background 20ms ease-in 0s; - font-size: 87.5%; - - svg { - height: 12px; - width: 12px; - color: var(--b3-theme-on-surface); - margin-right: 5px; - flex-shrink: 0; - } - - &:hover { - background-color: var(--b3-list-icon-hover); - } - } } &__cell { @@ -234,6 +237,13 @@ border-radius: var(--b3-border-radius); } + &--add { + position: sticky; + left: 25px; + font-size: 87.5%; + padding: 0 0.5em; + } + .block__icon { position: absolute; right: 5px; @@ -265,6 +275,7 @@ &__celltext { overflow: hidden; + line-height: normal; .b3-chip { margin: 1px 2px; @@ -283,6 +294,14 @@ } &__firstcol { + background-color: var(--b3-theme-background); + border-right: 1px solid var(--b3-theme-surface-lighter); + + position: sticky; + left: 0; + width: 24px; + z-index: 1; + svg { color: var(--b3-theme-on-surface); height: 33px; @@ -292,10 +311,6 @@ box-sizing: border-box; float: left; } - - &:hover svg { - opacity: 1; - } } &__widthdrag { @@ -380,6 +395,7 @@ .protyle-wysiwyg--select, .protyle-wysiwyg--hl { .av__row--header, + .av__firstcol, .av__row--footer { background-color: transparent; } diff --git a/app/src/protyle/render/av/action.ts b/app/src/protyle/render/av/action.ts index a8daeba91..7538fc8dd 100644 --- a/app/src/protyle/render/av/action.ts +++ b/app/src/protyle/render/av/action.ts @@ -32,7 +32,7 @@ export const avClick = (protyle: IProtyle, event: MouseEvent & { target: HTMLEle if (event.shiftKey) { const rowElement = hasClosestByClassName(event.target, "av__row"); if (rowElement && !rowElement.classList.contains("av__row--header")) { - selectRow(rowElement.querySelector(".av__firstcol"), "toggle"); + selectRow(rowElement.querySelector(".av__check"), "toggle"); return true; } } @@ -63,8 +63,8 @@ export const avClick = (protyle: IProtyle, event: MouseEvent & { target: HTMLEle return true; } - const gutterElement = hasClosestByClassName(event.target, "ariaLabel"); - if (gutterElement && gutterElement.parentElement.classList.contains("av__gutters")) { + const gutterElement = hasClosestByClassName(event.target, "av__gutter"); + if (gutterElement) { const rowElement = gutterElement.parentElement.parentElement; if (gutterElement.dataset.action === "add") { const avID = blockElement.getAttribute("data-av-id"); @@ -97,7 +97,7 @@ export const avClick = (protyle: IProtyle, event: MouseEvent & { target: HTMLEle return true; } - const checkElement = hasClosestByClassName(event.target, "av__firstcol"); + const checkElement = hasClosestByClassName(event.target, "av__check"); if (checkElement) { window.siyuan.menus.menu.remove(); selectRow(checkElement, "toggle"); @@ -199,10 +199,10 @@ export const avClick = (protyle: IProtyle, event: MouseEvent & { target: HTMLEle if (cellElement && !cellElement.parentElement.classList.contains("av__row--header")) { const type = cellElement.parentElement.parentElement.firstElementChild.querySelector(`[data-col-id="${cellElement.getAttribute("data-col-id")}"]`).getAttribute("data-dtype") as TAVCol; if (type === "updated" || type === "created" || (type === "block" && !cellElement.getAttribute("data-detached"))) { - selectRow(cellElement.parentElement.querySelector(".av__firstcol"), "toggle"); + selectRow(cellElement.parentElement.querySelector(".av__check"), "toggle"); } else { cellElement.parentElement.parentElement.querySelectorAll(".av__row--select").forEach(item => { - item.querySelector(".av__firstcol use").setAttribute("xlink:href", "#iconUncheck"); + item.querySelector(".av__check use").setAttribute("xlink:href", "#iconUncheck"); item.classList.remove("av__row--select"); }); updateHeader(cellElement.parentElement); @@ -259,7 +259,7 @@ export const avContextmenu = (protyle: IProtyle, rowElement: HTMLElement, positi blockElement.querySelectorAll(".av__row--select").forEach(item => { item.classList.remove("av__row--select"); }); - blockElement.querySelectorAll(".av__firstcol use").forEach(item => { + blockElement.querySelectorAll(".av__check use").forEach(item => { item.setAttribute("xlink:href", "#iconUncheck"); }); } @@ -269,7 +269,7 @@ export const avContextmenu = (protyle: IProtyle, rowElement: HTMLElement, positi return true; } rowElement.classList.add("av__row--select"); - rowElement.querySelector(".av__firstcol use").setAttribute("xlink:href", "#iconCheck"); + rowElement.querySelector(".av__check use").setAttribute("xlink:href", "#iconCheck"); const rowIds: string[] = []; const blockIds: string[] = []; const rowElements = blockElement.querySelectorAll(".av__row--select:not(.av__row--header)"); diff --git a/app/src/protyle/render/av/keydown.ts b/app/src/protyle/render/av/keydown.ts index 2557737ff..9a87a06e1 100644 --- a/app/src/protyle/render/av/keydown.ts +++ b/app/src/protyle/render/av/keydown.ts @@ -19,7 +19,7 @@ export const avKeydown = (event: KeyboardEvent, nodeElement: HTMLElement, protyl if (selectCellElement) { if (event.key === "Escape") { selectCellElement.classList.remove("av__cell--select"); - selectRow(selectCellElement.parentElement.querySelector(".av__firstcol"), "select"); + selectRow(selectCellElement.parentElement.querySelector(".av__check"), "select"); event.preventDefault(); return true; } @@ -100,11 +100,11 @@ export const avKeydown = (event: KeyboardEvent, nodeElement: HTMLElement, protyl } if (event.key === "Escape") { event.preventDefault(); - selectRow(selectRowElements[0].querySelector(".av__firstcol"), "unselectAll"); + selectRow(selectRowElements[0].querySelector(".av__check"), "unselectAll"); return true; } if (event.key === "Enter") { - selectRow(selectRowElements[0].querySelector(".av__firstcol"), "unselectAll"); + selectRow(selectRowElements[0].querySelector(".av__check"), "unselectAll"); popTextCell(protyle, [selectRowElements[0].querySelector(".av__cell")]); event.preventDefault(); return true; @@ -112,9 +112,9 @@ export const avKeydown = (event: KeyboardEvent, nodeElement: HTMLElement, protyl // TODO event.shiftKey if (event.key === "ArrowUp") { const previousRowElement = selectRowElements[0].previousElementSibling; - selectRow(selectRowElements[0].querySelector(".av__firstcol"), "unselectAll"); + selectRow(selectRowElements[0].querySelector(".av__check"), "unselectAll"); if (previousRowElement && !previousRowElement.classList.contains("av__row--header")) { - selectRow(previousRowElement.querySelector(".av__firstcol"), "select"); + selectRow(previousRowElement.querySelector(".av__check"), "select"); cellScrollIntoView(nodeElement, previousRowElement.getBoundingClientRect(), true); } else { nodeElement.classList.add("protyle-wysiwyg--select"); @@ -124,9 +124,9 @@ export const avKeydown = (event: KeyboardEvent, nodeElement: HTMLElement, protyl } if (event.key === "ArrowDown") { const nextRowElement = selectRowElements[selectRowElements.length - 1].nextElementSibling; - selectRow(selectRowElements[0].querySelector(".av__firstcol"), "unselectAll"); + selectRow(selectRowElements[0].querySelector(".av__check"), "unselectAll"); if (nextRowElement && !nextRowElement.classList.contains("av__row--add")) { - selectRow(nextRowElement.querySelector(".av__firstcol"), "select"); + selectRow(nextRowElement.querySelector(".av__check"), "select"); cellScrollIntoView(nodeElement, nextRowElement.getBoundingClientRect(), true); } else { nodeElement.classList.add("protyle-wysiwyg--select"); @@ -137,4 +137,3 @@ export const avKeydown = (event: KeyboardEvent, nodeElement: HTMLElement, protyl } return false; }; - diff --git a/app/src/protyle/render/av/render.ts b/app/src/protyle/render/av/render.ts index 4e3732464..903baac11 100644 --- a/app/src/protyle/render/av/render.ts +++ b/app/src/protyle/render/av/render.ts @@ -6,6 +6,7 @@ import * as dayjs from "dayjs"; import {unicode2Emoji} from "../../../emoji"; import {focusBlock} from "../../util/selection"; import {isMac} from "../../util/compatibility"; +import {stickyScrollY} from "../../scroll/stickyScroll"; export const avRender = (element: Element, protyle: IProtyle, cb?: () => void) => { let avElements: Element[] = []; @@ -40,8 +41,6 @@ export const avRender = (element: Element, protyle: IProtyle, cb?: () => void) = e.firstElementChild.innerHTML = html; } const left = e.querySelector(".av__scroll")?.scrollLeft || 0; - const headerTransform = (e.querySelector(".av__row--header") as HTMLElement)?.style.transform; - const footerTransform = (e.querySelector(".av__row--footer") as HTMLElement)?.style.transform; let selectCellId = ""; const selectCellElement = e.querySelector(".av__cell--select") as HTMLElement; if (selectCellElement) { @@ -52,7 +51,7 @@ export const avRender = (element: Element, protyle: IProtyle, cb?: () => void) = }, (response) => { const data = response.data.view as IAVTable; // header - let tableHTML = '
'; + let tableHTML = '
'; let calcHTML = ""; data.columns.forEach((column: IAVColumn) => { if (column.hidden) { @@ -80,10 +79,10 @@ style="width: ${column.width || "200px"}">${getCalcValue(column) || ' { tableHTML += `
- - + +
-
`; +
`; row.cells.forEach((cell, index) => { if (data.columns[index].hidden) { return; @@ -150,7 +149,7 @@ style="width: ${column.width || "200px"}">${getCalcValue(column) || '`; + text += ``; } } tableHTML += `
${text}
`;
-
+
${tableHTML}
- - ${window.siyuan.languages.addAttr} +
+ +
+
+ ${window.siyuan.languages.addAttr} +
- +
`; @@ -212,11 +215,16 @@ ${cell.color ? `color:${cell.color};` : ""}">${text}
`; if (left) { e.querySelector(".av__scroll").scrollLeft = left; } - if (headerTransform) { - (e.querySelector(".av__row--header") as HTMLElement).style.transform = headerTransform; - } - if (footerTransform) { - (e.querySelector(".av__row--footer") as HTMLElement).style.transform = footerTransform; + const bodyElement = e.querySelector(".av__body"); + if (bodyElement) { + const headerElement = bodyElement.querySelector(".av__row--header"); + const footerElement = bodyElement.querySelector(".av__row--footer"); + stickyScrollY( + protyle.contentElement, + bodyElement as HTMLElement, + headerElement ? [{element: headerElement}] : [], + footerElement ? [{element: footerElement}] : [], + ); } if (selectCellId) { const newCellElement = e.querySelector(`.av__row[data-id="${selectCellId.split(Constants.ZWSP)[0]}"] .av__cell[data-col-id="${selectCellId.split(Constants.ZWSP)[1]}"]`); diff --git a/app/src/protyle/render/av/row.ts b/app/src/protyle/render/av/row.ts index 5a2646ba9..3d9f79794 100644 --- a/app/src/protyle/render/av/row.ts +++ b/app/src/protyle/render/av/row.ts @@ -6,12 +6,12 @@ export const selectRow = (checkElement: Element, type: "toggle" | "select" | "un const useElement = checkElement.querySelector("use"); if (rowElement.classList.contains("av__row--header") || type === "unselectAll") { if ("#iconCheck" === useElement.getAttribute("xlink:href")) { - rowElement.parentElement.querySelectorAll(".av__firstcol").forEach(item => { + rowElement.parentElement.querySelectorAll(".av__check").forEach(item => { item.querySelector("use").setAttribute("xlink:href", "#iconUncheck"); item.parentElement.classList.remove("av__row--select"); }); } else { - rowElement.parentElement.querySelectorAll(".av__firstcol").forEach(item => { + rowElement.parentElement.querySelectorAll(".av__check").forEach(item => { item.querySelector("use").setAttribute("xlink:href", "#iconCheck"); item.parentElement.classList.add("av__row--select"); }); diff --git a/app/src/protyle/scroll/event.ts b/app/src/protyle/scroll/event.ts index 99fb14f50..f149de553 100644 --- a/app/src/protyle/scroll/event.ts +++ b/app/src/protyle/scroll/event.ts @@ -4,6 +4,7 @@ import {fetchPost} from "../../util/fetch"; import {onGet} from "../util/onGet"; import {isMobile} from "../../util/functions"; import {hasClosestBlock, hasClosestByClassName} from "../util/hasClosest"; +import {stickyScrollY} from "./stickyScroll"; let getIndexTimeout: number; export const scrollEvent = (protyle: IProtyle, element: HTMLElement) => { @@ -25,26 +26,18 @@ export const scrollEvent = (protyle: IProtyle, element: HTMLElement) => { } protyle.wysiwyg.element.querySelectorAll(".av").forEach((item: HTMLElement) => { - if (item.parentElement.classList.contains("protyle-wysiwyg")) { - const headerTop = item.offsetTop + 43; - const headerElement = item.querySelector(".av__row--header") as HTMLElement; - if (headerElement) { - if (headerTop < element.scrollTop && headerTop + headerElement.parentElement.clientHeight > element.scrollTop) { - headerElement.style.transform = `translateY(${element.scrollTop - headerTop}px)`; - } else { - headerElement.style.transform = ""; - } - } - const footerElement = item.querySelector(".av__row--footer") as HTMLElement; - if (footerElement) { - const footerBottom = headerTop + footerElement.parentElement.clientHeight; - const scrollBottom = element.scrollTop + element.clientHeight + 5; - if (headerTop + 42 + 36 * 2 < scrollBottom && footerBottom > scrollBottom) { - footerElement.style.transform = `translateY(${scrollBottom - footerBottom}px)`; - } else { - footerElement.style.transform = ""; - } - } + const bodyElement = item.querySelector(".av__body") as HTMLElement; + + if (bodyElement) { + const headerElement = bodyElement.querySelector(".av__row--header") as HTMLElement; + const footerElement = bodyElement.querySelector(".av__row--footer") as HTMLElement; + + stickyScrollY( + element, + bodyElement, + headerElement ? [{element: headerElement}] : [], + footerElement ? [{element: footerElement}] : [], + ); } }); diff --git a/app/src/protyle/scroll/stickyScroll.ts b/app/src/protyle/scroll/stickyScroll.ts new file mode 100644 index 000000000..42b88c3c7 --- /dev/null +++ b/app/src/protyle/scroll/stickyScroll.ts @@ -0,0 +1,276 @@ +export interface IStickyPositionY { + top: number; + bottom: number; +} + +export interface IStickyElementY { + element: HTMLElement; + offset?: number; +} + +export interface IStickyContextY extends IStickyElementY { + rect: DOMRect; + base: number; + origin: IStickyPositionY; + current: IStickyPositionY; + target: IStickyPositionY; + style: IStickyPositionY; +} + +export const stickyScrollY = ( + view: HTMLElement, // 视口元素 + container: HTMLElement, // 容器元素 + topElements: IStickyElementY[] = [], // 顶部粘性元素 + bottomElements: IStickyElementY[] = [], // 底部粘性元素 +) => { + if (topElements.length === 0 && bottomElements.length === 0) { + return; + } + + const viewRect = view.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + + /** + * ┏---------------┓ + * | view | + * ┗---------------┛ → viewRect.bottom + * ┌-----------┐ --→ containerRect.top + * | container | + * └-----------┘ + * ====== OR ====== + * ┌-----------┐ + * | container | + * └-----------┘ --→ containerRect.bottom + * ┏---------------┓ → viewRect.top + * | view | + * ┗---------------┛ + */ + if (viewRect.bottom <= containerRect.top || containerRect.bottom <= viewRect.top) { + return; + } + + const topContext: IStickyContextY[] = topElements.map(item => { + const rect = item.element.getBoundingClientRect(); + const base = Number.parseFloat(item.element.style.top) || 0; + item.offset ??= 0; + + return { + ...item, + rect, + base, + origin: { + top: rect.top - base, + bottom: rect.bottom - base, + }, + current: { + top: rect.top, + bottom: rect.bottom, + }, + target: { + top: null, + bottom: null, + }, + style: { + top: null, + bottom: null, + } + }; + }); + const bottomContext: IStickyContextY[] = bottomElements.map(item => { + const rect = item.element.getBoundingClientRect(); + const base = Number.parseFloat(item.element.style.bottom) || 0; + item.offset ??= 0; + + return { + ...item, + rect, + base, + origin: { + top: rect.top + base, + bottom: rect.bottom + base, + }, + current: { + top: rect.top, + bottom: rect.bottom, + }, + target: { + top: null, + bottom: null, + }, + style: { + top: null, + bottom: null, + } + }; + }); + + let handleTop = false; + let handleBottom = false; + + switch (true) { + /** + * ┏---------------┓ → viewRect.top + * | | + * | view | + * | ┌-----------┐ | → containerRect.top + * ┗-╊-----------╊-┛ → viewRect.bottom + * | container | + * └-----------┘ --→ containerRect.bottom + */ + case viewRect.top <= containerRect.top + && containerRect.top <= viewRect.bottom + && viewRect.top <= viewRect.bottom: + handleBottom = true; + break; + + /** + * ┏---------------┓ → viewRect.top + * | view | + * | ┌-----------┐ | → containerRect.top + * | | container | | + * | └-----------┘ | → containerRect.bottom + * ┗---------------┛ → viewRect.bottom + */ + case viewRect.top <= containerRect.top + && containerRect.bottom <= viewRect.bottom: + break; + + + /** + * ┌-----------┐ --→ containerRect.top + * ┏-╊-----------╊-┓ → viewRect.top + * | | | |}→ view + * ┗-╊-----------╊-┛ → viewRect.bottom + * | container | + * └-----------┘ --→ containerRect.bottom + */ + case containerRect.top <= viewRect.top + && viewRect.bottom <= containerRect.bottom: + handleTop = true; + handleBottom = true; + break; + + /** + * ┌-----------┐ --→ containerRect.top + * | container | + * ┏-╊-----------╊-┓ → viewRect.top + * | └-----------┘ | → containerRect.bottom + * | view | + * ┗-|-----------|-┛ → viewRect.bottom + */ + case containerRect.top <= viewRect.top + && viewRect.top <= containerRect.bottom + && containerRect.bottom <= viewRect.bottom: + handleTop = true; + break; + + default: + break; + } + + if (handleTop) { + if (topContext.length > 0) { + topContext.reduceRight((next, current) => { + switch (true) { + /** + * ┌-----------┐ --→ containerRect.top + * ┏-╊-----------╊-┓ → viewRect.top + * | | ┌┄┄┄┄┄┄┄┐ | | → current.target.top - current.offset + * | | ├╌╌╌╌╌╌╌┤ | | → current.origin.top + * | | └╌╌╌╌╌╌╌┘ | | → current.origin.bottom + * | | ┌╌╌╌╌╌╌╌┐ | | → next.origin.top + * | | └╌╌╌╌╌╌╌┘ | | → next.origin.bottom + * ┗-╊-----------╊-┛ → viewRect.bottom + * └-----------┘ --→ containerRect.bottom + */ + case viewRect.top <= (current.origin.top - current.offset): + current.target.top = current.origin.top; + current.target.bottom = current.origin.bottom; + current.style.top = null; + break; + + /** + * ┌-----------┐ --→ containerRect.top + * | ┌╌╌╌╌╌╌╌┐ | --→ current.origin.top + * | └╌╌╌╌╌╌╌┘ | --→ current.origin.bottom + * ┏-╊-----------╊-┓ → viewRect.top + * | | ┌-------┐ | | → current.target.top + * | | └-------┘ | | → current.target.bottom + * ┗-╊-----------╊-┛ → viewRect.bottom + * | container | + * └-----------┘ --→ containerRect.bottom + */ + default: + current.target.top = viewRect.top + current.offset; + current.target.bottom = current.target.top + current.rect.height; + if (next) { + const nextTop = Math.min(next.target.top, next.origin.top); + if (nextTop < current.target.bottom) { + const diff = nextTop - current.target.bottom; + current.target.top += diff; + current.target.bottom += diff; + } + } + current.style.top = current.base + (current.target.top - current.current.top); + break; + } + return current; + }, null); + } + } + if (handleBottom) { + if (bottomContext.length > 0) { + bottomContext.reduce((last, current) => { + switch (true) { + /** + * ┌-----------┐ --→ containerRect.top + * ┏-╊-----------╊-┓ → viewRect.top + * | | ┌╌╌╌╌╌╌╌┐ | | → last.origin.top + * | | └╌╌╌╌╌╌╌┘ | | → last.origin.bottom + * | | ┌╌╌╌╌╌╌╌┐ | | → current.origin.top + * | | ├╌╌╌╌╌╌╌┤ | | → current.origin.bottom + * | | └┄┄┄┄┄┄┄┘ | | → current.target.bottom + current.offset + * ┗-╊-----------╊-┛ → viewRect.bottom + * └-----------┘ --→ containerRect.bottom + */ + case (current.origin.bottom + current.offset) <= viewRect.bottom: + current.target.top = current.origin.top; + current.target.bottom = current.origin.bottom; + current.style.bottom = null; + break; + + /** + * ┌-----------┐ --→ containerRect.top + * ┏-╊-----------╊-┓ → viewRect.top + * | | ┌-------┐ | | → current.target.top + * | | └-------┘ | | → current.target.bottom + * ┗-╊-----------╊-┛ → viewRect.bottom + * | ┌╌╌╌╌╌╌╌┐ | --→ current.origin.top + * | └╌╌╌╌╌╌╌┘ | --→ current.origin.bottom + * | container | + * └-----------┘ --→ containerRect.bottom + */ + default: + current.target.bottom = viewRect.bottom - current.offset; + current.target.top = current.target.bottom - current.rect.height; + if (last) { + const lastBottom = Math.max(last.target.bottom, last.origin.bottom); + if (current.target.top < lastBottom) { + const diff = lastBottom - current.target.top; + current.target.top += diff; + current.target.bottom += diff; + } + } + current.style.bottom = current.base - (current.target.bottom - current.current.bottom); + break; + } + return current; + }, null); + } + } + + [...topContext, ...bottomContext].forEach(item => { + item.element.style.top = item.style.top ? `${item.style.top}px` : null; + item.element.style.bottom = item.style.bottom ? `${item.style.bottom}px` : null; + }); +} diff --git a/app/src/protyle/wysiwyg/index.ts b/app/src/protyle/wysiwyg/index.ts index bb1f823a4..90fa8b42b 100644 --- a/app/src/protyle/wysiwyg/index.ts +++ b/app/src/protyle/wysiwyg/index.ts @@ -189,7 +189,7 @@ export class WYSIWYG { protyle.wysiwyg.element.querySelectorAll(".img--select, .av__cell--select, .av__row--select").forEach((item: HTMLElement) => { if (item.classList.contains("av__row--select") && !hasClosestByClassName(element, "av")) { item.classList.remove("av__row--select"); - item.querySelector(".av__firstcol use").setAttribute("xlink:href", "#iconUncheck"); + item.querySelector(".av__check use").setAttribute("xlink:href", "#iconUncheck"); updateHeader(item); } else { item.classList.remove("img--select", "av__cell--select");