chore: init monorepo snapshot
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
import { searchHistoryMaxItems, searchHistoryVersion } from '@/lib/constants'
|
||||
|
||||
/**
|
||||
* SearchHistoryManager - Manages search history persistence in localStorage
|
||||
*
|
||||
* This utility class handles:
|
||||
* - Storing and retrieving search history from localStorage
|
||||
* - Managing history size limits
|
||||
* - Sorting by access time and frequency
|
||||
* - Version compatibility
|
||||
*/
|
||||
|
||||
export interface SearchHistoryItem {
|
||||
label: string // Label name
|
||||
lastAccessed: number // Last access timestamp
|
||||
accessCount: number // Access count for sorting optimization
|
||||
}
|
||||
|
||||
export interface SearchHistoryData {
|
||||
items: SearchHistoryItem[]
|
||||
version: string // Data version for compatibility
|
||||
workspace?: string // Workspace isolation (if needed)
|
||||
}
|
||||
|
||||
export class SearchHistoryManager {
|
||||
private static readonly STORAGE_KEY = 'lightrag_search_history'
|
||||
private static readonly MAX_HISTORY = searchHistoryMaxItems
|
||||
private static readonly VERSION = searchHistoryVersion
|
||||
|
||||
/**
|
||||
* Get search history from localStorage
|
||||
* @returns Array of search history items sorted by last accessed time (descending)
|
||||
*/
|
||||
static getHistory(): SearchHistoryItem[] {
|
||||
try {
|
||||
const data = localStorage.getItem(this.STORAGE_KEY)
|
||||
if (!data) return []
|
||||
|
||||
const parsed: SearchHistoryData = JSON.parse(data)
|
||||
|
||||
// Version compatibility check
|
||||
if (parsed.version !== this.VERSION) {
|
||||
console.warn(`Search history version mismatch. Expected ${this.VERSION}, got ${parsed.version}. Clearing history.`)
|
||||
this.clearHistory()
|
||||
return []
|
||||
}
|
||||
|
||||
// Ensure items is an array
|
||||
if (!Array.isArray(parsed.items)) {
|
||||
console.warn('Invalid search history format. Clearing history.')
|
||||
this.clearHistory()
|
||||
return []
|
||||
}
|
||||
|
||||
// Sort by last accessed time (descending) then by access count (descending)
|
||||
return parsed.items.sort((a, b) => {
|
||||
if (b.lastAccessed !== a.lastAccessed) {
|
||||
return b.lastAccessed - a.lastAccessed
|
||||
}
|
||||
return (b.accessCount || 0) - (a.accessCount || 0)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error reading search history:', error)
|
||||
this.clearHistory()
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a label to search history (or update if exists)
|
||||
* @param label Label to add to history
|
||||
*/
|
||||
static addToHistory(label: string): void {
|
||||
if (!label || typeof label !== 'string' || label.trim() === '') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const history = this.getHistory()
|
||||
const now = Date.now()
|
||||
const trimmedLabel = label.trim()
|
||||
|
||||
// Find existing item
|
||||
const existingIndex = history.findIndex(item => item.label === trimmedLabel)
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Update existing item
|
||||
const existingItem = history[existingIndex]
|
||||
existingItem.lastAccessed = now
|
||||
existingItem.accessCount = (existingItem.accessCount || 0) + 1
|
||||
|
||||
// Move to front (will be sorted properly when saved)
|
||||
history.splice(existingIndex, 1)
|
||||
history.unshift(existingItem)
|
||||
} else {
|
||||
// Add new item to the beginning
|
||||
history.unshift({
|
||||
label: trimmedLabel,
|
||||
lastAccessed: now,
|
||||
accessCount: 1
|
||||
})
|
||||
}
|
||||
|
||||
// Limit history size
|
||||
if (history.length > this.MAX_HISTORY) {
|
||||
history.splice(this.MAX_HISTORY)
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
const data: SearchHistoryData = {
|
||||
items: history,
|
||||
version: this.VERSION
|
||||
}
|
||||
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data))
|
||||
} catch (error) {
|
||||
console.error('Error saving search history:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all search history
|
||||
*/
|
||||
static clearHistory(): void {
|
||||
try {
|
||||
localStorage.removeItem(this.STORAGE_KEY)
|
||||
} catch (error) {
|
||||
console.error('Error clearing search history:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize history with default popular labels if empty
|
||||
* @param popularLabels Array of popular labels to use as defaults
|
||||
*/
|
||||
static async initializeWithDefaults(popularLabels: string[]): Promise<void> {
|
||||
const history = this.getHistory()
|
||||
|
||||
if (history.length === 0 && popularLabels.length > 0) {
|
||||
try {
|
||||
const now = Date.now()
|
||||
const defaultItems: SearchHistoryItem[] = popularLabels.map((label, index) => ({
|
||||
label: label.trim(),
|
||||
lastAccessed: now - index, // Ensure proper ordering
|
||||
accessCount: 0 // Mark as default/popular items
|
||||
}))
|
||||
|
||||
const data: SearchHistoryData = {
|
||||
items: defaultItems,
|
||||
version: this.VERSION
|
||||
}
|
||||
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data))
|
||||
} catch (error) {
|
||||
console.error('Error initializing search history with defaults:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent searches (items with accessCount > 0)
|
||||
* @param limit Maximum number of recent searches to return
|
||||
* @returns Array of recent search items
|
||||
*/
|
||||
static getRecentSearches(limit: number = 10): SearchHistoryItem[] {
|
||||
const history = this.getHistory()
|
||||
return history
|
||||
.filter(item => item.accessCount > 0)
|
||||
.slice(0, limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get popular recommendations (items with accessCount = 0, i.e., defaults)
|
||||
* @param limit Maximum number of recommendations to return
|
||||
* @returns Array of popular recommendation items
|
||||
*/
|
||||
static getPopularRecommendations(limit?: number): SearchHistoryItem[] {
|
||||
const history = this.getHistory()
|
||||
const recommendations = history.filter(item => item.accessCount === 0)
|
||||
return limit ? recommendations.slice(0, limit) : recommendations
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all history items as simple string array
|
||||
* @param limit Maximum number of items to return
|
||||
* @returns Array of label strings
|
||||
*/
|
||||
static getHistoryLabels(limit?: number): string[] {
|
||||
const history = this.getHistory()
|
||||
const labels = history.map(item => item.label)
|
||||
return limit ? labels.slice(0, limit) : labels
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a label exists in history
|
||||
* @param label Label to check
|
||||
* @returns True if label exists in history
|
||||
*/
|
||||
static hasLabel(label: string): boolean {
|
||||
if (!label || typeof label !== 'string') return false
|
||||
const history = this.getHistory()
|
||||
return history.some(item => item.label === label.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a specific label from history
|
||||
* @param label Label to remove
|
||||
*/
|
||||
static removeLabel(label: string): void {
|
||||
if (!label || typeof label !== 'string') return
|
||||
|
||||
try {
|
||||
const history = this.getHistory()
|
||||
const trimmedLabel = label.trim()
|
||||
const filteredHistory = history.filter(item => item.label !== trimmedLabel)
|
||||
|
||||
if (filteredHistory.length !== history.length) {
|
||||
const data: SearchHistoryData = {
|
||||
items: filteredHistory,
|
||||
version: this.VERSION
|
||||
}
|
||||
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error removing label from search history:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage statistics
|
||||
* @returns Object with history statistics
|
||||
*/
|
||||
static getStats(): {
|
||||
totalItems: number
|
||||
recentSearches: number
|
||||
popularRecommendations: number
|
||||
storageSize: number
|
||||
} {
|
||||
const history = this.getHistory()
|
||||
const recentCount = history.filter(item => item.accessCount > 0).length
|
||||
const popularCount = history.filter(item => item.accessCount === 0).length
|
||||
|
||||
let storageSize = 0
|
||||
try {
|
||||
const data = localStorage.getItem(this.STORAGE_KEY)
|
||||
storageSize = data ? data.length : 0
|
||||
} catch {
|
||||
// Ignore error
|
||||
}
|
||||
|
||||
return {
|
||||
totalItems: history.length,
|
||||
recentSearches: recentCount,
|
||||
popularRecommendations: popularCount,
|
||||
storageSize
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Robust clipboard utility with multiple fallback strategies
|
||||
* Handles various browser environments and security contexts
|
||||
*/
|
||||
|
||||
export interface CopyResult {
|
||||
success: boolean;
|
||||
method: 'clipboard-api' | 'execCommand' | 'manual-select' | 'fallback';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text to clipboard with multiple fallback strategies
|
||||
* @param text - Text to copy to clipboard
|
||||
* @returns Promise<CopyResult> - Result object with success status and method used
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<CopyResult> {
|
||||
if (!text || text.trim() === '') {
|
||||
return {
|
||||
success: false,
|
||||
method: 'fallback',
|
||||
error: 'No text provided'
|
||||
};
|
||||
}
|
||||
|
||||
// Strategy 1: Modern Clipboard API (preferred)
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return {
|
||||
success: true,
|
||||
method: 'clipboard-api'
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Clipboard API failed:', error);
|
||||
// Continue to fallback methods
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Legacy execCommand (for older browsers)
|
||||
try {
|
||||
const result = await copyWithExecCommand(text);
|
||||
if (result.success) {
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('execCommand failed:', error);
|
||||
// Continue to fallback methods
|
||||
}
|
||||
|
||||
// Strategy 3: Manual text selection (most compatible)
|
||||
try {
|
||||
const result = await copyWithManualSelection(text);
|
||||
if (result.success) {
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Manual selection failed:', error);
|
||||
}
|
||||
|
||||
// Strategy 4: Complete fallback - return error
|
||||
return {
|
||||
success: false,
|
||||
method: 'fallback',
|
||||
error: 'All copy methods failed. Please copy the text manually.'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy using legacy execCommand method
|
||||
*/
|
||||
async function copyWithExecCommand(text: string): Promise<CopyResult> {
|
||||
return new Promise((resolve) => {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
textarea.style.top = '-9999px';
|
||||
textarea.style.opacity = '0';
|
||||
textarea.setAttribute('readonly', '');
|
||||
|
||||
document.body.appendChild(textarea);
|
||||
|
||||
try {
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, text.length);
|
||||
|
||||
const successful = document.execCommand('copy');
|
||||
|
||||
if (successful) {
|
||||
resolve({
|
||||
success: true,
|
||||
method: 'execCommand'
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
success: false,
|
||||
method: 'execCommand',
|
||||
error: 'execCommand returned false'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
resolve({
|
||||
success: false,
|
||||
method: 'execCommand',
|
||||
error: error instanceof Error ? error.message : 'execCommand failed'
|
||||
});
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy using manual text selection method
|
||||
*/
|
||||
async function copyWithManualSelection(text: string): Promise<CopyResult> {
|
||||
return new Promise((resolve) => {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'absolute';
|
||||
textarea.style.left = '-9999px';
|
||||
textarea.style.top = '-9999px';
|
||||
textarea.style.opacity = '0';
|
||||
textarea.style.pointerEvents = 'none';
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.setAttribute('tabindex', '-1');
|
||||
|
||||
document.body.appendChild(textarea);
|
||||
|
||||
try {
|
||||
// Focus and select the text
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, text.length);
|
||||
|
||||
// Try to trigger copy event
|
||||
const copyEvent = new ClipboardEvent('copy', {
|
||||
clipboardData: new DataTransfer()
|
||||
});
|
||||
|
||||
if (copyEvent.clipboardData) {
|
||||
copyEvent.clipboardData.setData('text/plain', text);
|
||||
document.dispatchEvent(copyEvent);
|
||||
|
||||
resolve({
|
||||
success: true,
|
||||
method: 'manual-select'
|
||||
});
|
||||
} else {
|
||||
// Fallback: keep text selected for manual copy
|
||||
resolve({
|
||||
success: false,
|
||||
method: 'manual-select',
|
||||
error: 'Manual selection prepared, but automatic copy failed'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
resolve({
|
||||
success: false,
|
||||
method: 'manual-select',
|
||||
error: error instanceof Error ? error.message : 'Manual selection failed'
|
||||
});
|
||||
} finally {
|
||||
// Clean up after a short delay to allow copy operation
|
||||
setTimeout(() => {
|
||||
if (document.body.contains(textarea)) {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if clipboard functionality is available
|
||||
*/
|
||||
export function isClipboardSupported(): boolean {
|
||||
return !!(
|
||||
(navigator.clipboard && typeof navigator.clipboard.writeText === 'function') ||
|
||||
typeof document !== 'undefined'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the best available clipboard method
|
||||
*/
|
||||
export function getBestClipboardMethod(): 'clipboard-api' | 'execCommand' | 'manual-select' | 'none' {
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
return 'clipboard-api';
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
return 'execCommand';
|
||||
}
|
||||
|
||||
return 'none';
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
const DEFAULT_NODE_COLOR = '#5D6D7E'
|
||||
|
||||
const TYPE_SYNONYMS: Record<string, string> = {
|
||||
unknown: 'unknown',
|
||||
未知: 'unknown',
|
||||
|
||||
other: 'other',
|
||||
其它: 'other',
|
||||
|
||||
concept: 'concept',
|
||||
object: 'concept',
|
||||
type: 'concept',
|
||||
category: 'concept',
|
||||
model: 'concept',
|
||||
project: 'concept',
|
||||
condition: 'concept',
|
||||
rule: 'concept',
|
||||
regulation: 'concept',
|
||||
article: 'concept',
|
||||
law: 'concept',
|
||||
legalclause: 'concept',
|
||||
policy: 'concept',
|
||||
disease: 'concept',
|
||||
概念: 'concept',
|
||||
对象: 'concept',
|
||||
类别: 'concept',
|
||||
分类: 'concept',
|
||||
模型: 'concept',
|
||||
项目: 'concept',
|
||||
条件: 'concept',
|
||||
规则: 'concept',
|
||||
法律: 'concept',
|
||||
法律条款: 'concept',
|
||||
条文: 'concept',
|
||||
政策: 'policy',
|
||||
疾病: 'concept',
|
||||
|
||||
method: 'method',
|
||||
process: 'method',
|
||||
方法: 'method',
|
||||
过程: 'method',
|
||||
|
||||
artifact: 'artifact',
|
||||
technology: 'artifact',
|
||||
tech: 'artifact',
|
||||
product: 'artifact',
|
||||
equipment: 'artifact',
|
||||
device: 'artifact',
|
||||
stuff: 'artifact',
|
||||
component: 'artifact',
|
||||
material: 'artifact',
|
||||
chemical: 'artifact',
|
||||
drug: 'artifact',
|
||||
medicine: 'artifact',
|
||||
food: 'artifact',
|
||||
weapon: 'artifact',
|
||||
arms: 'artifact',
|
||||
人工制品: 'artifact',
|
||||
人造物品: 'artifact',
|
||||
技术: 'technology',
|
||||
科技: 'technology',
|
||||
产品: 'artifact',
|
||||
设备: 'artifact',
|
||||
装备: 'artifact',
|
||||
物品: 'artifact',
|
||||
材料: 'artifact',
|
||||
化学: 'artifact',
|
||||
药物: 'artifact',
|
||||
食品: 'artifact',
|
||||
武器: 'artifact',
|
||||
军火: 'artifact',
|
||||
|
||||
naturalobject: 'naturalobject',
|
||||
natural: 'naturalobject',
|
||||
phenomena: 'naturalobject',
|
||||
substance: 'naturalobject',
|
||||
plant: 'naturalobject',
|
||||
自然对象: 'naturalobject',
|
||||
自然物体: 'naturalobject',
|
||||
自然现象: 'naturalobject',
|
||||
物质: 'naturalobject',
|
||||
物体: 'naturalobject',
|
||||
|
||||
data: 'data',
|
||||
figure: 'data',
|
||||
value: 'data',
|
||||
数据: 'data',
|
||||
数字: 'data',
|
||||
数值: 'data',
|
||||
|
||||
content: 'content',
|
||||
book: 'content',
|
||||
video: 'content',
|
||||
内容: 'content',
|
||||
作品: 'content',
|
||||
书籍: 'content',
|
||||
视频: 'content',
|
||||
|
||||
organization: 'organization',
|
||||
org: 'organization',
|
||||
company: 'organization',
|
||||
组织: 'organization',
|
||||
公司: 'organization',
|
||||
机构: 'organization',
|
||||
组织机构: 'organization',
|
||||
|
||||
event: 'event',
|
||||
事件: 'event',
|
||||
activity: 'event',
|
||||
活动: 'event',
|
||||
|
||||
person: 'person',
|
||||
people: 'person',
|
||||
human: 'person',
|
||||
role: 'person',
|
||||
人物: 'person',
|
||||
人类: 'person',
|
||||
人: 'person',
|
||||
角色: 'person',
|
||||
|
||||
creature: 'creature',
|
||||
animal: 'creature',
|
||||
beings: 'creature',
|
||||
being: 'creature',
|
||||
alien: 'creature',
|
||||
ghost: 'creature',
|
||||
动物: 'creature',
|
||||
生物: 'creature',
|
||||
神仙: 'creature',
|
||||
鬼怪: 'creature',
|
||||
妖怪: 'creature',
|
||||
|
||||
location: 'location',
|
||||
geography: 'location',
|
||||
geo: 'location',
|
||||
place: 'location',
|
||||
address: 'location',
|
||||
地点: 'location',
|
||||
位置: 'location',
|
||||
地址: 'location',
|
||||
地理: 'location',
|
||||
地域: 'location'
|
||||
}
|
||||
|
||||
const NODE_TYPE_COLORS: Record<string, string> = {
|
||||
person: '#4169E1',
|
||||
creature: '#bd7ebe',
|
||||
organization: '#00cc00',
|
||||
location: '#cf6d17',
|
||||
event: '#00bfa0',
|
||||
concept: '#e3493b',
|
||||
method: '#b71c1c',
|
||||
content: '#0f558a',
|
||||
data: '#0000ff',
|
||||
artifact: '#4421af',
|
||||
naturalobject: '#b2e061',
|
||||
other: '#f4d371',
|
||||
unknown: '#b0b0b0'
|
||||
}
|
||||
|
||||
const EXTENDED_COLORS = [
|
||||
'#84a3e1',
|
||||
'#5a2c6d',
|
||||
'#2F4F4F',
|
||||
'#003366',
|
||||
'#9b3a31',
|
||||
'#00CED1',
|
||||
'#b300b3',
|
||||
'#0f705d',
|
||||
'#ff99cc',
|
||||
'#6ef7b3',
|
||||
'#cd071e'
|
||||
]
|
||||
|
||||
const PREDEFINED_COLOR_SET = new Set(Object.values(NODE_TYPE_COLORS))
|
||||
|
||||
interface ResolveNodeColorResult {
|
||||
color: string
|
||||
map: Map<string, string>
|
||||
updated: boolean
|
||||
}
|
||||
|
||||
export const resolveNodeColor = (
|
||||
nodeType: string | undefined,
|
||||
currentMap: Map<string, string> | undefined
|
||||
): ResolveNodeColorResult => {
|
||||
const typeColorMap = currentMap ?? new Map<string, string>()
|
||||
const normalizedType = nodeType ? nodeType.toLowerCase() : 'unknown'
|
||||
const standardType = TYPE_SYNONYMS[normalizedType]
|
||||
const cacheKey = standardType || normalizedType
|
||||
|
||||
if (typeColorMap.has(cacheKey)) {
|
||||
return {
|
||||
color: typeColorMap.get(cacheKey) || DEFAULT_NODE_COLOR,
|
||||
map: typeColorMap,
|
||||
updated: false
|
||||
}
|
||||
}
|
||||
|
||||
if (standardType) {
|
||||
const color = NODE_TYPE_COLORS[standardType] || DEFAULT_NODE_COLOR
|
||||
const newMap = new Map(typeColorMap)
|
||||
newMap.set(standardType, color)
|
||||
return {
|
||||
color,
|
||||
map: newMap,
|
||||
updated: true
|
||||
}
|
||||
}
|
||||
|
||||
const usedExtendedColors = new Set(
|
||||
Array.from(typeColorMap.values()).filter((color) => !PREDEFINED_COLOR_SET.has(color))
|
||||
)
|
||||
|
||||
const unusedColor = EXTENDED_COLORS.find((color) => !usedExtendedColors.has(color))
|
||||
const color = unusedColor || DEFAULT_NODE_COLOR
|
||||
|
||||
const newMap = new Map(typeColorMap)
|
||||
newMap.set(normalizedType, color)
|
||||
|
||||
return {
|
||||
color,
|
||||
map: newMap,
|
||||
updated: true
|
||||
}
|
||||
}
|
||||
|
||||
export { DEFAULT_NODE_COLOR }
|
||||
@@ -0,0 +1,65 @@
|
||||
import { visit } from 'unist-util-visit'
|
||||
import type { Plugin } from 'unified'
|
||||
import type { Root, Text } from 'mdast'
|
||||
|
||||
// Simple footnote plugin for remark - only renders inline citations
|
||||
export const remarkFootnotes: Plugin<[], Root> = () => {
|
||||
return (tree: Root) => {
|
||||
// Find footnote references and replace them with inline citations
|
||||
visit(tree, 'text', (node: Text, index, parent) => {
|
||||
if (!parent || typeof index !== 'number') return
|
||||
|
||||
const text = node.value
|
||||
const footnoteRegex = /\[\^([^\]]+)\]/g
|
||||
let match
|
||||
const replacements: any[] = []
|
||||
let lastIndex = 0
|
||||
|
||||
while ((match = footnoteRegex.exec(text)) !== null) {
|
||||
const [fullMatch, id] = match
|
||||
const startIndex = match.index!
|
||||
|
||||
// Add text before footnote
|
||||
if (startIndex > lastIndex) {
|
||||
replacements.push({
|
||||
type: 'text',
|
||||
value: text.slice(lastIndex, startIndex)
|
||||
})
|
||||
}
|
||||
|
||||
// Check if there's another footnote immediately following this one
|
||||
const nextIndex = startIndex + fullMatch.length
|
||||
const remainingText = text.slice(nextIndex)
|
||||
const hasConsecutiveFootnote = /^\[\^[^\]]+\]/.test(remainingText)
|
||||
|
||||
// Add footnote reference as HTML with placeholder link
|
||||
const footnoteHtml = `<sup><a href="#footnote-${id}" class="footnote-ref">${id}</a></sup>`
|
||||
|
||||
// Add spacing if there's a consecutive footnote
|
||||
const htmlWithSpacing = hasConsecutiveFootnote
|
||||
? footnoteHtml + ' '
|
||||
: footnoteHtml
|
||||
|
||||
replacements.push({
|
||||
type: 'html',
|
||||
value: htmlWithSpacing
|
||||
})
|
||||
|
||||
lastIndex = startIndex + fullMatch.length
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < text.length) {
|
||||
replacements.push({
|
||||
type: 'text',
|
||||
value: text.slice(lastIndex)
|
||||
})
|
||||
}
|
||||
|
||||
// Replace the text node if we found footnotes
|
||||
if (replacements.length > 1) {
|
||||
parent.children.splice(index, 1, ...replacements)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user