From 507a8b41659f627c1e0640ed2942e7ab7c639fe8 Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Mon, 1 Sep 2025 07:27:50 -0400 Subject: [PATCH 01/16] slack: persist and display comments per page --- .../text-selection/TextSelectionMenu.tsx | 2 + znai-slack-bot/slack_bot.py | 125 +++++++++++++++++- 2 files changed, 123 insertions(+), 4 deletions(-) diff --git a/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx b/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx index 374939334..bafdb5562 100644 --- a/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx @@ -217,6 +217,8 @@ export function TextSelectionMenu({ containerNode }: { containerNode: HTMLDivEle const body = { selectedText: panelData!.prefixSuffixMatch.selection, + selectedPrefix: panelData!.prefixSuffixMatch.prefix, + selectedSuffix: panelData!.prefixSuffixMatch.suffix, pageUrl: pageUrl, username: "web-user", slackChannel: getDocMeta().slackChannel, diff --git a/znai-slack-bot/slack_bot.py b/znai-slack-bot/slack_bot.py index 7ec501aac..012572420 100644 --- a/znai-slack-bot/slack_bot.py +++ b/znai-slack-bot/slack_bot.py @@ -1,10 +1,17 @@ import os import json +import csv +import threading +import time +from datetime import datetime, timedelta 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 +27,9 @@ slack_client = WebClient(token=slack_token) +CSV_FILE = "active_questions.csv" +CSV_HEADERS = ["timestamp", "page_url", "context", "selected_text", "selected_prefix", "selected_suffix", "question", "slack_link", "username", "channel", "message_ts", "completed"] + @app.route('/ask-in-slack', methods=['POST']) def ask_in_slack(): try: @@ -36,6 +46,8 @@ def ask_in_slack(): return jsonify({"error": "No JSON data provided"}), 400 selected_text = data.get('selectedText') + selected_prefix = data.get('selectedPrefix', '') + selected_suffix = data.get('selectedSuffix', '') page_url = data.get('pageUrl') username = data.get('username') slack_channel = data.get('slackChannel') @@ -63,8 +75,26 @@ 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([{ + 'timestamp': datetime.now().isoformat(), + 'page_url': page_url, + 'context': context or '', + 'selected_text': selected_text or '', + 'selected_prefix': selected_prefix, + 'selected_suffix': selected_suffix, + 'question': question, + 'slack_link': slack_link, + 'username': username or 'anonymous', + 'channel': slack_channel, + 'message_ts': message_ts, + 'completed': 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,15 +106,102 @@ def ask_in_slack(): return jsonify({"error": str(e)}), 500 +def persist_questions_to_csv(questions): + file_exists = os.path.exists(CSV_FILE) + + with open(CSV_FILE, 'a', newline='', encoding='utf-8') as csvfile: + writer = csv.DictWriter(csvfile, fieldnames=CSV_HEADERS) + + if 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['completed'] = row['completed'].lower() == 'true' if row['completed'] else False + questions.append(row) + + return questions + +def update_question_completion_status(message_ts, completed): + questions = load_questions_from_csv() + + for question in questions: + if question['message_ts'] == message_ts: + question['completed'] = completed + + persist_questions_to_csv(questions) + +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', 'completed', '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: + questions = load_questions_from_csv() + return jsonify({"questions": questions}), 200 + + except Exception as e: + print(f"Error getting active questions: {type(e).__name__}: {str(e)}") + 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) +def periodic_completion_check(): + while True: + try: + questions = load_questions_from_csv() + cutoff_time = datetime.now() - timedelta(hours=24) + + for question in questions: + if question['completed']: + continue + + question_time = datetime.fromisoformat(question['timestamp'].replace('Z', '+00:00').replace('+00:00', '')) + if question_time < cutoff_time: + continue + + is_completed = check_slack_message_completion(question['channel'], question['message_ts']) + if is_completed: + update_question_completion_status(question['message_ts'], True) + print(f"Marked question {question['message_ts']} as completed") + + time.sleep(60) + + except Exception as e: + print(f"Error in periodic completion check: {e}") + if __name__ == '__main__': + completion_thread = threading.Thread(target=periodic_completion_check, daemon=True) + completion_thread.start() + app.run(host='0.0.0.0', port=5111, debug=True) \ No newline at end of file From a4f1da45fee13131921b458c9a909b25ccbbf3d5 Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Mon, 1 Sep 2025 18:16:20 -0400 Subject: [PATCH 02/16] in progress --- znai-slack-bot/slack_bot.py | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/znai-slack-bot/slack_bot.py b/znai-slack-bot/slack_bot.py index 012572420..e3dffa19d 100644 --- a/znai-slack-bot/slack_bot.py +++ b/znai-slack-bot/slack_bot.py @@ -39,7 +39,6 @@ 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: @@ -176,32 +175,5 @@ def format_slack_message(username, question, context, page_url): return "\n\n".join(message_parts) -def periodic_completion_check(): - while True: - try: - questions = load_questions_from_csv() - cutoff_time = datetime.now() - timedelta(hours=24) - - for question in questions: - if question['completed']: - continue - - question_time = datetime.fromisoformat(question['timestamp'].replace('Z', '+00:00').replace('+00:00', '')) - if question_time < cutoff_time: - continue - - is_completed = check_slack_message_completion(question['channel'], question['message_ts']) - if is_completed: - update_question_completion_status(question['message_ts'], True) - print(f"Marked question {question['message_ts']} as completed") - - time.sleep(60) - - except Exception as e: - print(f"Error in periodic completion check: {e}") - if __name__ == '__main__': - completion_thread = threading.Thread(target=periodic_completion_check, daemon=True) - completion_thread.start() - app.run(host='0.0.0.0', port=5111, debug=True) \ No newline at end of file From 0ac04a7a0bde413e7b02a93b8d495581bae59c71 Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Mon, 1 Sep 2025 18:16:33 -0400 Subject: [PATCH 03/16] in progress --- znai-slack-bot/slack_bot.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/znai-slack-bot/slack_bot.py b/znai-slack-bot/slack_bot.py index e3dffa19d..af7369c50 100644 --- a/znai-slack-bot/slack_bot.py +++ b/znai-slack-bot/slack_bot.py @@ -1,9 +1,7 @@ import os import json import csv -import threading -import time -from datetime import datetime, timedelta +from datetime import datetime from flask import Flask, request, jsonify from flask_cors import CORS from slack_sdk import WebClient From 63fab3f4a055471739979a71ac94c7d0a8fea275 Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Mon, 1 Sep 2025 19:08:51 -0400 Subject: [PATCH 04/16] in progress --- .../text-selection/HighlightUrlText.tsx | 93 +++--------------- ...ghlightUrlText.css => HighlightedText.css} | 0 .../text-selection/HighlightedText.tsx | 95 +++++++++++++++++++ .../text-selection/TextSelectionMenu.tsx | 6 +- .../text-selection/highlightUrl.ts | 10 +- .../text-selection/textHighlighter.css | 2 +- .../{textHighlihter.js => textHighlighter.js} | 8 ++ 7 files changed, 125 insertions(+), 89 deletions(-) rename znai-reactjs/src/doc-elements/text-selection/{HighlightUrlText.css => HighlightedText.css} (100%) create mode 100644 znai-reactjs/src/doc-elements/text-selection/HighlightedText.tsx rename znai-reactjs/src/doc-elements/text-selection/{textHighlihter.js => textHighlighter.js} (97%) diff --git a/znai-reactjs/src/doc-elements/text-selection/HighlightUrlText.tsx b/znai-reactjs/src/doc-elements/text-selection/HighlightUrlText.tsx index f20b48107..7108566d2 100644 --- a/znai-reactjs/src/doc-elements/text-selection/HighlightUrlText.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/HighlightUrlText.tsx @@ -14,87 +14,20 @@ * 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 100% rename from znai-reactjs/src/doc-elements/text-selection/HighlightUrlText.css rename to znai-reactjs/src/doc-elements/text-selection/HighlightedText.css 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..982a210f1 --- /dev/null +++ b/znai-reactjs/src/doc-elements/text-selection/HighlightedText.tsx @@ -0,0 +1,95 @@ +import { useEffect, useRef } from "react"; +import { TextHighlighter } from "./textHighlighter"; + +import "./HighlightedText.css"; + +interface Props { + containerNode: HTMLDivElement; + textSelection: string; + prefix: string; + suffix: string; + question: string; + displayBubbleAndScrollIntoView: boolean; +} + +export function HighlightedText({ + containerNode, + textSelection, + prefix, + suffix, + question, + displayBubbleAndScrollIntoView, +}: Props) { + const bubbleRef = useRef(null); + + useEffect(() => { + if (!bubbleRef.current) { + return; + } + + const bubble = bubbleRef.current as HTMLDivElement; + + function showBubbleIfHasQuestion() { + if (!question) { + return; + } + bubble.style.display = "block"; + } + + function hideBubble() { + bubble.style.display = "none"; + } + + function toggleBubble() { + const bubble = bubbleRef.current as HTMLDivElement; + if (bubble.style.display === "block") { + hideBubble(); + } else { + showBubbleIfHasQuestion(); + } + } + + const highlighter = new TextHighlighter(containerNode); + const highlights = highlighter.highlight(textSelection, prefix, suffix, question ? toggleBubble : null); + const firstHighlightedElement = highlights[0]; + if (!firstHighlightedElement) { + return; + } + + if (question && bubbleRef.current) { + const containerRect = containerNode.getBoundingClientRect(); + + if (displayBubbleAndScrollIntoView) { + showBubbleIfHasQuestion(); + } + + const range = document.createRange(); + range.setStart(firstHighlightedElement, 0); + range.setEnd(highlights[highlights.length - 1], highlights[highlights.length - 1].childNodes.length); + const selectionRect = range.getBoundingClientRect(); + + bubble.innerText = question.endsWith("/") ? question.substring(0, question.length - 1) : question; + + 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`; + } + + if (displayBubbleAndScrollIntoView) { + setTimeout(() => { + firstHighlightedElement.scrollIntoView({ behavior: "smooth", block: "center" }); + }, 100); + } + + return () => { + highlighter.clearHighlights(); + hideBubble(); + }; + }, []); + + return
; +} diff --git a/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx b/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx index bafdb5562..8cb726145 100644 --- a/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx @@ -196,8 +196,8 @@ 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 }); try { await navigator.clipboard.writeText(pageUrl); setNotification({ type: "success", message: "Link is generated and copied to clipboard" }); @@ -213,7 +213,7 @@ export function TextSelectionMenu({ containerNode }: { containerNode: HTMLDivEle return; } - const pageUrl = buildHighlightUrl(panelData!.prefixSuffixMatch, question); + const pageUrl = buildHighlightUrl({ ...panelData!.prefixSuffixMatch, question }); const body = { selectedText: panelData!.prefixSuffixMatch.selection, diff --git a/znai-reactjs/src/doc-elements/text-selection/highlightUrl.ts b/znai-reactjs/src/doc-elements/text-selection/highlightUrl.ts index 4b8ea95c9..158f89b45 100644 --- a/znai-reactjs/src/doc-elements/text-selection/highlightUrl.ts +++ b/znai-reactjs/src/doc-elements/text-selection/highlightUrl.ts @@ -23,7 +23,7 @@ export interface HighlightParams { prefix: string; selection: string; suffix: string; - question?: string; + question: string; } export function extractHighlightParams(): HighlightParams | null { @@ -38,14 +38,14 @@ export function extractHighlightParams(): HighlightParams | null { prefix: decodeURIComponent(prefix), selection: decodeURIComponent(selection), suffix: decodeURIComponent(suffix), - question: question ? decodeURIComponent(question) : undefined, + question: question ? decodeURIComponent(question) : "", }; } 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 +55,8 @@ 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)); } 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 97% rename from znai-reactjs/src/doc-elements/text-selection/textHighlihter.js rename to znai-reactjs/src/doc-elements/text-selection/textHighlighter.js index ccff3130d..781ae457b 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"); }); From fc49cacf73d258fb0650d8654adc5087f93ba9cd Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Mon, 1 Sep 2025 19:24:37 -0400 Subject: [PATCH 05/16] in progress --- znai-reactjs/src/App.jsx | 1 + .../doc-elements/text-selection/HighlightedText.css | 2 +- .../doc-elements/text-selection/TextSelectionMenu.css | 2 +- .../doc-elements/text-selection/TextSelectionMenu.tsx | 10 +++++++++- znai-reactjs/src/structure/DocumentationNavigation.jsx | 9 +++++++-- znai-reactjs/src/structure/docMeta.ts | 1 + znai-slack-bot/slack_bot.py | 5 ++++- 7 files changed, 24 insertions(+), 6 deletions(-) diff --git a/znai-reactjs/src/App.jsx b/znai-reactjs/src/App.jsx index 01439dd19..e29555d1b 100644 --- a/znai-reactjs/src/App.jsx +++ b/znai-reactjs/src/App.jsx @@ -114,6 +114,7 @@ const docMeta = { previewEnabled: true, slackChannel: "help-domain-name", sendToSlackUrl: "http://localhost:5111/ask-in-slack", + sendToSlackIncludeContentType: true, viewOn: { link: "https://github.com/testingisdocumenting/znai/znai-cli/documentation", title: "View On GitHub", diff --git a/znai-reactjs/src/doc-elements/text-selection/HighlightedText.css b/znai-reactjs/src/doc-elements/text-selection/HighlightedText.css index 6991c396d..94cad8250 100644 --- a/znai-reactjs/src/doc-elements/text-selection/HighlightedText.css +++ b/znai-reactjs/src/doc-elements/text-selection/HighlightedText.css @@ -11,7 +11,7 @@ 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::after { 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 8cb726145..56377ebaa 100644 --- a/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx @@ -23,6 +23,7 @@ import { getDocMeta } from "../../structure/docMeta"; import { buildContext } from "./markdownContextBuilder"; import { Notification } from "../../components/Notification"; +import { currentPageId, documentationNavigation } from "../../structure/DocumentationNavigation"; import "./TextSelectionMenu.css"; export function TextSelectionMenu({ containerNode }: { containerNode: HTMLDivElement }) { @@ -214,12 +215,12 @@ export function TextSelectionMenu({ containerNode }: { containerNode: HTMLDivEle } const pageUrl = buildHighlightUrl({ ...panelData!.prefixSuffixMatch, question }); - const body = { selectedText: panelData!.prefixSuffixMatch.selection, selectedPrefix: panelData!.prefixSuffixMatch.prefix, selectedSuffix: panelData!.prefixSuffixMatch.suffix, pageUrl: pageUrl, + pageId: currentPageId(), username: "web-user", slackChannel: getDocMeta().slackChannel, question: question, @@ -227,9 +228,16 @@ export function TextSelectionMenu({ containerNode }: { containerNode: HTMLDivEle }; 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), }); diff --git a/znai-reactjs/src/structure/DocumentationNavigation.jsx b/znai-reactjs/src/structure/DocumentationNavigation.jsx index eb66840fc..cff89c825 100644 --- a/znai-reactjs/src/structure/DocumentationNavigation.jsx +++ b/znai-reactjs/src/structure/DocumentationNavigation.jsx @@ -16,7 +16,7 @@ */ import * as Promise from "promise"; -import { getDocId } from "./docMeta"; +import { getDocId, getDocMeta } from "./docMeta"; import { isTocItemIndex } from "./toc/TableOfContents"; import { mainPanelClassName } from "../layout/classNames"; @@ -128,6 +128,11 @@ class DocumentationNavigation { } } +function currentPageId() { + const pageLocation = documentationNavigation.currentPageLocation(); + return [getDocMeta().id, pageLocation.dirName, pageLocation.fileName].filter((part) => !!part).join("/"); +} + const documentationNavigation = new DocumentationNavigation(); -export { documentationNavigation }; +export { documentationNavigation, currentPageId }; diff --git a/znai-reactjs/src/structure/docMeta.ts b/znai-reactjs/src/structure/docMeta.ts index f7c609a8b..8aa396091 100644 --- a/znai-reactjs/src/structure/docMeta.ts +++ b/znai-reactjs/src/structure/docMeta.ts @@ -24,6 +24,7 @@ export interface DocMeta { previewEnabled: boolean; slackChannel?: string; sendToSlackUrl?: string; + sendToSlackIncludeContentType?: boolean; useTopHeader?: boolean; hidePresentationTrigger?: boolean; support?: DocMetaSupport; diff --git a/znai-slack-bot/slack_bot.py b/znai-slack-bot/slack_bot.py index af7369c50..3c6aae7f2 100644 --- a/znai-slack-bot/slack_bot.py +++ b/znai-slack-bot/slack_bot.py @@ -26,7 +26,7 @@ slack_client = WebClient(token=slack_token) CSV_FILE = "active_questions.csv" -CSV_HEADERS = ["timestamp", "page_url", "context", "selected_text", "selected_prefix", "selected_suffix", "question", "slack_link", "username", "channel", "message_ts", "completed"] +CSV_HEADERS = ["timestamp", "page_id", "page_url", "context", "selected_text", "selected_prefix", "selected_suffix", "question", "slack_link", "username", "channel", "message_ts", "completed"] @app.route('/ask-in-slack', methods=['POST']) def ask_in_slack(): @@ -45,6 +45,7 @@ def ask_in_slack(): selected_text = data.get('selectedText') selected_prefix = data.get('selectedPrefix', '') selected_suffix = data.get('selectedSuffix', '') + page_id = data.get('pageId') page_url = data.get('pageUrl') username = data.get('username') slack_channel = data.get('slackChannel') @@ -52,6 +53,7 @@ def ask_in_slack(): 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}") @@ -77,6 +79,7 @@ def ask_in_slack(): persist_questions_to_csv([{ 'timestamp': datetime.now().isoformat(), + 'page_id': page_id, 'page_url': page_url, 'context': context or '', 'selected_text': selected_text or '', From d08403ea5e9910e8e5ea6a2421ab7287475f3716 Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Mon, 1 Sep 2025 20:24:08 -0400 Subject: [PATCH 06/16] in progress --- znai-reactjs/src/App.jsx | 1 + .../text-selection/HighlightedText.tsx | 46 ++++++------ .../text-selection/SlackActiveQuestions.tsx | 75 +++++++++++++++++++ .../text-selection/TextSelectionMenu.tsx | 2 +- .../src/layout/DocumentationLayout.tsx | 5 +- znai-reactjs/src/structure/docMeta.ts | 1 + znai-slack-bot/slack_bot.py | 5 ++ 7 files changed, 110 insertions(+), 25 deletions(-) create mode 100644 znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx diff --git a/znai-reactjs/src/App.jsx b/znai-reactjs/src/App.jsx index e29555d1b..c6287fe7e 100644 --- a/znai-reactjs/src/App.jsx +++ b/znai-reactjs/src/App.jsx @@ -114,6 +114,7 @@ const docMeta = { previewEnabled: true, slackChannel: "help-domain-name", sendToSlackUrl: "http://localhost:5111/ask-in-slack", + slackActiveQuestionsUrl: "http://localhost:5111/active-questions", sendToSlackIncludeContentType: true, viewOn: { link: "https://github.com/testingisdocumenting/znai/znai-cli/documentation", diff --git a/znai-reactjs/src/doc-elements/text-selection/HighlightedText.tsx b/znai-reactjs/src/doc-elements/text-selection/HighlightedText.tsx index 982a210f1..0b8a7657d 100644 --- a/znai-reactjs/src/doc-elements/text-selection/HighlightedText.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/HighlightedText.tsx @@ -29,11 +29,30 @@ export function HighlightedText({ const bubble = bubbleRef.current as HTMLDivElement; - function showBubbleIfHasQuestion() { + function updateBubblePosition() { + 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(); + + bubble.innerText = question.endsWith("/") ? question.substring(0, question.length - 1) : question; + + 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() { if (!question) { return; } bubble.style.display = "block"; + updateBubblePosition(); } function hideBubble() { @@ -45,7 +64,7 @@ export function HighlightedText({ if (bubble.style.display === "block") { hideBubble(); } else { - showBubbleIfHasQuestion(); + showBubbleAndCalcPositionIfHasQuestion(); } } @@ -56,27 +75,8 @@ export function HighlightedText({ return; } - if (question && bubbleRef.current) { - const containerRect = containerNode.getBoundingClientRect(); - - if (displayBubbleAndScrollIntoView) { - showBubbleIfHasQuestion(); - } - - const range = document.createRange(); - range.setStart(firstHighlightedElement, 0); - range.setEnd(highlights[highlights.length - 1], highlights[highlights.length - 1].childNodes.length); - const selectionRect = range.getBoundingClientRect(); - - bubble.innerText = question.endsWith("/") ? question.substring(0, question.length - 1) : question; - - 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`; + if (question && bubbleRef.current && displayBubbleAndScrollIntoView) { + showBubbleAndCalcPositionIfHasQuestion(); } if (displayBubbleAndScrollIntoView) { 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..972cdb9c4 --- /dev/null +++ b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx @@ -0,0 +1,75 @@ +import React, { useEffect, useState } from "react"; +import { currentPageId } from "../../structure/DocumentationNavigation"; +import { getDocMeta } from "../../structure/docMeta"; +import { Notification } from "../../components/Notification"; +import { HighlightedText } from "./HighlightedText"; + +interface Question { + selectedText: string; + prefix: string; + suffix: string; + question: string; +} + +export function SlackActiveQuestions({ containerNode }: { containerNode: HTMLDivElement }) { + const [questions, setQuestions] = useState([]); + const [notification, setNotification] = useState<{ type: "success" | "error"; message: string } | null>(null); + + useEffect(() => { + void fetchActiveQuestions(); + }, []); + + async function fetchActiveQuestions() { + try { + const pageId = currentPageId(); + const baseUrl = getDocMeta().slackActiveQuestionsUrl; + if (!baseUrl) { + return; + } + + const url = `${baseUrl}?pageId=${encodeURIComponent(pageId)}`; + + const response = await fetch(url, { + method: "GET", + credentials: "include", + }); + + if (response.ok) { + const data = await response.json(); + const questions = data.questions.map((item: any) => ({ + selectedText: item.selected_text, + prefix: item.selected_prefix, + suffix: item.selected_suffix, + question: item.question, + })); + 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}` }); + } + } + + const renderedQuestions = questions.map((question, idx) => ( + + )); + + return ( + <> + {renderedQuestions} + {notification && ( + setNotification(null)} /> + )} + + ); +} diff --git a/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx b/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx index 56377ebaa..83fe6dbf1 100644 --- a/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx @@ -23,7 +23,7 @@ import { getDocMeta } from "../../structure/docMeta"; import { buildContext } from "./markdownContextBuilder"; import { Notification } from "../../components/Notification"; -import { currentPageId, documentationNavigation } from "../../structure/DocumentationNavigation"; +import { currentPageId } from "../../structure/DocumentationNavigation"; import "./TextSelectionMenu.css"; export function TextSelectionMenu({ containerNode }: { containerNode: HTMLDivElement }) { diff --git a/znai-reactjs/src/layout/DocumentationLayout.tsx b/znai-reactjs/src/layout/DocumentationLayout.tsx index 3fdf8e5b7..e9892c68e 100644 --- a/znai-reactjs/src/layout/DocumentationLayout.tsx +++ b/znai-reactjs/src/layout/DocumentationLayout.tsx @@ -32,9 +32,11 @@ import { mainPanelClassName } from "./classNames"; 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; @@ -98,6 +100,7 @@ export function DocumentationLayout({
{contentRef.current && } {contentRef.current && } + {contentRef.current && }
{renderedPage}
diff --git a/znai-reactjs/src/structure/docMeta.ts b/znai-reactjs/src/structure/docMeta.ts index 8aa396091..93152cfcd 100644 --- a/znai-reactjs/src/structure/docMeta.ts +++ b/znai-reactjs/src/structure/docMeta.ts @@ -24,6 +24,7 @@ export interface DocMeta { previewEnabled: boolean; slackChannel?: string; sendToSlackUrl?: string; + slackActiveQuestionsUrl?: string; sendToSlackIncludeContentType?: boolean; useTopHeader?: boolean; hidePresentationTrigger?: boolean; diff --git a/znai-slack-bot/slack_bot.py b/znai-slack-bot/slack_bot.py index 3c6aae7f2..8019f1e5c 100644 --- a/znai-slack-bot/slack_bot.py +++ b/znai-slack-bot/slack_bot.py @@ -161,7 +161,12 @@ def check_slack_message_completion(channel, message_ts): @app.route('/active-questions', methods=['GET']) def get_active_questions(): try: + page_id = request.args.get('pageId') questions = load_questions_from_csv() + + if page_id: + questions = [q for q in questions if q.get('page_id') == page_id] + return jsonify({"questions": questions}), 200 except Exception as e: From 3101ce5933fda7ac99f2dff5c52dc8f8b2852a28 Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Mon, 1 Sep 2025 20:34:55 -0400 Subject: [PATCH 07/16] multiple messages --- .../text-selection/SlackActiveQuestions.tsx | 9 ++++++--- .../src/layout/DocumentationLayout.demo.tsx | 1 + znai-reactjs/src/layout/DocumentationLayout.tsx | 4 +++- .../src/structure/DocumentationNavigation.jsx | 14 +++++++++++--- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx index 972cdb9c4..d63cdb14c 100644 --- a/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx @@ -1,8 +1,9 @@ import React, { useEffect, useState } from "react"; -import { currentPageId } from "../../structure/DocumentationNavigation"; +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"; interface Question { selectedText: string; @@ -11,13 +12,15 @@ interface Question { question: string; } -export function SlackActiveQuestions({ containerNode }: { containerNode: HTMLDivElement }) { +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 pageId = pageIdFromTocItem(tocItem); + useEffect(() => { void fetchActiveQuestions(); - }, []); + }, [pageId]); async function fetchActiveQuestions() { try { 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 e9892c68e..13e56c1f1 100644 --- a/znai-reactjs/src/layout/DocumentationLayout.tsx +++ b/znai-reactjs/src/layout/DocumentationLayout.tsx @@ -46,6 +46,7 @@ interface Props { renderedFooter: React.ReactNode; docMeta: DocMeta; toc: TocItem[]; + tocItem: TocItem; selectedTocItem?: TocItem; onHeaderClick(): void; @@ -69,6 +70,7 @@ interface Props { export function DocumentationLayout({ zoomOverlay, searchPopup, + tocItem, renderedPage, renderedNextPrevNavigation, renderedFooter, @@ -100,7 +102,7 @@ export function DocumentationLayout({
{contentRef.current && } {contentRef.current && } - {contentRef.current && } + {contentRef.current && }
{renderedPage}
diff --git a/znai-reactjs/src/structure/DocumentationNavigation.jsx b/znai-reactjs/src/structure/DocumentationNavigation.jsx index cff89c825..4a85089c7 100644 --- a/znai-reactjs/src/structure/DocumentationNavigation.jsx +++ b/znai-reactjs/src/structure/DocumentationNavigation.jsx @@ -16,7 +16,7 @@ */ import * as Promise from "promise"; -import { getDocId, getDocMeta } from "./docMeta"; +import { getDocId } from "./docMeta"; import { isTocItemIndex } from "./toc/TableOfContents"; import { mainPanelClassName } from "../layout/classNames"; @@ -128,11 +128,19 @@ class DocumentationNavigation { } } +function joinPageIdParts(docId, dirName, fileName) { + return [docId, dirName, fileName].filter((part) => !!part).join("/"); +} + function currentPageId() { const pageLocation = documentationNavigation.currentPageLocation(); - return [getDocMeta().id, pageLocation.dirName, pageLocation.fileName].filter((part) => !!part).join("/"); + return joinPageIdParts(getDocId(), pageLocation.dirName, pageLocation.fileName); +} + +function pageIdFromTocItem(tocItem) { + return joinPageIdParts(getDocId(), tocItem.dirName, tocItem.fileName); } const documentationNavigation = new DocumentationNavigation(); -export { documentationNavigation, currentPageId }; +export { documentationNavigation, currentPageId, pageIdFromTocItem }; From 44871cef8100a779a4a94ff3f510e55a25f995b4 Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Wed, 3 Sep 2025 22:36:14 -0400 Subject: [PATCH 08/16] resolve button --- znai-reactjs/src/App.jsx | 1 + .../text-selection/HighlightedText.tsx | 15 +++-- .../text-selection/ResolveQuestionButton.css | 38 ++++++++++++ .../text-selection/ResolveQuestionButton.tsx | 45 ++++++++++++++ .../text-selection/SlackActiveQuestions.css | 27 ++++++++ .../text-selection/SlackActiveQuestions.tsx | 48 ++++++++++----- znai-reactjs/src/structure/docMeta.ts | 1 + znai-slack-bot/slack_bot.py | 61 +++++++++++++------ 8 files changed, 197 insertions(+), 39 deletions(-) create mode 100644 znai-reactjs/src/doc-elements/text-selection/ResolveQuestionButton.css create mode 100644 znai-reactjs/src/doc-elements/text-selection/ResolveQuestionButton.tsx create mode 100644 znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.css diff --git a/znai-reactjs/src/App.jsx b/znai-reactjs/src/App.jsx index c6287fe7e..f3a069cc8 100644 --- a/znai-reactjs/src/App.jsx +++ b/znai-reactjs/src/App.jsx @@ -116,6 +116,7 @@ const docMeta = { 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/doc-elements/text-selection/HighlightedText.tsx b/znai-reactjs/src/doc-elements/text-selection/HighlightedText.tsx index 0b8a7657d..d9cdae9da 100644 --- a/znai-reactjs/src/doc-elements/text-selection/HighlightedText.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/HighlightedText.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import React, { useEffect, useRef } from "react"; import { TextHighlighter } from "./textHighlighter"; import "./HighlightedText.css"; @@ -10,6 +10,7 @@ interface Props { suffix: string; question: string; displayBubbleAndScrollIntoView: boolean; + additionalView: React.ReactNode; } export function HighlightedText({ @@ -19,9 +20,12 @@ export function HighlightedText({ suffix, question, displayBubbleAndScrollIntoView, + additionalView, }: Props) { const bubbleRef = useRef(null); + const bubbleText = question.endsWith("/") ? question.substring(0, question.length - 1) : question; + useEffect(() => { if (!bubbleRef.current) { return; @@ -36,8 +40,6 @@ export function HighlightedText({ range.setEnd(highlights[highlights.length - 1], highlights[highlights.length - 1].childNodes.length); const selectionRect = range.getBoundingClientRect(); - bubble.innerText = question.endsWith("/") ? question.substring(0, question.length - 1) : question; - const bubbleRect = bubble.getBoundingClientRect(); const top = selectionRect.top - containerRect.top + containerNode.scrollTop - bubbleRect.height - 10; const selectionCenter = selectionRect.left + selectionRect.width / 2.0; @@ -91,5 +93,10 @@ export function HighlightedText({ }; }, []); - return
; + return ( +
+ {bubbleText} + {additionalView} +
+ ); } 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..b50544542 --- /dev/null +++ b/znai-reactjs/src/doc-elements/text-selection/ResolveQuestionButton.css @@ -0,0 +1,38 @@ +.resolve-question-button { + background: none; + border: none; + color: #0066cc; + cursor: pointer; + padding: 0; + font: inherit; + transition: color 0.2s ease; +} + +.resolve-question-button:hover { + color: #0052a3; +} + +.resolve-question-button.confirming { + color: #d32f2f; + font-weight: 500; +} + +.resolve-question-button.confirming:hover { + color: #c62828; +} + +.theme-znai-dark .resolve-question-button { + color: #60a5fa; +} + +.theme-znai-dark .resolve-question-button:hover { + color: #93bbf9; +} + +.theme-znai-dark .resolve-question-button.confirming { + color: #f87171; +} + +.theme-znai-dark .resolve-question-button.confirming:hover { + color: #fca5a5; +} 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..950d0287a --- /dev/null +++ b/znai-reactjs/src/doc-elements/text-selection/ResolveQuestionButton.tsx @@ -0,0 +1,45 @@ +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..7df1e033c --- /dev/null +++ b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.css @@ -0,0 +1,27 @@ +.znai-highlight-bubble-resolve-and-link { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 8px; +} + +.znai-highlight-bubble-link { + display: block; + text-align: right; + color: #93bbf9; + text-decoration: none; + margin-left: 8px; +} + +.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; +} diff --git a/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx index d63cdb14c..11794dbf7 100644 --- a/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx @@ -5,11 +5,16 @@ import { Notification } from "../../components/Notification"; import { HighlightedText } from "./HighlightedText"; import { TocItem } from "../../structure/TocItem"; +import "./SlackActiveQuestions.css"; +import { ResolveQuestionButton } from "./ResolveQuestionButton"; + interface Question { selectedText: string; prefix: string; suffix: string; question: string; + slackLink?: string; + slackMessageTs?: string; } export function SlackActiveQuestions({ containerNode, tocItem }: { containerNode: HTMLDivElement; tocItem: TocItem }) { @@ -39,11 +44,13 @@ export function SlackActiveQuestions({ containerNode, tocItem }: { containerNode if (response.ok) { const data = await response.json(); - const questions = data.questions.map((item: any) => ({ - selectedText: item.selected_text, - prefix: item.selected_prefix, - suffix: item.selected_suffix, + const questions = data.map((item: any) => ({ + selectedText: item.selectedText, + prefix: item.selectedPrefix, + suffix: item.selectedSuffix, question: item.question, + slackLink: item.slackLink, + slackMessageTs: item.slackMessageTs, })); setQuestions(questions); } else { @@ -55,17 +62,28 @@ export function SlackActiveQuestions({ containerNode, tocItem }: { containerNode } } - const renderedQuestions = questions.map((question, idx) => ( - - )); + const renderedQuestions = questions.map((question, idx) => { + const additionalView = ( +
+ console.log("resolve")} /> + + slack thread + +
+ ); + return ( + + ); + }); return ( <> diff --git a/znai-reactjs/src/structure/docMeta.ts b/znai-reactjs/src/structure/docMeta.ts index 93152cfcd..dece21f30 100644 --- a/znai-reactjs/src/structure/docMeta.ts +++ b/znai-reactjs/src/structure/docMeta.ts @@ -26,6 +26,7 @@ export interface DocMeta { 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 8019f1e5c..662099cb5 100644 --- a/znai-slack-bot/slack_bot.py +++ b/znai-slack-bot/slack_bot.py @@ -1,7 +1,6 @@ import os import json import csv -from datetime import datetime from flask import Flask, request, jsonify from flask_cors import CORS from slack_sdk import WebClient @@ -26,7 +25,7 @@ slack_client = WebClient(token=slack_token) CSV_FILE = "active_questions.csv" -CSV_HEADERS = ["timestamp", "page_id", "page_url", "context", "selected_text", "selected_prefix", "selected_suffix", "question", "slack_link", "username", "channel", "message_ts", "completed"] +CSV_HEADERS = ["timestamp", "pageId", "pageUrl", "context", "selectedText", "selectedPrefix", "selectedSuffix", "question", "slackLink", "username", "channel", "slackMessageTs", "completed"] @app.route('/ask-in-slack', methods=['POST']) def ask_in_slack(): @@ -78,19 +77,16 @@ def ask_in_slack(): slack_link = f"https://slack.com/archives/{slack_channel}/p{message_ts.replace('.', '')}" persist_questions_to_csv([{ - 'timestamp': datetime.now().isoformat(), - 'page_id': page_id, - 'page_url': page_url, + 'pageId': page_id, 'context': context or '', - 'selected_text': selected_text or '', - 'selected_prefix': selected_prefix, - 'selected_suffix': selected_suffix, + 'selectedText': selected_text or '', + 'selectedPrefix': selected_prefix, + 'selectedSuffix': selected_suffix, 'question': question, - 'slack_link': slack_link, - 'username': username or 'anonymous', + 'slackLink': slack_link, 'channel': slack_channel, - 'message_ts': message_ts, - 'completed': False + 'slackMessageTs': message_ts, + 'completed': False, }]) print(f"Message posted successfully: {message_ts}") @@ -106,13 +102,13 @@ def ask_in_slack(): return jsonify({"error": str(e)}), 500 -def persist_questions_to_csv(questions): +def persist_questions_to_csv(questions, mode='a'): file_exists = os.path.exists(CSV_FILE) - with open(CSV_FILE, 'a', newline='', encoding='utf-8') as csvfile: + with open(CSV_FILE, mode, newline='', encoding='utf-8') as csvfile: writer = csv.DictWriter(csvfile, fieldnames=CSV_HEADERS) - if not file_exists: + if mode == 'w' or (mode == 'a' and not file_exists): writer.writeheader() for question in questions: @@ -133,12 +129,18 @@ def load_questions_from_csv(): def update_question_completion_status(message_ts, completed): questions = load_questions_from_csv() + updated = False for question in questions: if question['message_ts'] == message_ts: question['completed'] = completed - - persist_questions_to_csv(questions) + updated = True + break + + if updated: + persist_questions_to_csv(questions, mode='w') + + return updated def check_slack_message_completion(channel, message_ts): try: @@ -164,15 +166,34 @@ def get_active_questions(): page_id = request.args.get('pageId') questions = load_questions_from_csv() - if page_id: - questions = [q for q in questions if q.get('page_id') == page_id] + questions = [q for q in questions if q.get('pageId') == page_id] - return jsonify({"questions": questions}), 200 + 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 completed") + return jsonify({"success": True, "message": f"Question {ts} marked as completed"}), 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): message_parts = [f"@{username} <{page_url}|asked>: {question}"] From 5613031a11177426f3517e5bad73b719774992e7 Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Wed, 3 Sep 2025 22:42:17 -0400 Subject: [PATCH 09/16] resolve button --- .../text-selection/ResolveQuestionButton.css | 18 +++++++++--------- .../text-selection/SlackActiveQuestions.css | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/znai-reactjs/src/doc-elements/text-selection/ResolveQuestionButton.css b/znai-reactjs/src/doc-elements/text-selection/ResolveQuestionButton.css index b50544542..3efe875eb 100644 --- a/znai-reactjs/src/doc-elements/text-selection/ResolveQuestionButton.css +++ b/znai-reactjs/src/doc-elements/text-selection/ResolveQuestionButton.css @@ -1,7 +1,7 @@ .resolve-question-button { background: none; border: none; - color: #0066cc; + color: #a8b9e3; cursor: pointer; padding: 0; font: inherit; @@ -9,30 +9,30 @@ } .resolve-question-button:hover { - color: #0052a3; + color: #c5d3ed; } .resolve-question-button.confirming { - color: #d32f2f; + color: #e6c074; font-weight: 500; } .resolve-question-button.confirming:hover { - color: #c62828; + color: #f0d08a; } .theme-znai-dark .resolve-question-button { - color: #60a5fa; + color: #7a9bd6; } .theme-znai-dark .resolve-question-button:hover { - color: #93bbf9; + color: #95b0e0; } .theme-znai-dark .resolve-question-button.confirming { - color: #f87171; + color: #d4a85c; } .theme-znai-dark .resolve-question-button.confirming:hover { - color: #fca5a5; -} + color: #e0b870; +} \ No newline at end of file diff --git a/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.css b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.css index 7df1e033c..a36db98f0 100644 --- a/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.css +++ b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.css @@ -10,7 +10,7 @@ text-align: right; color: #93bbf9; text-decoration: none; - margin-left: 8px; + margin-left: 16px; } .znai-highlight-bubble-link:hover { From aa2cbc2ad8d905d281a7971767204f17a9ef45c9 Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Sat, 6 Sep 2025 14:43:28 -0400 Subject: [PATCH 10/16] resolve button --- .../text-selection/HighlightedText.css | 5 ++- .../text-selection/HighlightedText.tsx | 4 +- .../text-selection/ResolveQuestionButton.css | 8 ++-- .../text-selection/ResolveQuestionButton.tsx | 11 ++--- .../text-selection/SlackActiveQuestions.css | 40 +++++++++++++++++-- .../text-selection/SlackActiveQuestions.tsx | 6 ++- 6 files changed, 55 insertions(+), 19 deletions(-) diff --git a/znai-reactjs/src/doc-elements/text-selection/HighlightedText.css b/znai-reactjs/src/doc-elements/text-selection/HighlightedText.css index 94cad8250..ccac36c91 100644 --- a/znai-reactjs/src/doc-elements/text-selection/HighlightedText.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; @@ -14,6 +13,10 @@ z-index: 20; } +.znai-highlight-question-bubble-text { + padding: 8px 16px; +} + .znai-highlight-question-bubble::after { content: ""; position: absolute; diff --git a/znai-reactjs/src/doc-elements/text-selection/HighlightedText.tsx b/znai-reactjs/src/doc-elements/text-selection/HighlightedText.tsx index d9cdae9da..57f043e95 100644 --- a/znai-reactjs/src/doc-elements/text-selection/HighlightedText.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/HighlightedText.tsx @@ -26,6 +26,8 @@ export function HighlightedText({ const bubbleText = question.endsWith("/") ? question.substring(0, question.length - 1) : question; + // TODO re-calculate bubble position on window size change + useEffect(() => { if (!bubbleRef.current) { return; @@ -95,7 +97,7 @@ export function HighlightedText({ return (
- {bubbleText} +
{bubbleText}
{additionalView}
); diff --git a/znai-reactjs/src/doc-elements/text-selection/ResolveQuestionButton.css b/znai-reactjs/src/doc-elements/text-selection/ResolveQuestionButton.css index 3efe875eb..53984311c 100644 --- a/znai-reactjs/src/doc-elements/text-selection/ResolveQuestionButton.css +++ b/znai-reactjs/src/doc-elements/text-selection/ResolveQuestionButton.css @@ -22,17 +22,17 @@ } .theme-znai-dark .resolve-question-button { - color: #7a9bd6; + color: #b8cef0; } .theme-znai-dark .resolve-question-button:hover { - color: #95b0e0; + color: #d0dff5; } .theme-znai-dark .resolve-question-button.confirming { - color: #d4a85c; + color: #f0c674; } .theme-znai-dark .resolve-question-button.confirming:hover { - color: #e0b870; + 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 index 950d0287a..b9c248c0c 100644 --- a/znai-reactjs/src/doc-elements/text-selection/ResolveQuestionButton.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/ResolveQuestionButton.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect, useRef } from 'react'; -import './ResolveQuestionButton.css'; +import React, { useState, useEffect, useRef } from "react"; +import "./ResolveQuestionButton.css"; interface Props { onClick(): void; @@ -35,11 +35,8 @@ export function ResolveQuestionButton({ onClick }: Props) { }; return ( - ); } diff --git a/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.css b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.css index a36db98f0..1bf7ee483 100644 --- a/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.css +++ b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.css @@ -2,15 +2,35 @@ display: flex; align-items: center; justify-content: space-between; - margin-top: 8px; + 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: block; - text-align: right; + display: inline-block; + background: none; + border: none; + padding: 0; color: #93bbf9; text-decoration: none; - margin-left: 16px; + transition: color 0.2s ease; } .znai-highlight-bubble-link:hover { @@ -25,3 +45,15 @@ .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 index 11794dbf7..d8ed4bdc5 100644 --- a/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx @@ -65,9 +65,11 @@ export function SlackActiveQuestions({ containerNode, tocItem }: { containerNode const renderedQuestions = questions.map((question, idx) => { const additionalView = (
- console.log("resolve")} /> +
+ console.log("resolve")} /> +
- slack thread + open thread
); From ebd8d38314770d90b633c60a6653d7abaecbc6bc Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Sat, 6 Sep 2025 15:28:33 -0400 Subject: [PATCH 11/16] resolve button --- .../text-selection/SlackActiveQuestions.tsx | 44 +++++++++++++++---- znai-slack-bot/slack_bot.py | 6 +-- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx index d8ed4bdc5..73f2dbddf 100644 --- a/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx @@ -5,16 +5,16 @@ import { Notification } from "../../components/Notification"; import { HighlightedText } from "./HighlightedText"; import { TocItem } from "../../structure/TocItem"; -import "./SlackActiveQuestions.css"; import { ResolveQuestionButton } from "./ResolveQuestionButton"; +import "./SlackActiveQuestions.css"; interface Question { selectedText: string; prefix: string; suffix: string; question: string; - slackLink?: string; - slackMessageTs?: string; + slackLink: string; + slackMessageTs: string; } export function SlackActiveQuestions({ containerNode, tocItem }: { containerNode: HTMLDivElement; tocItem: TocItem }) { @@ -62,12 +62,40 @@ export function SlackActiveQuestions({ containerNode, tocItem }: { containerNode } } - const renderedQuestions = questions.map((question, idx) => { - const additionalView = ( -
+ 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 (
- console.log("resolve")} /> + resolveQuestionPost(question)} />
+ ); + } + + const additionalView = ( +
+ {maybeResolveButton()} open thread @@ -75,7 +103,7 @@ export function SlackActiveQuestions({ containerNode, tocItem }: { containerNode ); return ( Date: Sat, 6 Sep 2025 15:32:39 -0400 Subject: [PATCH 12/16] resolve button --- .../text-selection/HighlightedText.tsx | 19 ++++++++++++------- .../text-selection/SlackActiveQuestions.tsx | 14 +++++++------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/znai-reactjs/src/doc-elements/text-selection/HighlightedText.tsx b/znai-reactjs/src/doc-elements/text-selection/HighlightedText.tsx index 57f043e95..5be074a09 100644 --- a/znai-reactjs/src/doc-elements/text-selection/HighlightedText.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/HighlightedText.tsx @@ -5,9 +5,9 @@ import "./HighlightedText.css"; interface Props { containerNode: HTMLDivElement; - textSelection: string; - prefix: string; - suffix: string; + selectedText: string; + selectedPrefix: string; + selectedSuffix: string; question: string; displayBubbleAndScrollIntoView: boolean; additionalView: React.ReactNode; @@ -15,9 +15,9 @@ interface Props { export function HighlightedText({ containerNode, - textSelection, - prefix, - suffix, + selectedText, + selectedPrefix, + selectedSuffix, question, displayBubbleAndScrollIntoView, additionalView, @@ -73,7 +73,12 @@ export function HighlightedText({ } const highlighter = new TextHighlighter(containerNode); - const highlights = highlighter.highlight(textSelection, prefix, suffix, question ? toggleBubble : null); + const highlights = highlighter.highlight( + selectedText, + selectedPrefix, + selectedSuffix, + question ? toggleBubble : null + ); const firstHighlightedElement = highlights[0]; if (!firstHighlightedElement) { return; diff --git a/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx index 73f2dbddf..3c8707cb9 100644 --- a/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx @@ -10,8 +10,8 @@ import "./SlackActiveQuestions.css"; interface Question { selectedText: string; - prefix: string; - suffix: string; + selectedPrefix: string; + selectedSuffix: string; question: string; slackLink: string; slackMessageTs: string; @@ -46,8 +46,8 @@ export function SlackActiveQuestions({ containerNode, tocItem }: { containerNode const data = await response.json(); const questions = data.map((item: any) => ({ selectedText: item.selectedText, - prefix: item.selectedPrefix, - suffix: item.selectedSuffix, + selectedPrefix: item.selectedPrefix, + selectedSuffix: item.selectedSuffix, question: item.question, slackLink: item.slackLink, slackMessageTs: item.slackMessageTs, @@ -105,9 +105,9 @@ export function SlackActiveQuestions({ containerNode, tocItem }: { containerNode Date: Sat, 6 Sep 2025 16:17:00 -0400 Subject: [PATCH 13/16] resolve button --- .../text-selection/HighlightUrlText.tsx | 7 ++-- .../text-selection/SlackActiveQuestions.tsx | 16 +++++++-- .../text-selection/TextSelectionMenu.tsx | 3 -- znai-slack-bot/slack_bot.py | 34 +++++++++++-------- 4 files changed, 36 insertions(+), 24 deletions(-) diff --git a/znai-reactjs/src/doc-elements/text-selection/HighlightUrlText.tsx b/znai-reactjs/src/doc-elements/text-selection/HighlightUrlText.tsx index 7108566d2..5ef8a6702 100644 --- a/znai-reactjs/src/doc-elements/text-selection/HighlightUrlText.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/HighlightUrlText.tsx @@ -23,11 +23,12 @@ export function HighlightUrlText({ containerNode }: { containerNode: HTMLDivElem return params ? ( ) : null; } diff --git a/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx index 3c8707cb9..5a4ba0710 100644 --- a/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx @@ -9,18 +9,23 @@ import { ResolveQuestionButton } from "./ResolveQuestionButton"; import "./SlackActiveQuestions.css"; interface Question { + id: string; selectedText: string; selectedPrefix: string; selectedSuffix: string; question: 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(() => { @@ -35,7 +40,7 @@ export function SlackActiveQuestions({ containerNode, tocItem }: { containerNode return; } - const url = `${baseUrl}?pageId=${encodeURIComponent(pageId)}`; + const url = `${baseUrl}?pageId=${encodeURIComponent(pageId)}&questionId=${questionId}`; const response = await fetch(url, { method: "GET", @@ -51,6 +56,8 @@ export function SlackActiveQuestions({ containerNode, tocItem }: { containerNode question: item.question, slackLink: item.slackLink, slackMessageTs: item.slackMessageTs, + id: item.id, + resolved: item.resolved, })); setQuestions(questions); } else { @@ -86,7 +93,9 @@ export function SlackActiveQuestions({ containerNode, tocItem }: { containerNode return null; } - return ( + return question.resolved ? ( +
resolved
+ ) : (
resolveQuestionPost(question)} />
@@ -101,6 +110,7 @@ export function SlackActiveQuestions({ containerNode, tocItem }: { containerNode
); + return ( ); }); diff --git a/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx b/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx index 83fe6dbf1..7b5abfd15 100644 --- a/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx @@ -214,14 +214,11 @@ export function TextSelectionMenu({ containerNode }: { containerNode: HTMLDivEle return; } - const pageUrl = buildHighlightUrl({ ...panelData!.prefixSuffixMatch, question }); const body = { selectedText: panelData!.prefixSuffixMatch.selection, selectedPrefix: panelData!.prefixSuffixMatch.prefix, selectedSuffix: panelData!.prefixSuffixMatch.suffix, - pageUrl: pageUrl, pageId: currentPageId(), - username: "web-user", slackChannel: getDocMeta().slackChannel, question: question, context: panelData!.context, diff --git a/znai-slack-bot/slack_bot.py b/znai-slack-bot/slack_bot.py index 20d863eac..d8814ebc9 100644 --- a/znai-slack-bot/slack_bot.py +++ b/znai-slack-bot/slack_bot.py @@ -1,6 +1,7 @@ import os import json import csv +import uuid from flask import Flask, request, jsonify from flask_cors import CORS from slack_sdk import WebClient @@ -25,7 +26,7 @@ slack_client = WebClient(token=slack_token) CSV_FILE = "active_questions.csv" -CSV_HEADERS = ["timestamp", "pageId", "pageUrl", "context", "selectedText", "selectedPrefix", "selectedSuffix", "question", "slackLink", "username", "channel", "slackMessageTs", "completed"] +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(): @@ -40,12 +41,14 @@ def ask_in_slack(): 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') selected_prefix = data.get('selectedPrefix', '') selected_suffix = data.get('selectedSuffix', '') - page_id = data.get('pageId') - page_url = data.get('pageUrl') username = data.get('username') slack_channel = data.get('slackChannel') question = data.get('question') @@ -77,6 +80,7 @@ def ask_in_slack(): 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 '', @@ -86,7 +90,7 @@ def ask_in_slack(): 'slackLink': slack_link, 'channel': slack_channel, 'slackMessageTs': message_ts, - 'completed': False, + 'resolved': False, }]) print(f"Message posted successfully: {message_ts}") @@ -122,18 +126,18 @@ def load_questions_from_csv(): with open(CSV_FILE, 'r', newline='', encoding='utf-8') as csvfile: reader = csv.DictReader(csvfile) for row in reader: - if not row['completed'].lower() == 'true': - questions.append(row) - + row['resolved'] = row['resolved'].lower() == 'true' + questions.append(row) + return questions -def update_question_completion_status(message_ts, completed): +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['completed'] = completed + question['resolved'] = resolved updated = True break @@ -152,7 +156,7 @@ def check_slack_message_completion(channel, 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', 'completed', 'done']: + if reaction['name'] in ['white_check_mark', 'heavy_check_mark', 'resolved', 'done']: return True return False @@ -164,9 +168,9 @@ def check_slack_message_completion(channel, message_ts): 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] + 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 @@ -182,8 +186,8 @@ def resolve_slack_question(ts): updated = update_question_completion_status(ts, True) if updated: - print(f"Successfully marked question with ts {ts} as completed") - return jsonify({"success": True, "message": f"Question {ts} marked as completed"}), 200 + 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 From f0cb1b74bd65cf96c9259885c486e0934b5bc632 Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Sat, 6 Sep 2025 18:09:37 -0400 Subject: [PATCH 14/16] resolve button --- .../text-selection/HighlightedText.tsx | 11 +++++++ .../text-selection/TextSelectionMenu.tsx | 33 +++++++++++++++---- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/znai-reactjs/src/doc-elements/text-selection/HighlightedText.tsx b/znai-reactjs/src/doc-elements/text-selection/HighlightedText.tsx index 5be074a09..4a2cf5a1f 100644 --- a/znai-reactjs/src/doc-elements/text-selection/HighlightedText.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/HighlightedText.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useRef } from "react"; import { TextHighlighter } from "./textHighlighter"; +import { addTextMenuListener, removeTextMenuListener, TextMenuListener } from "./TextSelectionMenu"; import "./HighlightedText.css"; interface Props { @@ -35,6 +36,13 @@ export function HighlightedText({ const bubble = bubbleRef.current as HTMLDivElement; + const textMenuListener: TextMenuListener = { + onHide() {}, + onShow() { + hideBubble(); + }, + }; + function updateBubblePosition() { const containerRect = containerNode.getBoundingClientRect(); const range = document.createRange(); @@ -94,9 +102,12 @@ export function HighlightedText({ }, 100); } + addTextMenuListener(textMenuListener); + return () => { highlighter.clearHighlights(); hideBubble(); + removeTextMenuListener(textMenuListener); }; }, []); diff --git a/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx b/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx index 7b5abfd15..9b6d3c6ee 100644 --- a/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx @@ -26,6 +26,21 @@ 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); @@ -181,7 +196,7 @@ export function TextSelectionMenu({ containerNode }: { containerNode: HTMLDivEle function handleKeyDown(e: KeyboardEvent) { if (e.key === "Escape") { - hidePopover(); + hideMenu(); } } @@ -202,7 +217,7 @@ export function TextSelectionMenu({ containerNode }: { containerNode: HTMLDivEle 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}` }); } @@ -240,7 +255,7 @@ export function TextSelectionMenu({ containerNode }: { containerNode: HTMLDivEle 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}` }); } @@ -258,9 +273,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"; } @@ -269,19 +286,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; } @@ -309,7 +328,7 @@ export function TextSelectionMenu({ containerNode }: { containerNode: HTMLDivEle const selection = getSelection(); if (selection === null || selection.rangeCount === 0 || selection.isCollapsed) { - hidePopover(); + hideMenu(); return; } } From 4263fe24370210b03fdf928db26f7849e9f73dd2 Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Sat, 6 Sep 2025 18:15:13 -0400 Subject: [PATCH 15/16] resolve button --- .../src/doc-elements/text-selection/textHighlighter.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/znai-reactjs/src/doc-elements/text-selection/textHighlighter.js b/znai-reactjs/src/doc-elements/text-selection/textHighlighter.js index 781ae457b..c9da05bf1 100644 --- a/znai-reactjs/src/doc-elements/text-selection/textHighlighter.js +++ b/znai-reactjs/src/doc-elements/text-selection/textHighlighter.js @@ -116,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; @@ -155,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); From c005a946afbaccf269c57cb15eea8d5b7c6e1a2b Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Sat, 6 Sep 2025 20:02:28 -0400 Subject: [PATCH 16/16] detached questions --- znai-reactjs/src/diff/DiffTracking.demo.tsx | 29 +- znai-reactjs/src/diff/DiffTracking.tsx | 8 +- .../src/doc-elements/Documentation.jsx | 1315 +++++++++-------- .../src/doc-elements/page/PageTitle.jsx | 108 +- .../page/default/DefaultPageContent.jsx | 30 +- .../text-selection/HighlightUrlText.tsx | 1 + .../text-selection/HighlightedText.css | 31 + .../text-selection/HighlightedText.tsx | 178 ++- .../text-selection/SlackActiveQuestions.tsx | 3 + .../text-selection/TextSelectionMenu.tsx | 9 +- .../text-selection/highlightUrl.ts | 7 + .../src/layout/DocumentationLayout.tsx | 2 +- .../{classNames.ts => classNamesAndIds.ts} | 2 + .../src/structure/DocumentationNavigation.jsx | 2 +- 14 files changed, 928 insertions(+), 797 deletions(-) rename znai-reactjs/src/layout/{classNames.ts => classNamesAndIds.ts} (85%) 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 5ef8a6702..c2a9e0890 100644 --- a/znai-reactjs/src/doc-elements/text-selection/HighlightUrlText.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/HighlightUrlText.tsx @@ -27,6 +27,7 @@ export function HighlightUrlText({ containerNode }: { containerNode: HTMLDivElem selectedPrefix={params.prefix} selectedSuffix={params.suffix} question={params.question} + context={params.context} displayBubbleAndScrollIntoView={true} additionalView={null} /> diff --git a/znai-reactjs/src/doc-elements/text-selection/HighlightedText.css b/znai-reactjs/src/doc-elements/text-selection/HighlightedText.css index ccac36c91..5ea3c4427 100644 --- a/znai-reactjs/src/doc-elements/text-selection/HighlightedText.css +++ b/znai-reactjs/src/doc-elements/text-selection/HighlightedText.css @@ -39,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 index 4a2cf5a1f..8b35273d2 100644 --- a/znai-reactjs/src/doc-elements/text-selection/HighlightedText.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/HighlightedText.tsx @@ -1,7 +1,11 @@ -import React, { useEffect, useRef } from "react"; +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 { @@ -10,6 +14,7 @@ interface Props { selectedPrefix: string; selectedSuffix: string; question: string; + context: string; displayBubbleAndScrollIntoView: boolean; additionalView: React.ReactNode; } @@ -20,86 +25,126 @@ export function HighlightedText({ selectedPrefix, selectedSuffix, question, + context, displayBubbleAndScrollIntoView, additionalView, }: Props) { - const bubbleRef = useRef(null); + const cleanedUpQuestion = question.endsWith("/") ? question.substring(0, question.length - 1) : question; - const bubbleText = question.endsWith("/") ? question.substring(0, question.length - 1) : question; + const bubbleRef = useRef(null); + const detachedQuestionRef = useRef(null); + const [bubbleContent, setBubbleContent] = useState(<>{cleanedUpQuestion}); - // TODO re-calculate bubble position on window size change + let [maybeDetachedHighlightedText, setMaybeDetachedHighlightedText] = useState(null); - useEffect(() => { + 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 textMenuListener: TextMenuListener = { - onHide() {}, - onShow() { - hideBubble(); - }, - }; + const containerRect = containerNode.getBoundingClientRect(); + const range = document.createRange(); + range.setStart(firstElement, 0); + range.setEnd(lastElement, lastElement.childNodes.length); + const selectionRect = range.getBoundingClientRect(); - function updateBubblePosition() { - 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 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; - 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`; + } - 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 showBubbleAndCalcPositionIfHasQuestion() { - if (!question) { - return; - } - bubble.style.display = "block"; - updateBubblePosition(); + function hideBubble() { + if (!bubbleRef.current) { + return; } + const bubble = bubbleRef.current as HTMLDivElement; + bubble.style.display = "none"; + } - function hideBubble() { - bubble.style.display = "none"; + function toggleBubble(firstElement: any, lastElement: any) { + if (!bubbleRef.current) { + return; } - function toggleBubble() { - const bubble = bubbleRef.current as HTMLDivElement; - if (bubble.style.display === "block") { - hideBubble(); - } else { - showBubbleAndCalcPositionIfHasQuestion(); - } + 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 : null + question ? () => toggleBubble(firstHighlightedElement, highlights[highlights.length - 1]) : null ); const firstHighlightedElement = highlights[0]; if (!firstHighlightedElement) { - return; + 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 (question && bubbleRef.current && displayBubbleAndScrollIntoView) { - showBubbleAndCalcPositionIfHasQuestion(); - } + if (firstHighlightedElement) { + if (question && bubbleRef.current && displayBubbleAndScrollIntoView) { + showBubbleAndCalcPositionIfHasQuestion(firstHighlightedElement, highlights[highlights.length - 1]); + } - if (displayBubbleAndScrollIntoView) { - setTimeout(() => { - firstHighlightedElement.scrollIntoView({ behavior: "smooth", block: "center" }); - }, 100); + scrollToBubbleIfRequired(firstHighlightedElement); } addTextMenuListener(textMenuListener); @@ -111,10 +156,39 @@ export function HighlightedText({ }; }, []); + 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 ( -
-
{bubbleText}
- {additionalView} -
+ <> +
+
{bubbleContent}
+ {additionalView} +
+ {maybeDetachedHighlightedText} + ); } diff --git a/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx index 5a4ba0710..b415ed964 100644 --- a/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx @@ -14,6 +14,7 @@ interface Question { selectedPrefix: string; selectedSuffix: string; question: string; + context: string; slackLink: string; slackMessageTs: string; resolved: boolean; @@ -54,6 +55,7 @@ export function SlackActiveQuestions({ containerNode, tocItem }: { containerNode selectedPrefix: item.selectedPrefix, selectedSuffix: item.selectedSuffix, question: item.question, + context: item.context, slackLink: item.slackLink, slackMessageTs: item.slackMessageTs, id: item.id, @@ -119,6 +121,7 @@ export function SlackActiveQuestions({ containerNode, tocItem }: { containerNode selectedPrefix={question.selectedPrefix} selectedSuffix={question.selectedSuffix} question={question.question} + context={question.context} additionalView={additionalView} displayBubbleAndScrollIntoView={!!questionId && question.id === questionId} /> diff --git a/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx b/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx index 9b6d3c6ee..431ef84b9 100644 --- a/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx @@ -201,8 +201,9 @@ export function TextSelectionMenu({ containerNode }: { containerNode: HTMLDivEle } function handleGenerateLink() { + const context = buildContext(); const prefixSuffixMatch = findPrefixSuffixAndMatch(containerNode); - setPanelData({ type: "linkgen", context: "", prefixSuffixMatch }); + setPanelData({ type: "linkgen", context, prefixSuffixMatch }); } function handleAskInSlack() { @@ -213,7 +214,11 @@ export function TextSelectionMenu({ containerNode }: { containerNode: HTMLDivEle async function generateLink() { const comment = linkCommentInputRef.current?.value?.trim() || ""; - const pageUrl = buildHighlightUrl({ ...panelData!.prefixSuffixMatch, question: comment }); + 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" }); diff --git a/znai-reactjs/src/doc-elements/text-selection/highlightUrl.ts b/znai-reactjs/src/doc-elements/text-selection/highlightUrl.ts index 158f89b45..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; + context: string; } export function extractHighlightParams(): HighlightParams | null { @@ -32,6 +34,7 @@ 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 { @@ -39,6 +42,7 @@ export function extractHighlightParams(): HighlightParams | null { selection: decodeURIComponent(selection), suffix: decodeURIComponent(suffix), question: question ? decodeURIComponent(question) : "", + context: context ? decodeURIComponent(context) : "", }; } @@ -58,6 +62,9 @@ export function buildHighlightUrl(params: HighlightParams): string { 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/layout/DocumentationLayout.tsx b/znai-reactjs/src/layout/DocumentationLayout.tsx index 13e56c1f1..5dd6db5e3 100644 --- a/znai-reactjs/src/layout/DocumentationLayout.tsx +++ b/znai-reactjs/src/layout/DocumentationLayout.tsx @@ -27,7 +27,7 @@ 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"; 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 4a85089c7..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" };