diff --git a/znai-reactjs/src/App.jsx b/znai-reactjs/src/App.jsx index 01439dd19..f3a069cc8 100644 --- a/znai-reactjs/src/App.jsx +++ b/znai-reactjs/src/App.jsx @@ -114,6 +114,9 @@ const docMeta = { previewEnabled: true, slackChannel: "help-domain-name", sendToSlackUrl: "http://localhost:5111/ask-in-slack", + slackActiveQuestionsUrl: "http://localhost:5111/active-questions", + sendToSlackIncludeContentType: true, + resolveSlackQuestionUrl: "http://localhost:5111/resolve-slack-question", viewOn: { link: "https://github.com/testingisdocumenting/znai/znai-cli/documentation", title: "View On GitHub", diff --git a/znai-reactjs/src/diff/DiffTracking.demo.tsx b/znai-reactjs/src/diff/DiffTracking.demo.tsx index ab73dfe88..be863d769 100644 --- a/znai-reactjs/src/diff/DiffTracking.demo.tsx +++ b/znai-reactjs/src/diff/DiffTracking.demo.tsx @@ -24,7 +24,7 @@ import { enableDiffTracking, enableDiffTrackingForOneDomChangeTransaction, } from "./DiffTracking"; -import { mainPanelClassName } from "../layout/classNames"; +import { mainPanelClassName } from "../layout/classNamesAndIds"; const [getTextValue, setTextValue] = simulateState("hello"); const [getSvgTextValue, setSvgTextValue] = simulateState("svg hello"); @@ -37,22 +37,14 @@ const [getScrollCaseBeforeItems, setScrollCaseBeforeItems] = simulateState([ "line4", "line5", ]); -const [getScrollCaseAfterItems, setScrollCaseAfterItems] = simulateState([ - "line1", - "line2", - "line3", - "line4", - "line5", -]); +const [getScrollCaseAfterItems, setScrollCaseAfterItems] = simulateState(["line1", "line2", "line3", "line4", "line5"]); export function diffTrackingDemo(registry: Registry) { registry.add("tracking control", () => (
- +
)); @@ -95,10 +87,7 @@ export function diffTrackingDemo(registry: Registry) { registry.add("with scroll", () => ( -
+
{getScrollCaseBeforeItems().map((item, idx) => (
@@ -143,16 +132,10 @@ function addAfterItem() { function addScrollCaseBeforeItem() { itemIdx++; - setScrollCaseBeforeItems([ - ...getScrollCaseBeforeItems(), - "another item " + itemIdx, - ]); + setScrollCaseBeforeItems([...getScrollCaseBeforeItems(), "another item " + itemIdx]); } function addScrollCaseAfterItem() { itemIdx++; - setScrollCaseAfterItems([ - ...getScrollCaseAfterItems(), - "another item " + itemIdx, - ]); + setScrollCaseAfterItems([...getScrollCaseAfterItems(), "another item " + itemIdx]); } diff --git a/znai-reactjs/src/diff/DiffTracking.tsx b/znai-reactjs/src/diff/DiffTracking.tsx index 6870b90a6..17f1c1168 100644 --- a/znai-reactjs/src/diff/DiffTracking.tsx +++ b/znai-reactjs/src/diff/DiffTracking.tsx @@ -19,7 +19,7 @@ import * as React from "react"; import { HtmlNodeDiff } from "./HtmlNodeDiff"; import "./DiffTracking.css"; -import { mainPanelClassName } from "../layout/classNames"; +import { mainPanelClassName } from "../layout/classNamesAndIds"; let enabled = false; let autoDisable = false; @@ -62,11 +62,7 @@ export class DiffTracking extends React.Component { }; } - componentDidUpdate( - prevProps: Props, - prevState: {}, - snapshot: { beforeNode: HTMLElement } - ) { + componentDidUpdate(prevProps: Props, prevState: {}, snapshot: { beforeNode: HTMLElement }) { if (!snapshot) { return; } diff --git a/znai-reactjs/src/doc-elements/Documentation.jsx b/znai-reactjs/src/doc-elements/Documentation.jsx index 6d8284602..53278919a 100644 --- a/znai-reactjs/src/doc-elements/Documentation.jsx +++ b/znai-reactjs/src/doc-elements/Documentation.jsx @@ -15,692 +15,721 @@ * limitations under the License. */ -import React, {Component} from 'react' -import * as Promise from 'promise' +import React, { Component } from "react"; +import * as Promise from "promise"; -import {themeRegistry} from '../theme/ThemeRegistry' +import { themeRegistry } from "../theme/ThemeRegistry"; -import SearchPopup from './search/SearchPopup' -import {getSearchPromise} from './search/searchPromise' -import {documentationNavigation} from '../structure/DocumentationNavigation' -import {documentationTracking} from './tracking/DocumentationTracking' -import {tableOfContents} from '../structure/toc/TableOfContents' -import {getAllPagesPromise} from './allPages' +import SearchPopup from "./search/SearchPopup"; +import { getSearchPromise } from "./search/searchPromise"; +import { documentationNavigation } from "../structure/DocumentationNavigation"; +import { documentationTracking } from "./tracking/DocumentationTracking"; +import { tableOfContents } from "../structure/toc/TableOfContents"; +import { getAllPagesPromise } from "./allPages"; -import Presentation from './presentation/Presentation' -import Preview from './preview/Preview' -import {DiffTracking, enableDiffTrackingForOneDomChangeTransaction} from '../diff/DiffTracking' +import Presentation from "./presentation/Presentation"; +import Preview from "./preview/Preview"; +import { DiffTracking, enableDiffTrackingForOneDomChangeTransaction } from "../diff/DiffTracking"; -import PresentationRegistry from './presentation/PresentationRegistry' +import PresentationRegistry from "./presentation/PresentationRegistry"; -import AllPagesAtOnce from './AllPagesAtOnce' +import AllPagesAtOnce from "./AllPagesAtOnce"; -import {mergeDocMeta} from '../structure/docMeta' +import { mergeDocMeta } from "../structure/docMeta"; -import {pageContentProcessor} from './pageContentProcessor.js' +import { pageContentProcessor } from "./pageContentProcessor.js"; -import {DocumentationModes} from './DocumentationModes' -import {pageTypesRegistry} from './page/PageTypesRegistry' +import { DocumentationModes } from "./DocumentationModes"; +import { pageTypesRegistry } from "./page/PageTypesRegistry"; -import {updateGlobalDocReferences} from './references/globalDocReferences' -import {areTocItemEquals} from '../structure/TocItem' +import { updateGlobalDocReferences } from "./references/globalDocReferences"; +import { areTocItemEquals } from "../structure/TocItem"; -import {isViewPortMobile, ViewPortProvider} from "../theme/ViewPortContext"; +import { isViewPortMobile, ViewPortProvider } from "../theme/ViewPortContext"; -import {presentationModeListeners} from "./presentation/PresentationModeListener"; +import { presentationModeListeners } from "./presentation/PresentationModeListener"; -import {mainPanelClassName} from '../layout/classNames'; -import {ZoomOverlay} from './zoom/ZoomOverlay'; +import { mainPanelClassName } from "../layout/classNamesAndIds"; +import { ZoomOverlay } from "./zoom/ZoomOverlay"; import { TooltipRenderer } from "../components/Tooltip"; -import './search/Search.css' +import "./search/Search.css"; export class Documentation extends Component { - constructor(props) { - super(props) + constructor(props) { + super(props); + + const { page, docMeta } = this.props; + + mergeDocMeta(docMeta); + this.searchPromise = getSearchPromise(docMeta); + + const autoSelectedTocItem = { + dirName: page.tocItem.dirName, + fileName: page.tocItem.fileName, + anchorId: page.tocItem.pageSectionIdTitles[0] ? page.tocItem.pageSectionIdTitles[0].id : null, + }; + + this.state = { + tocCollapsed: false, + tocSelected: false, + previousPageTocItem: null, + page: Documentation.processPage(page), + toc: tableOfContents.toc, + + // previous version put footer inside props + // we check props for backward compatibility with deployed docs + // should be safe to remove props.footer after October 2021 + footer: props.footer || window.footer, + + docMeta: docMeta, + forceSelectedTocItem: null, // via explicit TOC panel click + autoSelectedTocItem: autoSelectedTocItem, // based on scrolling + mode: DocumentationModes.DEFAULT, + isMobile: isViewPortMobile(), + presentationSectionId: "", + }; + + this.onHeaderClick = this.onHeaderClick.bind(this); + this.onPresentationOpen = this.onPresentationOpen.bind(this); + this.onPresentationClose = this.onPresentationClose.bind(this); + this.onTocToggle = this.onTocToggle.bind(this); + this.onTocSelect = this.onTocSelect.bind(this); + this.onTocItemClick = this.onTocItemClick.bind(this); + this.onTocItemPageSectionClick = this.onTocItemPageSectionClick.bind(this); + this.onSearchClick = this.onSearchClick.bind(this); + this.onSearchClose = this.onSearchClose.bind(this); + this.onPanelSelect = this.onPanelSelect.bind(this); + this.onNextPage = this.onNextPage.bind(this); + this.onPrevPage = this.onPrevPage.bind(this); + this.onSearchSelection = this.onSearchSelection.bind(this); + this.onPageUpdate = this.onPageUpdate.bind(this); + this.onTocUpdate = this.onTocUpdate.bind(this); + this.onFooterUpdate = this.onFooterUpdate.bind(this); + this.onDocMetaUpdate = this.onDocMetaUpdate.bind(this); + this.onMultiplePagesUpdate = this.onMultiplePagesUpdate.bind(this); + this.onPagesRemove = this.onPagesRemove.bind(this); + this.onDocReferencesUpdate = this.onDocReferencesUpdate.bind(this); + this.onPageGenError = this.onPageGenError.bind(this); + this.updateCurrentPageSection = this.updateCurrentPageSection.bind(this); + this.keyDownHandler = this.keyDownHandler.bind(this); + this.mouseClickHandler = this.mouseClickHandler.bind(this); + + documentationNavigation.addUrlChangeListener(this.onUrlChange.bind(this)); + } + + get theme() { + return themeRegistry.currentTheme; + } + + render() { + const { mode } = this.state; + + switch (mode) { + case DocumentationModes.DEFAULT: + return this.renderDefaultDocMode(); + case DocumentationModes.PRESENTATION: + return this.renderPresentationMode(); + case DocumentationModes.PRINT: + return this.renderPrintMode(); + default: + return
No handler for documentation mode: {mode}
; + } + } + + renderDefaultDocMode() { + const { + toc, + page, + docMeta, + footer, + autoSelectedTocItem, + forceSelectedTocItem, + tocCollapsed, + isSearchActive, + pageGenError, + } = this.state; + + const theme = this.theme; + const elementsLibrary = theme.elementsLibrary; + + const zoomOverlay = ; + + const searchPopup = isSearchActive ? ( + + ) : null; + + const renderedPage = ( + + ); + + const NextPrevNavigation = pageTypesRegistry.nextPrevNavigationComponent(page.tocItem); + const renderedNextPrevNavigation = ( + + ); + + const renderedFooter = + footer && Object.keys(footer).length ? ( + + ) : null; + + const preview = docMeta.previewEnabled ? ( + + ) : null; + + const DocumentationLayout = elementsLibrary.DocumentationLayout; + const selectedTocItem = { ...page.tocItem, ...(forceSelectedTocItem || autoSelectedTocItem) }; + + const PreviewTrackerWrapper = docMeta.previewEnabled ? DiffTracking : React.Fragment; + + return ( + + + + + {preview} + + + ); + } + + renderPresentationMode() { + const { presentationRegistry, presentationSectionId, docMeta, pageGenError } = this.state; + + return ( + + ); + } + + renderPrintMode() { + const { docMeta } = this.state; + + return ; + } + + componentDidMount() { + this.enableScrollListener(); + this.onPageLoad(); + + document.addEventListener("keydown", this.keyDownHandler); + + presentationModeListeners.addListener(this); + } + + componentWillUnmount() { + this.disableScrollListener(); + + document.removeEventListener("keydown", this.keyDownHandler); + + presentationModeListeners.removeListener(this); + } + + keyDownHandler(e) { + const { isSearchActive, mode } = this.state; + const isFromInputElement = e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA"; + if (e.code === "Slash" && !isSearchActive && mode === DocumentationModes.DEFAULT && !isFromInputElement) { + e.preventDefault(); + this.setState({ isSearchActive: true }); + } else if (mode === DocumentationModes.DEFAULT && e.code === "KeyP" && e.altKey) { + this.setState({ mode: DocumentationModes.PRINT }); + } else if ( + mode === DocumentationModes.DEFAULT && + ((e.code === "Equal" && e.altKey) || (e.code === "NumpadAdd" && e.altKey)) + ) { + this.onPresentationOpenWithHotKey(); + } else if (mode === DocumentationModes.DEFAULT && e.code === "ArrowLeft" && e.ctrlKey) { + this.onPrevPage(); + } else if (mode === DocumentationModes.DEFAULT && e.code === "ArrowRight" && e.ctrlKey) { + this.onNextPage(); + } else if (e.code === "Escape") { + this.onPresentationClose(); + } + } + + mouseClickHandler() {} + + onLayoutChange = (isMobile) => { + this.disableScrollListener(); + this.setState({ isMobile }, () => + setTimeout(() => { + this.saveMainPanelDomRef(); // TODO need a component to track sections + this.extractPageSectionNodes(); + this.updateCurrentPageSection(); + this.enableScrollListener(); + }, 0) + ); + }; + + saveMainPanelDomRef() { + this.mainPanelDom = document.querySelector("." + mainPanelClassName); + } + + enableScrollListener() { + const { isMobile } = this.state; + + this.saveMainPanelDomRef(); + if (isMobile) { + window.addEventListener("scroll", this.updateCurrentPageSection); + } else { + this.mainPanelDom.addEventListener("scroll", this.updateCurrentPageSection); + } + } + + disableScrollListener() { + const { isMobile } = this.state; + + if (isMobile) { + window.removeEventListener("scroll", this.updateCurrentPageSection); + } else { + this.mainPanelDom.removeEventListener("scroll", this.updateCurrentPageSection); + } + } + + onPresentationEnter = (pageSectionId) => { + documentationTracking.onPresentationOpen(); + this.setState({ mode: DocumentationModes.PRESENTATION, presentationSectionId: pageSectionId }); + }; + + changePage(newStateWithNewPage, urlHistoryState) { + this.setState( + { + ...newStateWithNewPage, + previousPageTocItem: this.state.page.tocItem, + page: Documentation.processPage(newStateWithNewPage.page), + pageGenError: null, + }, + () => this.onPageLoad(urlHistoryState) + ); + } + + static processPage(page) { + return { ...page, content: pageContentProcessor.process(page.content) }; + } + + scrollToTopIfNecessary() { + const { previousPageTocItem, page } = this.state; + + const tocItem = page.tocItem; + + if (previousPageTocItem === null || !areTocItemEquals(tocItem, previousPageTocItem)) { + this.scrollTop(); + } + } + + scrollTop = () => { + const { isMobile } = this.state; + if (isMobile) { + window.scrollTo(0, 0); + } else { + this.mainPanelDom.scrollTop = 0; + } + }; + + onPageLoad(urlHistoryState) { + const { page, docMeta } = this.state; + + this.extractPageSectionNodes(); + + const currentPageLocation = documentationNavigation.currentPageLocation(); + documentationTracking.onPageOpen(currentPageLocation); + + if (urlHistoryState && urlHistoryState.scrollTop) { + this.mainPanelDom.scrollTop = urlHistoryState.scrollTop; + } else { + const anchorId = currentPageLocation.anchorId; + if (anchorId) { + this.scrollToPageSection(anchorId); + } else { + this.scrollToTopIfNecessary(); + } + } + + this.updateCurrentPageSection(); + + const theme = this.theme; + const presentationRegistry = new PresentationRegistry( + theme.elementsLibrary, + theme.presentationElementHandlers, + page + ); + + document.title = page.tocItem.pageTitle ? docMeta.title + ": " + page.tocItem.pageTitle : docMeta.title; + + this.setState({ presentationRegistry }); + } + + onSearchClick() { + this.setState({ isSearchActive: true }); + } - const {page, docMeta} = this.props + onSearchClose() { + this.setState({ isSearchActive: false }); + } - mergeDocMeta(docMeta) - this.searchPromise = getSearchPromise(docMeta) - - const autoSelectedTocItem = { - dirName: page.tocItem.dirName, - fileName: page.tocItem.fileName, - anchorId: page.tocItem.pageSectionIdTitles[0] ? - page.tocItem.pageSectionIdTitles[0].id : null + onTocToggle(collapsed) { + this.setState({ tocCollapsed: collapsed }); + } + + onTocSelect() { + this.setState({ tocSelected: true }); + } + + onPanelSelect() { + this.setState({ tocSelected: false }); + } + + get nextPageTocItem() { + const { page } = this.state; + return tableOfContents.nextTocItem(page.tocItem); + } + + get prevPageTocItem() { + const { page } = this.state; + return tableOfContents.prevTocItem(page.tocItem); + } + + hasNextPage() { + return this.nextPageTocItem !== null; + } + + onNextPage() { + const next = this.nextPageTocItem; + if (next) { + documentationTracking.onNextPage(); + documentationNavigation.navigateToPage(next); + } + } + + onPrevPage() { + const prev = this.prevPageTocItem; + if (prev) { + documentationTracking.onPrevPage(); + documentationNavigation.navigateToPage(prev); + } + } + + onHeaderClick() { + documentationNavigation.navigateToIndex(); + } + + onPresentationOpen() { + this.onPresentationEnter(); + } + + onPresentationOpenWithHotKey() { + const { autoSelectedTocItem } = this.state; + this.onPresentationEnter(autoSelectedTocItem.anchorId); + } + + onPresentationClose() { + this.switchToDefaultMode(); + } + + switchToDefaultMode() { + this.setState({ mode: DocumentationModes.DEFAULT }, () => { + this.saveMainPanelDomRef(); + this.extractPageSectionNodes(); + this.updateCurrentPageSection(); + this.enableScrollListener(); + }); + } + + onTocItemClick(dirName, fileName) { + documentationTracking.onTocItemSelect({ dirName, fileName, anchorId: "" }); + documentationNavigation.navigateToPage({ dirName, fileName }); + } + + onTocItemPageSectionClick(sectionId) { + const { autoSelectedTocItem } = this.state; + + const forceSelectedTocItem = { ...autoSelectedTocItem, anchorId: sectionId }; + + documentationTracking.onTocItemSelect(forceSelectedTocItem); + this.setState({ forceSelectedTocItem }); + } + + onSearchSelection(query, id) { + this.onSearchClose(); + documentationTracking.onSearchResultSelect(query, id); + documentationNavigation.navigateToPage(id); + } + + navigateToPageIfRequired(tocItem) { + const currentToc = this.state.page.tocItem; + + if (!areTocItemEquals(currentToc, tocItem)) { + return documentationNavigation.navigateToPage(tocItem); + } + + return Promise.resolve(true); + } + + onTocUpdate(toc) { + tableOfContents.toc = toc; + this.setState({ + toc: toc, + pageGenError: null, + }); + } + + onFooterUpdate(footer) { + this.setState({ + footer: footer, + }); + } + + onDocMetaUpdate(docMeta) { + this.setState({ docMeta }); + } + + // one markup page was changed and view needs to be updated + // + onPageUpdate(pageProps) { + const updatePagesReference = () => + this.getAllPagesPromise().then((allPages) => { + allPages.update(pageProps); + }); + + this.navigateToPageAndDisplayChange(pageProps, updatePagesReference); + } + + // one of the files referred from a markup or multiple markups was changed + // we need to update multiple pages at once and either refresh current view (if one of the changed pages is current page) + // or navigate to the first modified page + // + onMultiplePagesUpdate(listOfPageProps) { + const updatePagesReference = () => + this.getAllPagesPromise().then((allPages) => { + listOfPageProps.forEach((newPage) => allPages.update(newPage)); + }); + + const currentToc = this.state.page.tocItem; + const matchingPages = listOfPageProps.filter((newPage) => areTocItemEquals(currentToc, newPage.tocItem)); + + if (matchingPages.length) { + this.updatePageAndDetectChangePosition(() => { + updatePagesReference(); + this.changePage({ page: matchingPages[0] }); + }); + } else { + this.navigateToPageAndDisplayChange(listOfPageProps[0], updatePagesReference); + } + } + + onPagesRemove(removedTocItems) { + const currentToc = this.state.page.tocItem; + const matchingPages = removedTocItems.filter((tocItem) => areTocItemEquals(currentToc, tocItem)); + + if (matchingPages.length) { + documentationNavigation.navigateToIndex(); + } + + this.getAllPagesPromise().then((allPages) => { + removedTocItems.forEach((tocItem) => allPages.remove(tocItem)); + }); + } + + onDocReferencesUpdate(docReferences) { + this.updatePageAndDetectChangePosition(() => { + updateGlobalDocReferences(docReferences); + this.changePage({ page: this.state.page }); + }); + } + + onPageGenError(error) { + console.error(error); + this.setState({ pageGenError: error }); + } + + navigateToPageAndDisplayChange(pageProps, updatePagesReference) { + // we force remove url hash so when there are changes on the page + // preview can jump to them and not be stuck in a selected page section + function removeHashFromUrl() { + if (window.location.hash.length > 0) { + window.history.pushState("", document.title, window.location.pathname + window.location.search); + } + } + + this.navigateToPageIfRequired(pageProps.tocItem).then(() => { + removeHashFromUrl(); + this.updatePageAndDetectChangePosition(() => { + updatePagesReference().then(() => { + this.changePage({ page: pageProps }); + }); + }).then( + () => {}, + (error) => console.error(error) + ); + }); + } + + updatePageAndDetectChangePosition(funcToUpdatePage) { + enableDiffTrackingForOneDomChangeTransaction(); + return funcToUpdatePage(); + } + + onUrlChange(url, urlHistoryState) { + return this.getAllPagesPromise().then( + (allPages) => { + const currentPageLocation = documentationNavigation.extractPageLocation(url); + + const matchingPage = allPages.find(currentPageLocation); + + if (!matchingPage) { + console.error("can't find any page with", currentPageLocation, "url: " + url); + return; } - this.state = { - tocCollapsed: false, - tocSelected: false, - previousPageTocItem: null, - page: Documentation.processPage(page), - toc: tableOfContents.toc, - - // previous version put footer inside props - // we check props for backward compatibility with deployed docs - // should be safe to remove props.footer after October 2021 - footer: props.footer || window.footer, - - docMeta: docMeta, - forceSelectedTocItem: null, // via explicit TOC panel click - autoSelectedTocItem: autoSelectedTocItem, // based on scrolling - mode: DocumentationModes.DEFAULT, - isMobile: isViewPortMobile(), - presentationSectionId: '' - } + this.changePage( + { + page: matchingPage, + forceSelectedTocItem: currentPageLocation, + autoSelectedTocItem: currentPageLocation, + lastChangeDataDom: null, + }, + urlHistoryState + ); - this.onHeaderClick = this.onHeaderClick.bind(this) - this.onPresentationOpen = this.onPresentationOpen.bind(this) - this.onPresentationClose = this.onPresentationClose.bind(this) - this.onTocToggle = this.onTocToggle.bind(this) - this.onTocSelect = this.onTocSelect.bind(this) - this.onTocItemClick = this.onTocItemClick.bind(this) - this.onTocItemPageSectionClick = this.onTocItemPageSectionClick.bind(this) - this.onSearchClick = this.onSearchClick.bind(this) - this.onSearchClose = this.onSearchClose.bind(this) - this.onPanelSelect = this.onPanelSelect.bind(this) - this.onNextPage = this.onNextPage.bind(this) - this.onPrevPage = this.onPrevPage.bind(this) - this.onSearchSelection = this.onSearchSelection.bind(this) - this.onPageUpdate = this.onPageUpdate.bind(this) - this.onTocUpdate = this.onTocUpdate.bind(this) - this.onFooterUpdate = this.onFooterUpdate.bind(this) - this.onDocMetaUpdate = this.onDocMetaUpdate.bind(this) - this.onMultiplePagesUpdate = this.onMultiplePagesUpdate.bind(this) - this.onPagesRemove = this.onPagesRemove.bind(this) - this.onDocReferencesUpdate = this.onDocReferencesUpdate.bind(this) - this.onPageGenError = this.onPageGenError.bind(this) - this.updateCurrentPageSection = this.updateCurrentPageSection.bind(this) - this.keyDownHandler = this.keyDownHandler.bind(this) - this.mouseClickHandler = this.mouseClickHandler.bind(this) - - documentationNavigation.addUrlChangeListener(this.onUrlChange.bind(this)) - } - - get theme() { - return themeRegistry.currentTheme - } + return true; + }, + (error) => console.error(error) + ); + } - render() { - const {mode} = this.state - - switch (mode) { - case DocumentationModes.DEFAULT: - return this.renderDefaultDocMode() - case DocumentationModes.PRESENTATION: - return this.renderPresentationMode() - case DocumentationModes.PRINT: - return this.renderPrintMode() - default: - return
No handler for documentation mode: {mode}
- } - } + getAllPagesPromise() { + const { docMeta } = this.state; + return getAllPagesPromise(docMeta); + } - renderDefaultDocMode() { - const { - toc, - page, - docMeta, - footer, - autoSelectedTocItem, - forceSelectedTocItem, - tocCollapsed, - isSearchActive, - pageGenError, - } = this.state - - const theme = this.theme - const elementsLibrary = theme.elementsLibrary - - const zoomOverlay = - - const searchPopup = isSearchActive ? : null - - const renderedPage = - - const NextPrevNavigation = pageTypesRegistry.nextPrevNavigationComponent(page.tocItem) - const renderedNextPrevNavigation = - - const renderedFooter = (footer && Object.keys(footer).length) ? - : null - - const preview = docMeta.previewEnabled ? : null - - const DocumentationLayout = elementsLibrary.DocumentationLayout - const selectedTocItem = {...page.tocItem, ...(forceSelectedTocItem || autoSelectedTocItem)} - - const PreviewTrackerWrapper = docMeta.previewEnabled ? - DiffTracking: - React.Fragment - - return ( - - - - - {preview} - - - ) - } + extractPageSectionNodes() { + this.pageSectionNodes = [...document.querySelectorAll(".znai-section-title")]; + } - renderPresentationMode() { - const { - presentationRegistry, - presentationSectionId, - docMeta, - pageGenError - } = this.state - - return ( - - ) - } + scrollToPageSection(pageSectionId) { + documentationNavigation.scrollToAnchor(pageSectionId); + } - renderPrintMode() { - const {docMeta} = this.state + updateCurrentPageSection() { + const { mode, page, autoSelectedTocItem, forceSelectedTocItem } = this.state; - return + if (mode !== DocumentationModes.DEFAULT) { + return; } - componentDidMount() { - this.enableScrollListener() - this.onPageLoad() - - document.addEventListener('keydown', this.keyDownHandler) + const sectionTitlesWithNode = combineSectionTitlesWithNodes(this.pageSectionNodes); + const withVisibleTitle = sectionsWithVisibleTitle(); - presentationModeListeners.addListener(this) + if (forceSelectedTocItem && isVisible(forceSelectedTocItem.anchorId)) { + return; } - componentWillUnmount() { - this.disableScrollListener() + const visible = withVisibleTitle.length ? withVisibleTitle[0] : closestToTopZero(); - document.removeEventListener('keydown', this.keyDownHandler) + const enrichedSelectedTocItem = { + ...autoSelectedTocItem, + anchorId: visible && visible.idTitle ? visible.idTitle.id : null, + }; + this.setState({ autoSelectedTocItem: enrichedSelectedTocItem, forceSelectedTocItem: null }); - presentationModeListeners.removeListener(this) + if (sectionTitlesWithNode.length !== 0 && autoSelectedTocItem.anchorId !== visible.idTitle.id) { + documentationTracking.onScrollToSection(visible.idTitle); } - keyDownHandler(e) { - const {isSearchActive, mode} = this.state - const isFromInputElement = e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' - if (e.code === "Slash" && !isSearchActive && mode === DocumentationModes.DEFAULT && !isFromInputElement) { - e.preventDefault() - this.setState({isSearchActive: true}) - } else if (mode === DocumentationModes.DEFAULT && e.code === 'KeyP' && e.altKey) { - this.setState({mode: DocumentationModes.PRINT}) - } else if ((mode === DocumentationModes.DEFAULT) && ( - (e.code === 'Equal' && e.altKey) || - (e.code === 'NumpadAdd' && e.altKey))) { - this.onPresentationOpenWithHotKey() - } else if (mode === DocumentationModes.DEFAULT && e.code === 'ArrowLeft' && e.ctrlKey) { - this.onPrevPage() - } else if (mode === DocumentationModes.DEFAULT && e.code === 'ArrowRight' && e.ctrlKey) { - this.onNextPage() - } else if (e.code === "Escape") { - this.onPresentationClose() - } - } + function combineSectionTitlesWithNodes(pageSectionNodes) { + const pageSections = page.tocItem.pageSectionIdTitles; - mouseClickHandler() { - } + return pageSectionNodes.filter(isNodeIdPresentInSections).map((n, idx) => { + return { idTitle: pageSections[idx], rect: n.getBoundingClientRect() }; + }); - onLayoutChange = (isMobile) => { - this.disableScrollListener() - this.setState({isMobile}, () => setTimeout(() => { - this.saveMainPanelDomRef() // TODO need a component to track sections - this.extractPageSectionNodes() - this.updateCurrentPageSection() - this.enableScrollListener() - }, 0)); + // case where znai page has an example of rendered markdown + // it generates extra nodes matching section-title css, but that node is not part + // of table of contents, so needs to be excluded + function isNodeIdPresentInSections(node) { + return pageSections.filter((s) => s.id === node.id).length > 0; + } } - saveMainPanelDomRef() { - this.mainPanelDom = document.querySelector("." + mainPanelClassName) + function sectionsWithVisibleTitle() { + const height = window.innerHeight; + return sectionTitlesWithNode.filter((st) => { + const rect = st.rect; + return rect.top > -10 && rect.top < height; + }); } - enableScrollListener() { - const {isMobile} = this.state; + function isVisible(id) { + const visibleWithForcedId = withVisibleTitle.filter((s) => s.idTitle.id === id); - this.saveMainPanelDomRef() - if (isMobile) { - window.addEventListener("scroll", this.updateCurrentPageSection) - } else { - this.mainPanelDom.addEventListener("scroll", this.updateCurrentPageSection) - } + return visibleWithForcedId.length > 0; } - disableScrollListener() { - const {isMobile} = this.state; - - if (isMobile) { - window.removeEventListener("scroll", this.updateCurrentPageSection) - } else { - this.mainPanelDom.removeEventListener("scroll", this.updateCurrentPageSection) - } - } - - onPresentationEnter = (pageSectionId) => { - documentationTracking.onPresentationOpen() - this.setState({mode: DocumentationModes.PRESENTATION, presentationSectionId: pageSectionId}) - } - - changePage(newStateWithNewPage, urlHistoryState) { - this.setState({ - ...newStateWithNewPage, - previousPageTocItem: this.state.page.tocItem, - page: Documentation.processPage(newStateWithNewPage.page), - pageGenError: null - }, () => this.onPageLoad(urlHistoryState)) - } - - static processPage(page) { - return {...page, content: pageContentProcessor.process(page.content)} - } - - scrollToTopIfNecessary() { - const {previousPageTocItem, page} = this.state - - const tocItem = page.tocItem - - if (previousPageTocItem === null || !areTocItemEquals(tocItem, previousPageTocItem)) { - this.scrollTop() - } - } - - scrollTop = () => { - const {isMobile} = this.state; - if (isMobile) { - window.scrollTo(0, 0) - } else { - this.mainPanelDom.scrollTop = 0 - } - } - - onPageLoad(urlHistoryState) { - const {page, docMeta} = this.state - - this.extractPageSectionNodes() - - const currentPageLocation = documentationNavigation.currentPageLocation() - documentationTracking.onPageOpen(currentPageLocation) - - if (urlHistoryState && urlHistoryState.scrollTop) { - this.mainPanelDom.scrollTop = urlHistoryState.scrollTop - } else { - const anchorId = currentPageLocation.anchorId - if (anchorId) { - this.scrollToPageSection(anchorId) - } else { - this.scrollToTopIfNecessary() - } - } - - this.updateCurrentPageSection() - - const theme = this.theme - const presentationRegistry = new PresentationRegistry(theme.elementsLibrary, theme.presentationElementHandlers, page) - - document.title = page.tocItem.pageTitle ? docMeta.title + ": " + page.tocItem.pageTitle : docMeta.title - - this.setState({presentationRegistry}) - } - - onSearchClick() { - this.setState({isSearchActive: true}) - } - - onSearchClose() { - this.setState({isSearchActive: false}) - } - - onTocToggle(collapsed) { - this.setState({tocCollapsed: collapsed}) - } - - onTocSelect() { - this.setState({tocSelected: true}) - } - - onPanelSelect() { - this.setState({tocSelected: false}) - } - - get nextPageTocItem() { - const {page} = this.state - return tableOfContents.nextTocItem(page.tocItem) - } - - get prevPageTocItem() { - const {page} = this.state - return tableOfContents.prevTocItem(page.tocItem) - } - - hasNextPage() { - return this.nextPageTocItem !== null - } - - onNextPage() { - const next = this.nextPageTocItem - if (next) { - documentationTracking.onNextPage() - documentationNavigation.navigateToPage(next) - } - } - - onPrevPage() { - const prev = this.prevPageTocItem - if (prev) { - documentationTracking.onPrevPage() - documentationNavigation.navigateToPage(prev) - } - } - - onHeaderClick() { - documentationNavigation.navigateToIndex() - } - - onPresentationOpen() { - this.onPresentationEnter() - } - - onPresentationOpenWithHotKey() { - const {autoSelectedTocItem} = this.state - this.onPresentationEnter(autoSelectedTocItem.anchorId) - } - - onPresentationClose() { - this.switchToDefaultMode() - } - - switchToDefaultMode() { - this.setState({mode: DocumentationModes.DEFAULT}, () => { - this.saveMainPanelDomRef() - this.extractPageSectionNodes() - this.updateCurrentPageSection() - this.enableScrollListener() - }) - } - - onTocItemClick(dirName, fileName) { - documentationTracking.onTocItemSelect({dirName, fileName, anchorId: ''}) - documentationNavigation.navigateToPage({dirName, fileName}) - } - - onTocItemPageSectionClick(sectionId) { - const {autoSelectedTocItem} = this.state - - const forceSelectedTocItem = {...autoSelectedTocItem, anchorId: sectionId} - - documentationTracking.onTocItemSelect(forceSelectedTocItem) - this.setState({forceSelectedTocItem}) - } - - onSearchSelection(query, id) { - this.onSearchClose() - documentationTracking.onSearchResultSelect(query, id) - documentationNavigation.navigateToPage(id) - } - - navigateToPageIfRequired(tocItem) { - const currentToc = this.state.page.tocItem - - if (!areTocItemEquals(currentToc, tocItem)) { - return documentationNavigation.navigateToPage(tocItem) - } - - return Promise.resolve(true) - } - - onTocUpdate(toc) { - tableOfContents.toc = toc - this.setState({ - toc: toc, - pageGenError: null - }) - } - - onFooterUpdate(footer) { - this.setState({ - footer: footer - }) - } - - onDocMetaUpdate(docMeta) { - this.setState({docMeta}) - } - - // one markup page was changed and view needs to be updated - // - onPageUpdate(pageProps) { - const updatePagesReference = () => this.getAllPagesPromise().then((allPages) => { - allPages.update(pageProps) - }) - - this.navigateToPageAndDisplayChange(pageProps, updatePagesReference) - } - - // one of the files referred from a markup or multiple markups was changed - // we need to update multiple pages at once and either refresh current view (if one of the changed pages is current page) - // or navigate to the first modified page - // - onMultiplePagesUpdate(listOfPageProps) { - const updatePagesReference = () => this.getAllPagesPromise().then((allPages) => { - listOfPageProps.forEach((newPage) => allPages.update(newPage)) - }) - - const currentToc = this.state.page.tocItem - const matchingPages = listOfPageProps.filter((newPage) => areTocItemEquals(currentToc, newPage.tocItem)) - - if (matchingPages.length) { - this.updatePageAndDetectChangePosition(() => { - updatePagesReference() - this.changePage({page: matchingPages[0]}) - }) - } else { - this.navigateToPageAndDisplayChange(listOfPageProps[0], updatePagesReference) - } - } - - onPagesRemove(removedTocItems) { - const currentToc = this.state.page.tocItem - const matchingPages = removedTocItems.filter((tocItem) => areTocItemEquals(currentToc, tocItem)) - - if (matchingPages.length) { - documentationNavigation.navigateToIndex() - } - - this.getAllPagesPromise().then((allPages) => { - removedTocItems.forEach((tocItem) => allPages.remove(tocItem)) - }) - } - - onDocReferencesUpdate(docReferences) { - this.updatePageAndDetectChangePosition(() => { - updateGlobalDocReferences(docReferences) - this.changePage({page: this.state.page}) - }) - } - - onPageGenError(error) { - console.error(error) - this.setState({pageGenError: error}) - } - - navigateToPageAndDisplayChange(pageProps, updatePagesReference) { - // we force remove url hash so when there are changes on the page - // preview can jump to them and not be stuck in a selected page section - function removeHashFromUrl() { - if (window.location.hash.length > 0) { - window.history.pushState("", document.title, window.location.pathname - + window.location.search); - } - } - - this.navigateToPageIfRequired(pageProps.tocItem).then(() => { - removeHashFromUrl(); - this.updatePageAndDetectChangePosition(() => { - updatePagesReference().then(() => { - this.changePage({page: pageProps}) - })}).then(() => { - }, (error) => console.error(error)) - }) - } - - updatePageAndDetectChangePosition(funcToUpdatePage) { - enableDiffTrackingForOneDomChangeTransaction() - return funcToUpdatePage() - } - - onUrlChange(url, urlHistoryState) { - return this.getAllPagesPromise().then((allPages) => { - const currentPageLocation = documentationNavigation.extractPageLocation(url) - - const matchingPage = allPages.find(currentPageLocation) - - if (!matchingPage) { - console.error("can't find any page with", currentPageLocation, "url: " + url) - return - } - - this.changePage({ - page: matchingPage, - forceSelectedTocItem: currentPageLocation, - autoSelectedTocItem: currentPageLocation, - lastChangeDataDom: null - }, urlHistoryState) - - return true - }, (error) => console.error(error)) - } - - getAllPagesPromise() { - const {docMeta} = this.state - return getAllPagesPromise(docMeta) - } - - extractPageSectionNodes() { - this.pageSectionNodes = [...document.querySelectorAll(".znai-section-title")] - } - - scrollToPageSection(pageSectionId) { - documentationNavigation.scrollToAnchor(pageSectionId) - } - - updateCurrentPageSection() { - const {mode, page, autoSelectedTocItem, forceSelectedTocItem} = this.state - - if (mode !== DocumentationModes.DEFAULT) { - return - } - - const sectionTitlesWithNode = combineSectionTitlesWithNodes(this.pageSectionNodes) - const withVisibleTitle = sectionsWithVisibleTitle() - - if (forceSelectedTocItem && isVisible(forceSelectedTocItem.anchorId)) { - return; - } - - const visible = withVisibleTitle.length ? withVisibleTitle[0] : closestToTopZero() - - const enrichedSelectedTocItem = { - ...autoSelectedTocItem, - anchorId: (visible && visible.idTitle) ? visible.idTitle.id : null - } - this.setState({autoSelectedTocItem: enrichedSelectedTocItem, forceSelectedTocItem: null}) - - if (sectionTitlesWithNode.length !== 0 && - autoSelectedTocItem.anchorId !== visible.idTitle.id) { - documentationTracking.onScrollToSection(visible.idTitle) - } - - function combineSectionTitlesWithNodes(pageSectionNodes) { - const pageSections = page.tocItem.pageSectionIdTitles - - return pageSectionNodes - .filter(isNodeIdPresentInSections) - .map((n, idx) => { - return {idTitle: pageSections[idx], rect: n.getBoundingClientRect()} - }) - - // case where znai page has an example of rendered markdown - // it generates extra nodes matching section-title css, but that node is not part - // of table of contents, so needs to be excluded - function isNodeIdPresentInSections(node) { - return pageSections.filter(s => s.id === node.id).length > 0 - } - } - - function sectionsWithVisibleTitle() { - const height = window.innerHeight - return (sectionTitlesWithNode).filter(st => { - const rect = st.rect - return rect.top > -10 && rect.top < height - }) - } - - function isVisible(id) { - const visibleWithForcedId = - withVisibleTitle.filter(s => s.idTitle.id === id) - - return visibleWithForcedId.length > 0 - } - - function closestToTopZero() { - const belowZero = sectionTitlesWithNode.filter(st => st.rect.top < 0) - return belowZero.length ? belowZero[belowZero.length - 1] : sectionTitlesWithNode[0] - } + function closestToTopZero() { + const belowZero = sectionTitlesWithNode.filter((st) => st.rect.top < 0); + return belowZero.length ? belowZero[belowZero.length - 1] : sectionTitlesWithNode[0]; } + } } diff --git a/znai-reactjs/src/doc-elements/page/PageTitle.jsx b/znai-reactjs/src/doc-elements/page/PageTitle.jsx index 34af55b4a..5ca7698c7 100644 --- a/znai-reactjs/src/doc-elements/page/PageTitle.jsx +++ b/znai-reactjs/src/doc-elements/page/PageTitle.jsx @@ -15,78 +15,76 @@ * limitations under the License. */ -import React from 'react' +import React from "react"; -import { Support } from "./Support" -import { Icon } from '../icons/Icon' +import { Support } from "./Support"; +import { Icon } from "../icons/Icon"; import { isPresentationButtonVisible } from "../../structure/docMeta.js"; -import "./PageTitle.css" +import { pageTitleBlockClassName } from "../../layout/classNamesAndIds"; +import "./PageTitle.css"; -const PageTitle = ({tocItem, onPresentationOpen, lastModifiedTime, docMeta}) => { - const displayTitle = !!tocItem.pageTitle - const title = displayTitle ? [{tocItem.pageTitle}, - (onPresentationOpen && isPresentationButtonVisible()) ? - : null] : [] +const PageTitle = ({ tocItem, onPresentationOpen, lastModifiedTime, docMeta }) => { + const displayTitle = !!tocItem.pageTitle; + const title = displayTitle + ? [ + + {tocItem.pageTitle} + , + onPresentationOpen && isPresentationButtonVisible() ? ( + + ) : null, + ] + : []; - return ( - -
- {title} -
-
- - - -
-
- ) -} + return ( + +
{title}
+
+ + + +
+
+ ); +}; -function ModifiedTime({lastModifiedTime}) { - if (!lastModifiedTime) { - return null - } +function ModifiedTime({ lastModifiedTime }) { + if (!lastModifiedTime) { + return null; + } - const modifiedTimeAsStr = new Date(lastModifiedTime).toDateString() - return ( -
- {modifiedTimeAsStr} -
- ) + const modifiedTimeAsStr = new Date(lastModifiedTime).toDateString(); + return
{modifiedTimeAsStr}
; } -function ViewOn({docMeta, tocItem}) { - const viewOn = docMeta.viewOn - if (!viewOn || !viewOn.link || !viewOn.title) { - return null - } +function ViewOn({ docMeta, tocItem }) { + const viewOn = docMeta.viewOn; + if (!viewOn || !viewOn.link || !viewOn.title) { + return null; + } - return ( - - ) + return ( + + ); } function buildViewOnLink(tocItem, link) { if (tocItem.viewOnRelativePath) { - return `${link}/${tocItem.viewOnRelativePath}` + return `${link}/${tocItem.viewOnRelativePath}`; } - const fileName = (tocItem.fileExtension === "" && tocItem.fileName === "index") ? "index.md" : - `${tocItem.fileName}.${tocItem.fileExtension}` + const fileName = + tocItem.fileExtension === "" && tocItem.fileName === "index" + ? "index.md" + : `${tocItem.fileName}.${tocItem.fileExtension}`; - return tocItem.dirName ? - `${link}/${tocItem.dirName}/${fileName}`: - `${link}/${fileName}` + return tocItem.dirName ? `${link}/${tocItem.dirName}/${fileName}` : `${link}/${fileName}`; } -export default PageTitle +export default PageTitle; diff --git a/znai-reactjs/src/doc-elements/page/default/DefaultPageContent.jsx b/znai-reactjs/src/doc-elements/page/default/DefaultPageContent.jsx index 7ccc24141..6d9cb5c74 100644 --- a/znai-reactjs/src/doc-elements/page/default/DefaultPageContent.jsx +++ b/znai-reactjs/src/doc-elements/page/default/DefaultPageContent.jsx @@ -1,4 +1,5 @@ /* + * Copyright 2025 znai maintainers * Copyright 2019 TWO SIGMA OPEN SOURCE, LLC * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -14,21 +15,22 @@ * limitations under the License. */ -import React from 'react' +import React from "react"; +import { afterTitleId } from "../../../layout/classNamesAndIds"; const DefaultPageContent = (props) => { - const {elementsLibrary, content} = props - const {PageTitle} = elementsLibrary + const { elementsLibrary, content } = props; + const { PageTitle } = elementsLibrary; - return ( - -
- -
- -
- ) -} + return ( + +
+ +
+ +
+
+ ); +}; -export default DefaultPageContent \ No newline at end of file +export default DefaultPageContent; diff --git a/znai-reactjs/src/doc-elements/text-selection/HighlightUrlText.tsx b/znai-reactjs/src/doc-elements/text-selection/HighlightUrlText.tsx index f20b48107..c2a9e0890 100644 --- a/znai-reactjs/src/doc-elements/text-selection/HighlightUrlText.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/HighlightUrlText.tsx @@ -14,87 +14,22 @@ * limitations under the License. */ -import { useEffect, useRef } from "react"; -import { TextHighlighter } from "./textHighlihter"; -import { mainPanelClassName } from "../../layout/classNames"; import { extractHighlightParams } from "./highlightUrl"; -import { documentationNavigation } from "../../structure/DocumentationNavigation"; -import "./HighlightUrlText.css"; +import { HighlightedText } from "./HighlightedText"; export function HighlightUrlText({ containerNode }: { containerNode: HTMLDivElement }) { - const bubbleRef = useRef(null); - - function toggleBubble() { - if (!bubbleRef.current) { - return; - } - - const bubble = bubbleRef.current as HTMLDivElement; - if (bubble.style.display === "block") { - bubble.style.display = "none"; - } else { - bubble.style.display = "block"; - } - } - - useEffect(() => { - const params = extractHighlightParams(); - let highlighter: TextHighlighter | null = null; - - if (params) { - const container = document.querySelector(mainPanelClassName) || document.body; - highlighter = new TextHighlighter(container); - const highlights = highlighter.highlight(params.selection, params.prefix, params.suffix, toggleBubble); - const firstHighlightedElement = highlights[0]; - if (!firstHighlightedElement) { - return; - } - - if (params.question && bubbleRef.current) { - const containerRect = containerNode.getBoundingClientRect(); - - const range = document.createRange(); - range.setStart(firstHighlightedElement, 0); - range.setEnd(highlights[highlights.length - 1], highlights[highlights.length - 1].childNodes.length); - const selectionRect = range.getBoundingClientRect(); - - const bubbleText = params.question.endsWith("/") - ? params.question.substring(0, params.question.length - 1) - : params.question; - const bubble = bubbleRef.current; - bubble.innerText = bubbleText; - bubble.style.display = "block"; - - const bubbleRect = bubble.getBoundingClientRect(); - const top = selectionRect.top - containerRect.top + containerNode.scrollTop - bubbleRect.height - 10; - const selectionCenter = selectionRect.left + selectionRect.width / 2.0; - const left = selectionCenter - bubbleRect.width / 2.0 - containerRect.left; - - bubble.style.top = `${top}px`; - bubble.style.left = `${left}px`; - } - - setTimeout(() => { - if (firstHighlightedElement) { - firstHighlightedElement.scrollIntoView({ behavior: "smooth", block: "center" }); - } - }, 100); - } - - const urlChangeListener = () => { - if (bubbleRef.current) { - bubbleRef.current.style.display = "none"; - } - if (highlighter) { - highlighter.clearHighlights(); - } - }; - - documentationNavigation.addUrlChangeListener(urlChangeListener); - return () => { - documentationNavigation.removeUrlChangeListener(urlChangeListener); - }; - }, []); - - return
; + const params = extractHighlightParams(); + + return params ? ( + + ) : null; } diff --git a/znai-reactjs/src/doc-elements/text-selection/HighlightUrlText.css b/znai-reactjs/src/doc-elements/text-selection/HighlightedText.css similarity index 56% rename from znai-reactjs/src/doc-elements/text-selection/HighlightUrlText.css rename to znai-reactjs/src/doc-elements/text-selection/HighlightedText.css index 6991c396d..5ea3c4427 100644 --- a/znai-reactjs/src/doc-elements/text-selection/HighlightUrlText.css +++ b/znai-reactjs/src/doc-elements/text-selection/HighlightedText.css @@ -3,7 +3,6 @@ position: absolute; background: #1e40af; color: white; - padding: 8px 16px; border-radius: 8px; font-size: 14px; line-height: 1.4; @@ -11,7 +10,11 @@ box-shadow: 0 12px 28px rgba(30, 64, 175, 0.25), 0 6px 12px rgba(0, 0, 0, 0.12); backdrop-filter: blur(10px); - z-index: 1000; + z-index: 20; +} + +.znai-highlight-question-bubble-text { + padding: 8px 16px; } .znai-highlight-question-bubble::after { @@ -36,3 +39,34 @@ .theme-znai-dark .znai-highlight-question-bubble::after { border-top: 10px solid #2563eb; } + +.znai-highlight-question-detached { + margin-top: 16px; +} + +.znai-highlight-question-content { + background-color: #ffeb3b; + color: black; + padding: 0 4px; + width: fit-content; + border-radius: 4px; + font-size: var(--znai-smaller-text-size); + cursor: pointer; +} + +.znai-highlight-question-detached:first-child { + margin-top: 48px; +} + +.znai-highlight-detached-question-info { + font-size: var(--znai-small-meta-text-size); + color: #ccc; +} + +.znai-highlight-detached-question-context { + display: block; + white-space: pre-wrap; + word-wrap: break-word; + font-family: var(--znai-code-font-family), monospace; + font-size: 11px; +} \ No newline at end of file diff --git a/znai-reactjs/src/doc-elements/text-selection/HighlightedText.tsx b/znai-reactjs/src/doc-elements/text-selection/HighlightedText.tsx new file mode 100644 index 000000000..8b35273d2 --- /dev/null +++ b/znai-reactjs/src/doc-elements/text-selection/HighlightedText.tsx @@ -0,0 +1,194 @@ +import React, { useEffect, useRef, useState } from "react"; +import { TextHighlighter } from "./textHighlighter"; + +import { addTextMenuListener, removeTextMenuListener, TextMenuListener } from "./TextSelectionMenu"; +import { createPortal } from "react-dom"; + +import { afterTitleId } from "../../layout/classNamesAndIds"; + +import "./HighlightedText.css"; + +interface Props { + containerNode: HTMLDivElement; + selectedText: string; + selectedPrefix: string; + selectedSuffix: string; + question: string; + context: string; + displayBubbleAndScrollIntoView: boolean; + additionalView: React.ReactNode; +} + +export function HighlightedText({ + containerNode, + selectedText, + selectedPrefix, + selectedSuffix, + question, + context, + displayBubbleAndScrollIntoView, + additionalView, +}: Props) { + const cleanedUpQuestion = question.endsWith("/") ? question.substring(0, question.length - 1) : question; + + const bubbleRef = useRef(null); + const detachedQuestionRef = useRef(null); + const [bubbleContent, setBubbleContent] = useState(<>{cleanedUpQuestion}); + + let [maybeDetachedHighlightedText, setMaybeDetachedHighlightedText] = useState(null); + + function updateBubblePosition(firstElement: any, lastElement: any) { + // TODO re-calculate bubble position on window size change + if (!bubbleRef.current) { + return; + } + const bubble = bubbleRef.current as HTMLDivElement; + + const containerRect = containerNode.getBoundingClientRect(); + const range = document.createRange(); + range.setStart(firstElement, 0); + range.setEnd(lastElement, lastElement.childNodes.length); + const selectionRect = range.getBoundingClientRect(); + + const bubbleRect = bubble.getBoundingClientRect(); + const top = selectionRect.top - containerRect.top + containerNode.scrollTop - bubbleRect.height - 10; + const selectionCenter = selectionRect.left + selectionRect.width / 2.0; + const left = selectionCenter - bubbleRect.width / 2.0 - containerRect.left; + + bubble.style.top = `${top}px`; + bubble.style.left = `${left}px`; + } + + function showBubbleAndCalcPositionIfHasQuestion(firstElement: any, lastElement: any) { + if (!question || !bubbleRef.current) { + return; + } + const bubble = bubbleRef.current as HTMLDivElement; + bubble.style.display = "block"; + updateBubblePosition(firstElement, lastElement); + } + + function hideBubble() { + if (!bubbleRef.current) { + return; + } + const bubble = bubbleRef.current as HTMLDivElement; + bubble.style.display = "none"; + } + + function toggleBubble(firstElement: any, lastElement: any) { + if (!bubbleRef.current) { + return; + } + + const bubble = bubbleRef.current as HTMLDivElement; + if (bubble.style.display === "block") { + hideBubble(); + } else { + showBubbleAndCalcPositionIfHasQuestion(firstElement, lastElement); + } + } + + function scrollToBubbleIfRequired(elementToScrollTo: any) { + if (displayBubbleAndScrollIntoView) { + setTimeout(() => { + elementToScrollTo.scrollIntoView({ behavior: "smooth", block: "center" }); + }, 100); + } + } + + useEffect(() => { + if (!bubbleRef.current) { + return; + } + + const textMenuListener: TextMenuListener = { + onHide() {}, + onShow() { + hideBubble(); + }, + }; + + const highlighter = new TextHighlighter(containerNode); + const highlights = highlighter.highlight( + selectedText, + selectedPrefix, + selectedSuffix, + question ? () => toggleBubble(firstHighlightedElement, highlights[highlights.length - 1]) : null + ); + const firstHighlightedElement = highlights[0]; + if (!firstHighlightedElement) { + const afterTitlePlaceholder = document.getElementById(afterTitleId); + if (afterTitlePlaceholder) { + setMaybeDetachedHighlightedText( + createPortal( +
{ + return question ? toggleBubble(detachedQuestionRef.current, detachedQuestionRef.current) : null; + }} + > +
{cleanedUpQuestion}
+
, + afterTitlePlaceholder + ) + ); + } else { + console.warn("can't find element with id: " + afterTitleId); + } + } + + if (firstHighlightedElement) { + if (question && bubbleRef.current && displayBubbleAndScrollIntoView) { + showBubbleAndCalcPositionIfHasQuestion(firstHighlightedElement, highlights[highlights.length - 1]); + } + + scrollToBubbleIfRequired(firstHighlightedElement); + } + + addTextMenuListener(textMenuListener); + + return () => { + highlighter.clearHighlights(); + hideBubble(); + removeTextMenuListener(textMenuListener); + }; + }, []); + + useEffect(() => { + if (detachedQuestionRef.current) { + setBubbleContent( +
+
+ This question is no longer attached to the content. Available context: +
+
{context}
+
+ ); + } + }, [maybeDetachedHighlightedText]); + + // attach bubble to a detached question + useEffect(() => { + if (!detachedQuestionRef.current || !question || !bubbleRef.current) { + return; + } + + const detachedQuestion = detachedQuestionRef.current as HTMLDivElement; + if (displayBubbleAndScrollIntoView) { + showBubbleAndCalcPositionIfHasQuestion(detachedQuestion, detachedQuestion); + scrollToBubbleIfRequired(detachedQuestion); + } + }, [bubbleContent]); + + return ( + <> +
+
{bubbleContent}
+ {additionalView} +
+ {maybeDetachedHighlightedText} + + ); +} diff --git a/znai-reactjs/src/doc-elements/text-selection/ResolveQuestionButton.css b/znai-reactjs/src/doc-elements/text-selection/ResolveQuestionButton.css new file mode 100644 index 000000000..53984311c --- /dev/null +++ b/znai-reactjs/src/doc-elements/text-selection/ResolveQuestionButton.css @@ -0,0 +1,38 @@ +.resolve-question-button { + background: none; + border: none; + color: #a8b9e3; + cursor: pointer; + padding: 0; + font: inherit; + transition: color 0.2s ease; +} + +.resolve-question-button:hover { + color: #c5d3ed; +} + +.resolve-question-button.confirming { + color: #e6c074; + font-weight: 500; +} + +.resolve-question-button.confirming:hover { + color: #f0d08a; +} + +.theme-znai-dark .resolve-question-button { + color: #b8cef0; +} + +.theme-znai-dark .resolve-question-button:hover { + color: #d0dff5; +} + +.theme-znai-dark .resolve-question-button.confirming { + color: #f0c674; +} + +.theme-znai-dark .resolve-question-button.confirming:hover { + color: #f5d48a; +} \ No newline at end of file diff --git a/znai-reactjs/src/doc-elements/text-selection/ResolveQuestionButton.tsx b/znai-reactjs/src/doc-elements/text-selection/ResolveQuestionButton.tsx new file mode 100644 index 000000000..b9c248c0c --- /dev/null +++ b/znai-reactjs/src/doc-elements/text-selection/ResolveQuestionButton.tsx @@ -0,0 +1,42 @@ +import React, { useState, useEffect, useRef } from "react"; +import "./ResolveQuestionButton.css"; + +interface Props { + onClick(): void; +} + +export function ResolveQuestionButton({ onClick }: Props) { + const [isConfirming, setIsConfirming] = useState(false); + const timeoutRef = useRef(null); + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + const handleClick = () => { + if (isConfirming) { + onClick(); + setIsConfirming(false); + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + } else { + setIsConfirming(true); + timeoutRef.current = setTimeout(() => { + setIsConfirming(false); + timeoutRef.current = null; + }, 2000); + } + }; + + return ( + + ); +} diff --git a/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.css b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.css new file mode 100644 index 000000000..1bf7ee483 --- /dev/null +++ b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.css @@ -0,0 +1,59 @@ +.znai-highlight-bubble-resolve-and-link { + display: flex; + align-items: center; + justify-content: space-between; + border-top: 1px solid rgba(255, 255, 255, 0.15); + padding: 8px 16px; + position: relative; +} + +.znai-highlight-bubble-resolve-and-link::before { + content: ""; + position: absolute; + top: -1px; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.2) 20%, rgba(255, 255, 255, 0.2) 80%, transparent); +} + +.znai-highlight-bubble-resolve-wrapper { + padding-right: 16px; + margin-right: 16px; + border-right: 1px solid rgba(255, 255, 255, 0.15); +} + +.znai-highlight-bubble-link { + display: inline-block; + background: none; + border: none; + padding: 0; + color: #93bbf9; + text-decoration: none; + transition: color 0.2s ease; +} + +.znai-highlight-bubble-link:hover { + color: #b8d4ff; + text-decoration: none; +} + +.theme-znai-dark .znai-highlight-bubble-link { + color: #a5d8ff; +} + +.theme-znai-dark .znai-highlight-bubble-link:hover { + color: #d0ebff; +} + +.theme-znai-dark .znai-highlight-bubble-resolve-and-link { + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.theme-znai-dark .znai-highlight-bubble-resolve-and-link::before { + background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.15) 20%, rgba(255, 255, 255, 0.15) 80%, transparent); +} + +.theme-znai-dark .znai-highlight-bubble-resolve-wrapper { + border-right: 1px solid rgba(255, 255, 255, 0.1); +} diff --git a/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx new file mode 100644 index 000000000..b415ed964 --- /dev/null +++ b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx @@ -0,0 +1,139 @@ +import React, { useEffect, useState } from "react"; +import { currentPageId, pageIdFromTocItem } from "../../structure/DocumentationNavigation"; +import { getDocMeta } from "../../structure/docMeta"; +import { Notification } from "../../components/Notification"; +import { HighlightedText } from "./HighlightedText"; +import { TocItem } from "../../structure/TocItem"; + +import { ResolveQuestionButton } from "./ResolveQuestionButton"; +import "./SlackActiveQuestions.css"; + +interface Question { + id: string; + selectedText: string; + selectedPrefix: string; + selectedSuffix: string; + question: string; + context: string; + slackLink: string; + slackMessageTs: string; + resolved: boolean; +} + +export function SlackActiveQuestions({ containerNode, tocItem }: { containerNode: HTMLDivElement; tocItem: TocItem }) { + const [questions, setQuestions] = useState([]); + const [notification, setNotification] = useState<{ type: "success" | "error"; message: string } | null>(null); + + const params = new URLSearchParams(window.location.search); + const questionId = params.get("questionId") || ""; + + const pageId = pageIdFromTocItem(tocItem); + + useEffect(() => { + void fetchActiveQuestions(); + }, [pageId]); + + async function fetchActiveQuestions() { + try { + const pageId = currentPageId(); + const baseUrl = getDocMeta().slackActiveQuestionsUrl; + if (!baseUrl) { + return; + } + + const url = `${baseUrl}?pageId=${encodeURIComponent(pageId)}&questionId=${questionId}`; + + const response = await fetch(url, { + method: "GET", + credentials: "include", + }); + + if (response.ok) { + const data = await response.json(); + const questions = data.map((item: any) => ({ + selectedText: item.selectedText, + selectedPrefix: item.selectedPrefix, + selectedSuffix: item.selectedSuffix, + question: item.question, + context: item.context, + slackLink: item.slackLink, + slackMessageTs: item.slackMessageTs, + id: item.id, + resolved: item.resolved, + })); + setQuestions(questions); + } else { + setNotification({ type: "error", message: `Failed to fetch slack questions: ${response.statusText}` }); + setQuestions([]); + } + } catch (err) { + setNotification({ type: "error", message: `Failed to fetch slack questions: ${err}` }); + } + } + + async function resolveQuestionPost(question: Question) { + try { + const response = await fetch(getDocMeta().resolveSlackQuestionUrl! + "/" + question.slackMessageTs, { + method: "POST", + credentials: "include", + }); + + if (response.ok) { + setNotification({ type: "success", message: "Resolved slack question" }); + setQuestions(questions.filter((q) => question.slackMessageTs !== q.slackMessageTs)); + } else { + setNotification({ type: "error", message: `Failed to resolve question: ${response.statusText}` }); + } + } catch (error) { + setNotification({ type: "error", message: "Network error: Unable to connect to server" }); + } + } + + const renderedQuestions = questions.map((question) => { + function maybeResolveButton() { + if (!getDocMeta().resolveSlackQuestionUrl) { + return null; + } + + return question.resolved ? ( +
resolved
+ ) : ( +
+ resolveQuestionPost(question)} /> +
+ ); + } + + const additionalView = ( +
+ {maybeResolveButton()} + + open thread + +
+ ); + + return ( + + ); + }); + + return ( + <> + {renderedQuestions} + {notification && ( + setNotification(null)} /> + )} + + ); +} diff --git a/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.css b/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.css index 2aaed002f..a2bfa1d32 100644 --- a/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.css +++ b/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.css @@ -23,7 +23,7 @@ backdrop-filter: blur(10px); color: var(--znai-regular-text-color); font-size: var(--znai-smaller-text-size); - z-index: 10; + z-index: 100; overflow: hidden; visibility: hidden; } diff --git a/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx b/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx index 374939334..431ef84b9 100644 --- a/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx @@ -23,8 +23,24 @@ import { getDocMeta } from "../../structure/docMeta"; import { buildContext } from "./markdownContextBuilder"; import { Notification } from "../../components/Notification"; +import { currentPageId } from "../../structure/DocumentationNavigation"; import "./TextSelectionMenu.css"; +export interface TextMenuListener { + onShow(): void; + onHide(): void; +} + +const textMenuListeners: TextMenuListener[] = []; + +export function addTextMenuListener(listener: TextMenuListener) { + textMenuListeners.push(listener); +} + +export function removeTextMenuListener(listener: TextMenuListener) { + textMenuListeners.splice(textMenuListeners.indexOf(listener), 1); +} + export function TextSelectionMenu({ containerNode }: { containerNode: HTMLDivElement }) { const menuRef = useRef(null); const expandedPanelRef = useRef(null); @@ -180,13 +196,14 @@ export function TextSelectionMenu({ containerNode }: { containerNode: HTMLDivEle function handleKeyDown(e: KeyboardEvent) { if (e.key === "Escape") { - hidePopover(); + hideMenu(); } } function handleGenerateLink() { + const context = buildContext(); const prefixSuffixMatch = findPrefixSuffixAndMatch(containerNode); - setPanelData({ type: "linkgen", context: "", prefixSuffixMatch }); + setPanelData({ type: "linkgen", context, prefixSuffixMatch }); } function handleAskInSlack() { @@ -196,12 +213,16 @@ export function TextSelectionMenu({ containerNode }: { containerNode: HTMLDivEle } async function generateLink() { - const comment = linkCommentInputRef.current?.value?.trim(); - const pageUrl = buildHighlightUrl(panelData!.prefixSuffixMatch, comment); + const comment = linkCommentInputRef.current?.value?.trim() || ""; + const pageUrl = buildHighlightUrl({ + ...panelData!.prefixSuffixMatch, + question: comment, + context: panelData?.context || "", + }); try { await navigator.clipboard.writeText(pageUrl); setNotification({ type: "success", message: "Link is generated and copied to clipboard" }); - hidePopover(); + hideMenu(); } catch (err) { setNotification({ type: "error", message: `Failed to generate link: ${err}` }); } @@ -213,27 +234,33 @@ export function TextSelectionMenu({ containerNode }: { containerNode: HTMLDivEle return; } - const pageUrl = buildHighlightUrl(panelData!.prefixSuffixMatch, question); - const body = { selectedText: panelData!.prefixSuffixMatch.selection, - pageUrl: pageUrl, - username: "web-user", + selectedPrefix: panelData!.prefixSuffixMatch.prefix, + selectedSuffix: panelData!.prefixSuffixMatch.suffix, + pageId: currentPageId(), slackChannel: getDocMeta().slackChannel, question: question, context: panelData!.context, }; try { + const headers = getDocMeta().sendToSlackIncludeContentType + ? { + "Content-Type": "application/json", + } + : undefined; + const response = await fetch(getDocMeta().sendToSlackUrl!, { method: "POST", credentials: "include", + headers, body: JSON.stringify(body), }); if (response.ok) { setNotification({ type: "success", message: "Successfully sent to Slack!" }); - hidePopover(); + hideMenu(); } else { setNotification({ type: "error", message: `Failed to send to Slack: ${response.statusText}` }); } @@ -251,9 +278,11 @@ export function TextSelectionMenu({ containerNode }: { containerNode: HTMLDivEle menu.style.top = `${top}px`; menu.style.left = `${left}px`; menu.style.visibility = "visible"; + + textMenuListeners.forEach((listener) => listener.onShow()); } - function hidePopover() { + function hideMenu() { if (menuRef.current) { menuRef.current.style.visibility = "hidden"; } @@ -262,19 +291,21 @@ export function TextSelectionMenu({ containerNode }: { containerNode: HTMLDivEle slackQuestionInputRef.current.value = ""; } setHasText(false); + + textMenuListeners.forEach((listener) => listener.onHide()); } function onMouseUp(event: MouseEvent) { if (panelData) { if (expandedPanelRef.current && event.target && !expandedPanelRef.current.contains(event.target as Node)) { - hidePopover(); + hideMenu(); } return; } const selection = getSelection(); if (selection === null || selection.rangeCount === 0 || selection.isCollapsed) { - hidePopover(); + hideMenu(); return; } @@ -302,7 +333,7 @@ export function TextSelectionMenu({ containerNode }: { containerNode: HTMLDivEle const selection = getSelection(); if (selection === null || selection.rangeCount === 0 || selection.isCollapsed) { - hidePopover(); + hideMenu(); return; } } diff --git a/znai-reactjs/src/doc-elements/text-selection/highlightUrl.ts b/znai-reactjs/src/doc-elements/text-selection/highlightUrl.ts index 4b8ea95c9..a287ae067 100644 --- a/znai-reactjs/src/doc-elements/text-selection/highlightUrl.ts +++ b/znai-reactjs/src/doc-elements/text-selection/highlightUrl.ts @@ -18,12 +18,14 @@ const HIGHLIGHT_PREFIX_PARAM = "highlightPrefix"; const HIGHLIGHT_SELECTION_PARAM = "highlightSelection"; const HIGHLIGHT_SUFFIX_PARAM = "highlightSuffix"; const HIGHLIGHT_QUESTION_PARAM = "highlightQuestion"; +const HIGHLIGHT_CONTEXT_PARAM = "highlightContext"; export interface HighlightParams { prefix: string; selection: string; suffix: string; - question?: string; + question: string; + context: string; } export function extractHighlightParams(): HighlightParams | null { @@ -32,20 +34,22 @@ export function extractHighlightParams(): HighlightParams | null { const selection = params.get(HIGHLIGHT_SELECTION_PARAM); const suffix = params.get(HIGHLIGHT_SUFFIX_PARAM); const question = params.get(HIGHLIGHT_QUESTION_PARAM); + const context = params.get(HIGHLIGHT_CONTEXT_PARAM); if (prefix !== null && selection && suffix !== null) { return { prefix: decodeURIComponent(prefix), selection: decodeURIComponent(selection), suffix: decodeURIComponent(suffix), - question: question ? decodeURIComponent(question) : undefined, + question: question ? decodeURIComponent(question) : "", + context: context ? decodeURIComponent(context) : "", }; } return null; } -export function buildHighlightUrl(params: HighlightParams, question?: string): string { +export function buildHighlightUrl(params: HighlightParams): string { let builtUrl = location.origin + location.pathname; if (!builtUrl.endsWith("/")) { builtUrl += "/"; @@ -55,8 +59,11 @@ export function buildHighlightUrl(params: HighlightParams, question?: string): s url.searchParams.set(HIGHLIGHT_PREFIX_PARAM, encodeURIComponent(params.prefix)); url.searchParams.set(HIGHLIGHT_SELECTION_PARAM, encodeURIComponent(params.selection)); url.searchParams.set(HIGHLIGHT_SUFFIX_PARAM, encodeURIComponent(params.suffix)); - if (question) { - url.searchParams.set(HIGHLIGHT_QUESTION_PARAM, encodeURIComponent(question)); + if (params.question) { + url.searchParams.set(HIGHLIGHT_QUESTION_PARAM, encodeURIComponent(params.question)); + } + if (params.context) { + url.searchParams.set(HIGHLIGHT_CONTEXT_PARAM, encodeURIComponent(params.context)); } return url.toString(); diff --git a/znai-reactjs/src/doc-elements/text-selection/textHighlighter.css b/znai-reactjs/src/doc-elements/text-selection/textHighlighter.css index 6b5f819fa..e75f74dff 100644 --- a/znai-reactjs/src/doc-elements/text-selection/textHighlighter.css +++ b/znai-reactjs/src/doc-elements/text-selection/textHighlighter.css @@ -1,12 +1,12 @@ .znai-highlight { background-color: #ffeb3b; color: black; - cursor: pointer; transition: background-color 0.2s; } .znai-highlight-hover { background-color: #efdd47; + cursor: pointer; } .znai-highlight.clicked { diff --git a/znai-reactjs/src/doc-elements/text-selection/textHighlihter.js b/znai-reactjs/src/doc-elements/text-selection/textHighlighter.js similarity index 94% rename from znai-reactjs/src/doc-elements/text-selection/textHighlihter.js rename to znai-reactjs/src/doc-elements/text-selection/textHighlighter.js index ccff3130d..c9da05bf1 100644 --- a/znai-reactjs/src/doc-elements/text-selection/textHighlihter.js +++ b/znai-reactjs/src/doc-elements/text-selection/textHighlighter.js @@ -89,12 +89,20 @@ export class TextHighlighter { const highlightGroup = []; const handleMouseEnter = () => { + if (!onClick) { + return; + } + highlightGroup.forEach((span) => { span.classList.add("znai-highlight-hover"); }); }; const handleMouseLeave = () => { + if (!onClick) { + return; + } + highlightGroup.forEach((span) => { span.classList.remove("znai-highlight-hover"); }); @@ -108,6 +116,12 @@ export class TextHighlighter { } }; + const handleDoubleClick = (e) => { + if (onClick) { + e.stopPropagation(); + } + }; + match.nodes.forEach((nodeInfo, nodeIndex) => { const parent = nodeInfo.node.parentNode; if (!parent) return; @@ -147,6 +161,7 @@ export class TextHighlighter { span.addEventListener("mouseenter", handleMouseEnter); span.addEventListener("mouseleave", handleMouseLeave); span.addEventListener("click", handleClick); + span.addEventListener("dblclick", handleDoubleClick); }); this.highlights.push(highlightGroup); diff --git a/znai-reactjs/src/layout/DocumentationLayout.demo.tsx b/znai-reactjs/src/layout/DocumentationLayout.demo.tsx index 2c11bf750..d1efadb0a 100644 --- a/znai-reactjs/src/layout/DocumentationLayout.demo.tsx +++ b/znai-reactjs/src/layout/DocumentationLayout.demo.tsx @@ -43,6 +43,7 @@ export function documentationLayoutDemo(registry: Registry) { renderedFooter={dummy} docMeta={docMeta} toc={testLongToc()} + tocItem={{ dirName: "dir", fileName: "file" }} selectedTocItem={undefined} onHeaderClick={noOp} onSearchClick={noOp} diff --git a/znai-reactjs/src/layout/DocumentationLayout.tsx b/znai-reactjs/src/layout/DocumentationLayout.tsx index 3fdf8e5b7..5dd6db5e3 100644 --- a/znai-reactjs/src/layout/DocumentationLayout.tsx +++ b/znai-reactjs/src/layout/DocumentationLayout.tsx @@ -27,14 +27,16 @@ import { TocMobileHeader } from "./mobile/TocMobileHeader"; import { TocMobilePanel } from "./mobile/TocMobilePanel"; -import { mainPanelClassName } from "./classNames"; +import { mainPanelClassName } from "./classNamesAndIds"; import { TopHeader } from "./TopHeader"; import { TextSelectionMenu } from "../doc-elements/text-selection/TextSelectionMenu"; +import { HighlightUrlText } from "../doc-elements/text-selection/HighlightUrlText"; +import { SlackActiveQuestions } from "../doc-elements/text-selection/SlackActiveQuestions"; + import "./DocumentationLayout.css"; import "./mobile/MobileLayoutOverrides.css"; -import { HighlightUrlText } from "../doc-elements/text-selection/HighlightUrlText"; interface Props { zoomOverlay: React.ReactNode; @@ -44,6 +46,7 @@ interface Props { renderedFooter: React.ReactNode; docMeta: DocMeta; toc: TocItem[]; + tocItem: TocItem; selectedTocItem?: TocItem; onHeaderClick(): void; @@ -67,6 +70,7 @@ interface Props { export function DocumentationLayout({ zoomOverlay, searchPopup, + tocItem, renderedPage, renderedNextPrevNavigation, renderedFooter, @@ -98,6 +102,7 @@ export function DocumentationLayout({
{contentRef.current && } {contentRef.current && } + {contentRef.current && }
{renderedPage}
diff --git a/znai-reactjs/src/layout/classNames.ts b/znai-reactjs/src/layout/classNamesAndIds.ts similarity index 85% rename from znai-reactjs/src/layout/classNames.ts rename to znai-reactjs/src/layout/classNamesAndIds.ts index 578f566a9..e09abf652 100644 --- a/znai-reactjs/src/layout/classNames.ts +++ b/znai-reactjs/src/layout/classNamesAndIds.ts @@ -15,3 +15,5 @@ */ export const mainPanelClassName = "znai-main-panel"; +export const pageTitleBlockClassName = "page-title-block"; +export const afterTitleId = "znai-after-title"; diff --git a/znai-reactjs/src/structure/DocumentationNavigation.jsx b/znai-reactjs/src/structure/DocumentationNavigation.jsx index eb66840fc..4149b440a 100644 --- a/znai-reactjs/src/structure/DocumentationNavigation.jsx +++ b/znai-reactjs/src/structure/DocumentationNavigation.jsx @@ -18,7 +18,7 @@ import * as Promise from "promise"; import { getDocId } from "./docMeta"; import { isTocItemIndex } from "./toc/TableOfContents"; -import { mainPanelClassName } from "../layout/classNames"; +import { mainPanelClassName } from "../layout/classNamesAndIds.js"; const index = { dirName: "", fileName: "index" }; @@ -128,6 +128,19 @@ class DocumentationNavigation { } } +function joinPageIdParts(docId, dirName, fileName) { + return [docId, dirName, fileName].filter((part) => !!part).join("/"); +} + +function currentPageId() { + const pageLocation = documentationNavigation.currentPageLocation(); + return joinPageIdParts(getDocId(), pageLocation.dirName, pageLocation.fileName); +} + +function pageIdFromTocItem(tocItem) { + return joinPageIdParts(getDocId(), tocItem.dirName, tocItem.fileName); +} + const documentationNavigation = new DocumentationNavigation(); -export { documentationNavigation }; +export { documentationNavigation, currentPageId, pageIdFromTocItem }; diff --git a/znai-reactjs/src/structure/docMeta.ts b/znai-reactjs/src/structure/docMeta.ts index f7c609a8b..dece21f30 100644 --- a/znai-reactjs/src/structure/docMeta.ts +++ b/znai-reactjs/src/structure/docMeta.ts @@ -24,6 +24,9 @@ export interface DocMeta { previewEnabled: boolean; slackChannel?: string; sendToSlackUrl?: string; + slackActiveQuestionsUrl?: string; + sendToSlackIncludeContentType?: boolean; + resolveSlackQuestionUrl?: string; useTopHeader?: boolean; hidePresentationTrigger?: boolean; support?: DocMetaSupport; diff --git a/znai-slack-bot/slack_bot.py b/znai-slack-bot/slack_bot.py index 7ec501aac..d8814ebc9 100644 --- a/znai-slack-bot/slack_bot.py +++ b/znai-slack-bot/slack_bot.py @@ -1,10 +1,15 @@ import os import json +import csv +import uuid from flask import Flask, request, jsonify from flask_cors import CORS from slack_sdk import WebClient from slack_sdk.errors import SlackApiError +# This is a NON production slack bot app to test znai slack integration +# You most likely need to implement your own bot with a proper storage story + app = Flask(__name__) CORS(app, supports_credentials=True, origins="*") @@ -20,6 +25,9 @@ slack_client = WebClient(token=slack_token) +CSV_FILE = "active_questions.csv" +CSV_HEADERS = ["id", "timestamp", "pageId", "pageUrl", "context", "selectedText", "selectedPrefix", "selectedSuffix", "question", "slackLink", "username", "channel", "slackMessageTs", "resolved"] + @app.route('/ask-in-slack', methods=['POST']) def ask_in_slack(): try: @@ -29,20 +37,25 @@ def ask_in_slack(): print("ERROR: No Slack token configured") return jsonify({"error": "Slack bot token not configured"}), 500 - # Get JSON data from request data = request.get_json() if not data: return jsonify({"error": "No JSON data provided"}), 400 - + + + question_id = str(uuid.uuid4()) + page_id = data.get('pageId') + page_url = f"http://localhost:5173/preview/{page_id}?questionId={question_id}" selected_text = data.get('selectedText') - page_url = data.get('pageUrl') + selected_prefix = data.get('selectedPrefix', '') + selected_suffix = data.get('selectedSuffix', '') username = data.get('username') slack_channel = data.get('slackChannel') question = data.get('question') context = data.get('context') print(f"Selected text: {selected_text[:100] if selected_text else None}...") + print(f"Page ID: {page_id}") print(f"Page URL: {page_url}") print(f"Username: {username}") print(f"Slack channel: {slack_channel}") @@ -63,8 +76,25 @@ def ask_in_slack(): text=slack_message ) - print(f"Message posted successfully: {result['ts']}") - return jsonify({"success": True, "ts": result['ts']}), 200 + message_ts = result['ts'] + slack_link = f"https://slack.com/archives/{slack_channel}/p{message_ts.replace('.', '')}" + + persist_questions_to_csv([{ + 'id': question_id, + 'pageId': page_id, + 'context': context or '', + 'selectedText': selected_text or '', + 'selectedPrefix': selected_prefix, + 'selectedSuffix': selected_suffix, + 'question': question, + 'slackLink': slack_link, + 'channel': slack_channel, + 'slackMessageTs': message_ts, + 'resolved': False, + }]) + + print(f"Message posted successfully: {message_ts}") + return jsonify({"success": True, "ts": message_ts, "slack_link": slack_link}), 200 except SlackApiError as e: print(f"SlackApiError in main handler: {e.response}") @@ -76,14 +106,104 @@ def ask_in_slack(): return jsonify({"error": str(e)}), 500 +def persist_questions_to_csv(questions, mode='a'): + file_exists = os.path.exists(CSV_FILE) + + with open(CSV_FILE, mode, newline='', encoding='utf-8') as csvfile: + writer = csv.DictWriter(csvfile, fieldnames=CSV_HEADERS) + + if mode == 'w' or (mode == 'a' and not file_exists): + writer.writeheader() + + for question in questions: + writer.writerow(question) + +def load_questions_from_csv(): + if not os.path.exists(CSV_FILE): + return [] + + questions = [] + with open(CSV_FILE, 'r', newline='', encoding='utf-8') as csvfile: + reader = csv.DictReader(csvfile) + for row in reader: + row['resolved'] = row['resolved'].lower() == 'true' + questions.append(row) + + return questions + +def update_question_completion_status(message_ts, resolved): + questions = load_questions_from_csv() + updated = False + + for question in questions: + if question['slackMessageTs'] == message_ts: + question['resolved'] = resolved + updated = True + break + + if updated: + persist_questions_to_csv(questions, mode='w') + + return updated + +def check_slack_message_completion(channel, message_ts): + try: + result = slack_client.reactions_get( + channel=channel, + timestamp=message_ts + ) + + if 'message' in result and 'reactions' in result['message']: + reactions = result['message']['reactions'] + for reaction in reactions: + if reaction['name'] in ['white_check_mark', 'heavy_check_mark', 'resolved', 'done']: + return True + + return False + except SlackApiError as e: + print(f"Error checking reactions for {message_ts}: {e}") + return False + +@app.route('/active-questions', methods=['GET']) +def get_active_questions(): + try: + page_id = request.args.get('pageId') + question_id = request.args.get('questionId') + questions = load_questions_from_csv() + questions = [q for q in questions if q.get('pageId') == page_id and (q.get('resolved') == False or q.get('id') == question_id)] + + return jsonify(questions), 200 + + except Exception as e: + print(f"Error getting active questions: {type(e).__name__}: {str(e)}") + return jsonify({"error": str(e)}), 500 + +@app.route('/resolve-slack-question/', methods=['POST']) +def resolve_slack_question(ts): + try: + print(f"=== resolve-slack-question request received for ts: {ts} ===") + + updated = update_question_completion_status(ts, True) + + if updated: + print(f"Successfully marked question with ts {ts} as resolved") + return jsonify({"success": True, "message": f"Question {ts} marked as resolved"}), 200 + else: + print(f"Question with ts {ts} not found") + return jsonify({"error": f"Question with ts {ts} not found"}), 404 + + except Exception as e: + print(f"Error resolving question: {type(e).__name__}: {str(e)}") + import traceback + traceback.print_exc() + return jsonify({"error": str(e)}), 500 + def format_slack_message(username, question, context, page_url): - # Build message text with "asked" as the link message_parts = [f"@{username} <{page_url}|asked>: {question}"] if context: message_parts.append(context) - # Return as single text message without sections for full width return "\n\n".join(message_parts) if __name__ == '__main__':