From 65935e402a89879565b11f5708130cec02c4d220 Mon Sep 17 00:00:00 2001 From: Aleksandar Toplek Date: Sat, 2 Dec 2023 20:17:37 +0100 Subject: [PATCH 01/26] feat(doprocess): Adding Comments --- web/apps/doprocess/app/layout.tsx | 2 + .../components/comments/Comments.tsx | 70 +++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 web/apps/doprocess/components/comments/Comments.tsx diff --git a/web/apps/doprocess/app/layout.tsx b/web/apps/doprocess/app/layout.tsx index feef8df55c..ee1da94e94 100644 --- a/web/apps/doprocess/app/layout.tsx +++ b/web/apps/doprocess/app/layout.tsx @@ -4,6 +4,7 @@ import './global.css'; import { Analytics } from '@vercel/analytics/react'; import { ClientProvider } from '../components/providers/ClientProvider'; import { AuthProvider } from '../components/providers/AuthProvider'; +import { Comments } from '../components/comments/Comments'; const inter = Inter({ subsets: ['latin'], @@ -20,6 +21,7 @@ export default function RootLayout({ children, }: { {children} + diff --git a/web/apps/doprocess/components/comments/Comments.tsx b/web/apps/doprocess/components/comments/Comments.tsx new file mode 100644 index 0000000000..984e83bff8 --- /dev/null +++ b/web/apps/doprocess/components/comments/Comments.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { useState } from 'react'; +import dynamic from 'next/dynamic'; +import { cx } from '@signalco/ui-primitives/cx'; +import { orderBy } from '@signalco/js'; +import { useWindowEvent } from '@signalco/hooks/useWindowEvent'; +import { useDocumentEvent } from '@signalco/hooks/useDocumentEvent'; +import { useResizeObserver } from '@enterwell/react-hooks'; + +const popoverWidth = 288; +const popoverWindowMargin = 8; + +export const Comments = dynamic(() => import('./Comments').then(m => m.CommentsGlobal), { ssr: false }); + +export function CommentsGlobal() { + const [commentSelection, setCommentSelection] = useState<{ right: number, bottom: number, top: number, text: string }>(); + + const [popoverHeight, setPopoverHeight] = useState(0); + const resizeObserverRef = useResizeObserver((_, entry) => { + setPopoverHeight(entry.contentRect.height); + }); + + useWindowEvent('keydown', (event: KeyboardEvent) => { + if (event.key === 'Escape' && commentSelection) { + event.stopPropagation(); + event.preventDefault(); + setCommentSelection(undefined); + } + }); + + useDocumentEvent('selectionchange', () => { + const selection = window.getSelection(); + const text = selection?.toString(); + if (!selection || !text?.length) { + setCommentSelection(undefined); + return; + } + + const rangesRects = selection.getRangeAt(selection.rangeCount - 1).getClientRects(); + const lastRangeRect = orderBy([...rangesRects], rr => rr.bottom).at(-1); + console.log('lastRangeRect', lastRangeRect) + + if (!lastRangeRect) { + setCommentSelection(undefined); + return; + } + + setCommentSelection({ right: lastRangeRect.right, bottom: lastRangeRect.bottom, top: lastRangeRect.top, text }); + }); + + const left = Math.min(window.innerWidth - popoverWidth / 2 - popoverWindowMargin, Math.max(popoverWidth / 2 + popoverWindowMargin, commentSelection?.right ?? 0)); + const top = (commentSelection?.bottom ?? 0) + popoverHeight + 20 + popoverWindowMargin > window.innerHeight + ? window.scrollY + (commentSelection?.top ?? 0) - popoverHeight - 40 + : window.scrollY + (commentSelection?.bottom ?? 0) + 20; + + console.log((commentSelection?.top ?? 0) + popoverHeight, popoverHeight, window.innerHeight) + + return ( +
+
Comment
+
+ ); +} From 62711977177cdf90c453b668d1368ad7dc6740f6 Mon Sep 17 00:00:00 2001 From: Aleksandar Toplek Date: Sat, 2 Dec 2023 20:18:09 +0100 Subject: [PATCH 02/26] feat(ui): Changed lucide icons stroke from 2 to 1.75 --- web/packages/ui/package.json | 3 +-- web/packages/ui/src/index.css | 18 +++++++++++------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/web/packages/ui/package.json b/web/packages/ui/package.json index cbe2eaf046..3740f8a526 100644 --- a/web/packages/ui/package.json +++ b/web/packages/ui/package.json @@ -7,8 +7,7 @@ "sideEffects": false, "files": [ "package.json", - "README.md", - "dist" + "README.md" ], "exports": { "./index.css": "./src/index.css", diff --git a/web/packages/ui/src/index.css b/web/packages/ui/src/index.css index 2b766c06e9..011041d615 100644 --- a/web/packages/ui/src/index.css +++ b/web/packages/ui/src/index.css @@ -97,13 +97,17 @@ font-feature-settings: "rlig" 1, "calt" 1; } - .image--light { - display: var(--light-display); - } + ::selection { background: var(--bg-selection); color: var(--text-selection); } +} - .image--dark { - display: var(--dark-display); - } +.image--light { + display: var(--light-display); +} - ::selection { background: var(--bg-selection); color: var(--text-selection); } +.image--dark { + display: var(--dark-display); +} + +svg.lucide { + stroke-width: 1.75px; } From 8edf4bfcdc73ccefe0ece4d4ca7319775394feed Mon Sep 17 00:00:00 2001 From: Aleksandar Toplek Date: Sat, 2 Dec 2023 20:18:21 +0100 Subject: [PATCH 03/26] feat(hooks): Added useDocumentEvent --- web/packages/hooks/src/useDocumentEvent.ts | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 web/packages/hooks/src/useDocumentEvent.ts diff --git a/web/packages/hooks/src/useDocumentEvent.ts b/web/packages/hooks/src/useDocumentEvent.ts new file mode 100644 index 0000000000..9d19e31514 --- /dev/null +++ b/web/packages/hooks/src/useDocumentEvent.ts @@ -0,0 +1,8 @@ +import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'; + +export function useDocumentEvent(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => void) { + useIsomorphicLayoutEffect(() => { + document.addEventListener(type, listener); + return () => document.removeEventListener(type, listener); + }, []); +} From a7937e8d75f281af4fadc3bc2a236f8f92940c79 Mon Sep 17 00:00:00 2001 From: Aleksandar Toplek Date: Sat, 2 Dec 2023 20:18:25 +0100 Subject: [PATCH 04/26] Update package.json --- web/packages/hooks/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/web/packages/hooks/package.json b/web/packages/hooks/package.json index 8c02e7d8e6..0958eaee94 100644 --- a/web/packages/hooks/package.json +++ b/web/packages/hooks/package.json @@ -13,6 +13,7 @@ "./useIsServer": "./src/useIsServer.ts", "./useIsomorphicLayoutEffect": "./src/useIsomorphicLayoutEffect.ts", "./useInterval": "./src/useInterval.ts", + "./useDocumentEvent": "./src/useDocumentEvent.ts", "./useControllableState": "./src/useControllableState.ts", "./useCallbackRef": "./src/useCallbackRef.ts", "./useAudio": "./src/useAudio.ts" From cfa5e4b1fcfcd7f2446cf6622f8a44d6486448f6 Mon Sep 17 00:00:00 2001 From: Aleksandar Toplek Date: Sun, 3 Dec 2023 02:54:48 +0100 Subject: [PATCH 05/26] chore(doprocess): Comments node selector generation --- .../components/comments/Comments.tsx | 28 +++++- web/packages/js/src/DOMHelpers.ts | 91 +++++++++++++++++++ web/packages/js/src/index.ts | 1 + 3 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 web/packages/js/src/DOMHelpers.ts diff --git a/web/apps/doprocess/components/comments/Comments.tsx b/web/apps/doprocess/components/comments/Comments.tsx index 984e83bff8..0b2ecd3669 100644 --- a/web/apps/doprocess/components/comments/Comments.tsx +++ b/web/apps/doprocess/components/comments/Comments.tsx @@ -1,9 +1,9 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import dynamic from 'next/dynamic'; import { cx } from '@signalco/ui-primitives/cx'; -import { orderBy } from '@signalco/js'; +import { getElementSelector, orderBy } from '@signalco/js'; import { useWindowEvent } from '@signalco/hooks/useWindowEvent'; import { useDocumentEvent } from '@signalco/hooks/useDocumentEvent'; import { useResizeObserver } from '@enterwell/react-hooks'; @@ -14,7 +14,9 @@ const popoverWindowMargin = 8; export const Comments = dynamic(() => import('./Comments').then(m => m.CommentsGlobal), { ssr: false }); export function CommentsGlobal() { - const [commentSelection, setCommentSelection] = useState<{ right: number, bottom: number, top: number, text: string }>(); + const [commentSelection, setCommentSelection] = useState<{ + right: number, bottom: number, top: number, text: string, selector: string + }>(); const [popoverHeight, setPopoverHeight] = useState(0); const resizeObserverRef = useResizeObserver((_, entry) => { @@ -46,7 +48,12 @@ export function CommentsGlobal() { return; } - setCommentSelection({ right: lastRangeRect.right, bottom: lastRangeRect.bottom, top: lastRangeRect.top, text }); + setCommentSelection({ + right: lastRangeRect.right, + bottom: lastRangeRect.bottom, + top: lastRangeRect.top, text, + selector: getElementSelector(selection.focusNode?.parentElement) + }); }); const left = Math.min(window.innerWidth - popoverWidth / 2 - popoverWindowMargin, Math.max(popoverWidth / 2 + popoverWindowMargin, commentSelection?.right ?? 0)); @@ -56,6 +63,18 @@ export function CommentsGlobal() { console.log((commentSelection?.top ?? 0) + popoverHeight, popoverHeight, window.innerHeight) + // selectionRange: { startContainerNodeSelector, endContainerNodeSelector, text, startOffset, endOffset } + + const [focusedEl, setFocusedEl] = useState(); + useEffect(() => { + const el = commentSelection?.selector ? document.querySelector(commentSelection.selector) : undefined; + setFocusedEl((curr) => { + curr?.classList.remove('border'); + el?.classList.add('border'); + return el ?? undefined; + }); + }, [commentSelection?.selector]); + return (
Comment
+
{commentSelection?.selector}
); } diff --git a/web/packages/js/src/DOMHelpers.ts b/web/packages/js/src/DOMHelpers.ts new file mode 100644 index 0000000000..132db02ec3 --- /dev/null +++ b/web/packages/js/src/DOMHelpers.ts @@ -0,0 +1,91 @@ +function getQuerySelector(element: Element | Node | null | undefined, options: { proceedAfterId: boolean, includeClasses: boolean }) { + if (!(element instanceof Element)) + return ''; + + let currentNode: Element | null = element; + if (!currentNode) + return ''; + + // Walk from current element up to the root + const selectorParts = []; + while (currentNode && currentNode.nodeType === Node.ELEMENT_NODE) { + let currentSelector = ''; + if (currentNode.id) { + currentSelector += '#'.concat(escapeClass(currentNode.id)) + if (!options.proceedAfterId) { + selectorParts.unshift(currentSelector); + break; + } + } else { + // Handle tag + const currentTag = currentNode.nodeName.toLowerCase(); + if ('html' === currentTag) + break; + currentSelector += currentTag; + + // Handle classes + const currentClass = currentNode.classList.item(0); + if (options.includeClasses && currentClass) { + currentSelector += `.${escapeClass(currentClass)}`; + } + + let nthSibiling: Element | null = currentNode; + let sibilingMathingClasses = options.includeClasses && currentClass; + let sibilingsCount = 1 + while (nthSibiling = nthSibiling.previousElementSibling) { + if (nthSibiling.nodeName.toLowerCase() === currentTag) { + sibilingsCount++; + } + if (options.includeClasses && currentClass && nthSibiling.classList.contains(currentClass)) { + sibilingMathingClasses = false; + } + } + if (sibilingsCount !== 1 && !(options.includeClasses && sibilingMathingClasses)) { + currentSelector += `:nth-of-type(${sibilingsCount})`; + } + } + selectorParts.unshift(currentSelector); + currentNode = currentNode.parentNode instanceof Element ? currentNode.parentNode : null; + } + return selectorParts.join('>') +} +function escapeClass(selector: string) { + return selector.replace(/([^a-zA-Z0-9-_])/g, '\\$1') +} + +export const getElementSelector = (element: Element | Node | null | undefined) => { + const variations = [ + getQuerySelector(element, { + proceedAfterId: !1, + includeClasses: !1 + }), + getQuerySelector(element, { + proceedAfterId: !0, + includeClasses: !1 + }), + getQuerySelector(element, { + proceedAfterId: !1, + includeClasses: !0 + }), + getQuerySelector(element, { + proceedAfterId: !0, + includeClasses: !0 + }) + ]; + + const selectors = new Set(); + return variations + .filter(variationSelector => { + if (selectors.has(variationSelector)) + return false; + selectors.add(variationSelector); + try { + document.querySelector(variationSelector) + } catch (n) { + console.error('Faulty nodeId selector', variationSelector, String(n)); + return false; + } + return true; + }) + .join(','); +} diff --git a/web/packages/js/src/index.ts b/web/packages/js/src/index.ts index 92e055ea67..8997982088 100644 --- a/web/packages/js/src/index.ts +++ b/web/packages/js/src/index.ts @@ -2,3 +2,4 @@ export * from './ArrayHelpers'; export * from './StringHelpers'; export * from './SharedTypes'; export * from './ObjectHelpers'; +export * from './DOMHelpers'; From 851508bf8396c45ec4a27f22169cfcebfe98f07a Mon Sep 17 00:00:00 2001 From: Aleksandar Toplek Date: Mon, 4 Dec 2023 09:51:02 +0100 Subject: [PATCH 06/26] fix(hooks): Simplified exports for all hooks --- web/packages/hooks/package.json | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/web/packages/hooks/package.json b/web/packages/hooks/package.json index 0958eaee94..017fa06c82 100644 --- a/web/packages/hooks/package.json +++ b/web/packages/hooks/package.json @@ -5,18 +5,7 @@ "sideEffects": false, "type": "module", "exports": { - "./useWindowWidth": "./src/useWindowWidth.ts", - "./useWindowRect": "./src/useWindowRect.ts", - "./useWindowEvent": "./src/useWindowEvent.ts", - "./useTimeout": "./src/useTimeout.ts", - "./useSearchParam": "./src/useSearchParam.ts", - "./useIsServer": "./src/useIsServer.ts", - "./useIsomorphicLayoutEffect": "./src/useIsomorphicLayoutEffect.ts", - "./useInterval": "./src/useInterval.ts", - "./useDocumentEvent": "./src/useDocumentEvent.ts", - "./useControllableState": "./src/useControllableState.ts", - "./useCallbackRef": "./src/useCallbackRef.ts", - "./useAudio": "./src/useAudio.ts" + "./*": "./src/*.ts" }, "scripts": { "lint": "eslint ." From 0a6ed39e18891603aaab4992965f76cade5d3bfb Mon Sep 17 00:00:00 2001 From: Aleksandar Toplek Date: Mon, 4 Dec 2023 09:51:20 +0100 Subject: [PATCH 07/26] chore(doprocess): Improvements in text selection logic --- .../components/comments/Comments.tsx | 126 ++++++++++++------ 1 file changed, 86 insertions(+), 40 deletions(-) diff --git a/web/apps/doprocess/components/comments/Comments.tsx b/web/apps/doprocess/components/comments/Comments.tsx index 0b2ecd3669..8148839fc4 100644 --- a/web/apps/doprocess/components/comments/Comments.tsx +++ b/web/apps/doprocess/components/comments/Comments.tsx @@ -1,5 +1,6 @@ 'use client'; +import { createPortal } from 'react-dom'; import { useEffect, useState } from 'react'; import dynamic from 'next/dynamic'; import { cx } from '@signalco/ui-primitives/cx'; @@ -13,10 +14,18 @@ const popoverWindowMargin = 8; export const Comments = dynamic(() => import('./Comments').then(m => m.CommentsGlobal), { ssr: false }); +type CommentSelection = { + text: string; + startSelector: string; + startOffset: number; + startType: 'text' | 'element'; + endSelector?: string; + endOffset: number; + endType: 'text' | 'element'; +} + export function CommentsGlobal() { - const [commentSelection, setCommentSelection] = useState<{ - right: number, bottom: number, top: number, text: string, selector: string - }>(); + const [commentSelection, setCommentSelection] = useState(); const [popoverHeight, setPopoverHeight] = useState(0); const resizeObserverRef = useResizeObserver((_, entry) => { @@ -39,52 +48,89 @@ export function CommentsGlobal() { return; } - const rangesRects = selection.getRangeAt(selection.rangeCount - 1).getClientRects(); - const lastRangeRect = orderBy([...rangesRects], rr => rr.bottom).at(-1); - console.log('lastRangeRect', lastRangeRect) + setCommentSelection({ + text, + startSelector: getElementSelector(selection.anchorNode instanceof Element ? selection.anchorNode : selection.anchorNode?.parentElement), + startOffset: selection.anchorOffset, + startType: selection.anchorNode?.nodeType === Node.TEXT_NODE ? 'text' : 'element', + endSelector: getElementSelector(selection.focusNode instanceof Element ? selection.focusNode : selection.focusNode?.parentElement), + endOffset: selection.focusOffset, + endType: selection.focusNode?.nodeType === Node.TEXT_NODE ? 'text' : 'element' + }); + }); - if (!lastRangeRect) { - setCommentSelection(undefined); + const [focusedEl, setFocusedEl] = useState([]); + useEffect(() => { + if (!commentSelection) { + setFocusedEl([]); return; } - setCommentSelection({ - right: lastRangeRect.right, - bottom: lastRangeRect.bottom, - top: lastRangeRect.top, text, - selector: getElementSelector(selection.focusNode?.parentElement) - }); - }); + const startElement = commentSelection?.startSelector?.length + ? document.querySelector(commentSelection.startSelector) + : undefined; + const endElement = commentSelection?.endSelector?.length + ? document.querySelector(commentSelection.endSelector) + : startElement; + + if (!startElement || !endElement) { + setFocusedEl([]); + return; + } - const left = Math.min(window.innerWidth - popoverWidth / 2 - popoverWindowMargin, Math.max(popoverWidth / 2 + popoverWindowMargin, commentSelection?.right ?? 0)); - const top = (commentSelection?.bottom ?? 0) + popoverHeight + 20 + popoverWindowMargin > window.innerHeight - ? window.scrollY + (commentSelection?.top ?? 0) - popoverHeight - 40 - : window.scrollY + (commentSelection?.bottom ?? 0) + 20; + const startElementOrTextNode = commentSelection.startType === 'text' ? startElement.childNodes[0] : startElement; + const endElementOrTextNode = commentSelection.endType === 'text' ? endElement.childNodes[0] : endElement; - console.log((commentSelection?.top ?? 0) + popoverHeight, popoverHeight, window.innerHeight) + if (!startElementOrTextNode || !endElementOrTextNode) { + setFocusedEl([]); + return; + } - // selectionRange: { startContainerNodeSelector, endContainerNodeSelector, text, startOffset, endOffset } + // TODO: Fix reverse order of start/end elements + const range = document.createRange(); + const startOffset = startElementOrTextNode === endElementOrTextNode && commentSelection.startOffset > commentSelection.endOffset + ? commentSelection.endOffset + : commentSelection.startOffset; + const endOffset = startElementOrTextNode === endElementOrTextNode && commentSelection.startOffset > commentSelection.endOffset + ? commentSelection.startOffset + : commentSelection.endOffset; + range.setStart(startElementOrTextNode, startOffset); + range.setEnd(endElementOrTextNode, endOffset); + setFocusedEl([...range.getClientRects()]); + }, [commentSelection]); - const [focusedEl, setFocusedEl] = useState(); - useEffect(() => { - const el = commentSelection?.selector ? document.querySelector(commentSelection.selector) : undefined; - setFocusedEl((curr) => { - curr?.classList.remove('border'); - el?.classList.add('border'); - return el ?? undefined; - }); - }, [commentSelection?.selector]); + const lastRangeRect = orderBy(focusedEl, rr => rr.bottom).at(-1); + const {bottom, top, right} = lastRangeRect ?? {bottom: 0, top: 0, right: 0}; + const leftFixed = Math.min(window.innerWidth - popoverWidth / 2 - popoverWindowMargin, Math.max(popoverWidth / 2 + popoverWindowMargin, right ?? 0)); + const topFixed = (bottom ?? 0) + popoverHeight + 20 + popoverWindowMargin > window.innerHeight + ? window.scrollY + (top ?? 0) - popoverHeight - 40 + : window.scrollY + (bottom ?? 0) + 20; return ( -
-
Comment
-
{commentSelection?.selector}
-
+ <> +
+
Comment
+
+ {focusedEl?.map((rect, i) => ( +
false} + onSelect={() => false} + onDragStart={() => false} + style={{ + left: rect.left, + top: rect.top + rect.height * 0.1, + width: rect.width, + height: rect.height * 0.8 + }} /> + ))} + ); } From a593f1d049a24ff6abfb64d869ed5288aac7b921 Mon Sep 17 00:00:00 2001 From: Aleksandar Toplek Date: Mon, 4 Dec 2023 09:57:13 +0100 Subject: [PATCH 08/26] Update DOMHelpers.ts --- web/packages/js/src/DOMHelpers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/web/packages/js/src/DOMHelpers.ts b/web/packages/js/src/DOMHelpers.ts index 132db02ec3..92deda6813 100644 --- a/web/packages/js/src/DOMHelpers.ts +++ b/web/packages/js/src/DOMHelpers.ts @@ -50,6 +50,7 @@ function getQuerySelector(element: Element | Node | null | undefined, options: { return selectorParts.join('>') } function escapeClass(selector: string) { + // Fix ID numbers: "50" -> "\\35\\30" return selector.replace(/([^a-zA-Z0-9-_])/g, '\\$1') } From 27036864a01751706652a2678c28170f3aea4c74 Mon Sep 17 00:00:00 2001 From: Aleksandar Toplek Date: Wed, 6 Dec 2023 13:03:36 +0100 Subject: [PATCH 09/26] chore(doprocess): Split Comments component to subcomponents --- .../components/comments/Comments.tsx | 244 +++++++++++++----- web/packages/ui-icons/src/lucide/index.ts | 1 + 2 files changed, 174 insertions(+), 71 deletions(-) diff --git a/web/apps/doprocess/components/comments/Comments.tsx b/web/apps/doprocess/components/comments/Comments.tsx index 8148839fc4..6ebf08955a 100644 --- a/web/apps/doprocess/components/comments/Comments.tsx +++ b/web/apps/doprocess/components/comments/Comments.tsx @@ -1,10 +1,14 @@ 'use client'; -import { createPortal } from 'react-dom'; -import { useEffect, useState } from 'react'; +import { HTMLAttributes, useEffect, useState } from 'react'; import dynamic from 'next/dynamic'; +import { nanoid } from 'nanoid'; +import { Tooltip } from '@signalco/ui-primitives/Tooltip'; +import { IconButton } from '@signalco/ui-primitives/IconButton'; import { cx } from '@signalco/ui-primitives/cx'; -import { getElementSelector, orderBy } from '@signalco/js'; +import { Comment } from '@signalco/ui-icons'; +import { arrayMax, getElementSelector, orderBy } from '@signalco/js'; +import { useWindowRect } from '@signalco/hooks/useWindowRect'; import { useWindowEvent } from '@signalco/hooks/useWindowEvent'; import { useDocumentEvent } from '@signalco/hooks/useDocumentEvent'; import { useResizeObserver } from '@enterwell/react-hooks'; @@ -15,6 +19,7 @@ const popoverWindowMargin = 8; export const Comments = dynamic(() => import('./Comments').then(m => m.CommentsGlobal), { ssr: false }); type CommentSelection = { + id?: string; text: string; startSelector: string; startOffset: number; @@ -24,45 +29,14 @@ type CommentSelection = { endType: 'text' | 'element'; } -export function CommentsGlobal() { - const [commentSelection, setCommentSelection] = useState(); - - const [popoverHeight, setPopoverHeight] = useState(0); - const resizeObserverRef = useResizeObserver((_, entry) => { - setPopoverHeight(entry.contentRect.height); - }); - - useWindowEvent('keydown', (event: KeyboardEvent) => { - if (event.key === 'Escape' && commentSelection) { - event.stopPropagation(); - event.preventDefault(); - setCommentSelection(undefined); - } - }); +function useCommentSelectionRects(commentSelection: CommentSelection | null | undefined) { + const [selectionRects, setSelectionRects] = useState([]); - useDocumentEvent('selectionchange', () => { - const selection = window.getSelection(); - const text = selection?.toString(); - if (!selection || !text?.length) { - setCommentSelection(undefined); - return; - } + const rect = useWindowRect(window); - setCommentSelection({ - text, - startSelector: getElementSelector(selection.anchorNode instanceof Element ? selection.anchorNode : selection.anchorNode?.parentElement), - startOffset: selection.anchorOffset, - startType: selection.anchorNode?.nodeType === Node.TEXT_NODE ? 'text' : 'element', - endSelector: getElementSelector(selection.focusNode instanceof Element ? selection.focusNode : selection.focusNode?.parentElement), - endOffset: selection.focusOffset, - endType: selection.focusNode?.nodeType === Node.TEXT_NODE ? 'text' : 'element' - }); - }); - - const [focusedEl, setFocusedEl] = useState([]); useEffect(() => { if (!commentSelection) { - setFocusedEl([]); + setSelectionRects([]); return; } @@ -74,63 +48,191 @@ export function CommentsGlobal() { : startElement; if (!startElement || !endElement) { - setFocusedEl([]); + setSelectionRects([]); return; } - const startElementOrTextNode = commentSelection.startType === 'text' ? startElement.childNodes[0] : startElement; - const endElementOrTextNode = commentSelection.endType === 'text' ? endElement.childNodes[0] : endElement; + const startElementOrTextNode = commentSelection.startType === 'text' + ? startElement.childNodes[0] + : startElement; + const endElementOrTextNode = commentSelection.endType === 'text' + ? endElement.childNodes[0] + : endElement; if (!startElementOrTextNode || !endElementOrTextNode) { - setFocusedEl([]); + setSelectionRects([]); return; } - // TODO: Fix reverse order of start/end elements + // Fix reverse order of start/end elements + const isReversed = + (arrayMax([...startElement.getClientRects()], r => r?.top ?? 0) ?? 0) > + (arrayMax([...endElement.getClientRects()], r => r?.top ?? 0) ?? 0); + const firstElementOrTextNode = isReversed ? endElementOrTextNode : startElementOrTextNode; + const lastElementOrTextNode = isReversed ? startElementOrTextNode : endElementOrTextNode; + const range = document.createRange(); - const startOffset = startElementOrTextNode === endElementOrTextNode && commentSelection.startOffset > commentSelection.endOffset + const startOffset = isReversed || (firstElementOrTextNode === lastElementOrTextNode && commentSelection.startOffset > commentSelection.endOffset) ? commentSelection.endOffset : commentSelection.startOffset; - const endOffset = startElementOrTextNode === endElementOrTextNode && commentSelection.startOffset > commentSelection.endOffset + const endOffset = isReversed || (firstElementOrTextNode === lastElementOrTextNode && commentSelection.startOffset > commentSelection.endOffset) ? commentSelection.startOffset : commentSelection.endOffset; - range.setStart(startElementOrTextNode, startOffset); - range.setEnd(endElementOrTextNode, endOffset); - setFocusedEl([...range.getClientRects()]); - }, [commentSelection]); - const lastRangeRect = orderBy(focusedEl, rr => rr.bottom).at(-1); - const {bottom, top, right} = lastRangeRect ?? {bottom: 0, top: 0, right: 0}; - const leftFixed = Math.min(window.innerWidth - popoverWidth / 2 - popoverWindowMargin, Math.max(popoverWidth / 2 + popoverWindowMargin, right ?? 0)); - const topFixed = (bottom ?? 0) + popoverHeight + 20 + popoverWindowMargin > window.innerHeight - ? window.scrollY + (top ?? 0) - popoverHeight - 40 - : window.scrollY + (bottom ?? 0) + 20; + range.setStart(firstElementOrTextNode, Math.min(startOffset, firstElementOrTextNode.textContent?.length ?? 0)); + range.setEnd(lastElementOrTextNode, Math.min(endOffset, lastElementOrTextNode.textContent?.length ?? 0)); + setSelectionRects([...range.getClientRects()]); + }, [commentSelection, rect]); + + return selectionRects; +} +function CommentCursor(props: HTMLAttributes) { + return ( + + + + + + ); +} + +function CommentSelectionHighlight({ + commentSelection, creating, className, style +}: HTMLAttributes & { commentSelection: CommentSelection, creating?: boolean }) { + const selectionRects = useCommentSelectionRects(commentSelection); + const lastRect = orderBy(selectionRects, r => r.bottom).at(-1); return ( <> -
-
Comment
-
- {focusedEl?.map((rect, i) => ( + {selectionRects?.map((rect, i) => (
false} - onSelect={() => false} - onDragStart={() => false} + className={cx( + creating && 'pointer-events-none', + 'fixed select-none bg-red-400 opacity-40', + className + )} style={{ left: rect.left, - top: rect.top + rect.height * 0.1, + top: rect.top + (rect.height < 12 ? 0 : 4), width: rect.width, - height: rect.height * 0.8 + height: rect.height - (rect.height < 12 ? 0 : 8), + ...style }} /> ))} + {!creating && ( +
+ + 1 +
+ )} + + ) +} + +function CommentPopover({ rects, onCreate }: { rects: DOMRect[], onCreate: () => void }) { + const [popoverHeight, setPopoverHeight] = useState(0); + const resizeObserverRef = useResizeObserver((_, entry) => { + setPopoverHeight(entry.contentRect.height); + }); + + // Calculate position of comment popover + const lastRangeRect = orderBy(rects, rr => rr.bottom).at(-1); + const { bottom, top, right } = lastRangeRect ?? { bottom: 0, top: 0, right: 0 }; + const leftFixed = Math.min(window.innerWidth - popoverWidth / 2 - popoverWindowMargin, Math.max(popoverWidth / 2 + popoverWindowMargin, right ?? 0)); + const topFixed = (bottom ?? 0) + popoverHeight + 20 + popoverWindowMargin > window.innerHeight + ? window.scrollY + (top ?? 0) - popoverHeight - 40 + : window.scrollY + (bottom ?? 0) + 20; + + return ( +
+
+ + + + + +
+
+ ) +} + +export function CommentsGlobal() { + const [creatingCommentSelection, setCreatingCommentSelection] = useState(); + const [commentSelections, setCommentSelections] = useState([]); + + useWindowEvent('keydown', (event: KeyboardEvent) => { + if (event.key === 'Escape' && creatingCommentSelection) { + event.stopPropagation(); + event.preventDefault(); + setCreatingCommentSelection(undefined); + } + }); + + useDocumentEvent('selectionchange', () => { + // Ignore if selection is empty or no selection in document + const selection = window.getSelection(); + const text = selection?.toString(); + if (!selection || !text?.length) { + setCreatingCommentSelection(undefined); + return; + } + + setCreatingCommentSelection({ + text, + startSelector: getElementSelector(selection.anchorNode instanceof Element ? selection.anchorNode : selection.anchorNode?.parentElement), + startOffset: selection.anchorOffset, + startType: selection.anchorNode?.nodeType === Node.TEXT_NODE ? 'text' : 'element', + endSelector: getElementSelector(selection.focusNode instanceof Element ? selection.focusNode : selection.focusNode?.parentElement), + endOffset: selection.focusOffset, + endType: selection.focusNode?.nodeType === Node.TEXT_NODE ? 'text' : 'element' + }); + }); + + const creatingSelectionRects = useCommentSelectionRects(creatingCommentSelection); + + const handleCreateComment = () => { + if (!creatingCommentSelection) { + return; + } + + setCreatingCommentSelection(undefined); + setCommentSelections([...commentSelections, { + id: nanoid(), + ...creatingCommentSelection + }]); + } + + return ( + <> + {creatingCommentSelection && ( + + )} + {commentSelections.map((commentSelection) => ( + + ))} + {creatingSelectionRects?.length > 0 && ( + + )} ); } diff --git a/web/packages/ui-icons/src/lucide/index.ts b/web/packages/ui-icons/src/lucide/index.ts index 82e30345d0..6b99b6ce3c 100644 --- a/web/packages/ui-icons/src/lucide/index.ts +++ b/web/packages/ui-icons/src/lucide/index.ts @@ -26,6 +26,7 @@ export { ChevronsRight as Right, Copy, Copy as Duplicate, + MessageCircle as Comment, Check, CircleSlashed as Disabled, CircleEqual, From c2a3df7fadf246c9372a66df4c57c77a670f36fb Mon Sep 17 00:00:00 2001 From: Aleksandar Toplek Date: Wed, 6 Dec 2023 21:24:41 +0100 Subject: [PATCH 10/26] chore(doprocess): CommentPoint, toolbar and popover --- .../components/comments/CommentBubble.tsx | 115 +++++++++ .../components/comments/CommentIcon.tsx | 30 +++ .../comments/CommentPointOverlay.tsx | 46 ++++ .../comments/CommentSelectionHighlight.tsx | 35 +++ .../comments/CommentSelectionPopover.tsx | 46 ++++ .../components/comments/CommentToolbar.tsx | 33 +++ .../components/comments/Comments.tsx | 240 ++---------------- .../comments/CommentsBootstrapper.tsx | 19 ++ .../components/comments/CommentsGlobal.tsx | 109 ++++++++ .../comments/useCommentItemRects.tsx | 87 +++++++ .../components/comments/useComments.tsx | 53 ++++ web/packages/hooks/src/useWindowEvent.ts | 5 +- web/packages/ui-icons/src/lucide/index.ts | 1 + .../ui-primitives/src/Popper/Popper.tsx | 6 +- 14 files changed, 607 insertions(+), 218 deletions(-) create mode 100644 web/apps/doprocess/components/comments/CommentBubble.tsx create mode 100644 web/apps/doprocess/components/comments/CommentIcon.tsx create mode 100644 web/apps/doprocess/components/comments/CommentPointOverlay.tsx create mode 100644 web/apps/doprocess/components/comments/CommentSelectionHighlight.tsx create mode 100644 web/apps/doprocess/components/comments/CommentSelectionPopover.tsx create mode 100644 web/apps/doprocess/components/comments/CommentToolbar.tsx create mode 100644 web/apps/doprocess/components/comments/CommentsBootstrapper.tsx create mode 100644 web/apps/doprocess/components/comments/CommentsGlobal.tsx create mode 100644 web/apps/doprocess/components/comments/useCommentItemRects.tsx create mode 100644 web/apps/doprocess/components/comments/useComments.tsx diff --git a/web/apps/doprocess/components/comments/CommentBubble.tsx b/web/apps/doprocess/components/comments/CommentBubble.tsx new file mode 100644 index 0000000000..220e7653b4 --- /dev/null +++ b/web/apps/doprocess/components/comments/CommentBubble.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { HTMLAttributes, useState } from 'react'; +import { Stack } from '@signalco/ui-primitives/Stack'; +import { Row } from '@signalco/ui-primitives/Row'; +import { Popper } from '@signalco/ui-primitives/Popper'; +import { Input } from '@signalco/ui-primitives/Input'; +import { IconButton } from '@signalco/ui-primitives/IconButton'; +import { cx } from '@signalco/ui-primitives/cx'; +import { Card } from '@signalco/ui-primitives/Card'; +import { Check, Delete, Send } from '@signalco/ui-icons'; +import { orderBy } from '@signalco/js'; +import { useComments } from './useComments'; +import { useCommentItemRects } from './useCommentItemRects'; +import { CommentSelectionHighlight } from './CommentSelectionHighlight'; +import { CommentItem } from './Comments'; +import { CommentIcon } from './CommentIcon'; + +type CommentBubbleProps = HTMLAttributes & { + commentItem: CommentItem; +}; + +export function CommentBubble({ + commentItem, className, style +}: CommentBubbleProps) { + const selectionRects = useCommentItemRects(commentItem.position); + const lastRect = orderBy(selectionRects, r => r.bottom).at(-1); + const { upsert } = useComments(); + + const handleCreateComment = async (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + await upsert.mutateAsync({ + ...commentItem, + thread: { + ...commentItem.thread, + items: [ + ...commentItem.thread.items, + { + id: Math.random().toString(), + text: formData.get('text') as string + } + ] + } + }); + }; + + const handleResolveComment = async () => { + await upsert.mutateAsync({ + ...commentItem, + resolved: true + }); + }; + + const [open, setOpen] = useState(false); + + return ( + <> + {commentItem.position.type === 'text' && ( + + )} + + + + {commentItem.thread.items.length} + +
+ )} + side="right" + open={open} + onOpenChange={setOpen} + > + + + + + + + + {commentItem.thread.items.map((comment, i) => ( +
+ {comment.text} +
+ ))} +
+ + + +
+ + + +
+
+
+
+
+ + + ); +} diff --git a/web/apps/doprocess/components/comments/CommentIcon.tsx b/web/apps/doprocess/components/comments/CommentIcon.tsx new file mode 100644 index 0000000000..7d442fe3d5 --- /dev/null +++ b/web/apps/doprocess/components/comments/CommentIcon.tsx @@ -0,0 +1,30 @@ +import { HTMLAttributes } from 'react'; + +export function CommentIcon({ icon, size, variant, ...rest }: HTMLAttributes & { icon?: 'add' | undefined, size?: number | undefined, variant?: 'outlined' }) { + return ( + + + {icon && ( + + )} + + ); +} diff --git a/web/apps/doprocess/components/comments/CommentPointOverlay.tsx b/web/apps/doprocess/components/comments/CommentPointOverlay.tsx new file mode 100644 index 0000000000..651aaa3185 --- /dev/null +++ b/web/apps/doprocess/components/comments/CommentPointOverlay.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { useRef } from 'react'; +import { cx } from '@signalco/ui-primitives/cx'; +import { getElementSelector } from '@signalco/js'; +import { useWindowEvent } from '@signalco/hooks/useWindowEvent'; +import { CommentPoint } from './Comments'; + +type CommentPointOverlayProps = { + onPoint: (commentPoint: CommentPoint) => void; +}; + +export function CommentPointOverlay({ onPoint }: CommentPointOverlayProps) { + const hoveredElement = useRef(); + + useWindowEvent('mousemove', (e) => { + hoveredElement.current = document.elementsFromPoint(e.clientX, e.clientY) + ?.filter(el => el && !el.classList.contains('cm-overlay')) + .at(0); + }); + + useWindowEvent('click', (event: MouseEvent) => { + const el = hoveredElement.current; + if (!el) return; + + const selector = getElementSelector(el); + const elBounds = el.getBoundingClientRect(); + const relativeX = event.clientX - elBounds.left; + const relativeY = event.clientY - elBounds.top; + + onPoint({ + type: 'point', + selector, + xNormal: relativeX / elBounds.width, + yNormal: relativeY / elBounds.height + }); + }); + + return ( +
+ ); +} diff --git a/web/apps/doprocess/components/comments/CommentSelectionHighlight.tsx b/web/apps/doprocess/components/comments/CommentSelectionHighlight.tsx new file mode 100644 index 0000000000..b28052f1d9 --- /dev/null +++ b/web/apps/doprocess/components/comments/CommentSelectionHighlight.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { HTMLAttributes } from 'react'; +import { cx } from '@signalco/ui-primitives/cx'; +import { useCommentItemRects } from './useCommentItemRects'; +import { CommentSelection } from './Comments'; + +type CommentSelectionHighlightProps = HTMLAttributes & { + commentSelection: CommentSelection; +}; + +export function CommentSelectionHighlight({ + commentSelection, className, style +}: CommentSelectionHighlightProps) { + const selectionRects = useCommentItemRects(commentSelection); + return ( + <> + {selectionRects?.map((rect, i) => ( +
+ ))} + + ); +} diff --git a/web/apps/doprocess/components/comments/CommentSelectionPopover.tsx b/web/apps/doprocess/components/comments/CommentSelectionPopover.tsx new file mode 100644 index 0000000000..c4b99f4527 --- /dev/null +++ b/web/apps/doprocess/components/comments/CommentSelectionPopover.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { useState } from 'react'; +import { Tooltip } from '@signalco/ui-primitives/Tooltip'; +import { cx } from '@signalco/ui-primitives/cx'; +import { Button } from '@signalco/ui-primitives/Button'; +import { Comment } from '@signalco/ui-icons'; +import { orderBy } from '@signalco/js'; +import { useResizeObserver } from '@enterwell/react-hooks'; +import { popoverWidth, popoverWindowMargin } from './Comments'; + +export function CommentSelectionPopover({ rects, onCreate }: { rects: DOMRect[]; onCreate: () => void; }) { + const [popoverHeight, setPopoverHeight] = useState(0); + const resizeObserverRef = useResizeObserver((_, entry) => { + setPopoverHeight(entry.contentRect.height); + }); + + // Calculate position of comment popover + const lastRangeRect = orderBy(rects, rr => rr.bottom).at(-1); + const { bottom, top, right } = lastRangeRect ?? { bottom: 0, top: 0, right: 0 }; + const leftFixed = Math.min(window.innerWidth - popoverWidth / 2 - popoverWindowMargin, Math.max(popoverWidth / 2 + popoverWindowMargin, right ?? 0)); + const topFixed = (bottom ?? 0) + popoverHeight + 20 + popoverWindowMargin > window.innerHeight + ? window.scrollY + (top ?? 0) - popoverHeight - 40 + : window.scrollY + (bottom ?? 0) + 20; + + return ( +
+
+ + + +
+
+ ); +} diff --git a/web/apps/doprocess/components/comments/CommentToolbar.tsx b/web/apps/doprocess/components/comments/CommentToolbar.tsx new file mode 100644 index 0000000000..da8072922b --- /dev/null +++ b/web/apps/doprocess/components/comments/CommentToolbar.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { Row } from '@signalco/ui-primitives/Row'; +import { IconButton } from '@signalco/ui-primitives/IconButton'; +import { Divider } from '@signalco/ui-primitives/Divider'; +import { Inbox, Menu } from '@signalco/ui-icons'; +import { CommentIcon } from './CommentIcon'; + +type CommentToolbarProps = { + creatingPointComment: boolean; + onAddPointComment: () => void; + onShowSidebar: () => void; + onExitReview: () => void; +}; + +export function CommentToolbar({ creatingPointComment, onAddPointComment, onShowSidebar, onExitReview }: CommentToolbarProps) { + return ( +
+ + + + + + + + + + + + +
+ ); +} diff --git a/web/apps/doprocess/components/comments/Comments.tsx b/web/apps/doprocess/components/comments/Comments.tsx index 6ebf08955a..15b8746328 100644 --- a/web/apps/doprocess/components/comments/Comments.tsx +++ b/web/apps/doprocess/components/comments/Comments.tsx @@ -1,25 +1,21 @@ 'use client'; -import { HTMLAttributes, useEffect, useState } from 'react'; import dynamic from 'next/dynamic'; -import { nanoid } from 'nanoid'; -import { Tooltip } from '@signalco/ui-primitives/Tooltip'; -import { IconButton } from '@signalco/ui-primitives/IconButton'; -import { cx } from '@signalco/ui-primitives/cx'; -import { Comment } from '@signalco/ui-icons'; -import { arrayMax, getElementSelector, orderBy } from '@signalco/js'; -import { useWindowRect } from '@signalco/hooks/useWindowRect'; -import { useWindowEvent } from '@signalco/hooks/useWindowEvent'; -import { useDocumentEvent } from '@signalco/hooks/useDocumentEvent'; -import { useResizeObserver } from '@enterwell/react-hooks'; -const popoverWidth = 288; -const popoverWindowMargin = 8; +export const popoverWidth = 288; +export const popoverWindowMargin = 8; -export const Comments = dynamic(() => import('./Comments').then(m => m.CommentsGlobal), { ssr: false }); +export const Comments = dynamic(() => import('./CommentsBootstrapper').then(m => m.CommentsBootstrapper), { ssr: false }); -type CommentSelection = { - id?: string; +export type CommentPoint = { + type: 'point'; + selector: string; + xNormal: number; + yNormal: number; +}; + +export type CommentSelection = { + type: 'text'; text: string; startSelector: string; startOffset: number; @@ -29,210 +25,24 @@ type CommentSelection = { endType: 'text' | 'element'; } -function useCommentSelectionRects(commentSelection: CommentSelection | null | undefined) { - const [selectionRects, setSelectionRects] = useState([]); - - const rect = useWindowRect(window); - - useEffect(() => { - if (!commentSelection) { - setSelectionRects([]); - return; - } - - const startElement = commentSelection?.startSelector?.length - ? document.querySelector(commentSelection.startSelector) - : undefined; - const endElement = commentSelection?.endSelector?.length - ? document.querySelector(commentSelection.endSelector) - : startElement; - - if (!startElement || !endElement) { - setSelectionRects([]); - return; - } - - const startElementOrTextNode = commentSelection.startType === 'text' - ? startElement.childNodes[0] - : startElement; - const endElementOrTextNode = commentSelection.endType === 'text' - ? endElement.childNodes[0] - : endElement; - - if (!startElementOrTextNode || !endElementOrTextNode) { - setSelectionRects([]); - return; - } - - // Fix reverse order of start/end elements - const isReversed = - (arrayMax([...startElement.getClientRects()], r => r?.top ?? 0) ?? 0) > - (arrayMax([...endElement.getClientRects()], r => r?.top ?? 0) ?? 0); - const firstElementOrTextNode = isReversed ? endElementOrTextNode : startElementOrTextNode; - const lastElementOrTextNode = isReversed ? startElementOrTextNode : endElementOrTextNode; - - const range = document.createRange(); - const startOffset = isReversed || (firstElementOrTextNode === lastElementOrTextNode && commentSelection.startOffset > commentSelection.endOffset) - ? commentSelection.endOffset - : commentSelection.startOffset; - const endOffset = isReversed || (firstElementOrTextNode === lastElementOrTextNode && commentSelection.startOffset > commentSelection.endOffset) - ? commentSelection.startOffset - : commentSelection.endOffset; - - range.setStart(firstElementOrTextNode, Math.min(startOffset, firstElementOrTextNode.textContent?.length ?? 0)); - range.setEnd(lastElementOrTextNode, Math.min(endOffset, lastElementOrTextNode.textContent?.length ?? 0)); - setSelectionRects([...range.getClientRects()]); - }, [commentSelection, rect]); - - return selectionRects; -} +export type CommentItemPosition = CommentPoint | CommentSelection; -function CommentCursor(props: HTMLAttributes) { - return ( - - - - - - ); +export type CommentItemThreadItem = { + id: string; + text: string; } -function CommentSelectionHighlight({ - commentSelection, creating, className, style -}: HTMLAttributes & { commentSelection: CommentSelection, creating?: boolean }) { - const selectionRects = useCommentSelectionRects(commentSelection); - const lastRect = orderBy(selectionRects, r => r.bottom).at(-1); - return ( - <> - {selectionRects?.map((rect, i) => ( -
- ))} - {!creating && ( -
- - 1 -
- )} - - ) +export type CommentItemThread = { + items: CommentItemThreadItem[]; } -function CommentPopover({ rects, onCreate }: { rects: DOMRect[], onCreate: () => void }) { - const [popoverHeight, setPopoverHeight] = useState(0); - const resizeObserverRef = useResizeObserver((_, entry) => { - setPopoverHeight(entry.contentRect.height); - }); - - // Calculate position of comment popover - const lastRangeRect = orderBy(rects, rr => rr.bottom).at(-1); - const { bottom, top, right } = lastRangeRect ?? { bottom: 0, top: 0, right: 0 }; - const leftFixed = Math.min(window.innerWidth - popoverWidth / 2 - popoverWindowMargin, Math.max(popoverWidth / 2 + popoverWindowMargin, right ?? 0)); - const topFixed = (bottom ?? 0) + popoverHeight + 20 + popoverWindowMargin > window.innerHeight - ? window.scrollY + (top ?? 0) - popoverHeight - 40 - : window.scrollY + (bottom ?? 0) + 20; - - return ( -
-
- - - - - -
-
- ) +export type CommentItem = { + id?: string; + position: CommentPoint | CommentSelection; + thread: CommentItemThread; + resolved?: boolean; } -export function CommentsGlobal() { - const [creatingCommentSelection, setCreatingCommentSelection] = useState(); - const [commentSelections, setCommentSelections] = useState([]); - - useWindowEvent('keydown', (event: KeyboardEvent) => { - if (event.key === 'Escape' && creatingCommentSelection) { - event.stopPropagation(); - event.preventDefault(); - setCreatingCommentSelection(undefined); - } - }); - - useDocumentEvent('selectionchange', () => { - // Ignore if selection is empty or no selection in document - const selection = window.getSelection(); - const text = selection?.toString(); - if (!selection || !text?.length) { - setCreatingCommentSelection(undefined); - return; - } - - setCreatingCommentSelection({ - text, - startSelector: getElementSelector(selection.anchorNode instanceof Element ? selection.anchorNode : selection.anchorNode?.parentElement), - startOffset: selection.anchorOffset, - startType: selection.anchorNode?.nodeType === Node.TEXT_NODE ? 'text' : 'element', - endSelector: getElementSelector(selection.focusNode instanceof Element ? selection.focusNode : selection.focusNode?.parentElement), - endOffset: selection.focusOffset, - endType: selection.focusNode?.nodeType === Node.TEXT_NODE ? 'text' : 'element' - }); - }); - - const creatingSelectionRects = useCommentSelectionRects(creatingCommentSelection); - - const handleCreateComment = () => { - if (!creatingCommentSelection) { - return; - } - - setCreatingCommentSelection(undefined); - setCommentSelections([...commentSelections, { - id: nanoid(), - ...creatingCommentSelection - }]); - } - - return ( - <> - {creatingCommentSelection && ( - - )} - {commentSelections.map((commentSelection) => ( - - ))} - {creatingSelectionRects?.length > 0 && ( - - )} - - ); +export type CommentsGlobalProps = { + reviewParamKey?: string; } diff --git a/web/apps/doprocess/components/comments/CommentsBootstrapper.tsx b/web/apps/doprocess/components/comments/CommentsBootstrapper.tsx new file mode 100644 index 0000000000..0f9c897650 --- /dev/null +++ b/web/apps/doprocess/components/comments/CommentsBootstrapper.tsx @@ -0,0 +1,19 @@ +'use client'; +import { CommentsGlobal } from './CommentsGlobal'; +import { CommentsGlobalProps } from './Comments'; + + +export function CommentsBootstrapper({ + reviewParamKey = 'review' +}: CommentsGlobalProps) { + const urlInReview = new URL(window.location.href).searchParams.get(reviewParamKey) === 'true'; + console.log(urlInReview, window.location.href); + if (!urlInReview) { + return null; + } + + return ( + + ); +} diff --git a/web/apps/doprocess/components/comments/CommentsGlobal.tsx b/web/apps/doprocess/components/comments/CommentsGlobal.tsx new file mode 100644 index 0000000000..7ba905a620 --- /dev/null +++ b/web/apps/doprocess/components/comments/CommentsGlobal.tsx @@ -0,0 +1,109 @@ +'use client'; +import { useState } from 'react'; +import { nanoid } from 'nanoid'; +import { getElementSelector } from '@signalco/js'; +import { useWindowEvent } from '@signalco/hooks/useWindowEvent'; +import { useDocumentEvent } from '@signalco/hooks/useDocumentEvent'; +import { useComments } from './useComments'; +import { useCommentItemRects } from './useCommentItemRects'; +import { CommentToolbar } from './CommentToolbar'; +import { CommentSelectionPopover } from './CommentSelectionPopover'; +import { CommentSelectionHighlight } from './CommentSelectionHighlight'; +import { CommentsGlobalProps, CommentSelection, CommentPoint } from './Comments'; +import { CommentPointOverlay } from './CommentPointOverlay'; +import { CommentBubble } from './CommentBubble'; + + +export function CommentsGlobal({ + reviewParamKey = 'review' +}: CommentsGlobalProps) { + const [creatingCommentSelection, setCreatingCommentSelection] = useState(); + + const { query: commentItems, upsert: commentUpsert } = useComments(); + + const [creatingCommentPoint, setCreatingCommentPoint] = useState(false); + + useWindowEvent('keydown', (event: KeyboardEvent) => { + if (event.key === 'Escape' && (creatingCommentSelection || creatingCommentPoint)) { + event.stopPropagation(); + event.preventDefault(); + setCreatingCommentPoint(false); + setCreatingCommentSelection(undefined); + } + }, [creatingCommentSelection, creatingCommentPoint]); + + useDocumentEvent('selectionchange', () => { + // Ignore if selection is empty or no selection in document + const selection = window.getSelection(); + const text = selection?.toString(); + if (!selection || !text?.length) { + setCreatingCommentSelection(undefined); + return; + } + + setCreatingCommentSelection({ + text, + type: 'text', + startSelector: getElementSelector(selection.anchorNode instanceof Element ? selection.anchorNode : selection.anchorNode?.parentElement), + startOffset: selection.anchorOffset, + startType: selection.anchorNode?.nodeType === Node.TEXT_NODE ? 'text' : 'element', + endSelector: getElementSelector(selection.focusNode instanceof Element ? selection.focusNode : selection.focusNode?.parentElement), + endOffset: selection.focusOffset, + endType: selection.focusNode?.nodeType === Node.TEXT_NODE ? 'text' : 'element' + }); + }); + + const creatingSelectionRects = useCommentItemRects(creatingCommentSelection); + + const handleCreateComment = async () => { + if (!creatingCommentSelection) { + return; + } + + setCreatingCommentSelection(undefined); + await commentUpsert.mutateAsync({ + position: creatingCommentSelection, + thread: { items: [] } + }); + }; + + const handleCreateCommentPoint = async (commentPoint: CommentPoint) => { + setCreatingCommentPoint(false); + await commentUpsert.mutateAsync({ + position: commentPoint, + thread: { items: [] } + }); + }; + + const handleExitReview = () => { + const url = new URL(window.location.href); + url.searchParams.delete(reviewParamKey); + window.history.replaceState({}, '', url.toString()); + }; + + return ( + <> + {commentItems.data?.map((commentItem) => ( + + ))} + {creatingCommentSelection && ( + + )} + {creatingSelectionRects?.length > 0 && ( + + )} + {creatingCommentPoint && ( + + )} + setCreatingCommentPoint((curr) => !curr)} + onShowSidebar={() => { }} + onExitReview={handleExitReview} /> + + ); +} diff --git a/web/apps/doprocess/components/comments/useCommentItemRects.tsx b/web/apps/doprocess/components/comments/useCommentItemRects.tsx new file mode 100644 index 0000000000..232db75bbf --- /dev/null +++ b/web/apps/doprocess/components/comments/useCommentItemRects.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { arrayMax, orderBy } from '@signalco/js'; +import { useWindowRect } from '@signalco/hooks/useWindowRect'; +import { CommentItemPosition } from './Comments'; + + +export function useCommentItemRects(commentSelection: CommentItemPosition | null | undefined) { + const [selectionRects, setSelectionRects] = useState([]); + + const rect = useWindowRect(window); + + useEffect(() => { + if (!commentSelection) { + setSelectionRects([]); + return; + } + + if (commentSelection.type !== 'text') { + const element = commentSelection?.selector?.length + ? document.querySelector(commentSelection.selector) + : undefined; + + if (!element) { + setSelectionRects([]); + return; + } + + const rects = [...element.getClientRects()]; + const lastRect = orderBy(rects, r => r.bottom).at(-1); + + const calculatedRect = new DOMRect( + (lastRect?.left ?? 0) + (lastRect?.width ?? 0) * commentSelection.xNormal, + (lastRect?.top ?? 0) + (lastRect?.height ?? 0) * commentSelection.yNormal, + 1, + 1 + ); + + setSelectionRects([calculatedRect]); + } else if (commentSelection.type === 'text') { + const startElement = commentSelection?.startSelector?.length + ? document.querySelector(commentSelection.startSelector) + : undefined; + const endElement = commentSelection?.endSelector?.length + ? document.querySelector(commentSelection.endSelector) + : startElement; + + if (!startElement || !endElement) { + setSelectionRects([]); + return; + } + + const startElementOrTextNode = commentSelection.startType === 'text' + ? startElement.childNodes[0] + : startElement; + const endElementOrTextNode = commentSelection.endType === 'text' + ? endElement.childNodes[0] + : endElement; + + if (!startElementOrTextNode || !endElementOrTextNode) { + setSelectionRects([]); + return; + } + + // Fix reverse order of start/end elements + const isReversed = (arrayMax([...startElement.getClientRects()], r => r?.top ?? 0) ?? 0) > + (arrayMax([...endElement.getClientRects()], r => r?.top ?? 0) ?? 0); + const firstElementOrTextNode = isReversed ? endElementOrTextNode : startElementOrTextNode; + const lastElementOrTextNode = isReversed ? startElementOrTextNode : endElementOrTextNode; + + const range = document.createRange(); + const startOffset = isReversed || (firstElementOrTextNode === lastElementOrTextNode && commentSelection.startOffset > commentSelection.endOffset) + ? commentSelection.endOffset + : commentSelection.startOffset; + const endOffset = isReversed || (firstElementOrTextNode === lastElementOrTextNode && commentSelection.startOffset > commentSelection.endOffset) + ? commentSelection.startOffset + : commentSelection.endOffset; + + range.setStart(firstElementOrTextNode, Math.min(startOffset, firstElementOrTextNode.textContent?.length ?? 0)); + range.setEnd(lastElementOrTextNode, Math.min(endOffset, lastElementOrTextNode.textContent?.length ?? 0)); + setSelectionRects([...range.getClientRects()]); + } + }, [commentSelection, rect]); + + return selectionRects; +} diff --git a/web/apps/doprocess/components/comments/useComments.tsx b/web/apps/doprocess/components/comments/useComments.tsx new file mode 100644 index 0000000000..4890ad0196 --- /dev/null +++ b/web/apps/doprocess/components/comments/useComments.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { UseMutationResult, UseQueryResult, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { CommentItem } from './Comments'; + +export function useComments(): { + query: UseQueryResult; + upsert: UseMutationResult; + delete: UseMutationResult; +} { + const client = useQueryClient(); + const query = useQuery({ + queryKey: ['comments'], + queryFn: () => { + const comments = JSON.parse(localStorage.getItem('comments') ?? '[]') as CommentItem[]; + return comments.filter((c: CommentItem) => !c.resolved); + } + }); + + const mutateUpsert = useMutation({ + mutationFn: async (comment: CommentItem) => { + const comments = JSON.parse(localStorage.getItem('comments') ?? '[]') as CommentItem[]; + const currentComment = comments.find((c: CommentItem) => Boolean(comment.id) && c.id === comment.id); + if (currentComment) { + Object.assign(currentComment, comment); + } else { + comments.push(comment); + } + localStorage.setItem('comments', JSON.stringify(comments)); + client.invalidateQueries({ + queryKey: ['comments'] + }); + } + }); + + const mutateDelete = useMutation({ + mutationFn: async (id: string) => { + const comments = JSON.parse(localStorage.getItem('comments') ?? '[]') as CommentItem[]; + const currentComment = comments.find((c: CommentItem) => c.id === id); + if (currentComment) { + comments.splice(comments.indexOf(currentComment), 1); + } + localStorage.setItem('comments', JSON.stringify(comments)); + client.invalidateQueries({ + queryKey: ['comments'] + }); + } + }) + + return { + query, upsert: mutateUpsert, delete: mutateDelete + }; +} diff --git a/web/packages/hooks/src/useWindowEvent.ts b/web/packages/hooks/src/useWindowEvent.ts index 50aae68ef3..3724ecc893 100644 --- a/web/packages/hooks/src/useWindowEvent.ts +++ b/web/packages/hooks/src/useWindowEvent.ts @@ -1,8 +1,9 @@ +import { DependencyList } from 'react'; import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'; -export function useWindowEvent(type: K, listener: (this: Window, ev: WindowEventMap[K]) => void ) { +export function useWindowEvent(type: K, listener: (this: Window, ev: WindowEventMap[K]) => void, dependencies?: DependencyList | undefined) { useIsomorphicLayoutEffect(() => { window.addEventListener(type, listener); return () => window.removeEventListener(type, listener); - }, []); + }, dependencies ?? []); } diff --git a/web/packages/ui-icons/src/lucide/index.ts b/web/packages/ui-icons/src/lucide/index.ts index 6b99b6ce3c..ab5c32c223 100644 --- a/web/packages/ui-icons/src/lucide/index.ts +++ b/web/packages/ui-icons/src/lucide/index.ts @@ -49,6 +49,7 @@ export { Send, Users as People, Minimize, + Inbox, Link2 as Link, Link2Off as LinkOff, AlertTriangle as Warning, diff --git a/web/packages/ui-primitives/src/Popper/Popper.tsx b/web/packages/ui-primitives/src/Popper/Popper.tsx index 4e7b15656e..b4271299dc 100644 --- a/web/packages/ui-primitives/src/Popper/Popper.tsx +++ b/web/packages/ui-primitives/src/Popper/Popper.tsx @@ -6,10 +6,12 @@ export type PopperProps = HTMLAttributes & { trigger?: React.ReactNode; anchor?: React.ReactNode; open?: boolean; + side?: 'top' | 'right' | 'bottom' | 'left'; + align?: 'start' | 'center' | 'end'; onOpenChange?: (open: boolean) => void; }; -export function Popper({ className, trigger, anchor, open, onOpenChange, ...rest }: PopperProps) { +export function Popper({ className, trigger, anchor, side, align, open, onOpenChange, ...rest }: PopperProps) { return ( {trigger && ( @@ -26,6 +28,8 @@ export function Popper({ className, trigger, anchor, open, onOpenChange, ...rest Date: Thu, 7 Dec 2023 01:12:21 +0100 Subject: [PATCH 11/26] feat(ui): Timeago added nano format --- web/packages/ui/src/Timeago/Timeago.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/web/packages/ui/src/Timeago/Timeago.tsx b/web/packages/ui/src/Timeago/Timeago.tsx index 363bcd36ed..e28c88004a 100644 --- a/web/packages/ui/src/Timeago/Timeago.tsx +++ b/web/packages/ui/src/Timeago/Timeago.tsx @@ -1,21 +1,28 @@ -import ReactTimeago from 'react-timeago'; +import ReactTimeago, { Suffix, type Unit } from 'react-timeago'; import { Typography } from '@signalco/ui-primitives/Typography'; export type TimeagoProps = { date: number | Date | undefined; live?: boolean; + format?: 'default' | 'nano'; }; -export function Timeago(props: TimeagoProps) { - const { date, live } = props; +function nanoFormater( + value: number, + unit: Unit, + suffix: Suffix, +) { + return {`${suffix === 'from now' ? '-' : ''}${value}${unit[0]}`}; +} +export function Timeago({ date, live, format }: TimeagoProps) { const isNever = typeof date === 'number' || date == null; return (
{isNever ? ? - : } + : }
) } From 6392faf6fc6d5e203740bb8247eaf740df0e2bc8 Mon Sep 17 00:00:00 2001 From: Aleksandar Toplek Date: Thu, 7 Dec 2023 01:12:46 +0100 Subject: [PATCH 12/26] feat(ui): Popper align, side and offsets --- web/packages/ui-primitives/src/Popper/Popper.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/web/packages/ui-primitives/src/Popper/Popper.tsx b/web/packages/ui-primitives/src/Popper/Popper.tsx index b4271299dc..7e691e2e34 100644 --- a/web/packages/ui-primitives/src/Popper/Popper.tsx +++ b/web/packages/ui-primitives/src/Popper/Popper.tsx @@ -7,11 +7,13 @@ export type PopperProps = HTMLAttributes & { anchor?: React.ReactNode; open?: boolean; side?: 'top' | 'right' | 'bottom' | 'left'; + sideOffset?: number; align?: 'start' | 'center' | 'end'; + alignOffset?: number; onOpenChange?: (open: boolean) => void; }; -export function Popper({ className, trigger, anchor, side, align, open, onOpenChange, ...rest }: PopperProps) { +export function Popper({ className, trigger, anchor, side, sideOffset, align, alignOffset, open, onOpenChange, ...rest }: PopperProps) { return ( {trigger && ( @@ -26,10 +28,11 @@ export function Popper({ className, trigger, anchor, side, align, open, onOpenCh )} Date: Thu, 7 Dec 2023 01:17:39 +0100 Subject: [PATCH 13/26] feat(ui): Added Avatar sizes, src and alt props support --- .../ui-primitives/src/Avatar/Avatar.tsx | 48 ++++++++++++++----- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/web/packages/ui-primitives/src/Avatar/Avatar.tsx b/web/packages/ui-primitives/src/Avatar/Avatar.tsx index 639cb16f4b..dec95cd441 100644 --- a/web/packages/ui-primitives/src/Avatar/Avatar.tsx +++ b/web/packages/ui-primitives/src/Avatar/Avatar.tsx @@ -1,16 +1,40 @@ -import { PropsWithChildren } from 'react'; +import { HTMLAttributes } from 'react'; import { cx } from '../cx'; -export type AvatarProps = PropsWithChildren<{ - size?: 'sm' | 'md' | 'lg'; // TODO: Implement - src?: string; // TODO: Implement - alt?: string; // TODO: Implement - className?: string; -}>; +export type AvatarProps = HTMLAttributes & { + size?: 'sm' | 'md' | 'lg'; +} & ({ + children: React.ReactNode; + src?: never; + alt?: never; +} | { + children?: never; + src: string; + alt: string; +}); -export function Avatar({ children, className }: AvatarProps) { - return (
{children}
); +export function Avatar({ children, size, src, alt, className, ...rest }: AvatarProps) { + return ( +
+ {src ? ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + ) : ( + children + )} +
+ ); } From cf26ebc749117ae59f6f6eba68247304c546f905 Mon Sep 17 00:00:00 2001 From: Aleksandar Toplek Date: Thu, 7 Dec 2023 01:17:59 +0100 Subject: [PATCH 14/26] chore(doprocess): useComments fixed --- web/apps/doprocess/components/comments/useComments.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/web/apps/doprocess/components/comments/useComments.tsx b/web/apps/doprocess/components/comments/useComments.tsx index 4890ad0196..c335590f77 100644 --- a/web/apps/doprocess/components/comments/useComments.tsx +++ b/web/apps/doprocess/components/comments/useComments.tsx @@ -1,5 +1,6 @@ 'use client'; +import { nanoid } from 'nanoid'; import { UseMutationResult, UseQueryResult, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { CommentItem } from './Comments'; @@ -20,6 +21,9 @@ export function useComments(): { const mutateUpsert = useMutation({ mutationFn: async (comment: CommentItem) => { const comments = JSON.parse(localStorage.getItem('comments') ?? '[]') as CommentItem[]; + if (!comment.id) { + comment.id = nanoid(); + } const currentComment = comments.find((c: CommentItem) => Boolean(comment.id) && c.id === comment.id); if (currentComment) { Object.assign(currentComment, comment); @@ -41,6 +45,8 @@ export function useComments(): { comments.splice(comments.indexOf(currentComment), 1); } localStorage.setItem('comments', JSON.stringify(comments)); + }, + onSuccess: () => { client.invalidateQueries({ queryKey: ['comments'] }); From ec36892dc1abf9e5393608e5375cfe95ec9314b3 Mon Sep 17 00:00:00 2001 From: Aleksandar Toplek Date: Thu, 7 Dec 2023 02:10:52 +0100 Subject: [PATCH 15/26] fix(ui-primitives): Typography level body1 now sets text color --- web/packages/ui-primitives/src/Typography/Typography.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/packages/ui-primitives/src/Typography/Typography.tsx b/web/packages/ui-primitives/src/Typography/Typography.tsx index d222cdc6fb..7971af1557 100644 --- a/web/packages/ui-primitives/src/Typography/Typography.tsx +++ b/web/packages/ui-primitives/src/Typography/Typography.tsx @@ -31,7 +31,7 @@ export function populateTypographyStylesAndClasses({ className: cx( 'm-0', // Levels - level === 'body1' && 'text-base', + level === 'body1' && 'text-base text-primary', level === 'body2' && 'text-sm text-secondary-foreground', level === 'body3' && 'text-xs text-tertiary-foreground', level === 'h1' && 'text-5xl', From 00394c2e673a898487c1c08accbd44b0cff203fd Mon Sep 17 00:00:00 2001 From: Aleksandar Toplek Date: Thu, 7 Dec 2023 02:11:21 +0100 Subject: [PATCH 16/26] fix(ui-primitives): Popper border rounded changed from md to lg by default --- web/packages/ui-primitives/src/Popper/Popper.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/packages/ui-primitives/src/Popper/Popper.tsx b/web/packages/ui-primitives/src/Popper/Popper.tsx index 7e691e2e34..b7f5256e5e 100644 --- a/web/packages/ui-primitives/src/Popper/Popper.tsx +++ b/web/packages/ui-primitives/src/Popper/Popper.tsx @@ -34,7 +34,7 @@ export function Popper({ className, trigger, anchor, side, sideOffset, align, al alignOffset={alignOffset ?? (align === 'center' ? 0 : align === 'start' ? -4 : 4)} collisionPadding={Math.max(sideOffset ?? 0, alignOffset ?? 0)} className={cx( - 'z-50 w-72 rounded-md border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', + 'z-50 w-72 rounded-lg border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', className )} {...rest} From ad6970c5a9c7c530f03db1047ab1dc223b9dddcb Mon Sep 17 00:00:00 2001 From: Aleksandar Toplek Date: Thu, 7 Dec 2023 02:11:46 +0100 Subject: [PATCH 17/26] feat(ui-primitives): Input variant 'plain' --- web/packages/ui-primitives/src/Input/Input.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/packages/ui-primitives/src/Input/Input.tsx b/web/packages/ui-primitives/src/Input/Input.tsx index e33986a959..c6b5af2164 100644 --- a/web/packages/ui-primitives/src/Input/Input.tsx +++ b/web/packages/ui-primitives/src/Input/Input.tsx @@ -9,6 +9,7 @@ export type InputProps = InputHTMLAttributes & { label?: string; helperText?: string; fullWidth?: boolean; + variant?: 'outlined' | 'plain'; }; export function Input({ @@ -17,6 +18,7 @@ export function Input({ className, startDecorator, endDecorator, + variant, ...rest }: InputProps) { const VerticalContainer = useMemo(() => label || helperText @@ -35,7 +37,9 @@ export function Input({ {startDecorator ?? null} Date: Thu, 7 Dec 2023 02:12:07 +0100 Subject: [PATCH 18/26] feat(ui-primitives): Added Button and IconButton xs size --- web/packages/ui-primitives/src/Button/Button.tsx | 3 ++- web/packages/ui-primitives/src/IconButton/IconButton.tsx | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/web/packages/ui-primitives/src/Button/Button.tsx b/web/packages/ui-primitives/src/Button/Button.tsx index c64c1de0d6..9f7f377148 100644 --- a/web/packages/ui-primitives/src/Button/Button.tsx +++ b/web/packages/ui-primitives/src/Button/Button.tsx @@ -7,7 +7,7 @@ import { cx } from '../cx' export type ButtonProps = ButtonHTMLAttributes & { variant?: VariantKeys | 'link'; - size?: 'sm' | 'md' | 'lg'; + size?: 'xs' | 'sm' | 'md' | 'lg'; startDecorator?: ReactNode; endDecorator?: ReactNode; loading?: boolean; @@ -47,6 +47,7 @@ const Button = forwardRef(({ variant === 'solid' && 'bg-primary text-primary-foreground hover:bg-primary/90', variant === 'link' && 'underline-offset-4 hover:underline text-primary', (!size || size === 'md') && 'h-10 py-2 px-4', + size === 'xs' && 'h-7 px-2 rounded-md gap-0.5', size === 'sm' && 'h-9 px-3 rounded-md gap-0.5', size === 'lg' && 'h-11 px-6 rounded-md gap-2', fullWidth && 'w-full', diff --git a/web/packages/ui-primitives/src/IconButton/IconButton.tsx b/web/packages/ui-primitives/src/IconButton/IconButton.tsx index 0f8cc18d61..7a605595d1 100644 --- a/web/packages/ui-primitives/src/IconButton/IconButton.tsx +++ b/web/packages/ui-primitives/src/IconButton/IconButton.tsx @@ -26,6 +26,7 @@ const IconButton = forwardRef(({ variant === 'plain' && 'hover:bg-accent hover:text-accent-foreground', variant === 'solid' && 'bg-primary text-primary-foreground hover:bg-primary/90', (!size || size === 'md') && 'h-9 w-9 p-2 rounded-md', + size === 'xs' && 'h-6 w-6 rounded-sm p-1', size === 'sm' && 'h-8 w-8 rounded-sm p-2', size === 'lg' && 'h-12 w-12 rounded-md', fullWidth && 'w-full', From f4447b2569fab4730b48092a6fd3c3c09e7e8540 Mon Sep 17 00:00:00 2001 From: Aleksandar Toplek Date: Thu, 7 Dec 2023 02:12:20 +0100 Subject: [PATCH 19/26] chore(doprocess): Comment thread UI --- .../components/comments/CommentBubble.tsx | 75 ++++++++++++------- .../components/comments/CommentThreadItem.tsx | 55 ++++++++++++++ 2 files changed, 103 insertions(+), 27 deletions(-) create mode 100644 web/apps/doprocess/components/comments/CommentThreadItem.tsx diff --git a/web/apps/doprocess/components/comments/CommentBubble.tsx b/web/apps/doprocess/components/comments/CommentBubble.tsx index 220e7653b4..09666a9d5f 100644 --- a/web/apps/doprocess/components/comments/CommentBubble.tsx +++ b/web/apps/doprocess/components/comments/CommentBubble.tsx @@ -6,12 +6,13 @@ import { Row } from '@signalco/ui-primitives/Row'; import { Popper } from '@signalco/ui-primitives/Popper'; import { Input } from '@signalco/ui-primitives/Input'; import { IconButton } from '@signalco/ui-primitives/IconButton'; +import { Divider } from '@signalco/ui-primitives/Divider'; import { cx } from '@signalco/ui-primitives/cx'; -import { Card } from '@signalco/ui-primitives/Card'; -import { Check, Delete, Send } from '@signalco/ui-icons'; +import { Send } from '@signalco/ui-icons'; import { orderBy } from '@signalco/js'; import { useComments } from './useComments'; import { useCommentItemRects } from './useCommentItemRects'; +import { CommentThreadItem } from './CommentThreadItem'; import { CommentSelectionHighlight } from './CommentSelectionHighlight'; import { CommentItem } from './Comments'; import { CommentIcon } from './CommentIcon'; @@ -27,9 +28,17 @@ export function CommentBubble({ const lastRect = orderBy(selectionRects, r => r.bottom).at(-1); const { upsert } = useComments(); + const handleResolveComment = async () => { + await upsert.mutateAsync({ + ...commentItem, + resolved: true + }); + }; + const handleCreateComment = async (e: React.FormEvent) => { e.preventDefault(); const formData = new FormData(e.currentTarget); + e.currentTarget.reset(); await upsert.mutateAsync({ ...commentItem, thread: { @@ -45,13 +54,6 @@ export function CommentBubble({ }); }; - const handleResolveComment = async () => { - await upsert.mutateAsync({ - ...commentItem, - resolved: true - }); - }; - const [open, setOpen] = useState(false); return ( @@ -80,35 +82,54 @@ export function CommentBubble({
)} - side="right" + className="bg-background text-primary" + sideOffset={-32} + align="start" + alignOffset={32} open={open} onOpenChange={setOpen} > - - - - - - - - {commentItem.thread.items.map((comment, i) => ( -
- {comment.text} -
- ))} +
+ + {commentItem.thread.items.length > 0 && ( + <> + + {commentItem.thread.items.map((comment, i) => ( + <> +
+ +
+ {i !== commentItem.thread.items.length - 1 && ( + + )} + + ))} +
+ + + )}
- - - + + +
- +
- +
); diff --git a/web/apps/doprocess/components/comments/CommentThreadItem.tsx b/web/apps/doprocess/components/comments/CommentThreadItem.tsx new file mode 100644 index 0000000000..381590b4f4 --- /dev/null +++ b/web/apps/doprocess/components/comments/CommentThreadItem.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { Typography } from '@signalco/ui-primitives/Typography'; +import { Stack } from '@signalco/ui-primitives/Stack'; +import { Row } from '@signalco/ui-primitives/Row'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@signalco/ui-primitives/Menu'; +import { IconButton } from '@signalco/ui-primitives/IconButton'; +import { Avatar } from '@signalco/ui-primitives/Avatar'; +import { Check, MoreHorizontal } from '@signalco/ui-icons'; +import { Timeago } from '@signalco/ui/Timeago'; +import { CommentItemThreadItem } from './Comments'; + +export function CommentThreadItem({ comment, first, onDone }: { comment: CommentItemThreadItem; first?: boolean; onDone?: () => void; }) { + const { text } = comment; + const quote = 'quote';//comment.quote; + const author = 'Guest';//comment.author; + const avatarFallback = author[0]?.toUpperCase() ?? ''; + + return ( + + + + {avatarFallback} + {author} + + + + + + {first && ( + + + + )} + + + + + + + + Delete + + + + + {Boolean(quote?.length) && ( +
+ {quote} +
+ )} + {text} +
+ ); +} From 91000430f2027752560d59945a5dcdfa674650fd Mon Sep 17 00:00:00 2001 From: Aleksandar Toplek Date: Fri, 8 Dec 2023 03:37:39 +0100 Subject: [PATCH 20/26] chore(doprocess): Comments fixes for light theme --- web/apps/doprocess/components/comments/CommentBubble.tsx | 2 +- web/apps/doprocess/components/comments/CommentsGlobal.tsx | 2 +- .../doprocess/components/comments/useCommentItemRects.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/apps/doprocess/components/comments/CommentBubble.tsx b/web/apps/doprocess/components/comments/CommentBubble.tsx index 09666a9d5f..93704d98a6 100644 --- a/web/apps/doprocess/components/comments/CommentBubble.tsx +++ b/web/apps/doprocess/components/comments/CommentBubble.tsx @@ -77,7 +77,7 @@ export function CommentBubble({ }}> + className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-xs font-semibold text-white"> {commentItem.thread.items.length}
diff --git a/web/apps/doprocess/components/comments/CommentsGlobal.tsx b/web/apps/doprocess/components/comments/CommentsGlobal.tsx index 7ba905a620..033a577bba 100644 --- a/web/apps/doprocess/components/comments/CommentsGlobal.tsx +++ b/web/apps/doprocess/components/comments/CommentsGlobal.tsx @@ -1,4 +1,5 @@ 'use client'; + import { useState } from 'react'; import { nanoid } from 'nanoid'; import { getElementSelector } from '@signalco/js'; @@ -13,7 +14,6 @@ import { CommentsGlobalProps, CommentSelection, CommentPoint } from './Comments' import { CommentPointOverlay } from './CommentPointOverlay'; import { CommentBubble } from './CommentBubble'; - export function CommentsGlobal({ reviewParamKey = 'review' }: CommentsGlobalProps) { diff --git a/web/apps/doprocess/components/comments/useCommentItemRects.tsx b/web/apps/doprocess/components/comments/useCommentItemRects.tsx index 232db75bbf..f504c08ca5 100644 --- a/web/apps/doprocess/components/comments/useCommentItemRects.tsx +++ b/web/apps/doprocess/components/comments/useCommentItemRects.tsx @@ -9,7 +9,7 @@ import { CommentItemPosition } from './Comments'; export function useCommentItemRects(commentSelection: CommentItemPosition | null | undefined) { const [selectionRects, setSelectionRects] = useState([]); - const rect = useWindowRect(window); + const windowRect = useWindowRect(window); useEffect(() => { if (!commentSelection) { @@ -81,7 +81,7 @@ export function useCommentItemRects(commentSelection: CommentItemPosition | null range.setEnd(lastElementOrTextNode, Math.min(endOffset, lastElementOrTextNode.textContent?.length ?? 0)); setSelectionRects([...range.getClientRects()]); } - }, [commentSelection, rect]); + }, [commentSelection, windowRect]); return selectionRects; } From 35f97ea612a80053551f9d0577e94b3d01d29f79 Mon Sep 17 00:00:00 2001 From: Aleksandar Toplek Date: Fri, 8 Dec 2023 16:06:50 +0100 Subject: [PATCH 21/26] chore(doprocess): Comments: Working on threads scroll --- .../components/comments/CommentBubble.tsx | 33 ++++++++++++++----- .../components/comments/CommentThreadItem.tsx | 2 +- .../components/comments/CommentsGlobal.tsx | 20 ++++++----- .../components/comments/useComments.tsx | 3 +- 4 files changed, 39 insertions(+), 19 deletions(-) diff --git a/web/apps/doprocess/components/comments/CommentBubble.tsx b/web/apps/doprocess/components/comments/CommentBubble.tsx index 93704d98a6..d2ceeac5c0 100644 --- a/web/apps/doprocess/components/comments/CommentBubble.tsx +++ b/web/apps/doprocess/components/comments/CommentBubble.tsx @@ -1,6 +1,6 @@ 'use client'; -import { HTMLAttributes, useState } from 'react'; +import { Fragment, HTMLAttributes, useEffect, useRef, useState } from 'react'; import { Stack } from '@signalco/ui-primitives/Stack'; import { Row } from '@signalco/ui-primitives/Row'; import { Popper } from '@signalco/ui-primitives/Popper'; @@ -18,11 +18,15 @@ import { CommentItem } from './Comments'; import { CommentIcon } from './CommentIcon'; type CommentBubbleProps = HTMLAttributes & { + defaultOpen?: boolean; + creating?: boolean; + onCreated?: (commentItemId: string) => void; + onCanceled?: () => void; commentItem: CommentItem; }; export function CommentBubble({ - commentItem, className, style + defaultOpen, creating, onCreated, onCanceled, commentItem, className, style }: CommentBubbleProps) { const selectionRects = useCommentItemRects(commentItem.position); const lastRect = orderBy(selectionRects, r => r.bottom).at(-1); @@ -39,7 +43,7 @@ export function CommentBubble({ e.preventDefault(); const formData = new FormData(e.currentTarget); e.currentTarget.reset(); - await upsert.mutateAsync({ + const commentId = await upsert.mutateAsync({ ...commentItem, thread: { ...commentItem.thread, @@ -52,9 +56,19 @@ export function CommentBubble({ ] } }); + + if (creating) { + onCreated?.(commentId); + } }; - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(creating ?? defaultOpen); + const handleOpenChange = (open: boolean) => { + if (!open && creating) { + onCanceled?.(); + } + setOpen(open); + } return ( <> @@ -87,18 +101,18 @@ export function CommentBubble({ align="start" alignOffset={32} open={open} - onOpenChange={setOpen} + onOpenChange={handleOpenChange} >
+ {commentItem.thread.items.length > 0 && ( <> - + {commentItem.thread.items.map((comment, i) => ( - <> +
@@ -106,12 +120,13 @@ export function CommentBubble({ {i !== commentItem.thread.items.length - 1 && ( )} - + ))} )} +
void; }) { const { text } = comment; - const quote = 'quote';//comment.quote; + const quote: string | undefined = undefined;//'quote';//comment.quote; const author = 'Guest';//comment.author; const avatarFallback = author[0]?.toUpperCase() ?? ''; diff --git a/web/apps/doprocess/components/comments/CommentsGlobal.tsx b/web/apps/doprocess/components/comments/CommentsGlobal.tsx index 033a577bba..413d7d63ce 100644 --- a/web/apps/doprocess/components/comments/CommentsGlobal.tsx +++ b/web/apps/doprocess/components/comments/CommentsGlobal.tsx @@ -1,7 +1,6 @@ 'use client'; import { useState } from 'react'; -import { nanoid } from 'nanoid'; import { getElementSelector } from '@signalco/js'; import { useWindowEvent } from '@signalco/hooks/useWindowEvent'; import { useDocumentEvent } from '@signalco/hooks/useDocumentEvent'; @@ -10,7 +9,7 @@ import { useCommentItemRects } from './useCommentItemRects'; import { CommentToolbar } from './CommentToolbar'; import { CommentSelectionPopover } from './CommentSelectionPopover'; import { CommentSelectionHighlight } from './CommentSelectionHighlight'; -import { CommentsGlobalProps, CommentSelection, CommentPoint } from './Comments'; +import { CommentsGlobalProps, CommentSelection, CommentPoint, CommentItem } from './Comments'; import { CommentPointOverlay } from './CommentPointOverlay'; import { CommentBubble } from './CommentBubble'; @@ -18,8 +17,9 @@ export function CommentsGlobal({ reviewParamKey = 'review' }: CommentsGlobalProps) { const [creatingCommentSelection, setCreatingCommentSelection] = useState(); + const [creatingComment, setCreatingComment] = useState(); - const { query: commentItems, upsert: commentUpsert } = useComments(); + const { query: commentItems } = useComments(); const [creatingCommentPoint, setCreatingCommentPoint] = useState(false); @@ -61,7 +61,7 @@ export function CommentsGlobal({ } setCreatingCommentSelection(undefined); - await commentUpsert.mutateAsync({ + setCreatingComment({ position: creatingCommentSelection, thread: { items: [] } }); @@ -69,7 +69,7 @@ export function CommentsGlobal({ const handleCreateCommentPoint = async (commentPoint: CommentPoint) => { setCreatingCommentPoint(false); - await commentUpsert.mutateAsync({ + setCreatingComment({ position: commentPoint, thread: { items: [] } }); @@ -83,10 +83,14 @@ export function CommentsGlobal({ return ( <> - {commentItems.data?.map((commentItem) => ( + {(creatingComment ? [...(commentItems.data ?? []), creatingComment] : (commentItems.data ?? [])).map((commentItem) => ( + key={commentItem.id} + commentItem={commentItem} + creating={!commentItem.id} + onCreated={() => setCreatingComment(undefined)} + onCanceled={() => setCreatingComment(undefined)} + /> ))} {creatingCommentSelection && ( diff --git a/web/apps/doprocess/components/comments/useComments.tsx b/web/apps/doprocess/components/comments/useComments.tsx index c335590f77..126e3b0175 100644 --- a/web/apps/doprocess/components/comments/useComments.tsx +++ b/web/apps/doprocess/components/comments/useComments.tsx @@ -6,7 +6,7 @@ import { CommentItem } from './Comments'; export function useComments(): { query: UseQueryResult; - upsert: UseMutationResult; + upsert: UseMutationResult; delete: UseMutationResult; } { const client = useQueryClient(); @@ -34,6 +34,7 @@ export function useComments(): { client.invalidateQueries({ queryKey: ['comments'] }); + return comment.id; } }); From ed5424eaf304175e940c87c4d3ce964d6b66afbf Mon Sep 17 00:00:00 2001 From: Aleksandar Toplek Date: Sun, 10 Dec 2023 11:07:08 +0100 Subject: [PATCH 22/26] fix(ui): Added missing prop to TypographyEditable --- DEVELOPMENT.md | 4 ++++ web/packages/ui/src/TypographyEditable/TypographyEditable.tsx | 1 + 2 files changed, 5 insertions(+) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 1b9c708554..83c02e7992 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -39,6 +39,10 @@ Apps: - App on [http://localhost:3001](http://localhost:3001) - UI Docs on [http://localhost:6006](http://localhost:6006) +#### Turbo in local development + +Remote caching is enabled but `TURBO_REMOTE_CACHE_SIGNATURE_KEY` environemnt variable needs to be set. Contact any contributor to get access to signature key to enable remote caching for your development environment. + ## Configure env variables `.env.local` example: diff --git a/web/packages/ui/src/TypographyEditable/TypographyEditable.tsx b/web/packages/ui/src/TypographyEditable/TypographyEditable.tsx index 50342b4aa9..1664ab9ffe 100644 --- a/web/packages/ui/src/TypographyEditable/TypographyEditable.tsx +++ b/web/packages/ui/src/TypographyEditable/TypographyEditable.tsx @@ -9,6 +9,7 @@ export type TypographyEditableProps = Omit void; hideEditIcon?: boolean; multiple?: boolean; + placeholder?: string; }; export function TypographyEditable({ children, level, className, onChange, onEditingChanged, placeholder, hideEditIcon, multiple, ...rest }: TypographyEditableProps) { From e0e128c703291f940730fc027f7bfbb568732c6f Mon Sep 17 00:00:00 2001 From: Aleksandar Toplek Date: Sun, 10 Dec 2023 11:09:02 +0100 Subject: [PATCH 23/26] Update UserAvatar.tsx --- web/apps/app/components/users/UserAvatar.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/web/apps/app/components/users/UserAvatar.tsx b/web/apps/app/components/users/UserAvatar.tsx index 7b661ccb6f..344a0ba118 100644 --- a/web/apps/app/components/users/UserAvatar.tsx +++ b/web/apps/app/components/users/UserAvatar.tsx @@ -13,8 +13,6 @@ export default function UserAvatar({ user }: { user: User | undefined }) { } return ( - - {userNameInitials} - + ); } From aec7e6f34ba7de9a5ca5f20837adea0a54388d9a Mon Sep 17 00:00:00 2001 From: Aleksandar Toplek Date: Tue, 12 Dec 2023 00:47:03 +0100 Subject: [PATCH 24/26] feat(uier-toolbar): Moved comments from doprocess project to separate package --- web/apps/doprocess/app/layout.tsx | 4 +- web/apps/doprocess/next.config.js | 1 + web/apps/doprocess/package.json | 1 + .../ui-primitives/src/Button/Button.tsx | 2 +- web/packages/ui-primitives/src/Link/Link.tsx | 14 +- .../ui-primitives/src/Menu/DropdownMenu.tsx | 3 +- web/packages/ui/package.json | 1 - web/packages/uier-toolbar/.editorconfig | 10 + web/packages/uier-toolbar/.eslintrc.cjs | 4 + web/packages/uier-toolbar/README.md | 3 + web/packages/uier-toolbar/package.json | 52 +++++ web/packages/uier-toolbar/postcss.config.cjs | 6 + .../src/components}/CommentBubble.tsx | 42 ++-- .../src/components}/CommentIcon.tsx | 0 .../src/components}/CommentPointOverlay.tsx | 6 +- .../components}/CommentSelectionHighlight.tsx | 2 +- .../components}/CommentSelectionPopover.tsx | 0 .../src/components}/CommentThreadItem.tsx | 0 .../src/components}/CommentToolbar.tsx | 0 .../uier-toolbar/src/components}/Comments.tsx | 6 - .../src/components}/CommentsBootstrapper.tsx | 9 +- .../src/components}/CommentsGlobal.tsx | 4 +- .../src/hooks}/useCommentItemRects.tsx | 2 +- .../uier-toolbar/src/hooks}/useComments.tsx | 5 +- web/packages/uier-toolbar/src/index.css | 1 + web/packages/uier-toolbar/src/index.tsx | 20 ++ web/packages/uier-toolbar/tailwind.config.cjs | 12 ++ web/packages/uier-toolbar/tsconfig.json | 13 ++ web/pnpm-lock.yaml | 199 ++++++++++++++++-- web/signalco.code-workspace | 4 + 30 files changed, 356 insertions(+), 70 deletions(-) create mode 100644 web/packages/uier-toolbar/.editorconfig create mode 100644 web/packages/uier-toolbar/.eslintrc.cjs create mode 100644 web/packages/uier-toolbar/README.md create mode 100644 web/packages/uier-toolbar/package.json create mode 100644 web/packages/uier-toolbar/postcss.config.cjs rename web/{apps/doprocess/components/comments => packages/uier-toolbar/src/components}/CommentBubble.tsx (79%) rename web/{apps/doprocess/components/comments => packages/uier-toolbar/src/components}/CommentIcon.tsx (100%) rename web/{apps/doprocess/components/comments => packages/uier-toolbar/src/components}/CommentPointOverlay.tsx (90%) rename web/{apps/doprocess/components/comments => packages/uier-toolbar/src/components}/CommentSelectionHighlight.tsx (94%) rename web/{apps/doprocess/components/comments => packages/uier-toolbar/src/components}/CommentSelectionPopover.tsx (100%) rename web/{apps/doprocess/components/comments => packages/uier-toolbar/src/components}/CommentThreadItem.tsx (100%) rename web/{apps/doprocess/components/comments => packages/uier-toolbar/src/components}/CommentToolbar.tsx (100%) rename web/{apps/doprocess/components/comments => packages/uier-toolbar/src/components}/Comments.tsx (83%) rename web/{apps/doprocess/components/comments => packages/uier-toolbar/src/components}/CommentsBootstrapper.tsx (61%) rename web/{apps/doprocess/components/comments => packages/uier-toolbar/src/components}/CommentsGlobal.tsx (97%) rename web/{apps/doprocess/components/comments => packages/uier-toolbar/src/hooks}/useCommentItemRects.tsx (98%) rename web/{apps/doprocess/components/comments => packages/uier-toolbar/src/hooks}/useComments.tsx (94%) create mode 100644 web/packages/uier-toolbar/src/index.css create mode 100644 web/packages/uier-toolbar/src/index.tsx create mode 100644 web/packages/uier-toolbar/tailwind.config.cjs create mode 100644 web/packages/uier-toolbar/tsconfig.json diff --git a/web/apps/doprocess/app/layout.tsx b/web/apps/doprocess/app/layout.tsx index ee1da94e94..ffdec84153 100644 --- a/web/apps/doprocess/app/layout.tsx +++ b/web/apps/doprocess/app/layout.tsx @@ -1,10 +1,10 @@ +import Script from 'next/script'; import { Inter } from 'next/font/google'; import { Metadata, Viewport } from 'next'; import './global.css'; import { Analytics } from '@vercel/analytics/react'; import { ClientProvider } from '../components/providers/ClientProvider'; import { AuthProvider } from '../components/providers/AuthProvider'; -import { Comments } from '../components/comments/Comments'; const inter = Inter({ subsets: ['latin'], @@ -21,7 +21,7 @@ export default function RootLayout({ children, }: { {children} - +