mirror of
https://github.com/lossless-group/perplexed-plugin.git
synced 2026-07-22 06:49:50 +00:00
iterate(modal): rebuild of modal code
- Fixed method name mismatch between convertCitation and convertCitations - Made generateHexId private and added public getNewHexId method - Improved type safety for CitationMatch interface with explicit URL handling - Cleaned up unused imports and variables in CitationModal - Fixed file handling to use correct Obsidian API types - Updated citation conversion logic to handle multiple groups - Added proper error handling for file operations - Improved code documentation and type annotations - Formatted styles.css for better readability
This commit is contained in:
parent
08631c7264
commit
ad3303c0e8
5 changed files with 543 additions and 645 deletions
22
main.ts
22
main.ts
|
|
@ -44,11 +44,23 @@ export default class CiteWidePlugin extends Plugin {
|
|||
editorCallback: async (editor: Editor) => {
|
||||
try {
|
||||
const content = editor.getValue();
|
||||
const result = citationService.convertCitations(content);
|
||||
// Get all citation groups
|
||||
const groups = citationService.findCitations(content);
|
||||
let totalConverted = 0;
|
||||
let updatedContent = content;
|
||||
|
||||
if (result.changed) {
|
||||
editor.setValue(result.updatedContent);
|
||||
new Notice(`Updated ${result.stats.citationsConverted} citations`);
|
||||
// Convert each citation group
|
||||
for (const group of groups) {
|
||||
const result = citationService.convertCitation(updatedContent, group.number);
|
||||
if (result.changed) {
|
||||
updatedContent = result.content;
|
||||
totalConverted += result.stats.citationsConverted;
|
||||
}
|
||||
}
|
||||
|
||||
if (totalConverted > 0) {
|
||||
editor.setValue(updatedContent);
|
||||
new Notice(`Updated ${totalConverted} citations`);
|
||||
} else {
|
||||
new Notice('No citations needed conversion');
|
||||
}
|
||||
|
|
@ -66,7 +78,7 @@ export default class CiteWidePlugin extends Plugin {
|
|||
editorCallback: (editor: Editor) => {
|
||||
try {
|
||||
const cursor = editor.getCursor();
|
||||
const hexId = citationService.generateHexId();
|
||||
const hexId = citationService.getNewHexId();
|
||||
|
||||
// Insert the citation reference at cursor
|
||||
editor.replaceRange(`[^${hexId}]`, cursor);
|
||||
|
|
|
|||
|
|
@ -1,38 +1,11 @@
|
|||
import { App, Modal, Notice, Editor, TFile } from 'obsidian';
|
||||
import { citationService, type CitationConversionResult } from '../services/citationService';
|
||||
// cite-wide/src/modals/CitationModal.ts
|
||||
import { App, Modal, Notice, Editor } from 'obsidian';
|
||||
import { citationService, type CitationGroup } from '../services/citationService';
|
||||
|
||||
// Extend the Obsidian Modal interface to include contentEl
|
||||
declare module 'obsidian' {
|
||||
interface Modal {
|
||||
contentEl: HTMLElement;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a single citation match in the document
|
||||
*/
|
||||
interface CitationMatch {
|
||||
type: 'footnote' | 'reference' | 'perplexity';
|
||||
original: string;
|
||||
number: string;
|
||||
position: number;
|
||||
line: number;
|
||||
lineContent: string;
|
||||
}
|
||||
|
||||
interface CitationGroup {
|
||||
type: string;
|
||||
number: string;
|
||||
matches: CitationMatch[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for displaying and managing citations in the current document
|
||||
*/
|
||||
export class CitationModal extends Modal {
|
||||
private matches: CitationMatch[] = [];
|
||||
private editor: Editor;
|
||||
private content: string;
|
||||
private citationGroups: CitationGroup[] = [];
|
||||
|
||||
constructor(app: App, editor: Editor) {
|
||||
super(app);
|
||||
|
|
@ -40,185 +13,160 @@ export class CitationModal extends Modal {
|
|||
this.content = editor.getValue();
|
||||
}
|
||||
|
||||
async onOpen(): Promise<void> {
|
||||
async onOpen() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
contentEl.addClass('cite-wide-modal');
|
||||
|
||||
// Find all citations in the document
|
||||
this.findCitations();
|
||||
// Find all citation groups
|
||||
this.citationGroups = citationService.findCitations(this.content);
|
||||
|
||||
// If no citations found, show a message
|
||||
if (this.matches.length === 0) {
|
||||
contentEl.createEl('p', { text: 'No citations found in the current document.' });
|
||||
if (this.citationGroups.length === 0) {
|
||||
contentEl.createEl('p', {
|
||||
text: 'No citations found in the current document.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Group citations by type and number
|
||||
const groupedCitations = this.groupCitations();
|
||||
|
||||
// Create a container for the citation groups
|
||||
const container = contentEl.createDiv({ cls: 'citation-groups' });
|
||||
|
||||
// Render each citation group
|
||||
Object.entries(groupedCitations).forEach(([key, group]) => {
|
||||
const [type, number] = key.split(':');
|
||||
const groupEl = container.createDiv({ cls: 'citation-group' });
|
||||
|
||||
// Create header with toggle functionality and actions
|
||||
const header = groupEl.createDiv({ cls: 'citation-group-header' });
|
||||
|
||||
// Header content with integrated To Hex button
|
||||
const headerContent = header.createDiv({ cls: 'citation-group-header-content' });
|
||||
const headerTitle = headerContent.createEl('h3');
|
||||
|
||||
// Add the title text
|
||||
headerTitle.createSpan({
|
||||
text: `${type === 'footnote' ? 'Footnote' : 'Citation'} [${number}] (${group.matches.length} occurrences)`
|
||||
});
|
||||
|
||||
// Add Convert to Hex button next to the title
|
||||
const convertButton = headerTitle.createEl('button', {
|
||||
text: 'To Hex',
|
||||
cls: 'mod-cta',
|
||||
attr: { 'data-action': 'convert-all-group' }
|
||||
});
|
||||
convertButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const firstMatch = group.matches[0];
|
||||
if (firstMatch) {
|
||||
this.convertCitationToHex(firstMatch);
|
||||
}
|
||||
});
|
||||
|
||||
// Create content container (initially hidden)
|
||||
const content = groupEl.createDiv({ cls: 'citation-group-content' });
|
||||
|
||||
// Add toggle functionality to header
|
||||
header.onclick = () => {
|
||||
content.style.display = content.style.display === 'none' ? 'block' : 'none';
|
||||
};
|
||||
|
||||
// Add each citation context
|
||||
group.matches.forEach((match: CitationMatch) => {
|
||||
const contextEl = content.createDiv({ cls: 'citation-context' });
|
||||
contextEl.createEl('div', {
|
||||
text: `Line ${match.line + 1}: ${match.lineContent}`,
|
||||
cls: 'citation-line'
|
||||
});
|
||||
|
||||
// Add action buttons
|
||||
const actions = contextEl.createDiv({ cls: 'citation-actions' });
|
||||
|
||||
// View button
|
||||
actions.createEl('button', {
|
||||
text: 'View',
|
||||
cls: 'mod-cta',
|
||||
attr: { 'data-action': 'view' }
|
||||
}).addEventListener('click', () => this.scrollToCitation(match));
|
||||
});
|
||||
// Create a container for citation groups
|
||||
const container = contentEl.createDiv('cite-wide-container');
|
||||
|
||||
// Add a title
|
||||
container.createEl('h2', {
|
||||
text: 'Citations in Document',
|
||||
cls: 'cite-wide-title'
|
||||
});
|
||||
|
||||
// Add a button to convert all citations
|
||||
const footer = contentEl.createDiv({ cls: 'modal-button-container' });
|
||||
footer.createEl('button', {
|
||||
// Add each citation group
|
||||
for (const group of this.citationGroups) {
|
||||
this.renderCitationGroup(container, group);
|
||||
}
|
||||
|
||||
// Add convert all button
|
||||
const footer = contentEl.createDiv('cite-wide-footer');
|
||||
const convertAllBtn = footer.createEl('button', {
|
||||
text: 'Convert All to Hex',
|
||||
cls: 'mod-cta',
|
||||
attr: { 'data-action': 'convert-all' }
|
||||
}).addEventListener('click', () => this.convertAllCitations());
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups citations by their type and number
|
||||
*/
|
||||
private groupCitations(): Record<string, CitationGroup> {
|
||||
const groups: Record<string, CitationGroup> = {};
|
||||
|
||||
this.matches.forEach((match: CitationMatch) => {
|
||||
const key = `${match.type}:${match.number}`;
|
||||
if (!groups[key]) {
|
||||
groups[key] = {
|
||||
type: match.type,
|
||||
number: match.number,
|
||||
matches: []
|
||||
};
|
||||
}
|
||||
groups[key]?.matches.push(match);
|
||||
cls: 'mod-cta'
|
||||
});
|
||||
|
||||
return groups;
|
||||
convertAllBtn.addEventListener('click', () => this.convertAllCitations());
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls the editor to a specific citation
|
||||
*/
|
||||
private scrollToCitation(match: CitationMatch): void {
|
||||
const line = Math.max(0, match.line - 2); // Show a couple lines before the match
|
||||
this.editor.setCursor({ line, ch: 0 });
|
||||
this.editor.scrollIntoView({ from: { line, ch: 0 }, to: { line: line + 5, ch: 0 } });
|
||||
this.close();
|
||||
}
|
||||
private renderCitationGroup(container: HTMLElement, group: CitationGroup) {
|
||||
const groupEl = container.createDiv('cite-wide-group');
|
||||
const header = groupEl.createDiv('cite-wide-group-header');
|
||||
|
||||
// Create a collapsible header
|
||||
const headerContent = header.createDiv('cite-wide-group-header-content');
|
||||
headerContent.createEl('h3', {
|
||||
text: `Citation [${group.number}] (${group.matches.length} instances)`,
|
||||
cls: 'cite-wide-group-title'
|
||||
});
|
||||
|
||||
/**
|
||||
* Finds all citations in the current document
|
||||
*/
|
||||
private findCitations(): void {
|
||||
const lines = this.content.split('\n');
|
||||
if (group.url) {
|
||||
headerContent.createEl('a', {
|
||||
href: group.url,
|
||||
text: 'Source',
|
||||
cls: 'cite-wide-source-link',
|
||||
attr: { target: '_blank' }
|
||||
});
|
||||
}
|
||||
|
||||
lines.forEach((line: string, lineIndex: number) => {
|
||||
if (!line) return;
|
||||
// Add convert button
|
||||
const convertBtn = header.createEl('button', {
|
||||
text: 'Convert to Hex',
|
||||
cls: 'mod-cta cite-wide-convert-btn'
|
||||
});
|
||||
|
||||
// Find footnote references [^1]
|
||||
const footnoteRegex = /\[\^(\d+)\]/g;
|
||||
let footnoteMatch;
|
||||
while ((footnoteMatch = footnoteRegex.exec(line)) !== null) {
|
||||
this.matches.push({
|
||||
type: 'footnote',
|
||||
number: footnoteMatch[1] || '',
|
||||
original: footnoteMatch[0] || '',
|
||||
position: footnoteMatch.index || 0,
|
||||
line: lineIndex,
|
||||
lineContent: line
|
||||
});
|
||||
}
|
||||
convertBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.convertCitationGroup(group);
|
||||
});
|
||||
|
||||
// Find standard citations [1] (but not links [text](url))
|
||||
const citationRegex = /\[(\d+)\]/g;
|
||||
let citationMatch;
|
||||
while ((citationMatch = citationRegex.exec(line)) !== null) {
|
||||
// Skip if it's part of a markdown link
|
||||
if (!/\]\([^)]*$/.test(line.substring(0, citationMatch.index || 0))) {
|
||||
this.matches.push({
|
||||
type: 'reference',
|
||||
number: citationMatch[1] || '',
|
||||
original: citationMatch[0] || '',
|
||||
position: citationMatch.index || 0,
|
||||
line: lineIndex,
|
||||
lineContent: line
|
||||
});
|
||||
}
|
||||
}
|
||||
// Create collapsible content
|
||||
const content = groupEl.createDiv('cite-wide-group-content');
|
||||
content.style.display = 'none'; // Start collapsed
|
||||
|
||||
// Toggle content on header click
|
||||
header.addEventListener('click', () => {
|
||||
content.style.display = content.style.display === 'none' ? 'block' : 'none';
|
||||
});
|
||||
|
||||
// Add each citation instance
|
||||
group.matches.forEach((match) => {
|
||||
const instanceEl = content.createDiv('cite-wide-instance');
|
||||
|
||||
// Show line number and preview
|
||||
const lineInfo = instanceEl.createDiv('cite-wide-line-info');
|
||||
lineInfo.createEl('span', {
|
||||
text: `Line ${match.lineNumber + 1}: `,
|
||||
cls: 'cite-wide-line-number'
|
||||
});
|
||||
|
||||
// Create a preview of the line content
|
||||
const preview = match.lineContent.trim();
|
||||
const previewText = preview.length > 100
|
||||
? `${preview.substring(0, 100)}...`
|
||||
: preview;
|
||||
|
||||
lineInfo.createEl('span', {
|
||||
text: previewText,
|
||||
cls: 'cite-wide-line-preview'
|
||||
});
|
||||
|
||||
// Add view button
|
||||
const viewBtn = instanceEl.createEl('button', {
|
||||
text: 'View',
|
||||
cls: 'mod-cta-outline cite-wide-view-btn'
|
||||
});
|
||||
|
||||
viewBtn.addEventListener('click', () => {
|
||||
this.scrollToLine(match.lineNumber);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts all citations in the document to hex format
|
||||
*/
|
||||
private async convertAllCitations(): Promise<void> {
|
||||
new Notice('Converting all citations to hex format...');
|
||||
private async convertCitationGroup(group: CitationGroup) {
|
||||
try {
|
||||
const content = this.editor.getValue();
|
||||
const result = citationService.convertCitations(content);
|
||||
const result = citationService.convertCitation(
|
||||
this.content,
|
||||
group.number
|
||||
);
|
||||
|
||||
if (result?.changed) {
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (!activeFile) {
|
||||
new Notice('No active file found');
|
||||
return;
|
||||
if (result.changed) {
|
||||
await this.saveChanges(result.content);
|
||||
new Notice(`Converted citation [${group.number}] to hex format`);
|
||||
this.close();
|
||||
} else {
|
||||
new Notice('No changes were made to the document');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error converting citation:', error);
|
||||
new Notice('Error converting citation. See console for details.');
|
||||
}
|
||||
}
|
||||
|
||||
private async convertAllCitations() {
|
||||
try {
|
||||
let updatedContent = this.content;
|
||||
let totalConverted = 0;
|
||||
|
||||
// Process each group
|
||||
for (const group of this.citationGroups) {
|
||||
const result = citationService.convertCitation(
|
||||
updatedContent,
|
||||
group.number
|
||||
);
|
||||
|
||||
if (result.changed) {
|
||||
updatedContent = result.content;
|
||||
totalConverted += result.stats.citationsConverted;
|
||||
}
|
||||
}
|
||||
|
||||
await this.app.vault.modify(activeFile as TFile, result.updatedContent);
|
||||
new Notice('Successfully converted all citations to hex format');
|
||||
if (totalConverted > 0) {
|
||||
await this.saveChanges(updatedContent);
|
||||
new Notice(`Converted ${totalConverted} citations to hex format`);
|
||||
this.close();
|
||||
} else {
|
||||
new Notice('No citations were converted');
|
||||
|
|
@ -229,41 +177,26 @@ export class CitationModal extends Modal {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a specific citation to hex format
|
||||
*/
|
||||
private async convertCitationToHex(match: CitationMatch): Promise<void> {
|
||||
try {
|
||||
const content = this.editor.getValue();
|
||||
|
||||
// Use the citation service to handle the conversion
|
||||
const result = citationService.convertCitations(
|
||||
content,
|
||||
match.type === 'footnote' ? `[^${match.number}]` : `[${match.number}]`
|
||||
);
|
||||
|
||||
if (result?.changed) {
|
||||
// Get the current file
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (!activeFile) {
|
||||
new Notice('No active file found');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.app.vault.modify(activeFile as TFile, result.updatedContent);
|
||||
new Notice(`Successfully converted ${match.type} [${match.number}] to hex format`);
|
||||
this.close();
|
||||
} else {
|
||||
new Notice(`No changes were made to ${match.type} [${match.number}]`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error converting citation ${match.number}:`, error);
|
||||
new Notice(`Error converting citation ${match.number}. See console for details.`);
|
||||
private async saveChanges(newContent: string) {
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (!activeFile) {
|
||||
throw new Error('No active file');
|
||||
}
|
||||
|
||||
await this.app.vault.modify(activeFile, newContent);
|
||||
}
|
||||
|
||||
private scrollToLine(lineNumber: number) {
|
||||
this.editor.setCursor({ line: lineNumber, ch: 0 });
|
||||
this.editor.scrollIntoView({
|
||||
from: { line: Math.max(0, lineNumber - 2), ch: 0 },
|
||||
to: { line: lineNumber + 2, ch: 0 }
|
||||
}, true);
|
||||
this.close();
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,24 @@
|
|||
import * as crypto from 'crypto';
|
||||
// cite-wide/src/services/citationService.ts
|
||||
import crypto from 'crypto';
|
||||
|
||||
export interface CitationConversionResult {
|
||||
updatedContent: string;
|
||||
export interface CitationMatch {
|
||||
type: 'perplexity' | 'reference' | 'footnote';
|
||||
number: string;
|
||||
original: string;
|
||||
url: string | undefined; // Explicitly allow undefined
|
||||
index: number;
|
||||
lineContent: string;
|
||||
lineNumber: number;
|
||||
}
|
||||
|
||||
export interface CitationGroup {
|
||||
number: string;
|
||||
matches: CitationMatch[];
|
||||
url: string | undefined; // Explicitly allow undefined
|
||||
}
|
||||
|
||||
export interface ConversionResult {
|
||||
content: string;
|
||||
changed: boolean;
|
||||
stats: {
|
||||
citationsConverted: number;
|
||||
|
|
@ -9,287 +26,205 @@ export interface CitationConversionResult {
|
|||
}
|
||||
|
||||
export class CitationService {
|
||||
private hexCache: Map<string, string> = new Map(); // Cache URL to hex ID mapping
|
||||
|
||||
/**
|
||||
* Generate a random hex ID of specified length
|
||||
* @param length - Length of the hex ID to generate (default: 6)
|
||||
* @returns Random hex string
|
||||
* Find all citations in the content
|
||||
*/
|
||||
public generateHexId(length: number = 6): string {
|
||||
return crypto.randomBytes(Math.ceil(length / 2))
|
||||
public findCitations(content: string): CitationGroup[] {
|
||||
const lines = content.split('\n');
|
||||
const matches: CitationMatch[] = [];
|
||||
const urlMap = new Map<string, string>(); // Map of number to URL
|
||||
|
||||
// First pass: Find all footnote definitions to map numbers to URLs
|
||||
lines.forEach((line, _lineIndex) => {
|
||||
// Match Perplexity-style footnotes: "1. [url](url)"
|
||||
const footnoteMatch = line.match(/^(\d+)\.\s+(\[([^\]]+)\]\(([^)]+)\)|(https?:\/\/\S+))/);
|
||||
if (footnoteMatch) {
|
||||
const number = footnoteMatch[1];
|
||||
const url = footnoteMatch[4] || footnoteMatch[2];
|
||||
if (number && url) {
|
||||
urlMap.set(number, url);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Second pass: Find all citations in the content
|
||||
lines.forEach((line, lineIndex) => {
|
||||
// Match Perplexity-style citations: [1](url)
|
||||
const citationRegex = /\[(\d+)\]\(([^)]+)\)/g;
|
||||
let citationMatch;
|
||||
|
||||
while ((citationMatch = citationRegex.exec(line)) !== null) {
|
||||
const [fullMatch, number, url] = citationMatch;
|
||||
|
||||
// Skip if number is undefined
|
||||
if (!number) continue;
|
||||
|
||||
const match: CitationMatch = {
|
||||
type: 'perplexity',
|
||||
number,
|
||||
original: fullMatch,
|
||||
index: citationMatch.index,
|
||||
lineContent: line,
|
||||
lineNumber: lineIndex,
|
||||
url: url || undefined
|
||||
};
|
||||
|
||||
matches.push(match);
|
||||
}
|
||||
|
||||
// Match standard markdown links that might be citations
|
||||
const linkRegex = /\[(\d+)\]/g;
|
||||
let linkMatch;
|
||||
|
||||
while ((linkMatch = linkRegex.exec(line)) !== null) {
|
||||
const [fullMatch, number] = linkMatch;
|
||||
|
||||
// Skip if number is undefined or if it's part of a markdown link
|
||||
if (!number || line.substring(0, linkMatch.index).endsWith('](')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const url = urlMap.get(number);
|
||||
|
||||
// Create the match object with all required properties
|
||||
const match: CitationMatch = {
|
||||
type: 'reference',
|
||||
number,
|
||||
original: fullMatch,
|
||||
index: linkMatch.index,
|
||||
lineContent: line,
|
||||
lineNumber: lineIndex,
|
||||
url: url || undefined
|
||||
};
|
||||
|
||||
matches.push(match);
|
||||
}
|
||||
});
|
||||
|
||||
// Group matches by number
|
||||
const groups = new Map<string, CitationGroup>();
|
||||
|
||||
matches.forEach(match => {
|
||||
if (!groups.has(match.number)) {
|
||||
const newGroup: CitationGroup = {
|
||||
number: match.number,
|
||||
matches: [],
|
||||
url: match.url
|
||||
};
|
||||
groups.set(match.number, newGroup);
|
||||
}
|
||||
groups.get(match.number)?.matches.push(match);
|
||||
});
|
||||
|
||||
return Array.from(groups.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a specific citation to hex format
|
||||
*/
|
||||
public convertCitation(
|
||||
content: string,
|
||||
citationNumber: string,
|
||||
hexId?: string
|
||||
): ConversionResult {
|
||||
const groups = this.findCitations(content);
|
||||
const group = groups.find(g => g.number === citationNumber);
|
||||
|
||||
if (!group) {
|
||||
return { content, changed: false, stats: { citationsConverted: 0 } };
|
||||
}
|
||||
|
||||
// Generate or use provided hex ID
|
||||
const targetHexId = hexId || this.generateHexId();
|
||||
const url = group.url || group.matches[0]?.url;
|
||||
|
||||
if (!url) {
|
||||
return { content, changed: false, stats: { citationsConverted: 0 } };
|
||||
}
|
||||
|
||||
// Cache the URL to hex ID mapping
|
||||
this.hexCache.set(url, targetHexId);
|
||||
|
||||
let updatedContent = content;
|
||||
let citationsConverted = 0;
|
||||
|
||||
// Process in reverse to avoid position shifting
|
||||
const sortedMatches = [...group.matches].sort((a, b) => b.index - a.index);
|
||||
|
||||
for (const match of sortedMatches) {
|
||||
const before = updatedContent.substring(0, match.index);
|
||||
const after = updatedContent.substring(match.index + match.original.length);
|
||||
|
||||
// Ensure proper spacing
|
||||
const needsLeadingSpace = !before.endsWith(' ') && !before.endsWith('\n') && before.length > 0;
|
||||
const needsTrailingSpace = !after.startsWith(' ') && !after.startsWith('\n') && after.length > 0;
|
||||
|
||||
const replacement = `${needsLeadingSpace ? ' ' : ''}[^${targetHexId}]${needsTrailingSpace ? ' ' : ''}`;
|
||||
|
||||
updatedContent = before + replacement + after;
|
||||
citationsConverted++;
|
||||
}
|
||||
|
||||
// Add or update the footnote definition
|
||||
const footnoteDef = `[^${targetHexId}]: ${url}`;
|
||||
const footnoteSection = this.ensureFootnoteSection(updatedContent);
|
||||
|
||||
if (!footnoteSection.content.includes(`[^${targetHexId}]:`)) {
|
||||
updatedContent = updatedContent.replace(
|
||||
footnoteSection.marker,
|
||||
`${footnoteSection.marker}\n${footnoteDef}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
content: updatedContent,
|
||||
changed: citationsConverted > 0,
|
||||
stats: { citationsConverted }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the document has a footnotes section
|
||||
*/
|
||||
private ensureFootnoteSection(content: string): { content: string; marker: string } {
|
||||
const footnoteMarker = '\n\n# Footnotes\n';
|
||||
|
||||
if (content.includes(footnoteMarker)) {
|
||||
return { content, marker: footnoteMarker };
|
||||
}
|
||||
|
||||
const altMarker = '\n## Footnotes\n';
|
||||
if (content.includes(altMarker)) {
|
||||
return { content, marker: altMarker };
|
||||
}
|
||||
|
||||
// Add a new footnotes section at the end
|
||||
return {
|
||||
content: content + footnoteMarker,
|
||||
marker: footnoteMarker
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a consistent hex ID for a given URL
|
||||
*/
|
||||
private generateHexId(length: number = 6): string {
|
||||
return crypto
|
||||
.randomBytes(Math.ceil(length / 2))
|
||||
.toString('hex')
|
||||
.slice(0, length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert citations to random hex format
|
||||
* @param content - The markdown content to process
|
||||
* @param targetCitation - Optional specific citation to convert (e.g., '[1]' or '[^1]')
|
||||
* @returns Object with updated content and statistics
|
||||
* Generate a new hex ID for citations
|
||||
* @returns A new unique hex ID
|
||||
*/
|
||||
/**
|
||||
* Enhanced citation matching function that handles multiple citation formats
|
||||
*/
|
||||
private basicCitationMatch(content: string): Array<{
|
||||
type: 'footnote' | 'reference' | 'perplexity';
|
||||
number: string;
|
||||
index: number;
|
||||
original: string;
|
||||
lineContent: string;
|
||||
}> {
|
||||
const matches: Array<{
|
||||
type: 'footnote' | 'reference' | 'perplexity';
|
||||
number: string;
|
||||
index: number;
|
||||
original: string;
|
||||
lineContent: string;
|
||||
}> = [];
|
||||
|
||||
// Split content into lines to handle Perplexity-style footnotes
|
||||
const lines = content.split('\n');
|
||||
let currentPosition = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
// 1. Check for Perplexity-style footnotes (e.g., "1. [https://...]")
|
||||
const perplexityMatch = line.match(/^(\d+)\.\s+\[(https?:\/\/[^\]]+)\]/);
|
||||
if (perplexityMatch && perplexityMatch[1] && perplexityMatch[0]) {
|
||||
matches.push({
|
||||
type: 'perplexity',
|
||||
number: perplexityMatch[1],
|
||||
original: perplexityMatch[0],
|
||||
index: currentPosition + line.indexOf(perplexityMatch[0]),
|
||||
lineContent: line
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Find standard footnote references [^1]
|
||||
const footnoteRegex = /\[\^(\d+)\]/g;
|
||||
let footnoteMatch;
|
||||
while ((footnoteMatch = footnoteRegex.exec(line)) !== null) {
|
||||
if (footnoteMatch[1] && footnoteMatch[0]) {
|
||||
matches.push({
|
||||
type: 'footnote',
|
||||
number: footnoteMatch[1],
|
||||
original: footnoteMatch[0],
|
||||
index: currentPosition + (footnoteMatch.index || 0),
|
||||
lineContent: line
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Find standard citations [1] (but not links [text](url))
|
||||
const citationRegex = /\[(\d+)\]/g;
|
||||
let citationMatch;
|
||||
while ((citationMatch = citationRegex.exec(line)) !== null) {
|
||||
// Skip if it's part of a markdown link
|
||||
if (citationMatch[1] && citationMatch[0] &&
|
||||
!/\]\([^)]*$/.test(line.substring(0, citationMatch.index || 0))) {
|
||||
matches.push({
|
||||
type: 'reference',
|
||||
number: citationMatch[1],
|
||||
original: citationMatch[0],
|
||||
index: currentPosition + (citationMatch.index || 0),
|
||||
lineContent: line
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update position for the next line
|
||||
currentPosition += line.length + 1; // +1 for the newline character
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced citation replacement function
|
||||
*/
|
||||
private basicMatchAndReplace(
|
||||
content: string,
|
||||
targetNumber: string,
|
||||
hexId: string,
|
||||
matchType: 'footnote' | 'reference' | 'perplexity' = 'reference',
|
||||
lineContent: string = ''
|
||||
): string {
|
||||
if (matchType === 'perplexity' && lineContent) {
|
||||
// For Perplexity-style footnotes, replace the entire line with a footnote reference
|
||||
const url = lineContent.match(/^(\d+)\.\s+\[(https?:\/\/[^\]]+)\]/)?.[2] || '';
|
||||
const footnoteDef = `[^${hexId}]: ${url}`;
|
||||
|
||||
// Replace the line with just the footnote reference
|
||||
let updatedContent = content.replace(
|
||||
lineContent,
|
||||
`[^${hexId}]`
|
||||
);
|
||||
|
||||
// Add footnotes section if it doesn't exist
|
||||
if (!updatedContent.includes('# Footnotes')) {
|
||||
updatedContent += '\n\n# Footnotes\n';
|
||||
}
|
||||
|
||||
// Add the footnote definition if it doesn't exist
|
||||
if (!updatedContent.includes(`[^${hexId}]:`)) {
|
||||
updatedContent += `\n${footnoteDef}`;
|
||||
}
|
||||
|
||||
return updatedContent;
|
||||
} else {
|
||||
// For standard footnotes and references, replace only the specific instance
|
||||
const matches = this.basicCitationMatch(content);
|
||||
const targetMatch = matches.find(m =>
|
||||
m.type === matchType &&
|
||||
m.number === targetNumber &&
|
||||
(matchType === 'perplexity' ? m.lineContent === lineContent : true)
|
||||
);
|
||||
|
||||
if (!targetMatch) {
|
||||
return content;
|
||||
}
|
||||
|
||||
// For reference type with URL, convert to footnote format and add URL to footnotes
|
||||
const citationWithUrlPattern = new RegExp(`\\[${targetNumber}\\]\\(([^)]+)\\)`);
|
||||
if (matchType === 'reference' && citationWithUrlPattern.test(targetMatch.original)) {
|
||||
// Extract the URL from the original citation
|
||||
const urlMatch = targetMatch.original.match(citationWithUrlPattern);
|
||||
const url = urlMatch ? urlMatch[1] : '';
|
||||
|
||||
// Replace the entire [number](url) with [^hex]
|
||||
const before = content.substring(0, targetMatch.index);
|
||||
const after = content.substring(targetMatch.index + targetMatch.original.length);
|
||||
|
||||
// Add the URL to the footnotes section if it doesn't exist
|
||||
let updatedContent = `${before}[^${hexId}]${after}`;
|
||||
|
||||
// Ensure footnotes section exists
|
||||
if (!updatedContent.includes('# Footnotes')) {
|
||||
updatedContent += '\n\n# Footnotes\n';
|
||||
}
|
||||
|
||||
// Add the footnote definition if it doesn't exist
|
||||
if (!updatedContent.includes(`[^${hexId}]:`)) {
|
||||
updatedContent += `\n[^${hexId}]: ${url}`;
|
||||
}
|
||||
|
||||
return updatedContent;
|
||||
} else {
|
||||
// For other types, just replace the number inside the brackets
|
||||
const before = content.substring(0, targetMatch.index + (matchType === 'footnote' ? 2 : 1));
|
||||
const after = content.substring(targetMatch.index + targetMatch.original.length - 1);
|
||||
return `${before}${hexId}${after}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public convertCitations(content: string, targetCitation?: string): CitationConversionResult {
|
||||
if (!content) {
|
||||
return { updatedContent: content || '', changed: false, stats: { citationsConverted: 0 } };
|
||||
}
|
||||
|
||||
const matches = this.basicCitationMatch(content);
|
||||
if (matches.length === 0) {
|
||||
return { updatedContent: content, changed: false, stats: { citationsConverted: 0 } };
|
||||
}
|
||||
|
||||
let updatedContent = content;
|
||||
const processedOriginals = new Set<string>();
|
||||
let citationsConverted = 0;
|
||||
|
||||
// If we have a target citation, only process that one
|
||||
const matchesToProcess = targetCitation
|
||||
? matches.filter(m => m.original === targetCitation)
|
||||
: matches;
|
||||
|
||||
// Process each match in reverse order to avoid position shifting issues
|
||||
for (let i = matchesToProcess.length - 1; i >= 0; i--) {
|
||||
const match = matchesToProcess[i];
|
||||
if (!match || processedOriginals.has(match.original)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate a hex ID for this citation
|
||||
const hexId = this.generateHexId();
|
||||
|
||||
// Replace the citation
|
||||
updatedContent = this.basicMatchAndReplace(
|
||||
updatedContent,
|
||||
match.number,
|
||||
hexId,
|
||||
match.type,
|
||||
match.lineContent
|
||||
);
|
||||
|
||||
citationsConverted++;
|
||||
processedOriginals.add(match.original);
|
||||
} catch (error) {
|
||||
console.error(`Error processing citation ${match.number}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Map to store URL to hex ID mappings
|
||||
const urlToHexMap = new Map<string, string>();
|
||||
const hexToUrlMap = new Map<string, string>();
|
||||
|
||||
// First pass: Find all [number](url) patterns and replace with [^hex]
|
||||
// This handles citations that are adjacent to text or punctuation
|
||||
updatedContent = updatedContent.replace(/([^\s\[]|^)\[(\d+)\]\(([^)]+)\)/g, (_match, prefix, _number, url) => {
|
||||
let hexId: string;
|
||||
|
||||
// If we've seen this URL before, use the existing hex ID
|
||||
if (urlToHexMap.has(url)) {
|
||||
hexId = urlToHexMap.get(url)!;
|
||||
} else {
|
||||
// Generate a new hex ID for this URL
|
||||
hexId = this.generateHexId();
|
||||
urlToHexMap.set(url, hexId);
|
||||
hexToUrlMap.set(hexId, url);
|
||||
}
|
||||
|
||||
citationsConverted++;
|
||||
// Add a space before the citation if it's not at the start of the line
|
||||
const space = prefix === '' ? '' : ' ';
|
||||
return `${prefix}${space}[^${hexId}]`;
|
||||
});
|
||||
|
||||
// If we found any citations, add the footnotes section
|
||||
if (citationsConverted > 0) {
|
||||
// Check if footnotes section already exists
|
||||
const hasFootnotesSection = /\n#+\s*Footnotes\s*\n/.test(updatedContent);
|
||||
|
||||
// If no footnotes section exists, add one
|
||||
if (!hasFootnotesSection) {
|
||||
updatedContent += '\n\n# Footnotes\n';
|
||||
} else {
|
||||
updatedContent += '\n';
|
||||
}
|
||||
|
||||
// Add all footnote definitions
|
||||
hexToUrlMap.forEach((url, hexId) => {
|
||||
// Check if this footnote is already defined
|
||||
const footnoteRegex = new RegExp(`\\[\\^${hexId}\\]:.*`, 'g');
|
||||
if (!footnoteRegex.test(updatedContent)) {
|
||||
updatedContent += `\n[^${hexId}]: ${url}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
updatedContent,
|
||||
changed: citationsConverted > 0,
|
||||
stats: {
|
||||
citationsConverted
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error processing citations:', error);
|
||||
return {
|
||||
updatedContent: content, // Return original content on error
|
||||
changed: false,
|
||||
stats: { citationsConverted: 0 }
|
||||
};
|
||||
}
|
||||
public getNewHexId(): string {
|
||||
return this.generateHexId();
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance
|
||||
export const citationService = new CitationService();
|
||||
export const citationService = new CitationService();
|
||||
|
|
@ -1,98 +1,108 @@
|
|||
/* Citations Modal Styles */
|
||||
/* cite-wide/src/styles/citations.css */
|
||||
|
||||
.cite-wide-modal {
|
||||
padding: 20px;
|
||||
padding: 1.5rem;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.cite-wide-modal h3 {
|
||||
.cite-wide-title {
|
||||
margin-top: 0;
|
||||
color: var(--text-normal);
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.cite-wide-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 15px 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.cite-wide-table th,
|
||||
.cite-wide-table td {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.cite-wide-table th {
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
background-color: var(--background-primary-alt);
|
||||
.cite-wide-container {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.cite-type-cell {
|
||||
font-weight: 600;
|
||||
color: var(--text-accent);
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.cite-number-cell {
|
||||
width: 80px;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.cite-context-cell {
|
||||
max-width: 200px;
|
||||
.cite-wide-group {
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cite-actions-cell {
|
||||
width: 150px;
|
||||
text-align: right !important;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cite-actions-cell button {
|
||||
margin-left: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.cite-actions-cell button.mod-cta {
|
||||
background-color: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cite-actions-cell button.mod-cta:hover {
|
||||
background-color: var(--interactive-accent-hover);
|
||||
}
|
||||
|
||||
.button-spacer {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.modal-button-container {
|
||||
.cite-wide-group-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
padding-top: 15px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: var(--background-secondary);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.cite-wide-group-header:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.cite-wide-group-header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cite-wide-group-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cite-wide-source-link {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.8;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.cite-wide-group-content {
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: var(--background-primary);
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
/* Make the modal responsive */
|
||||
@media (max-width: 600px) {
|
||||
.cite-wide-table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.cite-context-cell {
|
||||
max-width: 150px;
|
||||
}
|
||||
.cite-wide-instance {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.cite-wide-instance:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.cite-wide-line-info {
|
||||
flex: 1;
|
||||
font-family: var(--font-monospace);
|
||||
font-size: 0.9em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cite-wide-line-number {
|
||||
color: var(--text-muted);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.cite-wide-line-preview {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.cite-wide-convert-btn {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.cite-wide-view-btn {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.cite-wide-footer {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
146
styles.css
146
styles.css
|
|
@ -1,82 +1,90 @@
|
|||
/* src/styles/citations.css */
|
||||
.cite-wide-modal {
|
||||
padding: 20px;
|
||||
padding: 1.5rem;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.cite-wide-modal h3 {
|
||||
.cite-wide-title {
|
||||
margin-top: 0;
|
||||
color: var(--text-normal);
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.cite-wide-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 15px 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.cite-wide-table th,
|
||||
.cite-wide-table td {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
.cite-wide-table th {
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
background-color: var(--background-primary-alt);
|
||||
.cite-wide-container {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.cite-type-cell {
|
||||
font-weight: 600;
|
||||
color: var(--text-accent);
|
||||
width: 100px;
|
||||
}
|
||||
.cite-number-cell {
|
||||
width: 80px;
|
||||
text-align: center !important;
|
||||
}
|
||||
.cite-context-cell {
|
||||
max-width: 200px;
|
||||
.cite-wide-group {
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cite-actions-cell {
|
||||
width: 150px;
|
||||
text-align: right !important;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cite-actions-cell button {
|
||||
margin-left: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.cite-actions-cell button.mod-cta {
|
||||
background-color: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cite-actions-cell button.mod-cta:hover {
|
||||
background-color: var(--interactive-accent-hover);
|
||||
}
|
||||
.button-spacer {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
}
|
||||
.modal-button-container {
|
||||
.cite-wide-group-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
padding-top: 15px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: var(--background-secondary);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.cite-wide-group-header:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
.cite-wide-group-header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.cite-wide-group-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cite-wide-source-link {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.8;
|
||||
text-decoration: none;
|
||||
}
|
||||
.cite-wide-group-content {
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: var(--background-primary);
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.cite-wide-table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.cite-context-cell {
|
||||
max-width: 150px;
|
||||
}
|
||||
.cite-wide-instance {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
.cite-wide-instance:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.cite-wide-line-info {
|
||||
flex: 1;
|
||||
font-family: var(--font-monospace);
|
||||
font-size: 0.9em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cite-wide-line-number {
|
||||
color: var(--text-muted);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
.cite-wide-line-preview {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.cite-wide-convert-btn {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.cite-wide-view-btn {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.cite-wide-footer {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue