Files

872 lines
32 KiB
PHP
Raw Permalink Normal View History

2026-02-23 17:21:48 +08:00
<?php
/**
* 编辑器+短代码行内自定义标签
*
* @package CustomInlineTags
* @author 石头厝
* @version 1.0.0
* @link https://www.shitoucuo.com
*/
class CustomInlineTags_Plugin implements Typecho_Plugin_Interface
{
// 存储所有标签的静态变量
private static $_allTags = array();
/**
* 激活插件
*/
public static function activate()
{
// 添加文章保存时的钩子
Typecho_Plugin::factory('Widget_Contents_Post_Edit')->write = array('CustomInlineTags_Plugin', 'parseTagsOnSave');
Typecho_Plugin::factory('Widget_Contents_Page_Edit')->write = array('CustomInlineTags_Plugin', 'parseTagsOnSave');
// 添加内容解析钩子
Typecho_Plugin::factory('Widget_Abstract_Contents')->contentEx = array('CustomInlineTags_Plugin', 'parseContent');
Typecho_Plugin::factory('Widget_Abstract_Contents')->excerptEx = array('CustomInlineTags_Plugin', 'parseContent');
// 添加编辑器脚本 - 关键使用header钩子添加脚本
Typecho_Plugin::factory('admin/header.php')->header = array('CustomInlineTags_Plugin', 'addHeader');
Typecho_Plugin::factory('admin/write-post.php')->bottom = array('CustomInlineTags_Plugin', 'addEditorButton');
Typecho_Plugin::factory('admin/write-page.php')->bottom = array('CustomInlineTags_Plugin', 'addEditorButton');
return _t('插件已激活');
}
/**
* 禁用插件
*/
public static function deactivate()
{
return _t('插件已禁用');
}
/**
* 插件配置面板
*/
public static function config(Typecho_Widget_Helper_Form $form)
{
// 标签样式设置
$style = new Typecho_Widget_Helper_Form_Element_Textarea('tagStyle',
NULL,
"display: inline-block;\nbackground: #f0f0f0;\ncolor: #333;\npadding: 2px 8px;\nmargin: 0 4px;\nborder-radius: 12px;\nfont-size: 0.9em;\nfont-weight: normal;\ntext-decoration: none;",
_t('标签样式'),
_t('自定义标签的CSS样式'));
$form->addInput($style);
// 标签前缀
$prefix = new Typecho_Widget_Helper_Form_Element_Text('tagPrefix',
NULL,
'#',
_t('标签前缀'),
_t('在标签前显示的前缀符号'));
$form->addInput($prefix);
// 是否启用标签链接
$enableLink = new Typecho_Widget_Helper_Form_Element_Radio('enableLink',
array(
'1' => _t('启用'),
'0' => _t('禁用')
),
'1',
_t('启用标签链接'),
_t('点击标签是否跳转到标签页面'));
$form->addInput($enableLink);
// 伪静态标签链接格式
$tagUrlFormat = new Typecho_Widget_Helper_Form_Element_Text('tagUrlFormat',
NULL,
'/tag-{slug}.html',
_t('标签链接格式'),
_t('根据你的伪静态设置填写标签链接格式,{slug}会被替换为标签slug'));
$form->addInput($tagUrlFormat);
// 是否新窗口打开
$openNewWindow = new Typecho_Widget_Helper_Form_Element_Radio('openNewWindow',
array(
'1' => _t('是'),
'0' => _t('否')
),
'1',
_t('新窗口打开'),
_t('点击标签是否在新窗口打开'));
$form->addInput($openNewWindow);
}
/**
* 个人配置面板
*/
public static function personalConfig(Typecho_Widget_Helper_Form $form) {}
/**
* 获取所有标签
*/
private static function getAllTags()
{
if (!empty(self::$_allTags)) {
return self::$_allTags;
}
try {
$db = Typecho_Db::get();
$rows = $db->fetchAll($db->select('name')
->from('table.metas')
->where('type = ?', 'tag')
->order('name', Typecho_Db::SORT_ASC));
foreach ($rows as $row) {
self::$_allTags[] = $row['name'];
}
} catch (Exception $e) {
self::$_allTags = array();
}
return self::$_allTags;
}
/**
* 添加头部资源
*/
public static function addHeader()
{
// 只在写文章/页面页面添加
if (strpos($_SERVER['REQUEST_URI'], 'write-post') !== false ||
strpos($_SERVER['REQUEST_URI'], 'write-page') !== false) {
// 获取所有标签并转为JSON
$allTags = self::getAllTags();
$tagsJson = json_encode($allTags);
echo '<style>
.custom-tag-btn {
display: inline-block;
width: 24px;
height: 24px;
line-height: 24px;
text-align: center;
cursor: pointer;
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 3px;
margin-left: 5px;
font-weight: bold;
}
.custom-tag-btn:hover {
background: #e5e5e5;
}
.custom-inline-tag {
transition: all 0.2s ease;
}
.custom-inline-tag:hover {
transform: translateY(-1px);
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
/* 标签选择弹窗样式 */
.custom-tag-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
z-index: 10000;
width: 400px;
max-width: 90%;
padding: 20px;
}
.custom-tag-modal h3 {
margin-top: 0;
margin-bottom: 15px;
color: #333;
font-size: 16px;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
}
.dark .custom-tag-input{color:#000!important;}
.custom-tag-input {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
margin-bottom: 15px;
}
.custom-tag-input:focus {
border-color: #467B96;
outline: none;
}
.custom-tag-suggestions {
max-height: 200px;
overflow-y: auto;
border: 1px solid #eee;
border-radius: 4px;
margin-bottom: 15px;
}
.custom-tag-suggestion {
padding: 8px 12px;
cursor: pointer;
color:#000;
border-bottom: 1px solid #f5f5f5;
font-size: 14px;
}
.custom-tag-suggestion:hover {
background-color: #f5f5f5;
}
.custom-tag-suggestion.selected {
background-color: #e9f7fe;
}
.custom-tag-buttons {
text-align: right;
}
.custom-tag-btn-confirm,
.custom-tag-btn-cancel {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
margin-left: 10px;
}
.custom-tag-btn-confirm {
background-color: #467B96;
color: white;
}
.custom-tag-btn-confirm:hover {
background-color: #3a6980;
}
.custom-tag-btn-cancel {
background-color: #f5f5f5;
color: #666;
}
.custom-tag-btn-cancel:hover {
background-color: #e5e5e5;
}
.custom-tag-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 9999;
}
.custom-tag-empty {
padding: 20px;
text-align: center;
color: #999;
font-style: italic;
}
.custom-tag-selected {
background-color: #f0f9ff;
border-left: 3px solid #467B96;
}
.dark .custom-tag-modal input[type=text]{background-color:#fff!important;color:#000!important;font-weight:none!important;}
.dark .title input[type=text]{background-color:#1f2937!important;color:#fff!important;font-weight:none!important;}
/* 深色模式适配 - 仅添加以下CSS */
.dark .custom-tag-btn {
background: #374151 !important;
border: 1px solid #4b5563 !important;
color: #d1d5db !important;
}
.dark .custom-tag-btn:hover {
background: #4b5563 !important;
}
.dark .custom-tag-modal {
background: #1f2937 !important;
border: 1px solid #374151 !important;
}
.dark .custom-tag-modal h3 {
color: #ffffff !important;
border-bottom: 1px solid #374151 !important;
}
.dark .custom-tag-input {
background: #101928 !important;
border: 1px solid #374151 !important;
color: #ffffff !important;
}
.dark .custom-tag-input:focus {
border-color: #467B96 !important;
outline: none !important;
}
.dark .custom-tag-suggestions {
background: #101928 !important;
border: 1px solid #374151 !important;
}
.dark .custom-tag-suggestion {
color: #d1d5db !important;
border-bottom: 1px solid #374151 !important;
}
.dark .custom-tag-suggestion:hover {
background-color: #1f2937 !important;
}
.dark .custom-tag-suggestion.selected {
background-color: rgba(70, 123, 150, 0.2) !important;
}
.dark .custom-tag-suggestion strong {
color: #467B96 !important;
}
.dark .custom-tag-btn-confirm {
background-color: #467B96 !important;
color: #ffffff !important;
}
.dark .custom-tag-btn-confirm:hover {
background-color: #3a6980 !important;
}
.dark .custom-tag-btn-cancel {
background-color: #374151 !important;
color: #d1d5db !important;
}
.dark .custom-tag-btn-cancel:hover {
background-color: #4b5563 !important;
}
.dark .custom-tag-empty {
color: #9ca3af !important;
}
.dark .custom-tag-selected {
background-color: rgba(70, 123, 150, 0.1) !important;
border-left: 3px solid #467B96 !important;
}
.dark #custom-tag-button span {
color: #d1d5db !important;
}
.dark .custom-tag-input::placeholder {
color: #9ca3af !important;
}
</style>';
// 在JavaScript中嵌入标签数据
echo '<script>var CUSTOM_TAGS_ALL_TAGS = ' . $tagsJson . ';</script>';
}
}
/**
* 添加编辑器按钮 - 带标签联想搜索(本地搜索版)
*/
public static function addEditorButton()
{
?>
<style>
.dark body{color:#000!important;}
</style>
<script>
(function() {
// 等待编辑器加载完成
var initInterval = setInterval(function() {
var wmdButtonRow = document.querySelector('.wmd-button-row');
if (wmdButtonRow) {
clearInterval(initInterval);
addTagButton(wmdButtonRow);
}
}, 300);
function addTagButton(toolbar) {
// 检查是否已经添加过按钮
if (document.getElementById('custom-tag-button')) {
return;
}
// 创建按钮容器
var buttonContainer = document.createElement('li');
buttonContainer.id = 'custom-tag-button';
buttonContainer.className = 'wmd-button';
buttonContainer.style.cssText = 'width: 20px; height: 20px; position: relative;';
buttonContainer.title = '插入标签';
// 创建按钮内容
var buttonSpan = document.createElement('span');
buttonSpan.style.cssText = 'display: block; width: 100%; height: 100%; text-align: center; line-height: 20px; font-weight: bold; color: #AAA; cursor: pointer;';
buttonSpan.textContent = '#';
// 添加到容器
buttonContainer.appendChild(buttonSpan);
// 添加到工具栏末尾
toolbar.appendChild(buttonContainer);
// 添加点击事件
buttonContainer.onclick = function(e) {
e.preventDefault();
showTagSelector();
return false;
};
}
// 在本地搜索标签
function searchTags(keyword) {
var allTags = window.CUSTOM_TAGS_ALL_TAGS || [];
if (!keyword) {
// 如果没有关键词返回最近使用的标签这里简化返回前20个
return allTags.slice(0, 20);
}
keyword = keyword.toLowerCase();
var results = [];
// 搜索包含关键词的标签
for (var i = 0; i < allTags.length; i++) {
if (allTags[i].toLowerCase().indexOf(keyword) !== -1) {
results.push(allTags[i]);
if (results.length >= 20) {
break;
}
}
}
return results;
}
// 显示标签选择器
function showTagSelector() {
// 创建遮罩层
var overlay = document.createElement('div');
overlay.className = 'custom-tag-modal-overlay';
// 创建弹窗
var modal = document.createElement('div');
modal.className = 'custom-tag-modal';
modal.innerHTML = '<h3>选择或输入标签</h3>' +
'<input type="text" class="custom-tag-input" placeholder="输入标签名称搜索..." autocomplete="off">' +
'<div class="custom-tag-suggestions"></div>' +
'<div class="custom-tag-buttons">' +
'<button type="button" class="custom-tag-btn-cancel">取消</button>' +
'<button type="button" class="custom-tag-btn-confirm">插入标签</button>' +
'</div>';
// 添加到页面
document.body.appendChild(overlay);
document.body.appendChild(modal);
// 获取DOM元素
var input = modal.querySelector('.custom-tag-input');
var suggestions = modal.querySelector('.custom-tag-suggestions');
var confirmBtn = modal.querySelector('.custom-tag-btn-confirm');
var cancelBtn = modal.querySelector('.custom-tag-btn-cancel');
var selectedTag = '';
var selectedIndex = -1;
var suggestionItems = [];
// 聚焦输入框
setTimeout(function() {
input.focus();
}, 100);
// 加载初始标签列表
updateSuggestions('');
// 输入框输入事件
var searchTimer;
input.addEventListener('input', function() {
clearTimeout(searchTimer);
searchTimer = setTimeout(function() {
updateSuggestions(input.value.trim());
}, 200);
});
// 输入框键盘事件
input.addEventListener('keydown', function(e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
selectNext();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectPrev();
} else if (e.key === 'Enter') {
e.preventDefault();
if (selectedIndex >= 0 && suggestionItems[selectedIndex]) {
selectTag(suggestionItems[selectedIndex].dataset.tag);
} else if (input.value.trim()) {
insertTag(input.value.trim());
}
} else if (e.key === 'Escape') {
e.preventDefault();
closeModal();
}
});
// 确认按钮点击
confirmBtn.addEventListener('click', function() {
if (selectedTag) {
insertTag(selectedTag);
} else if (input.value.trim()) {
insertTag(input.value.trim());
} else {
alert('请输入标签名称!');
}
});
// 取消按钮点击
cancelBtn.addEventListener('click', closeModal);
// 点击遮罩层关闭
overlay.addEventListener('click', closeModal);
// 更新建议列表
function updateSuggestions(keyword) {
var tags = searchTags(keyword);
suggestions.innerHTML = '';
suggestionItems = [];
selectedIndex = -1;
if (tags.length === 0) {
if (keyword) {
suggestions.innerHTML = '<div class="custom-tag-empty">没有找到匹配的标签</div>';
} else {
suggestions.innerHTML = '<div class="custom-tag-empty">暂无标签,请输入新标签</div>';
}
return;
}
tags.forEach(function(tag, index) {
var item = document.createElement('div');
item.className = 'custom-tag-suggestion';
item.textContent = tag;
item.dataset.tag = tag;
// 高亮匹配的关键字
if (keyword) {
var escapedKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\\\$&');
var regex = new RegExp('(' + escapedKeyword + ')', 'gi');
item.innerHTML = tag.replace(regex, '<strong>$1</strong>');
}
item.addEventListener('click', function() {
selectTag(tag);
});
item.addEventListener('mouseenter', function() {
clearSelection();
selectedIndex = index;
item.classList.add('selected');
});
suggestions.appendChild(item);
suggestionItems.push(item);
});
// 如果有建议,默认选择第一个
if (suggestionItems.length > 0) {
selectedIndex = 0;
suggestionItems[0].classList.add('selected');
}
}
// 选择下一个
function selectNext() {
if (suggestionItems.length === 0) return;
clearSelection();
selectedIndex = (selectedIndex + 1) % suggestionItems.length;
suggestionItems[selectedIndex].classList.add('selected');
scrollToSelected();
}
// 选择上一个
function selectPrev() {
if (suggestionItems.length === 0) return;
clearSelection();
selectedIndex = selectedIndex <= 0 ? suggestionItems.length - 1 : selectedIndex - 1;
suggestionItems[selectedIndex].classList.add('selected');
scrollToSelected();
}
// 清除选择
function clearSelection() {
suggestionItems.forEach(function(item) {
item.classList.remove('selected');
});
}
// 滚动到选中的项目
function scrollToSelected() {
if (selectedIndex >= 0 && suggestionItems[selectedIndex]) {
suggestionItems[selectedIndex].scrollIntoView({
block: 'nearest',
behavior: 'smooth'
});
}
}
// 选择标签
function selectTag(tag) {
selectedTag = tag;
input.value = tag;
// 高亮显示选中的标签
suggestionItems.forEach(function(item) {
item.classList.remove('custom-tag-selected');
if (item.dataset.tag === tag) {
item.classList.add('custom-tag-selected');
}
});
}
// 插入标签
function insertTag(tagName) {
// 验证标签名称
tagName = tagName.trim();
if (tagName.length < 2 || tagName.length > 20) {
alert('标签名称长度应在2-20个字符之间');
return;
}
if (!/^[\u4e00-\u9fa5a-zA-Z0-9_\-\s]+$/.test(tagName)) {
alert('标签名称只能包含中文、英文、数字、下划线和减号!');
return;
}
// 创建短代码
var shortcode = '[tag]' + tagName + '[/tag]';
// 获取编辑器textarea
var textarea = document.getElementById('text');
if (!textarea) {
alert('无法找到编辑器!');
return;
}
// 获取光标位置
var startPos = textarea.selectionStart;
var endPos = textarea.selectionEnd;
// 检查光标前的内容避免与Markdown标题混淆
var beforeText = textarea.value.substring(0, startPos);
var lines = beforeText.split('\\n');
var currentLine = lines[lines.length - 1];
// 如果当前行以#开头且没有空格,提示用户
var trimmedLine = currentLine.trim();
if (trimmedLine.match(/^#+[^#\\s]/)) {
if (!confirm('检测到当前位置可能在Markdown标题中插入标签可能会影响标题显示。确定要插入吗')) {
return;
}
}
// 插入短代码
textarea.value = textarea.value.substring(0, startPos) +
shortcode +
textarea.value.substring(endPos);
// 移动光标到短代码后面
textarea.selectionStart = textarea.selectionEnd = startPos + shortcode.length;
textarea.focus();
// 触发input事件
var event = new Event('input', { bubbles: true });
textarea.dispatchEvent(event);
// 如果是Markdown编辑器触发预览更新
if (typeof window.convertMarkdown === 'function') {
window.convertMarkdown();
}
// 关闭弹窗
closeModal();
}
// 关闭弹窗
function closeModal() {
if (overlay.parentNode) {
document.body.removeChild(overlay);
}
if (modal.parentNode) {
document.body.removeChild(modal);
}
}
}
// 添加快捷键支持
document.addEventListener('keydown', function(e) {
// Ctrl+Alt+T 插入标签
if (e.ctrlKey && e.altKey && e.keyCode === 84) { // T键
e.preventDefault();
showTagSelector();
}
});
// 重新检查是否加载了工具栏(有些主题可能动态加载)
setTimeout(function() {
var wmdButtonRow = document.querySelector('.wmd-button-row');
if (wmdButtonRow && !document.getElementById('custom-tag-button')) {
addTagButton(wmdButtonRow);
}
}, 1000);
})();
</script>
<?php
}
/**
* 解析内容中的标签短代码
*/
public static function parseContent($content, $widget, $lastResult)
{
$content = empty($lastResult) ? $content : $lastResult;
// 如果是在后台列表或编辑页面,不转换
if ($widget instanceof Widget_Archive && !$widget->is('single')) {
return $content;
}
// 获取插件配置
$options = Helper::options()->plugin('CustomInlineTags');
$prefix = $options->tagPrefix ?: '#';
$style = $options->tagStyle ?: "display: inline-block; background: #f0f0f0; color: #333; padding: 2px 8px; margin: 0 4px; border-radius: 12px; font-size: 0.9em; font-weight: normal; text-decoration: none;";
$className = 'custom-inline-tag';
$enableLink = isset($options->enableLink) ? $options->enableLink : '1';
$tagUrlFormat = isset($options->tagUrlFormat) ? $options->tagUrlFormat : '/tag-{slug}.html';
$openNewWindow = isset($options->openNewWindow) ? $options->openNewWindow : '1';
// 替换短代码为HTML
$content = preg_replace_callback(
'/\[tag\]([^\[\]]+?)\[\/tag\]/',
function($matches) use ($prefix, $style, $className, $enableLink, $tagUrlFormat, $openNewWindow, $widget) {
$tagName = htmlspecialchars(trim($matches[1]), ENT_QUOTES, 'UTF-8');
$displayName = $prefix . $tagName;
if ($enableLink == '1' && $widget instanceof Widget_Archive) {
// 获取标签链接
$tagUrl = '';
try {
$db = Typecho_Db::get();
$tag = $db->fetchRow($db->select()
->from('table.metas')
->where('type = ?', 'tag')
->where('name = ?', $tagName)
->limit(1));
if ($tag) {
// 使用Typecho的标签链接生成方法
if (method_exists($widget, 'permalink')) {
// 创建临时对象获取标签链接
$reflection = new ReflectionClass($widget);
$params = $reflection->getProperty('_params');
$params->setAccessible(true);
$widgetParams = $params->getValue($widget);
// 临时修改参数获取标签链接
$originalParams = $widgetParams;
$widgetParams['type'] = 'tag';
$widgetParams['slug'] = $tag['slug'];
// 尝试获取标签链接
try {
$tagUrl = $widget->permalink;
} catch (Exception $e) {
// 如果失败,使用自定义格式
$tagSlug = urlencode($tag['slug']);
$tagUrl = str_replace('{slug}', $tagSlug, $tagUrlFormat);
$tagUrl = Typecho_Common::url($tagUrl, Helper::options()->index);
}
// 恢复原始参数
$params->setValue($widget, $originalParams);
} else {
// 使用自定义格式
$tagSlug = urlencode($tag['slug']);
$tagUrl = str_replace('{slug}', $tagSlug, $tagUrlFormat);
$tagUrl = Typecho_Common::url($tagUrl, Helper::options()->index);
}
}
} catch (Exception $e) {
// 出错时不添加链接
error_log('CustomInlineTags Error: ' . $e->getMessage());
}
if ($tagUrl) {
// 构建链接属性
$linkAttributes = sprintf('href="%s" class="%s" data-tag="%s" style="%s" title="查看标签相关文章"',
htmlspecialchars($tagUrl),
$className,
$tagName,
$style
);
// 如果启用了新窗口打开添加target="_blank"
if ($openNewWindow == '1') {
$linkAttributes .= ' target="_blank"';
}
return sprintf('<a %s>%s</a>',
$linkAttributes,
$displayName
);
}
}
// 如果不启用链接或找不到标签
return sprintf(
'<span class="%s" data-tag="%s" style="%s">%s</span>',
$className,
$tagName,
$style,
$displayName
);
},
$content
);
return $content;
}
/**
* 保存文章时提取标签并保存到数据库
*/
public static function parseTagsOnSave($contents, $widget)
{
$content = $contents['text'];
$tags = array();
// 从短代码中提取标签
preg_match_all('/\[tag\]([^\[\]]+?)\[\/tag\]/', $content, $matches);
if (!empty($matches[1])) {
foreach ($matches[1] as $tag) {
$tag = trim($tag);
if (!empty($tag) && !in_array($tag, $tags)) {
$tags[] = $tag;
}
}
}
// 如果找到了标签,添加到文章的标签字段
if (!empty($tags)) {
// 获取现有的标签
$existingTags = isset($contents['tags']) ? $contents['tags'] : '';
$existingTagsArray = array_filter(array_map('trim', explode(',', $existingTags)));
// 合并标签并去重
$allTags = array_unique(array_merge($existingTagsArray, $tags));
// 更新标签字段
$contents['tags'] = implode(',', $allTags);
}
return $contents;
}
}