[WIKI-509] feat: comment copy link option (#7385)

* feat: comment copy link option

* chore: add translations

* chore: update block position

* chore: rename use id scroll hook

* refactor: setTimeout function

* refactor: use-hash-scroll hook
This commit is contained in:
Aaryan Khandelwal 2025-07-14 17:07:44 +05:30 committed by GitHub
parent f90e553881
commit 2c70c1aaa8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 694 additions and 250 deletions

View file

@ -1,3 +1,4 @@
export * from "./use-hash-scroll";
export * from "./use-local-storage";
export * from "./use-outside-click-detector";
export * from "./use-platform-os";

View file

@ -0,0 +1,128 @@
import { useCallback, useEffect, useState } from "react";
type TArgs = {
elementId: string;
pathname: string;
scrollDelay?: number;
};
type TReturnType = {
isHashMatch: boolean;
hashIds: string[];
scrollToElement: () => boolean;
};
/**
* Custom hook for handling hash-based scrolling to a specific element
* Supports multiple IDs in URL hash (comma-separated, space-separated, or other delimiters)
*
* @param {TArgs} args - The ID of the element to scroll to
* @returns {TReturnType} Object containing hash match status and scroll function
*/
export const useHashScroll = (args: TArgs): TReturnType => {
const { elementId, pathname, scrollDelay = 200 } = args;
// State to track if the current hash contains the provided element ID
const [isHashMatch, setIsHashMatch] = useState(false);
// State to track all IDs found in the hash
const [hashIds, setHashIds] = useState<string[]>([]);
/**
* Scrolls to the element with the provided ID
* @returns {boolean} - Whether the scroll was successful
*/
const scrollToElement = useCallback((): boolean => {
try {
const element = document.getElementById(elementId);
if (element) {
setTimeout(() => {
element.scrollIntoView({
behavior: "smooth",
block: "center",
});
}, scrollDelay);
return true;
}
return false;
} catch (error) {
console.warn("Hash scroll error:", error);
return false;
}
}, [elementId, scrollDelay]);
/**
* Extracts multiple IDs from hash string
* Supports various delimiters: comma, space, pipe, semicolon
* @param {string} hashString - The hash part of the URL
* @returns {string[]} - Array of clean ID strings
*/
const extractIdsFromHash = (hashString: string | null): string[] => {
if (!hashString) return [];
// Split by common delimiters and clean up
return hashString
.split(/[,\s|;]+/) // Split by comma, space, pipe, or semicolon
.map((id) => id.trim()) // Remove whitespace
.filter((id) => id.length > 0); // Remove empty strings
};
/**
* Get current hash from window.location
* @returns {string | null} - Current hash without the # symbol
*/
const getCurrentHash = (): string | null => {
if (typeof window === "undefined") return null;
const hash = window.location.hash;
return hash ? hash.slice(1) : null; // Remove the # symbol
};
// Effect to handle hash changes and initial load
useEffect(() => {
if (!elementId) {
setIsHashMatch(false);
setHashIds([]);
return;
}
const handleHashChange = () => {
const hash = getCurrentHash();
// Extract all IDs from the hash
const idsInHash = extractIdsFromHash(hash);
setHashIds(idsInHash);
// Check if provided element ID is present in the hash
const hashMatches = idsInHash.includes(elementId);
setIsHashMatch(hashMatches);
// If hash matches, attempt to scroll to the element
if (hashMatches) {
scrollToElement();
}
};
// Handle initial load
handleHashChange();
// Listen for hash changes
window.addEventListener("hashchange", handleHashChange);
return () => {
window.removeEventListener("hashchange", handleHashChange);
};
}, [elementId, pathname, scrollToElement]); // Include pathname to handle route changes
// Return object with hash match status and utility functions
return {
// Whether the current URL hash contains the provided element ID
isHashMatch,
// Array of all IDs found in the current hash
hashIds,
// Manually trigger scroll to the element
scrollToElement,
};
};

View file

@ -1042,6 +1042,10 @@
},
"upload": {
"error": "Nahrání přílohy se nezdařilo. Zkuste to prosím později."
},
"copy_link": {
"success": "Odkaz na komentář byl zkopírován do schránky",
"error": "Chyba při kopírování odkazu na komentář. Zkuste to prosím později."
}
},
"empty_state": {

View file

@ -1042,6 +1042,10 @@
},
"upload": {
"error": "Anhang konnte nicht hochgeladen werden. Bitte versuchen Sie es später erneut."
},
"copy_link": {
"success": "Kommentar-Link in die Zwischenablage kopiert",
"error": "Fehler beim Kopieren des Kommentar-Links. Bitte versuchen Sie es später erneut."
}
},
"empty_state": {

View file

@ -885,6 +885,10 @@
},
"upload": {
"error": "Asset upload failed. Please try again later."
},
"copy_link": {
"success": "Comment link copied to clipboard",
"error": "Error copying comment link. Please try again later."
}
},
"empty_state": {

View file

@ -1045,6 +1045,10 @@
},
"upload": {
"error": "Error al subir el archivo. Por favor, inténtalo más tarde."
},
"copy_link": {
"success": "Enlace del comentario copiado al portapapeles",
"error": "Error al copiar el enlace del comentario. Inténtelo de nuevo más tarde."
}
},
"empty_state": {

View file

@ -1043,6 +1043,10 @@
},
"upload": {
"error": "Échec du téléchargement du fichier. Veuillez réessayer plus tard."
},
"copy_link": {
"success": "Lien du commentaire copié dans le presse-papiers",
"error": "Erreur lors de la copie du lien du commentaire. Veuillez réessayer plus tard."
}
},
"empty_state": {

View file

@ -1042,6 +1042,10 @@
},
"upload": {
"error": "Gagal mengunggah aset. Silakan coba lagi nanti."
},
"copy_link": {
"success": "Tautan komentar berhasil disalin ke clipboard",
"error": "Gagal menyalin tautan komentar. Silakan coba lagi nanti."
}
},
"empty_state": {

View file

@ -1041,6 +1041,10 @@
},
"upload": {
"error": "Caricamento dell'asset fallito. Per favore, riprova più tardi."
},
"copy_link": {
"success": "Link del commento copiato negli appunti",
"error": "Errore durante la copia del link del commento. Riprova più tardi."
}
},
"empty_state": {

View file

@ -1043,6 +1043,10 @@
},
"upload": {
"error": "アセットのアップロードに失敗しました。後でもう一度お試しください。"
},
"copy_link": {
"success": "コメントリンクがクリップボードにコピーされました",
"error": "コメントリンクのコピーに失敗しました。後でもう一度お試しください。"
}
},
"empty_state": {

View file

@ -1044,6 +1044,10 @@
},
"upload": {
"error": "자산 업로드 실패. 나중에 다시 시도해주세요."
},
"copy_link": {
"success": "댓글 링크가 클립보드에 복사되었습니다",
"error": "댓글 링크 복사 중 오류가 발생했습니다. 나중에 다시 시도해 주세요."
}
},
"empty_state": {

View file

@ -1044,6 +1044,10 @@
},
"upload": {
"error": "Nie udało się przesłać załącznika. Spróbuj później."
},
"copy_link": {
"success": "Link do komentarza skopiowany do schowka",
"error": "Błąd podczas kopiowania linka do komentarza. Spróbuj ponownie później."
}
},
"empty_state": {

View file

@ -1044,6 +1044,10 @@
},
"upload": {
"error": "Falha ao carregar o recurso. Por favor, tente novamente mais tarde."
},
"copy_link": {
"success": "Link do comentário copiado para a área de transferência",
"error": "Erro ao copiar o link do comentário. Tente novamente mais tarde."
}
},
"empty_state": {

View file

@ -1042,6 +1042,10 @@
},
"upload": {
"error": "Încărcarea fișierului a eșuat. Te rugăm să încerci mai târziu."
},
"copy_link": {
"success": "Linkul comentariului a fost copiat în clipboard",
"error": "Eroare la copierea linkului comentariului. Încercați din nou mai târziu."
}
},
"empty_state": {

View file

@ -1044,6 +1044,10 @@
},
"upload": {
"error": "Ошибка загрузки файла. Попробуйте позже."
},
"copy_link": {
"success": "Ссылка на комментарий скопирована в буфер обмена",
"error": "Ошибка при копировании ссылки на комментарий. Попробуйте позже."
}
},
"empty_state": {

View file

@ -1044,6 +1044,10 @@
},
"upload": {
"error": "Nahratie prílohy zlyhalo. Skúste to prosím neskôr."
},
"copy_link": {
"success": "Odkaz na komentár bol skopírovaný do schránky",
"error": "Chyba pri kopírovaní odkazu na komentár. Skúste to prosím neskôr."
}
},
"empty_state": {

View file

@ -1045,6 +1045,10 @@
},
"upload": {
"error": "Dosya yüklenemedi. Lütfen daha sonra tekrar deneyin."
},
"copy_link": {
"success": "Yorum bağlantısı panoya kopyalandı",
"error": "Yorum bağlantısı kopyalanırken hata oluştu. Lütfen daha sonra tekrar deneyin."
}
},
"empty_state": {

View file

@ -1044,6 +1044,10 @@
},
"upload": {
"error": "Не вдалося завантажити вкладення. Спробуйте пізніше."
},
"copy_link": {
"success": "Посилання на коментар скопійовано в буфер обміну",
"error": "Помилка при копіюванні посилання на коментар. Спробуйте пізніше."
}
},
"empty_state": {

View file

@ -1043,6 +1043,10 @@
},
"upload": {
"error": "Không thể tải lên tài nguyên. Vui lòng thử lại sau."
},
"copy_link": {
"success": "Liên kết bình luận đã được sao chép vào clipboard",
"error": "Lỗi khi sao chép liên kết bình luận. Vui lòng thử lại sau."
}
},
"empty_state": {

View file

@ -1043,6 +1043,10 @@
},
"upload": {
"error": "资源上传失败。请稍后重试。"
},
"copy_link": {
"success": "评论链接已复制到剪贴板",
"error": "复制评论链接时出错。请稍后再试。"
}
},
"empty_state": {

View file

@ -1044,6 +1044,10 @@
},
"upload": {
"error": "資產上傳失敗。請稍後再試。"
},
"copy_link": {
"success": "評論連結已複製到剪貼簿",
"error": "複製評論連結時出錯。請稍後再試。"
}
},
"empty_state": {

View file

@ -41,12 +41,13 @@ export type TIssueComment = {
};
export type TCommentsOperations = {
copyCommentLink: (commentId: string) => void;
createComment: (data: Partial<TIssueComment>) => Promise<Partial<TIssueComment> | undefined>;
updateComment: (commentId: string, data: Partial<TIssueComment>) => Promise<void>;
removeComment: (commentId: string) => Promise<void>;
uploadCommentAsset: (blockId: string, file: File, commentId?: string) => Promise<TFileSignedURLResponse>;
addCommentReaction: (commentId: string, reactionEmoji: string) => Promise<void>;
deleteCommentReaction: (commentId: string, reactionEmoji: string, userReactions: TCommentReaction[]) => Promise<void>;
deleteCommentReaction: (commentId: string, reactionEmoji: string) => Promise<void>;
react: (commentId: string, reactionEmoji: string, userReactions: string[]) => Promise<void>;
reactionIds: (commentId: string) =>
| {