A formatting helper toolbar for Danbooru note formatting dialogs, adding buttons to wrap highlighted text with HTML tags for easy formatting.
Per
// ==UserScript==
// @name Danbooru Note Formatting Helper
// @namespace http://tampermonkey.net/
// @version 1.23.0
// @description A formatting helper toolbar for Danbooru note formatting dialogs, adding buttons to wrap highlighted text with HTML tags for easy formatting.
// @author FunkyJustin
// @license MIT
// @match https://danbooru.donmai.us/*
// @grant none
// ==/UserScript==
(function() {
'use strict';
// Global try-catch wrapper to prevent errors from halting execution
try {
// Configuration object for easy extension - declared first to avoid hoisting issues
const CONFIG = {
version: '1.23.0',
toolbarId: 'note-formatting-toolbar',
buttons: [
// Font Group
{
id: 'font-size-btn',
label: 'Size',
title: 'Font Size (prompt px/em)',
action: (ta) => promptFontSize(ta)
},
{
id: 'big-btn',
label: 'Big',
title: 'Big Text (<big>)',
action: (ta) => applyFormat(ta, '<big>', '</big>')
},
{
id: 'small-btn',
label: 'Small',
title: 'Small Text (<small>)',
action: (ta) => applyFormat(ta, '<small>', '</small>')
},
{
id: 'color-btn',
label: 'Color ▼',
title: 'Text Color (click for palette/picker)',
action: (ta) => openColorPicker(ta, 'color')
},
{
id: 'highlight-btn',
label: 'Highlight ▼',
title: 'Text Highlight (background-color, click for palette/picker)',
action: (ta) => openColorPicker(ta, 'background-color')
},
{
id: 'bold-btn',
label: '𝐁',
title: 'Bold (<b>) (Ctrl+B)',
action: (ta) => applyFormat(ta, '<b>', '</b>')
},
{
id: 'italic-btn',
label: '𝑰',
title: 'Italic (<i>) (Ctrl+I)',
action: (ta) => applyFormat(ta, '<i>', '</i>')
},
{
id: 'underline-btn',
label: 'U̲',
title: 'Underline (<u>) (Ctrl+U)',
action: (ta) => applyFormat(ta, '<u>', '</u>')
},
{
id: 'strikethrough-btn',
label: 'S̶',
title: 'Strikethrough (<s>)',
action: (ta) => applyFormat(ta, '<s>', '</s>')
},
// Paragraph Group
{
id: 'align-left-btn',
label: '⟦',
title: 'Align Left (<div align="left">)',
action: (ta) => applyFormat(ta, '<div align="left">', '</div>')
},
{
id: 'align-center-btn',
label: '⦳',
title: 'Align Center (<div align="center">)',
action: (ta) => applyFormat(ta, '<div align="center">', '</div>')
},
{
id: 'align-right-btn',
label: '⟧',
title: 'Align Right (<div align="right">)',
action: (ta) => applyFormat(ta, '<div align="right">', '</div>')
},
// Insert Group
{
id: 'link-btn',
label: '🔗',
title: 'Insert Link (<a href="...">)',
action: (ta) => insertLink(ta)
},
{
id: 'tn-btn',
label: 'TN',
title: 'Translator Note (<tn>)',
action: (ta) => applyFormat(ta, '<tn>', '</tn>')
},
// Clear/History/Settings Group
{
id: 'clear-btn',
label: 'Clear',
title: 'Clear Formatting (strip HTML tags)',
action: (ta) => clearFormatting(ta)
},
{
id: 'undo-btn',
label: '↶',
title: 'Undo (Ctrl+Z)',
action: (ta) => undo(ta)
},
{
id: 'redo-btn',
label: '↷',
title: 'Redo (Ctrl+Y)',
action: (ta) => redo(ta)
},
{
id: 'toggle-resize-btn',
label: 'AR',
title: 'Toggle Auto-Resize On/Off',
action: (ta) => toggleAutoResize()
},
{
id: 'settings-btn',
label: '⚙',
title: 'Set Min Width/Height for Auto-Resize',
action: (ta) => openSettings()
}
],
shortcuts: {
bold: 'Ctrl+B',
italic: 'Ctrl+I',
underline: 'Ctrl+U',
undo: 'Ctrl+Z',
redo: 'Ctrl+Y'
},
manualTrigger: 'Ctrl+Shift+I', // Force inject
historyLimit: 50, // Max undo/redo states
commonColors: ['#FF0000', '#FF8000', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#8B00FF'], // ROYGBIV hex
autoResize: {
enabled: true,
defaultWidth: 523,
defaultHeight: 362
},
styles: `
#${'note-formatting-toolbar'} {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 5px 0;
padding: 5px;
background: #f0f0f0;
border: 1px solid #ccc;
border-radius: 4px;
font-family: sans-serif;
font-size: 12px;
justify-content: center;
overflow-x: auto;
max-width: 100%;
}
#${'note-formatting-toolbar'} .group {
display: flex;
gap: 2px;
padding: 2px 5px;
background: white;
border-radius: 3px;
border: 1px solid #ddd;
}
#${'note-formatting-toolbar'} button {
padding: 4px 6px;
border: 1px solid #ccc;
background: white;
border-radius: 3px;
cursor: pointer;
font-weight: bold;
font-size: 11px;
min-width: auto;
}
#${'note-formatting-toolbar'} button:hover {
background: #e0e0e0;
}
.note-edit-dialog .ui-dialog-content {
position: relative;
}
.note-edit-dialog #note-formatting-toolbar {
margin-bottom: 5px;
}
#toolbar-minimize {
margin-left: auto;
background: #ddd;
font-size: 10px;
padding: 2px 4px;
}
#debug-indicator {
position: fixed;
top: 10px;
right: 10px;
background: #ffeb3b;
color: #000;
padding: 5px;
border: 1px solid #ccc;
font-size: 12px;
z-index: 9999;
display: none;
max-width: 300px;
}
/* Color Picker Styles */
#color-picker {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #1E1E2C;
color: #E0E0E0;
border: 1px solid #ccc;
border-radius: 8px;
padding: 20px;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
z-index: 10000;
min-width: 300px;
font-family: sans-serif;
}
#color-picker h3 {
margin: 0 0 10px 0;
text-align: center;
color: #F0F0F0;
}
.palette {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 5px;
margin-bottom: 15px;
}
.palette button {
width: 30px;
height: 30px;
border: 1px solid #666;
border-radius: 4px;
cursor: pointer;
}
.custom-section {
margin-bottom: 10px;
}
.custom-section label {
display: block;
margin-bottom: 5px;
color: #D0D0D0;
}
.rgb-sliders {
display: flex;
gap: 10px;
margin-bottom: 10px;
}
.rgb-sliders input[type="range"] {
flex: 1;
background: #333;
color: #fff;
}
.rgb-inputs {
display: flex;
gap: 5px;
}
.rgb-inputs input[type="number"] {
width: 50px;
background: #333;
color: #fff;
border: 1px solid #666;
}
#brightness-slider {
margin-bottom: 10px;
}
#color-preview {
width: 100%;
height: 30px;
border: 1px solid #666;
border-radius: 4px;
margin-bottom: 10px;
}
.picker-buttons {
display: flex;
gap: 10px;
justify-content: center;
}
.picker-buttons button {
padding: 8px 16px;
border: 1px solid #666;
border-radius: 4px;
cursor: pointer;
background: #333;
color: #E0E0E0;
}
.picker-buttons button:hover {
background: #444;
}
#color-picker-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 9999;
}
`
};
// Global variables for state management (per-tab isolation for multi-tab support)
let observer = null;
let activeDialogs = new Set(); // Track multiple dialogs if possible (rare, but robust)
let toolbar = null;
let textarea = null;
let isMinimized = false;
let debugMode = true; // Enable for logging; set to false in prod if needed
let manualTriggerHandler = null;
let history = []; // Undo/redo history array of {value, start, end}
let historyIndex = -1; // Current position in history
let colorPicker = null; // Color picker dialog
// Utility: Log with prefix for easy console filtering - uses CONFIG.version safely
function log(message, data = null) {
const version = CONFIG ? CONFIG.version : 'unknown';
console.log(`[NoteFmtHelper v${version}] ${message}`, data || '');
}
// Load auto-resize config from localStorage (persistent across tabs/sessions)
function loadAutoResizeConfig() {
try {
const savedEnabled = localStorage.getItem('nfh_autoResizeEnabled');
if (savedEnabled !== null) {
CONFIG.autoResize.enabled = savedEnabled === 'true';
}
const savedWidth = localStorage.getItem('nfh_defaultWidth');
if (savedWidth && !isNaN(parseInt(savedWidth))) {
CONFIG.autoResize.defaultWidth = parseInt(savedWidth);
}
const savedHeight = localStorage.getItem('nfh_defaultHeight');
if (savedHeight && !isNaN(parseInt(savedHeight))) {
CONFIG.autoResize.defaultHeight = parseInt(savedHeight);
}
log('Auto-resize config loaded from localStorage.', CONFIG.autoResize);
} catch (err) {
log('Load auto-resize config error', err);
}
}
// Utility: Inject styles if not already present - stringified to avoid const issues
function injectStyles() {
try {
if (document.getElementById('note-formatting-styles')) return;
const style = document.createElement('style');
style.id = 'note-formatting-styles';
style.textContent = CONFIG.styles; // Template interpolation already handles ID replacement
document.head.appendChild(style);
log('Styles injected.');
} catch (err) {
console.error('[NoteFmtHelper] Style injection error:', err);
}
}
// Utility: Create or update toolbar with minimize button (Word-like ribbon with groups)
function createToolbar() {
try {
if (toolbar) return toolbar;
toolbar = document.createElement('div');
toolbar.id = CONFIG.toolbarId;
// Groups for Word-like layout
const groups = {
font: ['font-size-btn', 'big-btn', 'small-btn', 'color-btn', 'highlight-btn', 'bold-btn', 'italic-btn', 'underline-btn', 'strikethrough-btn'],
paragraph: ['align-left-btn', 'align-center-btn', 'align-right-btn'],
insert: ['link-btn', 'tn-btn'],
history: ['clear-btn', 'undo-btn', 'redo-btn', 'toggle-resize-btn', 'settings-btn']
};
Object.entries(groups).forEach(([groupName, btnIds]) => {
const group = document.createElement('div');
group.className = 'group';
group.title = groupName.charAt(0).toUpperCase() + groupName.slice(1); // e.g., "Font"
btnIds.forEach(btnId => {
const btnConfig = CONFIG.buttons.find(b => b.id === btnId);
if (btnConfig) {
const button = document.createElement('button');
button.id = btnConfig.id;
button.type = 'button';
button.innerHTML = btnConfig.label;
button.title = btnConfig.title;
button.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (textarea && !textarea.disabled) {
if (document.activeElement !== textarea) textarea.focus();
// Pre-snapshot for formatting actions (exclude undo/redo/toggle/settings)
if (!['undo-btn', 'redo-btn', 'toggle-resize-btn', 'settings-btn'].includes(btnConfig.id)) {
saveState(textarea);
}
btnConfig.action(textarea);
log(`Button ${btnConfig.id} clicked.`);
} else if (['toggle-resize-btn', 'settings-btn'].includes(btnConfig.id)) {
// Allow these even without textarea focus (global config)
btnConfig.action();
log(`${btnConfig.id} button clicked (global).`);
} else {
log('Button clicked but textarea not ready.', { focused: document.activeElement });
}
});
group.appendChild(button);
}
});
toolbar.appendChild(group);
});
// Minimize button (smaller, optional for buttonset fit)
const minimizeBtn = document.createElement('button');
minimizeBtn.id = 'toolbar-minimize';
minimizeBtn.type = 'button';
minimizeBtn.innerHTML = '−'; // Unicode minus for minimize
minimizeBtn.title = 'Minimize Formatting Toolbar';
minimizeBtn.style.fontSize = '0.8em';
minimizeBtn.style.padding = '0.3em 0.5em';
minimizeBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
toggleMinimize();
});
toolbar.appendChild(minimizeBtn);
// Version display (small span, not a button) - in corner as per style
const versionSpan = document.createElement('span');
versionSpan.id = 'toolbar-version';
versionSpan.textContent = `v${CONFIG.version}`;
versionSpan.className = 'ui-widget';
versionSpan.style.cssText = 'margin-left: 5px; font-size: 0.8em; color: #666; padding: 0.4em 0; align-self: center;';
toolbar.appendChild(versionSpan);
// Responsive: Adjust on resize
window.addEventListener('resize', adjustToolbarLayout);
adjustToolbarLayout();
log('Toolbar created as Word-like ribbon with groups and icons.');
return toolbar;
} catch (err) {
log('Toolbar creation error', err);
return null;
}
}
// Minimize/Maximize functionality for better UX (hides main buttons)
function toggleMinimize() {
try {
isMinimized = !isMinimized;
const mainButtons = toolbar ? toolbar.querySelectorAll('button:not(#toolbar-minimize)') : [];
const versionSpan = document.getElementById('toolbar-version');
const minimizeBtn = document.getElementById('toolbar-minimize');
if (isMinimized) {
mainButtons.forEach(btn => btn.style.display = 'none');
if (versionSpan) versionSpan.style.display = 'none';
if (minimizeBtn) {
minimizeBtn.innerHTML = '+'; // Plus for maximize
minimizeBtn.title = 'Expand Formatting Toolbar';
}
log('Formatting toolbar minimized.');
} else {
mainButtons.forEach(btn => btn.style.display = '');
if (versionSpan) versionSpan.style.display = '';
if (minimizeBtn) {
minimizeBtn.innerHTML = '−';
minimizeBtn.title = 'Minimize Formatting Toolbar';
}
adjustToolbarLayout();
log('Formatting toolbar expanded.');
}
} catch (err) {
log('Toggle minimize error', err);
}
}
// Responsive layout adjustment (for buttonset fit)
function adjustToolbarLayout() {
try {
if (toolbar && window.innerWidth < 800) { // Broader check for dialog context
toolbar.style.flexDirection = 'column';
toolbar.style.alignItems = 'stretch';
toolbar.querySelectorAll('button').forEach(btn => btn.style.minWidth = 'auto');
} else {
toolbar.style.flexDirection = 'row';
toolbar.style.alignItems = 'center';
toolbar.querySelectorAll('button').forEach(btn => btn.style.minWidth = 'initial');
}
} catch (err) {
log('Layout adjustment error', err);
}
}
// Auto-expand dialog to fit ribbon + textarea
function autoResizeDialog(dialog) {
try {
if (!CONFIG.autoResize.enabled) {
log('Auto-resize disabled; skipped.');
return;
}
if (dialog.dataset.helperResized === 'true') return; // One-time per dialog (reset via settings if needed)
const tb = dialog.querySelector(`#${CONFIG.toolbarId}`);
const ta = dialog.querySelector('textarea');
if (!tb || !ta) {
log('Auto-resize skipped: Missing toolbar/textarea.');
return;
}
// Re-measure after height set (no inner timeout needed for immediacy)
const tbRect = tb.getBoundingClientRect();
const taRect = ta.getBoundingClientRect();
// Calculate content dimensions
const contentWidth = Math.max(tbRect.width, taRect.width) + 30; // Padding for borders/scroll
const contentHeight = tbRect.height + taRect.height + 50; // Buffer for padding/margins
const newWidth = Math.max(CONFIG.autoResize.defaultWidth, Math.max(500, contentWidth));
const newHeight = Math.max(CONFIG.autoResize.defaultHeight, contentHeight + 150); // Header/footer/dialog chrome buffer
// Always use direct style resize (reliable, no jQuery dependency/error)
dialog.style.width = `${newWidth}px`;
dialog.style.height = `${newHeight}px`;
log(`Auto-resized dialog via direct style: ${newWidth}x${newHeight}px`);
dialog.dataset.helperResized = 'true';
} catch (err) {
log('Auto-resize error', err);
}
}
// Toggle auto-resize enable/disable
function toggleAutoResize() {
try {
CONFIG.autoResize.enabled = !CONFIG.autoResize.enabled;
localStorage.setItem('nfh_autoResizeEnabled', CONFIG.autoResize.enabled.toString());
const status = CONFIG.autoResize.enabled ? 'enabled' : 'disabled';
log(`Auto-resize ${status}.`);
showDebugIndicator(`Auto-resize ${status}.`, 'info');
// If enabling, re-apply to current dialog
if (CONFIG.autoResize.enabled) {
const dialog = document.querySelector('.note-edit-dialog');
if (dialog) {
dialog.dataset.helperResized = 'false';
setTimeout(() => autoResizeDialog(dialog), 100);
}
}
} catch (err) {
log('Toggle auto-resize error', err);
}
}
// Open settings for width/height mins only
function openSettings() {
try {
let newWidthStr = prompt('Enter default min width (px):', CONFIG.autoResize.defaultWidth.toString());
let newWidth = parseInt(newWidthStr);
if (isNaN(newWidth) || newWidth < 300) {
newWidth = CONFIG.autoResize.defaultWidth;
log('Invalid width; using default.');
}
CONFIG.autoResize.defaultWidth = newWidth;
localStorage.setItem('nfh_defaultWidth', newWidth.toString());
let newHeightStr = prompt('Enter default min height (px):', CONFIG.autoResize.defaultHeight.toString());
let newHeight = parseInt(newHeightStr);
if (isNaN(newHeight) || newHeight < 200) {
newHeight = CONFIG.autoResize.defaultHeight;
log('Invalid height; using default.');
}
CONFIG.autoResize.defaultHeight = newHeight;
localStorage.setItem('nfh_defaultHeight', newHeight.toString());
log('Auto-resize dimensions updated.', CONFIG.autoResize);
showDebugIndicator(`Dimensions updated: ${newWidth}x${newHeight}`, 'info');
// Re-apply to current dialog if enabled
if (CONFIG.autoResize.enabled) {
const dialog = document.querySelector('.note-edit-dialog');
if (dialog) {
dialog.dataset.helperResized = 'false';
setTimeout(() => autoResizeDialog(dialog), 100);
}
}
} catch (err) {
log('Settings error', err);
alert('Settings update failed; check console.');
}
}
// Unified apply format function for tags, styles, aligns (replaces applyStyle/applyAlign)
function applyFormat(ta, openTag, closeTag) {
try {
const start = ta.selectionStart;
const end = ta.selectionEnd;
const text = ta.value;
const selected = text.substring(start, end);
const before = text.substring(0, start);
const after = text.substring(end);
let newText;
let cursorStart, cursorEnd;
if (selected) {
newText = before + openTag + selected + closeTag + after;
cursorStart = start + openTag.length;
cursorEnd = cursorStart + selected.length;
} else {
const placeholder = openTag + 'text' + closeTag;
newText = before + placeholder + after;
cursorStart = start + openTag.length;
cursorEnd = cursorStart + 4; // Position inside 'text'
}
ta.value = newText;
ta.selectionStart = cursorStart;
ta.selectionEnd = cursorEnd;
ta.focus();
ta.dispatchEvent(new Event('input', { bubbles: true }));
// Post-snapshot after successful apply
saveState(ta);
log(`Applied format: ${openTag.substring(0, 20)}...`);
} catch (err) {
log('Apply format error', err);
}
}
// Prompt for font size and apply via applyFormat
function promptFontSize(ta) {
try {
const size = prompt('Enter font size (e.g., 18px, 1.5em, large):', '16px');
if (!size) return; // No post-save if cancelled
const styleStr = `font-size: ${size};`;
const openTag = `<span style="${styleStr}">`;
applyFormat(ta, openTag, '</span>'); // Post-save inside applyFormat
} catch (err) {
log('Font size prompt error', err);
}
}
// Open color picker for text color or highlight
function openColorPicker(ta, type) {
try {
if (colorPicker) {
closeColorPicker();
}
// Overlay
const overlay = document.createElement('div');
overlay.id = 'color-picker-overlay';
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);z-index:9998;';
document.body.appendChild(overlay);
// Picker dialog
colorPicker = document.createElement('div');
colorPicker.id = 'color-picker';
colorPicker.innerHTML = `
<h3>${type === 'background-color' ? 'Text Highlight' : 'Text Color'}</h3>
<div class="palette"></div>
<div class="custom-section">
<label>Custom RGB</label>
<div class="rgb-sliders">
<input type="range" id="r-slider" min="0" max="255" value="255" />
<input type="range" id="g-slider" min="0" max="255" value="0" />
<input type="range" id="b-slider" min="0" max="255" value="0" />
</div>
<div class="rgb-inputs">
<span>R:</span><input type="number" id="r-input" min="0" max="255" value="255" />
<span>G:</span><input type="number" id="g-input" min="0" max="255" value="0" />
<span>B:</span><input type="number" id="b-input" min="0" max="255" value="0" />
</div>
<label>Brightness (Alpha 0-1)</label>
<input type="range" id="alpha-slider" min="0" max="1" step="0.01" value="1" />
<span id="alpha-value">1.00</span>
</div>
<div id="color-preview" style="background-color: rgb(255,0,0);"></div>
<div class="picker-buttons">
<button id="picker-ok">OK</button>
<button id="picker-cancel">Cancel</button>
</div>
`;
document.body.appendChild(colorPicker);
// Palette
const palette = colorPicker.querySelector('.palette');
CONFIG.commonColors.forEach(color => {
const btn = document.createElement('button');
btn.style.backgroundColor = color;
btn.addEventListener('click', () => {
const rgb = hexToRgb(color);
colorPicker.querySelector('#r-slider').value = rgb.r;
colorPicker.querySelector('#g-slider').value = rgb.g;
colorPicker.querySelector('#b-slider').value = rgb.b;
colorPicker.querySelector('#r-input').value = rgb.r;
colorPicker.querySelector('#g-input').value = rgb.g;
colorPicker.querySelector('#b-input').value = rgb.b;
updateColor(colorPicker);
});
palette.appendChild(btn);
});
// OK button - post-save after apply
colorPicker.querySelector('#picker-ok').addEventListener('click', (e) => {
const r = colorPicker.querySelector('#r-slider').value;
const g = colorPicker.querySelector('#g-slider').value;
const b = colorPicker.querySelector('#b-slider').value;
const alpha = colorPicker.querySelector('#alpha-slider').value;
const colorValue = alpha < 1 ? `rgba(${r}, ${g}, ${b}, ${alpha})` : `rgb(${r}, ${g}, ${b})`;
const styleStr = `${type}: ${colorValue};`;
const openTag = `<span style="${styleStr}">`;
applyFormat(ta, openTag, '</span>'); // Post-save inside applyFormat
closeColorPicker();
});
// Cancel button
colorPicker.querySelector('#picker-cancel').addEventListener('click', closeColorPicker);
// Close on overlay click
overlay.addEventListener('click', closeColorPicker);
// ESC key to close
const escHandler = (e) => {
if (e.key === 'Escape') closeColorPicker();
};
window.addEventListener('keydown', escHandler);
// Update color preview
function updateColor(picker) {
const r = picker.querySelector('#r-slider').value;
const g = picker.querySelector('#g-slider').value;
const b = picker.querySelector('#b-slider').value;
const alpha = picker.querySelector('#alpha-slider').value;
picker.querySelector('#alpha-value').textContent = alpha;
const color = `rgba(${r}, ${g}, ${b}, ${alpha})`;
picker.querySelector('#color-preview').style.backgroundColor = color;
picker.querySelector('#r-input').value = r;
picker.querySelector('#g-input').value = g;
picker.querySelector('#b-input').value = b;
}
// Update slider from input
function updateSlider(channel, picker) {
const input = picker.querySelector(`#${channel}-input`).value;
picker.querySelector(`#${channel}-slider`).value = input;
updateColor(picker);
}
// Add event listeners for inputs/sliders
colorPicker.addEventListener('input', (e) => {
if (e.target.matches('input[type="range"], input[type="number"]')) {
if (e.target.id.includes('input') && e.target.type === 'number') {
const channel = e.target.id.split('-')[0];
updateSlider(channel, colorPicker);
} else {
updateColor(colorPicker);
}
}
});
// Initial update
updateColor(colorPicker);
// Cleanup ESC on close
function closeColorPicker() {
if (colorPicker) colorPicker.remove();
if (overlay) overlay.remove();
colorPicker = null;
window.removeEventListener('keydown', escHandler);
}
function hexToRgb(hex) {
const r = parseInt(hex.substr(1,2), 16);
const g = parseInt(hex.substr(3,2), 16);
const b = parseInt(hex.substr(5,2), 16);
return { r, g, b };
}
} catch (err) {
log('Color picker open error', err);
}
}
// Clear formatting: Strip HTML tags
function clearFormatting(ta) {
try {
const text = ta.value.replace(/<[^>]*>/g, '');
ta.value = text;
ta.selectionStart = ta.selectionEnd = 0;
ta.focus();
ta.dispatchEvent(new Event('input', { bubbles: true }));
// Post-snapshot after clear
saveState(ta);
log('Formatting cleared.');
} catch (err) {
log('Clear formatting error', err);
}
}
// Insert link prompt with intelligent defaults
function insertLink(ta) {
try {
const start = ta.selectionStart;
const end = ta.selectionEnd;
const selected = ta.value.substring(start, end);
const url = prompt('Enter URL:');
if (!url) return; // No post-save if cancelled
let text;
if (selected) {
text = prompt('Enter link text (optional):', selected) || selected;
} else {
text = prompt('Enter link text (optional):', '') || url;
}
const linkHTML = `<a href="${url}">${text}</a>`;
insertAtCursor(ta, linkHTML); // Post-save inside insertAtCursor
} catch (err) {
log('Link insert error', err);
}
}
// Insert text at cursor
function insertAtCursor(ta, text) {
try {
const start = ta.selectionStart;
const end = ta.selectionEnd;
ta.value = ta.value.substring(0, start) + text + ta.value.substring(end);
ta.selectionStart = ta.selectionEnd = start + text.length;
ta.focus();
ta.dispatchEvent(new Event('input', { bubbles: true }));
// Post-snapshot after insert
saveState(ta);
} catch (err) {
log('Insert at cursor error', err);
}
}
// History management for undo/redo - now saves {value, start, end}
function saveState(ta) {
try {
const state = {
value: ta.value,
start: ta.selectionStart,
end: ta.selectionEnd
};
// Trim history to limit
if (history.length > CONFIG.historyLimit) {
history.shift();
if (historyIndex > 0) historyIndex--;
}
// Push current state after current index (linear, discards branches)
history = history.slice(0, historyIndex + 1);
history.push(state);
historyIndex = history.length - 1;
log(`State saved. History length: ${history.length}`);
} catch (err) {
log('Save state error', err);
}
}
function undo(ta) {
try {
if (historyIndex > 0) {
historyIndex--;
const state = history[historyIndex];
ta.value = state.value;
ta.selectionStart = state.start;
ta.selectionEnd = state.end;
ta.focus();
ta.dispatchEvent(new Event('input', { bubbles: true }));
log(`Undo to index ${historyIndex}`);
} else {
log('Undo: At start of history.');
}
} catch (err) {
log('Undo error', err);
}
}
function redo(ta) {
try {
if (historyIndex < history.length - 1) {
historyIndex++;
const state = history[historyIndex];
ta.value = state.value;
ta.selectionStart = state.start;
ta.selectionEnd = state.end;
ta.focus();
ta.dispatchEvent(new Event('input', { bubbles: true }));
log(`Redo to index ${historyIndex}`);
} else {
log('Redo: At end of history.');
}
} catch (err) {
log('Redo error', err);
}
}
// Setup initial history snapshot (no ongoing listener)
function setupHistory(ta) {
try {
// Initial save (pre-any-changes backup)
saveState(ta);
log('Initial history snapshot set up.');
} catch (err) {
log('History setup error', err);
}
}
// Keyboard shortcuts (per-tab, checks focus) - extended for undo/redo
function setupShortcuts() {
try {
// Remove existing listener to avoid duplicates in multi-tab/reload
document.removeEventListener('keydown', handleKeydown);
document.addEventListener('keydown', handleKeydown, true);
log('Shortcuts set up.');
} catch (err) {
log('Shortcuts setup error', err);
}
}
function handleKeydown(e) {
try {
if (!textarea || document.activeElement !== textarea) return;
if (e.ctrlKey) {
if (e.key === 'b') {
e.preventDefault();
CONFIG.buttons.find(b => b.id === 'bold-btn').action(textarea);
} else if (e.key === 'i') {
e.preventDefault();
CONFIG.buttons.find(b => b.id === 'italic-btn').action(textarea);
} else if (e.key === 'u') {
e.preventDefault();
CONFIG.buttons.find(b => b.id === 'underline-btn').action(textarea);
} else if (e.key === 'z' && !e.shiftKey) {
e.preventDefault();
CONFIG.buttons.find(b => b.id === 'undo-btn').action(textarea);
} else if (e.key === 'y' || (e.key === 'z' && e.shiftKey)) {
e.preventDefault();
CONFIG.buttons.find(b => b.id === 'redo-btn').action(textarea);
}
}
} catch (err) {
log('Keydown handler error', err);
}
}
// Manual force inject handler - defined early to avoid ReferenceError
function setupManualTrigger() {
try {
if (manualTriggerHandler) return;
manualTriggerHandler = (e) => {
if (e.ctrlKey && e.shiftKey && e.key === 'I') {
e.preventDefault();
const dialog = document.querySelector('.note-edit-dialog');
if (dialog) {
injectToolbar(dialog, true); // Force with banner
log('Manual trigger: Forced injection.');
} else {
showDebugIndicator('Manual trigger: No dialog open.', 'error');
}
}
};
document.addEventListener('keydown', manualTriggerHandler, true);
log('Manual trigger (Ctrl+Shift+I) set up.');
} catch (err) {
log('Manual trigger setup error', err);
}
}
// Dialog injection: Insert ribbon above textarea
function injectToolbar(dialog, isManual = false) {
try {
log('Attempting injection for dialog:', dialog);
const content = dialog.querySelector('.ui-dialog-content');
if (!content) {
log('No .ui-dialog-content found.');
return;
}
const headerSpan = content.querySelector('span');
if (!headerSpan) {
log('No header span found.');
return;
}
textarea = content.querySelector('textarea');
if (!textarea) {
log('No textarea found in content.');
return;
}
// Stable injection check: Use dataset attribute to prevent re-inject
if (dialog.dataset.helperInjected === 'true') {
log('Dialog already injected (dataset check). Skipping.');
return;
}
// Remove any existing toolbar in content
const existing = content.querySelector(`#${CONFIG.toolbarId}`);
if (existing) {
existing.remove();
log('Removed existing toolbar.');
}
const tb = createToolbar();
if (tb) {
content.insertBefore(tb, textarea); // Insert after header, before textarea
log('Formatting ribbon inserted above textarea.');
} else {
log('Failed to create toolbar.');
return;
}
// Early textarea height adjustment for immediate fit
const taFullHeight = Math.max(200, textarea.scrollHeight);
textarea.style.height = `${taFullHeight}px`;
// Mark as injected
dialog.dataset.helperInjected = 'true';
setupShortcuts(); // Re-setup for this textarea
setupHistory(textarea); // Initial snapshot only
// Auto-resize dialog immediately after insertion (minimal delay for layout settle)
setTimeout(() => autoResizeDialog(dialog), 100);
// Cleanup on dialog close - remove dataset
const observerCleanup = new MutationObserver((mutations) => {
if (!document.body.contains(dialog)) {
activeDialogs.delete(dialog); // Track by reference now
if (tb && tb.parentNode) tb.remove();
delete dialog.dataset.helperInjected;
delete dialog.dataset.helperResized;
history = [];
historyIndex = -1;
if (colorPicker) closeColorPicker();
observerCleanup.disconnect();
log(`Cleaned up for closed dialog.`);
}
});
observerCleanup.observe(document.body, { childList: true, subtree: true });
// Only show banner on first auto or manual
if (isManual || !activeDialogs.has(dialog)) {
log(`Ribbon injected above textarea for dialog.`);
showDebugIndicator('Word-like Ribbon Injected Above Textarea! Icons and Big/Small added.', 'success');
}
activeDialogs.add(dialog); // Track by reference for multi-dialog
} catch (err) {
log('Injection error', err);
showDebugIndicator('Injection Failed - Check Console. Try Ctrl+Shift+I.', 'error');
// Autonomous recovery: Poll will retry
}
}
// Debug indicator: Temporary overlay for visibility issues
function showDebugIndicator(message, type = 'info') {
try {
if (!debugMode) return;
let indicator = document.getElementById('debug-indicator');
if (!indicator) {
indicator = document.createElement('div');
indicator.id = 'debug-indicator';
document.body.appendChild(indicator);
}
indicator.textContent = `[NoteFmtHelper] ${message}`;
indicator.style.background = type === 'error' ? '#ffcccc' : type === 'success' ? '#ccffcc' : '#ffeb3b';
indicator.style.display = 'block';
setTimeout(() => { indicator.style.display = 'none'; }, 5000); // Shorter now to reduce spam
log(message);
} catch (err) {
console.error('[NoteFmtHelper] Debug indicator error', err);
}
}
// Main observer for dialog detection (subtree for nested changes)
function setupObserver() {
try {
if (observer) {
log('Observer already set up.');
return;
}
observer = new MutationObserver((mutations) => {
let detected = false;
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1 && node.matches && node.matches('.note-edit-dialog')) {
log('Dialog added via observer:', node);
detected = true;
setTimeout(() => injectToolbar(node), 500); // Keep for render stability
}
});
}
});
if (detected) log('Observer triggered injection.');
});
observer.observe(document.body, { childList: true, subtree: true });
log('Observer set up.');
} catch (err) {
log('Observer setup error', err);
}
}
// Fallback poll for edge cases (e.g., observer misses, multi-tab glitches) - faster for persistence
let pollInterval = null;
function startPoll() {
try {
if (pollInterval) return;
pollInterval = setInterval(() => {
const dialogs = document.querySelectorAll('.note-edit-dialog');
if (dialogs.length === 0) {
if (toolbar) {
toolbar.remove();
toolbar = null;
activeDialogs.clear();
log('No dialogs; cleaned up.');
}
return;
}
dialogs.forEach(dialog => {
// Use dataset check here too
if (dialog.dataset.helperInjected !== 'true') {
const content = dialog.querySelector('.ui-dialog-content');
const ta = content ? content.querySelector('textarea') : null;
if (content && ta) {
log('Poll detected eligible dialog:', dialog);
injectToolbar(dialog);
}
}
});
}, 3000); // Slower poll (3s) now that dataset prevents retry
log('Poll started (every 3s).');
} catch (err) {
log('Poll start error', err);
}
}
// Init: Robust, multi-tab safe - setupManualTrigger called after definition
function init() {
try {
loadAutoResizeConfig(); // Load user settings first
injectStyles();
setupObserver();
startPoll(); // Always run poll for reliability
setupManualTrigger(); // Defined early, no ReferenceError
// Multi-tab: Listen for focus to re-inject if needed
window.addEventListener('focus', (e) => {
setTimeout(() => {
setupObserver();
if (pollInterval) clearInterval(pollInterval);
startPoll();
}, 100);
});
showDebugIndicator(`Script Loaded v${CONFIG.version}! Open dialog for Word-like ribbon & advanced markup. Ctrl+Shift+I to force. AR/⚙ for auto-resize.`, 'info');
log(`Note Formatting Helper v${CONFIG.version} initialized. Ready for multi-tab use. Open DevTools > Console to monitor logs.`);
} catch (err) {
console.error('[NoteFmtHelper] Init error:', err);
showDebugIndicator('Init error - script partially loaded. Try Ctrl+Shift+I on dialog open.', 'error');
}
}
// DOM ready check
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// Handle page navigation (Danbooru hash changes)
window.addEventListener('hashchange', (e) => {
setTimeout(() => {
setupObserver();
if (pollInterval) clearInterval(pollInterval);
startPoll();
}, 500);
log('Page navigated; re-setup observers.');
});
} catch (globalErr) {
console.error('[NoteFmtHelper] Global error - script failed to load:', globalErr);
// Fallback: Create minimal debug indicator
const indicator = document.createElement('div');
indicator.id = 'debug-indicator';
indicator.style.cssText = 'position:fixed;top:10px;right:10px;background:#ffcccc;color:#000;padding:5px;border:1px solid #ccc;z-index:9999;';
indicator.textContent = '[NoteFmtHelper] Fatal error - reinstall script. Check console.';
document.body.appendChild(indicator);
}
})();