diff --git a/znai-docs/znai/release-notes/1.80/add-2025-09-09-highlight-inside-codesnippet-readmore.md b/znai-docs/znai/release-notes/1.80/add-2025-09-09-highlight-inside-codesnippet-readmore.md new file mode 100644 index 000000000..5e6f72f49 --- /dev/null +++ b/znai-docs/znai/release-notes/1.80/add-2025-09-09-highlight-inside-codesnippet-readmore.md @@ -0,0 +1,2 @@ +* Add: Initial attempt to highlight Slack questions inside code snippets "read more" part +* Add: Initial attempt to highlight Slack questions inside read more blocks \ No newline at end of file diff --git a/znai-reactjs/src/doc-elements/code-snippets/CodeSnippetWithCallouts.tsx b/znai-reactjs/src/doc-elements/code-snippets/CodeSnippetWithCallouts.tsx index fc0881291..a5082ce51 100644 --- a/znai-reactjs/src/doc-elements/code-snippets/CodeSnippetWithCallouts.tsx +++ b/znai-reactjs/src/doc-elements/code-snippets/CodeSnippetWithCallouts.tsx @@ -82,6 +82,7 @@ const CodeSnippetWithCallouts = ({ references={mergedReferences} isPresentation={isPresentation} wrap={wrap} + isHidden={false} endOfLineRender={() => { const calloutContent = callouts[lineIdx]; diff --git a/znai-reactjs/src/doc-elements/code-snippets/LineOfTokens.css b/znai-reactjs/src/doc-elements/code-snippets/LineOfTokens.css index 8c13d3baa..50f597490 100644 --- a/znai-reactjs/src/doc-elements/code-snippets/LineOfTokens.css +++ b/znai-reactjs/src/doc-elements/code-snippets/LineOfTokens.css @@ -20,6 +20,10 @@ white-space: pre; } +.znai-code-line.hidden { + display: none; +} + .znai-code-line.highlight { background: var(--znai-snippets-line-highlighted-background-color); margin-left: -16px; diff --git a/znai-reactjs/src/doc-elements/code-snippets/LineOfTokens.jsx b/znai-reactjs/src/doc-elements/code-snippets/LineOfTokens.jsx index ae86967dd..96893d272 100644 --- a/znai-reactjs/src/doc-elements/code-snippets/LineOfTokens.jsx +++ b/znai-reactjs/src/doc-elements/code-snippets/LineOfTokens.jsx @@ -23,34 +23,53 @@ import CodeToken from "./CodeToken"; import "./LineOfTokens.css"; -const LineOfTokens = ({tokens, references, isHighlighted, isPrevHighlighted, isNextHighlighted, isPresentation, wrap, endOfLineRender}) => { - const className = "znai-code-line" - + (isHighlighted ? " highlight" : "") - + (isHighlighted && !isPrevHighlighted ? " no-highlight-top-neighbour" : "") - + (isHighlighted && !isNextHighlighted ? " no-highlight-bottom-neighbour" : "") - + (wrap ? " wrap": "") - - const trimmedOnRight = lineWithTokensTrimmedOnRight(tokens) - const enhancedTokens = enhanceTokens(trimmedOnRight) - - return ( - - {enhancedTokens.map((t, idx) => )} - {endOfLineRender && endOfLineRender()} - {"\n"} - - ) - - function enhanceTokens(tokens) { - if (!references) { - return tokens - } - - return enhanceMatchedTokensWithMeta(tokens, Object.keys(references), () => 'link', (referenceText) => { - const reference = references[referenceText] - return reference.pageUrl - }) +const LineOfTokens = ({ + tokens, + references, + isHighlighted, + isPrevHighlighted, + isNextHighlighted, + isPresentation, + wrap, + endOfLineRender, + isHidden, +}) => { + const className = + "znai-code-line" + + (isHighlighted ? " highlight" : "") + + (isHighlighted && !isPrevHighlighted ? " no-highlight-top-neighbour" : "") + + (isHighlighted && !isNextHighlighted ? " no-highlight-bottom-neighbour" : "") + + (wrap ? " wrap" : "") + + (isHidden ? " hidden" : ""); + + const trimmedOnRight = lineWithTokensTrimmedOnRight(tokens); + const enhancedTokens = enhanceTokens(trimmedOnRight); + + return ( + + {enhancedTokens.map((t, idx) => ( + + ))} + {endOfLineRender && endOfLineRender()} + {"\n"} + + ); + + function enhanceTokens(tokens) { + if (!references) { + return tokens; } -} -export default LineOfTokens + return enhanceMatchedTokensWithMeta( + tokens, + Object.keys(references), + () => "link", + (referenceText) => { + const reference = references[referenceText]; + return reference.pageUrl; + } + ); + } +}; + +export default LineOfTokens; diff --git a/znai-reactjs/src/doc-elements/code-snippets/SimpleCodeSnippet.jsx b/znai-reactjs/src/doc-elements/code-snippets/SimpleCodeSnippet.jsx index 5df599b05..a09d3b382 100644 --- a/znai-reactjs/src/doc-elements/code-snippets/SimpleCodeSnippet.jsx +++ b/znai-reactjs/src/doc-elements/code-snippets/SimpleCodeSnippet.jsx @@ -15,150 +15,225 @@ * limitations under the License. */ -import React, {Component} from 'react' +import React, { Component } from "react"; -import {extractTextFromTokens, splitTokensIntoLines} from './codeUtils' -import LineOfTokens from './LineOfTokens' -import {convertToList} from '../propsUtils' -import {isAllAtOnce} from '../meta/meta'; -import {mergeWithGlobalDocReferences} from '../references/globalDocReferences' +import { extractTextFromTokens, splitTokensIntoLines } from "./codeUtils"; +import LineOfTokens from "./LineOfTokens"; +import { convertToList } from "../propsUtils"; +import { isAllAtOnce } from "../meta/meta"; +import { mergeWithGlobalDocReferences } from "../references/globalDocReferences"; -import {repeatChar} from "../../utils/strings"; +import { repeatChar } from "../../utils/strings"; -import './tokens.css' -import './SimpleCodeSnippet.css' +import { addHighlightedTextListener, removeHighlightedTextListener } from "../text-selection/HighlightedText.js"; -class SimpleCodeSnippet extends Component { - constructor(props) { - super(props) - - this.processProps(props) - this.state = { - clickedReadMore: false - } - } +import { reapplyTextHighlights } from "../text-selection/AllTextHighlights.js"; - // handles changes during preview - componentWillReceiveProps(nextProps) { - this.processProps(nextProps) - } +import { moveAndHideHtmlElementForAutoScroll } from "../text-selection/componentsHighlightUtils.js"; - processProps({tokens, linesOfCode, highlight}) { - this.linesOfTokens = !linesOfCode ? splitTokensIntoLines(tokens) : linesOfCode +import "./tokens.css"; +import "./SimpleCodeSnippet.css"; - // highlight is either a single line index/substring or a collection of line indexes and substrings - this.highlight = convertToList(highlight) +class SimpleCodeSnippet extends Component { + constructor(props) { + super(props); + + this.processProps(props); + this.state = { + clickedReadMore: false, + hasHighlightedText: false, + }; + } + + // handles changes during preview + componentWillReceiveProps(nextProps) { + this.processProps(nextProps); + } + + processProps({ tokens, linesOfCode, highlight }) { + this.linesOfTokens = !linesOfCode ? splitTokensIntoLines(tokens) : linesOfCode; + + // highlight is either a single line index/substring or a collection of line indexes and substrings + this.highlight = convertToList(highlight); + } + + componentDidMount() { + addHighlightedTextListener(this); + } + + componentWillUnmount() { + removeHighlightedTextListener(this); + } + + // highlight via slack questions or url link + onUserDrivenTextHighlight = (firstHighlightElement) => { + if (!this.props.readMore) { + return; } - render() { - const {clickedReadMore} = this.state - const {wrap, isPresentation, slideIdx, references} = this.props - - // slideIdx === 0 means no highlights, 1 - first highlight, etc - const highlightIsVisible = !isPresentation || slideIdx > 0 - - const visibleLines = this.limitLines(this.props) && !clickedReadMore && !isPresentation ? - this.linesOfTokens.slice(0, this.readMoreVisibleLines(this.props)) : - this.linesOfTokens - - const linesToRender = this.processLinesToRender(visibleLines) - - const mergedReferences = mergeWithGlobalDocReferences(references) - const isHighlightedByIdx = highlightIsVisible ? linesToRender.map((_, lineIdx) => this.isHighlighted(lineIdx)) : [] - - return ( -
-                {linesToRender.map((tokens, lineIdx) => (
-                    
-                ))}
-                {this.renderReadMore()}
-            
- ) + if (this.hiddenLinesContainerRef.current) { + // if highlight is inside hidden portion + // we attach and hide the first highlighted element so the overall highlight engine can scroll + // to the right place + const hasHighlight = this.hiddenLinesContainerRef.current.contains(firstHighlightElement); + if (hasHighlight) { + this.firstHighlightElement = firstHighlightElement; + this.restoreHighlightElement = moveAndHideHtmlElementForAutoScroll( + firstHighlightElement, + this.hiddenLinesContainerRef.current + ); + this.setState({ hasHighlightedText: true }); + } + } + }; + + render() { + this.hiddenLinesContainerRef = React.createRef(); + const { clickedReadMore } = this.state; + const { wrap, isPresentation, slideIdx, references } = this.props; + + // slideIdx === 0 means no highlights, 1 - first highlight, etc + const highlightIsVisible = !isPresentation || slideIdx > 0; + + const linesOfTokens = this.linesOfTokens; + const visibleLines = + this.limitLines(this.props) && !clickedReadMore && !isPresentation + ? linesOfTokens.slice(0, this.readMoreVisibleLines(this.props)) + : linesOfTokens; + + const hiddenLinesForHighlight = linesOfTokens.slice(visibleLines.length, linesOfTokens.length); + + const linesToRender = this.processLinesToRender(visibleLines); + + const mergedReferences = mergeWithGlobalDocReferences(references); + const isHighlightedByIdx = highlightIsVisible ? linesToRender.map((_, lineIdx) => this.isHighlighted(lineIdx)) : []; + + return ( +
+        {linesToRender.map((tokens, lineIdx) => (
+          
+        ))}
+
+        
+ {hiddenLinesForHighlight.map((tokens, lineIdx) => { + lineIdx = lineIdx + linesOfTokens.length; + return ( + + ); + })} +
+ {this.renderReadMore()} +
+ ); + } + + renderReadMore() { + const { clickedReadMore } = this.state; + const { isPresentation } = this.props; + + if (isPresentation || !this.limitLines(this.props)) { + return null; } - renderReadMore() { - const {clickedReadMore} = this.state - const {isPresentation} = this.props - - if (isPresentation || !this.limitLines(this.props)) { - return null - } - - const label = clickedReadMore ? "...collapse" : "read more..." + const label = clickedReadMore ? ( + "...collapse" + ) : ( + read more... + ); + + return ( +
+ {label} +
+ ); + } + + // hides lines for presentation mode to reveal them later + processLinesToRender(lines) { + const { isPresentation, slideIdx, revealLineStop } = this.props; + + if (!isPresentation) { + return lines; + } - return ( -
{label}
- ) + if (!revealLineStop || slideIdx >= revealLineStop.length) { + return lines; } - // hides lines for presentation mode to reveal them later - processLinesToRender(lines) { - const {isPresentation, slideIdx, revealLineStop} = this.props + const maxLineWidth = lines.reduce((max, line) => Math.max(max, extractTextFromTokens(line).length), 0); + const emptyLine = repeatChar(maxLineWidth - 1 /* new line symbol */, " "); - if (!isPresentation) { - return lines - } + const upToIdx = revealLineStop[slideIdx]; + return lines.map((line, idx) => (idx <= upToIdx ? line : [emptyLine])); + } - if (!revealLineStop || slideIdx >= revealLineStop.length) { - return lines + toggleReadMoreClick = () => { + this.setState( + (prev) => ({ clickedReadMore: !prev.clickedReadMore }), + () => { + if (this.hiddenLinesContainerRef.current && this.firstHighlightElement && this.restoreHighlightElement) { + this.firstHighlightElement = null; + this.restoreHighlightElement(); } + reapplyTextHighlights(); + } + ); + }; - const maxLineWidth = lines.reduce((max, line) => Math.max(max, extractTextFromTokens(line).length), 0) - const emptyLine = repeatChar(maxLineWidth - 1 /* new line symbol */, ' ') - - const upToIdx = revealLineStop[slideIdx] - return lines.map((line, idx) => idx <= upToIdx ? line : [emptyLine]) - } + limitLines(props) { + return this.linesOfTokens.length >= this.readMoreVisibleLines(props) && props.readMore; + } - toggleReadMoreClick = () => { - this.setState(prev => ({clickedReadMore: !prev.clickedReadMore})) - } + readMoreVisibleLines(props) { + return props.readMoreVisibleLines || 8; + } - limitLines(props) { - return this.linesOfTokens.length >= this.readMoreVisibleLines(props) && props.readMore - } + isHighlighted(lineIdx) { + const { meta, isPresentation, slideIdx, revealLineStop } = this.props; - readMoreVisibleLines(props) { - return props.readMoreVisibleLines || 8 - } + const highlightSliceIdx = calcHighlightSliceIdx(this.highlight); - isHighlighted(lineIdx) { - const {meta, isPresentation, slideIdx, revealLineStop} = this.props + const highlight = !isPresentation ? this.highlight : this.highlight.slice(0, highlightSliceIdx); - const highlightSliceIdx = calcHighlightSliceIdx(this.highlight) - - const highlight = !isPresentation ? - this.highlight: - this.highlight.slice(0, highlightSliceIdx) - - if (!highlight) { - return false - } + if (!highlight) { + return false; + } - return highlight.indexOf(lineIdx) !== -1 + return highlight.indexOf(lineIdx) !== -1; - function calcHighlightSliceIdx(originalHighlight) { - const allAtOnce = isAllAtOnce(meta) + function calcHighlightSliceIdx(originalHighlight) { + const allAtOnce = isAllAtOnce(meta); - if (!revealLineStop || revealLineStop.length === 0) { - return allAtOnce ? originalHighlight.length : slideIdx - } + if (!revealLineStop || revealLineStop.length === 0) { + return allAtOnce ? originalHighlight.length : slideIdx; + } - if (slideIdx <= revealLineStop.length) { - return 0 - } + if (slideIdx <= revealLineStop.length) { + return 0; + } - return allAtOnce ? - originalHighlight.length: - slideIdx - revealLineStop.length - } + return allAtOnce ? originalHighlight.length : slideIdx - revealLineStop.length; } + } } -export default SimpleCodeSnippet +export default SimpleCodeSnippet; diff --git a/znai-reactjs/src/doc-elements/code-snippets/Snippet.demo.jsx b/znai-reactjs/src/doc-elements/code-snippets/Snippet.demo.jsx index 08011dcd9..520954ef7 100644 --- a/znai-reactjs/src/doc-elements/code-snippets/Snippet.demo.jsx +++ b/znai-reactjs/src/doc-elements/code-snippets/Snippet.demo.jsx @@ -15,341 +15,390 @@ * limitations under the License. */ -import React from 'react' +import React from "react"; -import {Snippet} from './Snippet' -import {Tabs} from '../tabs/Tabs' +import { Snippet } from "./Snippet"; +import { Tabs } from "../tabs/Tabs"; -import {elementsLibrary} from '../DefaultElementsLibrary' +import { elementsLibrary } from "../DefaultElementsLibrary"; -import {TwoSidesLayoutRightPart} from '../page/two-sides/TwoSidesLayout' +import { TwoSidesLayoutRightPart } from "../page/two-sides/TwoSidesLayout"; -import {Page} from '../page/Page' -import {Section} from '../default-elements/Section'; +import { Page } from "../page/Page"; +import { Section } from "../default-elements/Section"; import { simulateState } from "react-component-viewer"; import ApiParameters from "../api/ApiParameters"; import { - codeWithMethodCalls, - contentParagraph, - contentSnippet, - personApiParameters + codeWithMethodCalls, + contentParagraph, + contentSnippet, + personApiParameters, } from "../demo-utils/contentGenerators"; -import './tokens.css' +import "./tokens.css"; +import { documentationNavigation } from "../../structure/DocumentationNavigation.jsx"; +import { AllTextHighlights } from "../text-selection/AllTextHighlights.js"; +import { ButtonToForceTextHighlight } from "../demo-utils/ButtonToForceTextHighlight.js"; const [getReadMore, setReadMore] = simulateState(true); export function snippetsDemo(registry) { - registry - .add('title', () => ) - .add('title collapsible', () => ) - .add('title no gap', () => ( - - )) - .add('separator no gap border', () => ( - - )) - .add('collapsible next to api params', () => (<> - - - )) - .add('title anchor', () => ) - .add('wide with title', () => ) - .add('wide with title and anchor', () => ) - .add('wide with title collapsible', () => ) - .add('with linked method calls', () => ) - .add('horizontal scroll', () => ) - .add('wrap', () => ) - .add('horizontal scroll with title and highlight', () => ) - .add('highlight by line idx', () => ) - .add('highlight right side background', () => ) - .add('read more', () => ) - .add('read more switch', () => ( -
- - ) -
)) - .add('tabs with wide', () => ) - .add('python code', () => ) - .add('large java code with javadocs', () => ) - .add('markdown code', () => ) + registry + .add("title", () => ) + .add("title collapsible", () => ( + + )) + .add("title no gap", () => ( + + )) + .add("separator no gap border", () => ( + + )) + .add("collapsible next to api params", () => ( + <> + + + + )) + .add("title anchor", () => ) + .add("wide with title", () => ) + .add("wide with title and anchor", () => ( + + )) + .add("wide with title collapsible", () => ( + + )) + .add("with linked method calls", () => ( + + )) + .add("horizontal scroll", () => ) + .add("wrap", () => ) + .add("horizontal scroll with title and highlight", () => ( + + )) + .add("highlight by line idx", () => ) + .add("highlight right side background", () => ( + + + + )) + .add("read more", () => ) + .add("read more switch", () => ( +
+ + + + ) + +
+ )) + .add("tabs with wide", () => ( + + )) + .add("python code", () => ) + .add("large java code with javadocs", () => ( + + )) + .add("markdown code", () => ); } export function snippetsTwoSidesDemo(registry) { - registry - .add('code after heading', () => ) - .add('text between code blocks', () => ) + registry + .add("code after heading", () => ( + + )) + .add("text between code blocks", () => ( + + )); } function docMeta() { - return { - "title": "Doc Title", - "type": "Guide" - } + return { + title: "Doc Title", + type: "Guide", + }; } export function snippetsTwoColumnsDemo(registry) { - const commonProps = { - elementsLibrary, - docMeta: docMeta(), - title: "Section title", - id: "section-title" - } - registry - .add('before snippet', () =>
) - .add('after snippet', () =>
) - .add('surrounded by text', () =>
) + const commonProps = { + elementsLibrary, + docMeta: docMeta(), + title: "Section title", + id: "section-title", + }; + registry + .add("before snippet", () =>
) + .add("after snippet", () =>
) + .add("surrounded by text", () =>
); - function beforeSnippetContent() { - return [ - { - type: "Columns", - config: {}, - columns: [ - {content: [contentSnippet(false)]}, - {content: [contentSnippet(false)]} - ] - }, - contentSnippet(false), - ] - } + function beforeSnippetContent() { + return [ + { + type: "Columns", + config: {}, + columns: [{ content: [contentSnippet(false)] }, { content: [contentSnippet(false)] }], + }, + contentSnippet(false), + ]; + } - function afterSnippetContent() { - return [ - contentSnippet(false), - { - type: "Columns", - config: {}, - columns: [ - {content: [contentSnippet(false)]}, - {content: [contentSnippet(false)]} - ] - } - ] - } + function afterSnippetContent() { + return [ + contentSnippet(false), + { + type: "Columns", + config: {}, + columns: [{ content: [contentSnippet(false)] }, { content: [contentSnippet(false)] }], + }, + ]; + } - function surroundedByTextContent() { - return [ - contentParagraph(false), - { - type: "Columns", - config: {}, - columns: [ - {content: [contentSnippet(false)]}, - {content: [contentSnippet(false)]} - ] - }, - contentParagraph(false), - ] - } + function surroundedByTextContent() { + return [ + contentParagraph(false), + { + type: "Columns", + config: {}, + columns: [{ content: [contentSnippet(false)] }, { content: [contentSnippet(false)] }], + }, + contentParagraph(false), + ]; + } } function htmlCode() { - return '\n' + return ( + '\n" + ); } function wideCode() { - return 'class InternationalPriceService implements PriceService { // classic\n' + - ' private static void LongJavaInterfaceNameWithSuperFactory createMegaAbstractFactory(final ExchangeCalendarLongerThanLife calendar) {\n' + - ' ...\n' + - ' }\n' + - '}\n' + return ( + "class InternationalPriceService implements PriceService { // classic\n" + + " private static void LongJavaInterfaceNameWithSuperFactory createMegaAbstractFactory(final ExchangeCalendarLongerThanLife calendar) {\n" + + " ...\n" + + " }\n" + + "}\n" + ); } function methodCallReferences() { - return { - 'http.header': { - pageUrl: '#http-header' - }, - 'body.get': { - pageUrl: '/chapter/page' - }, - 'should': { - pageUrl: 'http://example.com' - } - } + return { + "http.header": { + pageUrl: "#http-header", + }, + "body.get": { + pageUrl: "/chapter/page", + }, + should: { + pageUrl: "http://example.com", + }, + }; } function markdownCode() { - return '# Server Configuration\n\n' + - ':include-file: config/server.config\n\nNote: hello world\n' + return "# Server Configuration\n\n" + ":include-file: config/server.config\n\nNote: hello world\n"; } function longCode() { - let lines = [] - for (let i = 1; i <= 30; i++) { - lines.push('line of text number ' + i) - } + let lines = []; + for (let i = 1; i <= 30; i++) { + lines.push("line of text number " + i); + } - return lines.join('\n') + return lines.join("\n"); } -function tabsContent({label, wide}) { - return { - tabsContent: [{ - name: label, content: [{ - type: "Snippet", - wide: wide, - lang: "java", - snippet: wideCode() - }] - }] - } +function tabsContent({ label, wide }) { + return { + tabsContent: [ + { + name: label, + content: [ + { + type: "Snippet", + wide: wide, + lang: "java", + snippet: wideCode(), + }, + ], + }, + ], + }; } function twoSidesPage(content) { - return pageContent("two-sides", content) + return pageContent("two-sides", content); } function pageContent(type, content) { - return { - "type": "Page", - "content": content, - "lastModifiedTime": 1527473295000, - "tocItem": { - "sectionTitle": "Demo", - "pageTitle": "Demo", - "pageMeta": { - "type": [ - type - ] - }, - "fileName": "demo", - "dirName": "demo", - "pageSectionIdTitles": [] - } - } + return { + type: "Page", + content: content, + lastModifiedTime: 1527473295000, + tocItem: { + sectionTitle: "Demo", + pageTitle: "Demo", + pageMeta: { + type: [type], + }, + fileName: "demo", + dirName: "demo", + pageSectionIdTitles: [], + }, + }; } function snippetAfterSection() { - return [ - { - "title": "Primary Use Case", - "id": "primary-use-case", - "type": "Section", - "content": [contentSnippet(false)] - } - ] + return [ + { + title: "Primary Use Case", + id: "primary-use-case", + type: "Section", + content: [contentSnippet(false)], + }, + ]; } function textBetweenSnippetsMultipleBlocks() { - return [ - { - "title": "Primary Use Case", - "id": "primary-use-case", - "type": "Section", - "content": [ - contentSnippet(false), - contentSnippet(true), - contentParagraph(false), - contentParagraph(true), - contentSnippet(false), - ] - } - ] + return [ + { + title: "Primary Use Case", + id: "primary-use-case", + type: "Section", + content: [ + contentSnippet(false), + contentSnippet(true), + contentParagraph(false), + contentParagraph(true), + contentSnippet(false), + ], + }, + ]; } -function compactContentSnippet(title, {noGap, noGapBorder} = {}) { - return { - "type": "Snippet", - "lang": "java", - "title": title, - "noGap": noGap, - "noGapBorder": noGapBorder, - "snippet": codeWithMethodCalls(), - } +function compactContentSnippet(title, { noGap, noGapBorder } = {}) { + return { + type: "Snippet", + lang: "java", + title: title, + noGap: noGap, + noGapBorder: noGapBorder, + snippet: codeWithMethodCalls(), + }; } function pythonCode() { - return "import market\n" + - "import flight\n" + - "\n" + - "def main():\n" + - " id = market.book_trade('symbol', market.CURRENT_PRICE, 100)\n" + - "\n" + - " market.cancel_trade('id')\n" + - "\n" + - "if __name__ == \"__main__\":\n" + - " main() " + return ( + "import market\n" + + "import flight\n" + + "\n" + + "def main():\n" + + " id = market.book_trade('symbol', market.CURRENT_PRICE, 100)\n" + + "\n" + + " market.cancel_trade('id')\n" + + "\n" + + 'if __name__ == "__main__":\n' + + " main() " + ); } function largeJavaCodeWithJavaDocs() { - return "/**\n" + - " * Top level conceptual description of a Domain problem.\n" + - " *

\n" + - " * To avoid copy & paste of the content consider to re-use information.\n" + - " */\n" + - "class HelloWorld {\n" + - " /**\n" + - " * Each year we hire students from different universities to increase\n" + - " * diversity\n" + - " */\n" + - " private int numberOfStudents;\n" + - "\n" + - " /**\n" + - " * Conceptual description of a Domain problem.\n" + - " *

\n" + - " * It will work only if you put high level description here and\n" + - " * not implementation details.\n" + - " *\n" + - " * @param p1 important parameter of something\n" + - " * @param p2 sample offset according to the rules of the universe\n" + - " * @return name of the best sample\n" + - " */\n" + - " public String sampleMethod(String p1, int p2) {\n" + - " validate();\n" + - " process(p2); // important comment\n" + - " notifyAll(p1); // very important\n" + - "\n" + - " return bestSample();\n" + - " }\n" + - "\n" + - " public void sampleMethod(Map p1, int p2, boolean isActive) {\n" + - " // overloaded method\n" + - " }\n" + - "\n" + - " public void importantAction() {\n" + - " // TODO important\n" + - " }\n" + - "\n" + - " public Data createData() {\n" + - " // create data\n" + - " }\n" + - "}" -} \ No newline at end of file + return ( + "/**\n" + + " * Top level conceptual description of a Domain problem.\n" + + " *

\n" + + " * To avoid copy & paste of the content consider to re-use information.\n" + + " */\n" + + "class HelloWorld {\n" + + " /**\n" + + " * Each year we hire students from different universities to increase\n" + + " * diversity\n" + + " */\n" + + " private int numberOfStudents;\n" + + "\n" + + " /**\n" + + " * Conceptual description of a Domain problem.\n" + + " *

\n" + + " * It will work only if you put high level description here and\n" + + " * not implementation details.\n" + + " *\n" + + " * @param p1 important parameter of something\n" + + " * @param p2 sample offset according to the rules of the universe\n" + + " * @return name of the best sample\n" + + " */\n" + + " public String sampleMethod(String p1, int p2) {\n" + + " validate();\n" + + " process(p2); // important comment\n" + + " notifyAll(p1); // very important\n" + + "\n" + + " return bestSample();\n" + + " }\n" + + "\n" + + " public void sampleMethod(Map p1, int p2, boolean isActive) {\n" + + " // overloaded method\n" + + " }\n" + + "\n" + + " public void importantAction() {\n" + + " // TODO important\n" + + " }\n" + + "\n" + + " public Data createData() {\n" + + " // create data\n" + + " }\n" + + "}" + ); +} diff --git a/znai-reactjs/src/doc-elements/demo-utils/ButtonToForceTextHighlight.tsx b/znai-reactjs/src/doc-elements/demo-utils/ButtonToForceTextHighlight.tsx new file mode 100644 index 000000000..689d58480 --- /dev/null +++ b/znai-reactjs/src/doc-elements/demo-utils/ButtonToForceTextHighlight.tsx @@ -0,0 +1,23 @@ +import React from "react"; + +interface Props { + textToHighlight: string; +} + +export function ButtonToForceTextHighlight({ textToHighlight }: Props) { + return ( + + ); +} diff --git a/znai-reactjs/src/doc-elements/demo-utils/contentGenerators.ts b/znai-reactjs/src/doc-elements/demo-utils/contentGenerators.ts index b6d96a7a1..2f5c6e339 100644 --- a/znai-reactjs/src/doc-elements/demo-utils/contentGenerators.ts +++ b/znai-reactjs/src/doc-elements/demo-utils/contentGenerators.ts @@ -30,6 +30,22 @@ export function contentParagraph(isRightSide: boolean) { }; } +export function anotherContentParagraph(isRightSide: boolean) { + return { + type: "Paragraph", + content: [ + { + text: + "This is the first line of test text that demonstrates how content flows in a paragraph format. The second line continues the sample content while maintaining readability and proper spacing throughout. Finally, the third line completes this example paragraph with additional placeholder text for testing purposes.", + type: "SimpleText", + }, + ], + meta: { + rightSide: isRightSide, + }, + }; +} + export function contentSingleLink(text: string) { return { type: "Paragraph", diff --git a/znai-reactjs/src/doc-elements/read-more/ReadMore.demo.tsx b/znai-reactjs/src/doc-elements/read-more/ReadMore.demo.tsx index 81df4ef16..9e1603edf 100644 --- a/znai-reactjs/src/doc-elements/read-more/ReadMore.demo.tsx +++ b/znai-reactjs/src/doc-elements/read-more/ReadMore.demo.tsx @@ -17,14 +17,17 @@ import { Registry } from "react-component-viewer"; import * as React from "react"; import { elementsLibrary } from "../DefaultElementsLibrary"; -import { contentParagraph } from "../demo-utils/contentGenerators"; +import { anotherContentParagraph, contentParagraph } from "../demo-utils/contentGenerators"; import { ReadMore } from "./ReadMore"; +import { AllTextHighlights } from "../text-selection/AllTextHighlights"; +import { documentationNavigation } from "../../structure/DocumentationNavigation"; +import { ButtonToForceTextHighlight } from "../demo-utils/ButtonToForceTextHighlight"; export function readMoreDemo(registry: Registry) { registry.add("regular text", () => surroundWithText( elementsLibrary, - + ) ); } @@ -36,6 +39,8 @@ function surroundWithText(elementsLibrary: any, rendered: any) { {rendered} + + ); } diff --git a/znai-reactjs/src/doc-elements/read-more/ReadMore.tsx b/znai-reactjs/src/doc-elements/read-more/ReadMore.tsx index 156e406c3..db113f480 100644 --- a/znai-reactjs/src/doc-elements/read-more/ReadMore.tsx +++ b/znai-reactjs/src/doc-elements/read-more/ReadMore.tsx @@ -14,10 +14,12 @@ * limitations under the License. */ -import React, { useState } from "react"; +import React, { useRef, useState } from "react"; import { DocElementContent, ElementsLibraryMap } from "../default-elements/DocElement"; import { Icon } from "../icons/Icon"; + +import { useHighlightOfHiddenElement } from "../text-selection/componentsHighlightUtils"; import "./ReadMore.css"; interface Props { @@ -28,27 +30,29 @@ interface Props { export function ReadMore({ title, content, elementsLibrary }: Props) { const [expanded, setExpanded] = useState(false); + const containerRef = useRef(null); + const hiddenContainerRef = useRef(null); + const hasHiddenHighlightedElement = useHighlightOfHiddenElement(containerRef, hiddenContainerRef, expanded); + const expandedClassName = expanded ? "expanded" : "collapsed"; const topClassName = "znai-read-more content-block " + expandedClassName; - const summaryClassName = "znai-read-more-title-block content-block " + expandedClassName; + const summaryClassName = + "znai-read-more-title-block content-block " + + expandedClassName + + (hasHiddenHighlightedElement && !expanded ? " " + "znai-highlight single" : ""); const summary = (

setExpanded((prev) => !prev)}> {title}
); + const style = expanded ? { display: "block" } : { display: "none" }; return ( -
- {expanded ? ( - <> - {summary} -
- -
- - ) : ( - summary - )} +
+ {summary} +
+ +
); } diff --git a/znai-reactjs/src/doc-elements/testDocumentation.jsx b/znai-reactjs/src/doc-elements/testDocumentation.jsx index 731b7d200..b0ee75719 100644 --- a/znai-reactjs/src/doc-elements/testDocumentation.jsx +++ b/znai-reactjs/src/doc-elements/testDocumentation.jsx @@ -111,6 +111,69 @@ export const testDocumentation = { }, ], }, + { + title: "Title of the Hidden Details", + content: [ + { + type: "Paragraph", + content: [ + { + text: "Information you don't want users to see right away goes here.", + type: "SimpleText", + }, + { + type: "SoftLineBreak", + }, + { + text: "Can use all the markdown inside", + type: "SimpleText", + }, + ], + }, + { + delimiter: ".", + startNumber: 1, + type: "OrderedList", + content: [ + { + type: "ListItem", + content: [ + { + type: "Paragraph", + content: [ + { + text: "item A", + type: "SimpleText", + }, + ], + }, + ], + }, + { + type: "ListItem", + content: [ + { + type: "Paragraph", + content: [ + { + text: "item B", + type: "SimpleText", + }, + ], + }, + ], + }, + ], + }, + { + lang: "", + snippet: "let list = [1, 2, 3]\nlet value = 0", + lineNumber: "", + type: "Snippet", + }, + ], + type: "ReadMore", + }, { lang: "javascript", snippet: "class JsClass {\n constructor() {\n usefulAction()\n }\n}\n\nexport default JsClass", @@ -125,6 +188,14 @@ export const testDocumentation = { }, ], }, + { + lang: "java", + snippet: + 'public class DocScaffolding {\n private final Path workingDir;\n private Map> fileNameByDirName;\n\n public DocScaffolding(Path workingDir) {\n this.workingDir = workingDir;\n this.fileNameByDirName = new LinkedHashMap<>();\n }\n\n public void create() {\n createPages();\n createToc();\n createMeta();\n createIndex();\n createLookupPaths();\n }\n\n private void createLookupPaths() {\n createFileFromResource("lookup-paths");\n }\n\n private void createMeta() {\n createFileFromResource("meta.json");\n }\n}', + readMore: true, + readMoreVisibleLines: 3, + type: "Snippet", + }, { bulletMarker: "*", tight: true, diff --git a/znai-reactjs/src/doc-elements/text-selection/AllTextHighlights.tsx b/znai-reactjs/src/doc-elements/text-selection/AllTextHighlights.tsx new file mode 100644 index 000000000..6e909aca6 --- /dev/null +++ b/znai-reactjs/src/doc-elements/text-selection/AllTextHighlights.tsx @@ -0,0 +1,57 @@ +/* + * Copyright 2025 znai maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { HighlightUrlText } from "./HighlightUrlText"; +import { SlackActiveQuestions } from "./SlackActiveQuestions"; +import React, { useEffect, useState } from "react"; +import { TocItem } from "../../structure/TocItem"; + +export function reapplyTextHighlights() { + listeners.forEach((listener) => listener()); +} + +export type ReapplyTextHighlightListener = () => void; + +const listeners: ReapplyTextHighlightListener[] = []; + +export function addReapplyTextHighlightsListener(listener: ReapplyTextHighlightListener) { + listeners.push(listener); +} + +export function removeReapplyTextHighlightsListener(listener: ReapplyTextHighlightListener) { + listeners.splice(listeners.indexOf(listener), 1); +} + +export function AllTextHighlights({ containerNode, tocItem }: { containerNode: HTMLElement; tocItem: TocItem }) { + const [keyToForceReHighlight, setKeyToForceReHighlight] = useState(0); + // TODO move load questions from slack here + + useEffect(() => { + function forceReapply() { + setKeyToForceReHighlight((prev) => prev + 1); + } + + addReapplyTextHighlightsListener(forceReapply); + return () => removeReapplyTextHighlightsListener(forceReapply); + }, []); + + return ( + + + + + ); +} diff --git a/znai-reactjs/src/doc-elements/text-selection/HighlightUrlText.tsx b/znai-reactjs/src/doc-elements/text-selection/HighlightUrlText.tsx index c2a9e0890..92edf5b26 100644 --- a/znai-reactjs/src/doc-elements/text-selection/HighlightUrlText.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/HighlightUrlText.tsx @@ -17,7 +17,7 @@ import { extractHighlightParams } from "./highlightUrl"; import { HighlightedText } from "./HighlightedText"; -export function HighlightUrlText({ containerNode }: { containerNode: HTMLDivElement }) { +export function HighlightUrlText({ containerNode }: { containerNode: HTMLElement }) { const params = extractHighlightParams(); return params ? ( diff --git a/znai-reactjs/src/doc-elements/text-selection/HighlightedText.tsx b/znai-reactjs/src/doc-elements/text-selection/HighlightedText.tsx index 6f2e6d465..464a8f05f 100644 --- a/znai-reactjs/src/doc-elements/text-selection/HighlightedText.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/HighlightedText.tsx @@ -22,11 +22,25 @@ import { createPortal } from "react-dom"; import { afterTitleId } from "../../layout/classNamesAndIds"; -import "./HighlightedText.css"; import { removeTrailingSlashFromQueryParam } from "./queryParamUtils"; +import "./HighlightedText.css"; + +export interface HighlightedTextListener { + onUserDrivenTextHighlight(firstElement: HTMLElement): void; +} + +const highlightedTextListeners: HighlightedTextListener[] = []; + +export function addHighlightedTextListener(listener: HighlightedTextListener) { + highlightedTextListeners.push(listener); +} + +export function removeHighlightedTextListener(listener: HighlightedTextListener) { + highlightedTextListeners.splice(highlightedTextListeners.indexOf(listener), 1); +} interface Props { - containerNode: HTMLDivElement; + containerNode: HTMLElement; selectedText: string; selectedPrefix: string; selectedSuffix: string; @@ -154,14 +168,13 @@ export function HighlightedText({ } else { console.warn("can't find element with id: " + afterTitleId); } - } - - if (firstHighlightedElement) { + } else { if (question && bubbleRef.current && displayBubbleAndScrollIntoView) { showBubbleAndCalcPositionIfHasQuestion(firstHighlightedElement, highlights[highlights.length - 1]); } scrollToBubbleIfRequired(firstHighlightedElement); + highlightedTextListeners.forEach((listener) => listener.onUserDrivenTextHighlight(firstHighlightedElement)); } addTextMenuListener(textMenuListener); diff --git a/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx index 60c7b53b3..d2505881f 100644 --- a/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx @@ -37,7 +37,7 @@ interface Question { resolved: boolean; } -export function SlackActiveQuestions({ containerNode, tocItem }: { containerNode: HTMLDivElement; tocItem: TocItem }) { +export function SlackActiveQuestions({ containerNode, tocItem }: { containerNode: HTMLElement; tocItem: TocItem }) { const [questions, setQuestions] = useState([]); const [notification, setNotification] = useState<{ type: "success" | "error"; message: string } | null>(null); diff --git a/znai-reactjs/src/doc-elements/text-selection/componentsHighlightUtils.ts b/znai-reactjs/src/doc-elements/text-selection/componentsHighlightUtils.ts new file mode 100644 index 000000000..fb040a170 --- /dev/null +++ b/znai-reactjs/src/doc-elements/text-selection/componentsHighlightUtils.ts @@ -0,0 +1,97 @@ +import { MutableRefObject, useEffect, useRef, useState } from "react"; +import { addHighlightedTextListener, removeHighlightedTextListener } from "./HighlightedText"; +import { reapplyTextHighlights } from "./AllTextHighlights"; + +/* + +Use can highlight text inside hidden by default blocks of text. +When a page with the highlight is opened, we want the browser to scroll +to the element that contains the hidden text. + +To achieve that, components with hidden content should still +render the content but with display:none. We need the content +to be part of the DOM so the highlight engine can find them. + +Components can subscribe to "highlight" event and receive the firstHighlightElement +in the callback. + +If the element is part of the hidden section, components can +highlight their toggles to let users know there is a hidden, highlighted text. +But they also need to temporary move the "firstHighlightElement" somehwere in the +visible zone for the scroll to happen. + +Hook below attempts to encapsulate this logic. + +*/ + +export function useHighlightOfHiddenElement( + containerRef: MutableRefObject, + hiddenContainerRef: MutableRefObject, + contentVisibilityTrigger: any +) { + const restoreFirstHighlightElementFunRef = useRef<(() => void) | null>(null); + const [hasHiddenHighlightedElement, setHasHiddenHighlightedElement] = useState(false); + const onlyOnce = useRef(false); + + useEffect(() => { + const listener = { + onUserDrivenTextHighlight: (firstHighlightElement: HTMLElement) => { + if ( + containerRef.current && + hiddenContainerRef.current && + hiddenContainerRef.current.contains(firstHighlightElement) && + !onlyOnce.current + ) { + restoreFirstHighlightElementFunRef.current = moveAndHideHtmlElementForAutoScroll( + firstHighlightElement, + containerRef.current + ); + setHasHiddenHighlightedElement(true); + onlyOnce.current = true; + } + }, + }; + addHighlightedTextListener(listener); + return () => { + removeHighlightedTextListener(listener); + }; + }, []); + + // this may not work with Tabs + useEffect(() => { + if (restoreFirstHighlightElementFunRef.current && onlyOnce.current) { + restoreFirstHighlightElementFunRef.current(); + restoreFirstHighlightElementFunRef.current = null; + } + // TODO only reapply for the specific highlight(s) that are affected maybe somehow(?) + // or make sure that the scroll to the selected question is triggered everytime someone toggles read more + reapplyTextHighlights(); + }, [contentVisibilityTrigger]); + + return hasHiddenHighlightedElement; +} + +export function moveAndHideHtmlElementForAutoScroll(element: HTMLElement, newParent: HTMLElement) { + const originalParent = element.parentNode; + const originalNextSibling = element.nextSibling; + const originalDisplay = element.style.display; + const originalHeight = element.style.height; + const originalOverflow = element.style.overflow; + + newParent.appendChild(element); + element.style.display = "block"; + element.style.height = "0"; + element.style.overflow = "hidden"; + + return function restore() { + element.style.display = originalDisplay; + element.style.height = originalHeight; + element.style.overflow = originalOverflow; + + if (originalNextSibling) { + originalParent!.insertBefore(element, originalNextSibling); + } else { + originalParent!.appendChild(element); + } + }; +} diff --git a/znai-reactjs/src/layout/DocumentationLayout.tsx b/znai-reactjs/src/layout/DocumentationLayout.tsx index 5dd6db5e3..9f22e40d2 100644 --- a/znai-reactjs/src/layout/DocumentationLayout.tsx +++ b/znai-reactjs/src/layout/DocumentationLayout.tsx @@ -32,9 +32,7 @@ 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 { AllTextHighlights } from "../doc-elements/text-selection/AllTextHighlights"; import "./DocumentationLayout.css"; import "./mobile/MobileLayoutOverrides.css"; @@ -101,8 +99,7 @@ export function DocumentationLayout({ return (
{contentRef.current && } - {contentRef.current && } - {contentRef.current && } + {contentRef.current && }
{renderedPage}