MediaWiki:Common.js: Difference between revisions
MediaWiki interface page
More actions
m Fix color tool v3: execCommand + styleWithCSS + observer poll + surroundContents fallback |
m Fix color tool v4: TransactionBuilder direct span insertion with style |
||
| Line 397: | Line 397: | ||
/* ===== Custom VE Foreground Color Tool ===== | /* ===== Custom VE Foreground Color Tool ===== | ||
v4: Uses ve.dm.TransactionBuilder to commit inline color directly to | |||
the VE model as a proper transaction. Splits the selection into ranges | |||
per branch and applies the color annotation to each. */ | |||
(function () { | (function () { | ||
function initColorTool() { | function initColorTool() { | ||
if (typeof ve === 'undefined' || !ve.ui || !ve.ui.Tool || !OO) { | if (typeof ve === 'undefined' || !ve.ui || !ve.ui.Tool || !ve.dm || !OO) { | ||
setTimeout(initColorTool, 200); | setTimeout(initColorTool, 200); | ||
return; | return; | ||
| Line 463: | Line 464: | ||
ForegroundColorTool.prototype.applyColor = function (surface, color) { | ForegroundColorTool.prototype.applyColor = function (surface, color) { | ||
var | var model = surface.getModel(); | ||
var | var doc = model.getDocument(); | ||
var selection = | var selection = model.getSelection(); | ||
var range = selection.getRange(); | var range = selection.getRange(); | ||
if (range.isCollapsed()) { | if (range.isCollapsed()) { | ||
surface.getView().focus(); | |||
return; | return; | ||
} | } | ||
// | // Use the fragment API to apply color to each range in the selection | ||
var fragment = model.getFragment(selection, 'editable'); | |||
var ranges = fragment.getRanges(); | |||
// | // Build a transaction that applies the color annotation to each range | ||
var | var txBuilder = new ve.dm.TransactionBuilder(); | ||
var annotated = 0; | |||
// | ranges.forEach(function (r) { | ||
if ( | if (r.isCollapsed()) return; | ||
// Get the content branches within this range | |||
var branches = r.getCoveringBranches(); | |||
if (!branches || branches.length === 0) return; | |||
branches.forEach(function (branchInfo) { | |||
var branch = branchInfo.branch; | |||
var start = branchInfo.start; | |||
var end = branchInfo.end; | |||
if (branch === undefined || start === end) return; | |||
// Get the actual nodes at this position | |||
var textStart = branch.getNodeAtOffset(start); | |||
var textEnd = branch.getNodeAtOffset(end - 1); | |||
if (!textStart) return; | |||
// Find the offset within the start text node | |||
var startOffsetInNode = start - branch.getOffset(textStart, true); | |||
var endOffsetInNode = end - branch.getOffset(textEnd, true); | |||
// Create wrapper nodes | |||
var openSpan = ['span', { style: 'color: ' + color + ';' }, [], { 'type': '+style' }]; | |||
var closeSpan = ['span', { }, [], { 'type': 'closeStyle' }]; | |||
// Apply using internalList manipulation | |||
txBuilder.inserting(branch, start, [{type: 'span', attributes: {style: 'color: ' + color + ';'}}, {type: '/span'}]); | |||
annotated++; | |||
}); | |||
}); | |||
if (annotated > 0) { | |||
try { | |||
txBuilder.commit(); | |||
} catch (err) { | |||
console.log('[ColorTool] TransactionBuilder failed, trying alternative:', err); | |||
// Alternative: use fragment.annotateContent with a custom annotation | |||
tool.applyColorV2(model, fragment, color); | |||
} | |||
} else { | |||
// Fallback: simpler approach | |||
tool.applyColorV2(model, fragment, color); | |||
} | } | ||
// | surface.getView().focus(); | ||
}; | |||
var | |||
ForegroundColorTool.prototype.applyColorV2 = function (model, fragment, color) { | |||
// Simpler fallback: use the existing textStyle/span annotation | |||
// with `style` attribute in the data element's content | |||
// We need to create a custom inline annotation. | |||
// | |||
// Approach: insert <span style="color:..."> markup directly into the | |||
// content branches using the TransactionBuilder's insertContent method. | |||
var doc = model.getDocument(); | |||
var ranges = fragment.getRanges(); | |||
ranges.forEach(function (r) { | |||
if (r.isCollapsed()) return; | |||
var branches = r.getCoveringBranches(); | |||
if (!branches) return; | |||
branches.forEach(function (branchInfo) { | |||
var branch = branchInfo.branch; | |||
var start = branchInfo.start; | |||
var end = branchInfo.end; | |||
if (branch === undefined || start === end) return; | |||
// Use TransactionBuilder to wrap with span | |||
var tx = new ve.dm.TransactionBuilder(); | |||
var insertAt = start; | |||
var deleteCount = end - start; | |||
// Get the current content to preserve it | |||
var content = branch.slice(start, end); | |||
// Insert: open span + content + close span | |||
var newContent = [] | |||
.concat({type: 'span', attributes: {style: 'color: ' + color + ';'}}) | |||
.concat(content) | |||
.concat({type: '/span'}); | |||
try { | |||
tx.remove(branch, insertAt, deleteCount); | |||
tx.insert(branch, insertAt, newContent); | |||
tx.commit(); | |||
} catch (err) { | |||
console.log('[ColorTool] alt failed:', err); | |||
} | } | ||
} | }); | ||
}); | |||
} | |||
}; | }; | ||
| Line 525: | Line 577: | ||
ve.ui.toolFactory.register(ForegroundColorTool); | ve.ui.toolFactory.register(ForegroundColorTool); | ||
console.log('[VEForAll] Foreground color tool registered'); | console.log('[VEForAll] Foreground color tool v4 registered'); | ||
} | } | ||
Revision as of 10:12, 1 August 2026
( function () {
'use strict';
var ACTIVE_CLASS = 'citizen-toc-list-item--active';
var ACTIVE_TOP_CLASS = 'citizen-toc-level-1--active';
var LINK_SELECTOR = '.citizen-toc-link:not(.citizen-toc-top)';
var NAVIGATION_KEYS = [
'ArrowDown',
'ArrowUp',
'End',
'Home',
'PageDown',
'PageUp',
' '
];
function setupHomeCarousel() {
var carousel = document.querySelector( '.lcd-home-carousel' );
if ( !carousel || carousel.dataset.techdocsCarouselReady === '1' ) {
return;
}
var slides = Array.prototype.slice.call(
carousel.querySelectorAll( '.lcd-home-carousel__slide' )
);
if ( slides.length === 0 ) {
return;
}
carousel.dataset.techdocsCarouselReady = '1';
var interval = parseInt( carousel.dataset.interval, 10 );
if ( !Number.isFinite( interval ) || interval < 1000 ) {
interval = 4000;
}
var currentIndex = Math.max( 0, slides.findIndex( function ( slide ) {
return slide.classList.contains( 'lcd-home-carousel__slide--active' );
} ) );
var timerId = 0;
var dots = [];
var dotsContainer = document.createElement( 'div' );
dotsContainer.className = 'lcd-home-carousel__dots';
dotsContainer.setAttribute( 'role', 'group' );
dotsContainer.setAttribute( 'aria-label', '轮播进度' );
function showSlide( index ) {
currentIndex = ( index + slides.length ) % slides.length;
slides.forEach( function ( slide, slideIndex ) {
var isActive = slideIndex === currentIndex;
slide.classList.toggle(
'lcd-home-carousel__slide--active',
isActive
);
slide.setAttribute( 'aria-hidden', isActive ? 'false' : 'true' );
} );
dots.forEach( function ( dot, dotIndex ) {
if ( dotIndex === currentIndex ) {
dot.setAttribute( 'aria-current', 'true' );
} else {
dot.removeAttribute( 'aria-current' );
}
} );
}
function stopTimer() {
if ( timerId ) {
window.clearInterval( timerId );
timerId = 0;
}
}
function startTimer() {
stopTimer();
if ( slides.length > 1 && !document.hidden ) {
timerId = window.setInterval( function () {
showSlide( currentIndex + 1 );
}, interval );
}
}
slides.forEach( function ( slide, slideIndex ) {
var dot = document.createElement( 'button' );
dot.type = 'button';
dot.className = 'lcd-home-carousel__dot';
dot.setAttribute(
'aria-label',
'显示第 ' + ( slideIndex + 1 ) + ' 张轮播图片'
);
dot.addEventListener( 'click', function () {
showSlide( slideIndex );
startTimer();
} );
dots.push( dot );
dotsContainer.appendChild( dot );
} );
carousel.appendChild( dotsContainer );
showSlide( currentIndex );
startTimer();
document.addEventListener( 'visibilitychange', function () {
if ( document.hidden ) {
stopTimer();
} else {
startTimer();
}
} );
window.addEventListener( 'pagehide', stopTimer );
}
function setupUniqueProductTargets() {
var candidateFields = Array.prototype.slice.call(
document.querySelectorAll(
'input[name^="首页产品卡["][name$="[页面]"]'
)
);
var fields = candidateFields.filter( function ( field ) {
return field.name.indexOf( '[map_field]' ) === -1;
} );
var form = fields.length > 0 ? fields[ 0 ].closest( 'form' ) : null;
if ( !form || form.dataset.techdocsUniqueProductsReady === '1' ) {
return;
}
form.dataset.techdocsUniqueProductsReady = '1';
var message = document.createElement( 'div' );
message.className = 'errorbox';
message.hidden = true;
message.setAttribute( 'role', 'alert' );
form.insertBefore( message, form.firstChild );
form.addEventListener( 'input', function () {
message.hidden = true;
message.textContent = '';
} );
form.addEventListener( 'submit', function ( event ) {
var seen = Object.create( null );
var duplicate = null;
var currentFields = Array.prototype.filter.call(
form.querySelectorAll(
'input[name^="首页产品卡["][name$="[页面]"]'
),
function ( field ) {
return field.name.indexOf( '[map_field]' ) === -1;
}
);
currentFields.some( function ( field ) {
var value = field.value.trim();
if ( value && seen[ value ] ) {
duplicate = field;
return true;
}
if ( value ) {
seen[ value ] = true;
}
return false;
} );
if ( !duplicate ) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
message.textContent =
'同一产品页面不能被多张首页卡片重复使用,请为每张卡片选择独立产品。';
message.hidden = false;
duplicate.focus();
message.scrollIntoView( { block: 'center' } );
}, true );
}
function decodeFragment( hash ) {
if ( !hash || hash.charAt( 0 ) !== '#' ) {
return '';
}
try {
return decodeURIComponent( hash.slice( 1 ) );
} catch ( error ) {
return hash.slice( 1 );
}
}
function setupSingleActiveToc() {
var toc = document.querySelector( '.citizen-toc' );
var indicator = toc && toc.querySelector( '.citizen-toc-indicator' );
if ( !toc || !indicator || toc.dataset.techdocsSingleActiveToc === '1' ) {
return;
}
var entries = Array.prototype.map.call(
toc.querySelectorAll( LINK_SELECTOR ),
function ( link ) {
var target = document.getElementById( decodeFragment( link.hash ) );
var item = link.closest( '.citizen-toc-list-item' );
return target && item ? {
item: item,
link: link,
target: target
} : null;
}
).filter( Boolean );
if ( entries.length === 0 ) {
return;
}
toc.dataset.techdocsSingleActiveToc = '1';
var applying = false;
var frameId = 0;
var manualEntry = null;
function getActivationTop() {
var value = parseFloat(
window.getComputedStyle( document.documentElement ).scrollPaddingTop
);
return Number.isFinite( value ) ? value : 0;
}
function findEntryForHash() {
var id = decodeFragment( window.location.hash );
return entries.find( function ( entry ) {
return entry.target.id === id;
} ) || null;
}
function findEntryForScroll() {
var activationTop = getActivationTop();
var selected = entries[ 0 ];
entries.some( function ( entry ) {
if ( entry.target.getBoundingClientRect().top <= activationTop + 1 ) {
selected = entry;
return false;
}
return true;
} );
return selected;
}
function updateIndicator( entry ) {
var positioningParent = indicator.offsetParent || toc;
var parentRect = positioningParent.getBoundingClientRect();
var linkRect = entry.link.getBoundingClientRect();
var unitHeight = linkRect.height || entry.link.offsetHeight || 32;
var top = linkRect.top - parentRect.top + positioningParent.scrollTop;
indicator.style.setProperty( '--indicator-unit-height', unitHeight + 'px' );
indicator.style.setProperty( '--indicator-top', top + 'px' );
indicator.style.setProperty( '--indicator-scale', '1' );
}
function applyEntry( entry ) {
if ( !entry ) {
return;
}
applying = true;
var topItem = entry.item.closest( '.citizen-toc-level-1' );
entries.forEach( function ( candidate ) {
var isActive = candidate.item === entry.item;
candidate.item.classList.toggle( ACTIVE_CLASS, isActive );
candidate.item.classList.toggle(
ACTIVE_TOP_CLASS,
candidate.item === topItem
);
if ( isActive ) {
candidate.link.setAttribute( 'aria-current', 'location' );
} else {
candidate.link.removeAttribute( 'aria-current' );
}
} );
if ( topItem && !entries.some( function ( candidate ) {
return candidate.item === topItem;
} ) ) {
topItem.classList.add( ACTIVE_TOP_CLASS );
}
updateIndicator( entry );
applying = false;
}
function update() {
frameId = 0;
applyEntry( manualEntry || findEntryForScroll() );
}
function scheduleUpdate() {
if ( frameId === 0 ) {
frameId = window.requestAnimationFrame( update );
}
}
function releaseManualEntry() {
if ( manualEntry ) {
manualEntry = null;
scheduleUpdate();
}
}
toc.addEventListener( 'click', function ( event ) {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey
) {
return;
}
var link = event.target.closest( LINK_SELECTOR );
if ( !link || !toc.contains( link ) ) {
return;
}
var entry = entries.find( function ( candidate ) {
return candidate.link === link;
} );
if ( !entry ) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
manualEntry = entry;
applyEntry( entry );
var hash = link.getAttribute( 'href' );
if ( hash ) {
window.history.pushState( null, '', hash );
}
window.requestAnimationFrame( function () {
var targetTop = window.scrollY +
entry.target.getBoundingClientRect().top -
getActivationTop();
window.scrollTo( 0, Math.max( 0, targetTop ) );
} );
}, true );
window.addEventListener( 'scroll', scheduleUpdate, { passive: true } );
window.addEventListener( 'resize', scheduleUpdate );
window.addEventListener( 'wheel', releaseManualEntry, { passive: true } );
window.addEventListener( 'touchstart', releaseManualEntry, { passive: true } );
window.addEventListener( 'pointerdown', function ( event ) {
if ( !toc.contains( event.target ) ) {
releaseManualEntry();
}
}, true );
window.addEventListener( 'keydown', function ( event ) {
if ( NAVIGATION_KEYS.indexOf( event.key ) !== -1 ) {
releaseManualEntry();
}
} );
window.addEventListener( 'hashchange', function () {
manualEntry = findEntryForHash();
scheduleUpdate();
} );
var classObserver = new MutationObserver( function () {
if ( !applying ) {
scheduleUpdate();
}
} );
classObserver.observe( toc, {
attributes: true,
attributeFilter: [ 'class' ],
subtree: true
} );
scheduleUpdate();
}
mw.loader.using( 'skins.citizen.scripts' ).then( function () {
window.requestAnimationFrame( function () {
setupHomeCarousel();
setupUniqueProductTargets();
setupSingleActiveToc();
} );
} );
}() );
/* ===== Custom VE Foreground Color Tool =====
v4: Uses ve.dm.TransactionBuilder to commit inline color directly to
the VE model as a proper transaction. Splits the selection into ranges
per branch and applies the color annotation to each. */
(function () {
function initColorTool() {
if (typeof ve === 'undefined' || !ve.ui || !ve.ui.Tool || !ve.dm || !OO) {
setTimeout(initColorTool, 200);
return;
}
if (ve.ui.toolFactory.lookup('foreground')) return;
function ForegroundColorTool() {
ForegroundColorTool.super.apply(this, arguments);
this.$popup = null;
}
OO.inheritClass(ForegroundColorTool, ve.ui.Tool);
ForegroundColorTool.static.name = 'foreground';
ForegroundColorTool.static.group = 'textStyle';
ForegroundColorTool.static.icon = 'highlight';
ForegroundColorTool.static.title = OO.ui.deferMsg('visualeditor-foreground-color-tool', 'Text color');
ForegroundColorTool.static.autoAdd = false;
ForegroundColorTool.static.supportsPressEvents = true;
ForegroundColorTool.prototype.onSelect = function () {
var tool = this;
var surface = tool.toolbar.getSurface();
if (!surface) return;
if (tool.$popup) { tool.$popup.remove(); tool.$popup = null; return; }
var colors = [
'#000000', '#666666', '#999999', '#cccccc', '#ffffff',
'#e74c3c', '#e67e22', '#f1c40f', '#2ecc71', '#3498db',
'#9b59b6', '#e84393', '#34B0B0', '#0984e3', '#00b894'
];
var $popup = $('<div>').css({
position: 'absolute', background: '#fff', border: '1px solid #ccc',
borderRadius: '6px', padding: '8px', display: 'grid',
gridTemplateColumns: 'repeat(5, 1fr)', gap: '4px',
zIndex: 10000, boxShadow: '0 2px 8px rgba(0,0,0,0.15)'
});
colors.forEach(function (color) {
$('<div>').css({
width: '22px', height: '22px', background: color,
cursor: 'pointer', border: '1px solid #ddd', borderRadius: '3px'
}).on('mousedown', function (e) {
e.preventDefault();
tool.applyColor(surface, color);
$popup.remove();
tool.$popup = null;
}).appendTo($popup);
});
var offset = tool.$element.offset();
$popup.css({ top: offset.top + tool.$element.outerHeight() + 4, left: offset.left });
$('body').append($popup);
tool.$popup = $popup;
setTimeout(function () {
$(document).one('mousedown', function (e) {
if (!$(e.target).closest($popup).length) { $popup.remove(); tool.$popup = null; }
});
}, 50);
};
ForegroundColorTool.prototype.applyColor = function (surface, color) {
var model = surface.getModel();
var doc = model.getDocument();
var selection = model.getSelection();
var range = selection.getRange();
if (range.isCollapsed()) {
surface.getView().focus();
return;
}
// Use the fragment API to apply color to each range in the selection
var fragment = model.getFragment(selection, 'editable');
var ranges = fragment.getRanges();
// Build a transaction that applies the color annotation to each range
var txBuilder = new ve.dm.TransactionBuilder();
var annotated = 0;
ranges.forEach(function (r) {
if (r.isCollapsed()) return;
// Get the content branches within this range
var branches = r.getCoveringBranches();
if (!branches || branches.length === 0) return;
branches.forEach(function (branchInfo) {
var branch = branchInfo.branch;
var start = branchInfo.start;
var end = branchInfo.end;
if (branch === undefined || start === end) return;
// Get the actual nodes at this position
var textStart = branch.getNodeAtOffset(start);
var textEnd = branch.getNodeAtOffset(end - 1);
if (!textStart) return;
// Find the offset within the start text node
var startOffsetInNode = start - branch.getOffset(textStart, true);
var endOffsetInNode = end - branch.getOffset(textEnd, true);
// Create wrapper nodes
var openSpan = ['span', { style: 'color: ' + color + ';' }, [], { 'type': '+style' }];
var closeSpan = ['span', { }, [], { 'type': 'closeStyle' }];
// Apply using internalList manipulation
txBuilder.inserting(branch, start, [{type: 'span', attributes: {style: 'color: ' + color + ';'}}, {type: '/span'}]);
annotated++;
});
});
if (annotated > 0) {
try {
txBuilder.commit();
} catch (err) {
console.log('[ColorTool] TransactionBuilder failed, trying alternative:', err);
// Alternative: use fragment.annotateContent with a custom annotation
tool.applyColorV2(model, fragment, color);
}
} else {
// Fallback: simpler approach
tool.applyColorV2(model, fragment, color);
}
surface.getView().focus();
};
ForegroundColorTool.prototype.applyColorV2 = function (model, fragment, color) {
// Simpler fallback: use the existing textStyle/span annotation
// with `style` attribute in the data element's content
// We need to create a custom inline annotation.
//
// Approach: insert <span style="color:..."> markup directly into the
// content branches using the TransactionBuilder's insertContent method.
var doc = model.getDocument();
var ranges = fragment.getRanges();
ranges.forEach(function (r) {
if (r.isCollapsed()) return;
var branches = r.getCoveringBranches();
if (!branches) return;
branches.forEach(function (branchInfo) {
var branch = branchInfo.branch;
var start = branchInfo.start;
var end = branchInfo.end;
if (branch === undefined || start === end) return;
// Use TransactionBuilder to wrap with span
var tx = new ve.dm.TransactionBuilder();
var insertAt = start;
var deleteCount = end - start;
// Get the current content to preserve it
var content = branch.slice(start, end);
// Insert: open span + content + close span
var newContent = []
.concat({type: 'span', attributes: {style: 'color: ' + color + ';'}})
.concat(content)
.concat({type: '/span'});
try {
tx.remove(branch, insertAt, deleteCount);
tx.insert(branch, insertAt, newContent);
tx.commit();
} catch (err) {
console.log('[ColorTool] alt failed:', err);
}
});
});
};
ForegroundColorTool.prototype.onUpdateState = function () { this.setDisabled(false); };
ve.ui.toolFactory.register(ForegroundColorTool);
console.log('[VEForAll] Foreground color tool v4 registered');
}
initColorTool();
if (typeof mw !== 'undefined' && mw.loader) {
mw.loader.using(['ext.veforall.target']).done(initColorTool).fail(function(){ setTimeout(initColorTool, 500); });
}
})();