feat(icons): add the FA5 catalog, REST endpoint and unified icon helpers
Mirror the reference theme's universal picker on FA5: a generated full catalog of the bundled FA5 Free 5.15.4 fonts (src/Icons/fa5-icons.php, generated from the shipped CSS + SVG fonts), a zibsvg group parsed from the runtime sprite so it cannot drift, and GET /aksrt/v1/icons/catalog. Shared admin pieces: aksrt_icon_markup()/aksrt_sanitize_icon_value() with an SVG allowlist, the picker field/modal assets, and one canonical aksrt-font-awesome handle registered through aksrt_enqueue_fontawesome().
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
/* AKSRT universal icon picker (admin) */
|
||||
|
||||
#aksrt-modal-icon.aksrt-icon-modal {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#aksrt-modal-icon.aksrt-icon-modal:not(.hidden) {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100100;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#aksrt-modal-icon .aksrt-icon-modal__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
#aksrt-modal-icon .aksrt-icon-modal__inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
max-height: min(76vh, 640px);
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.28);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#aksrt-modal-icon .aksrt-icon-modal__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e2e4e7;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#aksrt-modal-icon .aksrt-icon-modal__close {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#aksrt-modal-icon .aksrt-icon-modal__toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid #e2e4e7;
|
||||
}
|
||||
|
||||
#aksrt-modal-icon .aksrt-icon-modal__tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#aksrt-modal-icon .aksrt-icon-modal__tab.is-active {
|
||||
border-color: #2271b1;
|
||||
color: #2271b1;
|
||||
}
|
||||
|
||||
#aksrt-modal-icon .aksrt-icon-modal__body {
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
|
||||
#aksrt-modal-icon .aksrt-icon-modal__loading {
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#aksrt-modal-icon .aksrt-icon-modal__error {
|
||||
color: #b32d2e;
|
||||
}
|
||||
|
||||
#aksrt-modal-icon .aksrt-icon-modal__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(42px, 1fr));
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
#aksrt-modal-icon .aksrt-icon-picker__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: #f6f7f7;
|
||||
font-size: 18px;
|
||||
color: #3c434a;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#aksrt-modal-icon .aksrt-icon-picker__item:hover {
|
||||
border-color: #2271b1;
|
||||
color: #2271b1;
|
||||
}
|
||||
|
||||
#aksrt-modal-icon .aksrt-icon-picker__item .icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
#aksrt-modal-icon .aksrt-icon-picker__empty {
|
||||
grid-column: 1 / -1;
|
||||
padding: 20px 0;
|
||||
color: #787c82;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Custom SVG tab */
|
||||
|
||||
#aksrt-modal-icon .aksrt-icon-modal__custom {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
#aksrt-modal-icon .aksrt-icon-modal__custom textarea {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
#aksrt-modal-icon .aksrt-icon-modal__custom-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
#aksrt-modal-icon .aksrt-icon-modal__custom-preview {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px dashed #c3c4c7;
|
||||
border-radius: 4px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
#aksrt-modal-icon .aksrt-icon-modal__custom-preview svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
#aksrt-modal-icon .aksrt-icon-modal__grid--session {
|
||||
margin-top: 12px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #e2e4e7;
|
||||
}
|
||||
|
||||
/* Field markup (widgets, nav menus, meta boxes) */
|
||||
|
||||
.aksrt-icon-picker {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.aksrt-icon-picker .aksrt-icon-picker__preview {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid #dcdcde;
|
||||
border-radius: 4px;
|
||||
background: #f6f7f7;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.aksrt-icon-picker .aksrt-icon-picker__preview svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.aksrt-icon-picker .aksrt-icon-picker__value {
|
||||
flex: 1 1 160px;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* AKSRT universal icon picker — shared admin modal for FA5, the built-in
|
||||
* SVG sprite and inline custom SVG values ("fa fas fa-x" / "zibsvg-key" /
|
||||
* "customsvg-<svg>").
|
||||
*
|
||||
* Any admin screen: call aksrt_icon_picker_assets() (PHP) and render fields
|
||||
* via aksrt_icon_picker_field(); interaction is fully delegated, so lazy
|
||||
* loaded widget forms and repeater rows work without re-init.
|
||||
*/
|
||||
(function ($) {
|
||||
'use strict';
|
||||
|
||||
const config = window.aksrtIconPicker || {};
|
||||
const i18n = config.i18n || {};
|
||||
|
||||
const state = {
|
||||
loaded: false,
|
||||
loading: false,
|
||||
activeTab: 'fas',
|
||||
tabs: [],
|
||||
grids: {},
|
||||
sessionCustom: [],
|
||||
$target: null,
|
||||
};
|
||||
|
||||
function modal() {
|
||||
return $('#aksrt-modal-icon');
|
||||
}
|
||||
|
||||
function setBusy(busy) {
|
||||
modal().find('.aksrt-icon-modal__loading').each((_i, el) => { el.hidden = !busy; });
|
||||
}
|
||||
|
||||
/* Custom SVG cleanup — strip authoring leftovers, keep pure vector markup. */
|
||||
function cleanSvg(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
if (!text) return '';
|
||||
let doc;
|
||||
try {
|
||||
doc = new window.DOMParser().parseFromString(text, 'image/svg+xml');
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
if (doc.querySelector('parsererror')) return '';
|
||||
const svg = doc.querySelector('svg');
|
||||
if (!svg || svg.querySelector('script, foreignObject, animate')) return '';
|
||||
['class', 'id', 'style', 't', 'version', 'p-id'].forEach((attr) => svg.removeAttribute(attr));
|
||||
Array.prototype.slice.call(svg.attributes).forEach((attr) => {
|
||||
if (attr.name.indexOf('data-') === 0) svg.removeAttribute(attr.name);
|
||||
});
|
||||
const keep = new window.XMLSerializer().serializeToString(svg);
|
||||
return /<path|<use|<g\b|<circle|<rect|<polygon|<ellipse|<line/i.test(keep) ? keep : '';
|
||||
}
|
||||
|
||||
function escapeText(text) {
|
||||
return String(text).replace(/[&<>"']/g, (ch) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[ch]));
|
||||
}
|
||||
|
||||
function previewMarkup(value) {
|
||||
value = String(value || '').trim();
|
||||
if (!value) return '';
|
||||
if (value.indexOf('customsvg-') === 0) return cleanSvg(value.slice(10));
|
||||
if (value.indexOf('zibsvg-') === 0) {
|
||||
const key = value.slice(7).replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
return key ? `<svg class="icon" aria-hidden="true"><use xlink:href="#icon-${key}"></use></svg>` : '';
|
||||
}
|
||||
const el = document.createElement('i');
|
||||
el.className = !/\bfa\b/.test(value) && value.indexOf('fa-') !== -1 ? `fa ${value}` : value;
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
return el.outerHTML;
|
||||
}
|
||||
|
||||
function seedSessionCustom() {
|
||||
$('input.aksrt-icon-picker__value').each(function () {
|
||||
const value = String(this.value || '').trim();
|
||||
if (value.indexOf('customsvg-') !== 0) return;
|
||||
const svg = value.slice(10);
|
||||
if (svg && state.sessionCustom.indexOf(svg) === -1) state.sessionCustom.push(svg);
|
||||
});
|
||||
}
|
||||
|
||||
function itemButton(value, name, innerHtml) {
|
||||
return $('<button type="button" class="aksrt-icon-picker__item" role="option">')
|
||||
.attr({ 'data-value': value, 'data-name': name, title: name })
|
||||
.html(innerHtml);
|
||||
}
|
||||
|
||||
function buildGrids(groups) {
|
||||
state.tabs = [];
|
||||
state.grids = {};
|
||||
const $wrap = modal().find('.aksrt-icon-modal__grids').empty();
|
||||
|
||||
groups.forEach((group) => {
|
||||
state.tabs.push(group);
|
||||
const $grid = $('<div class="aksrt-icon-modal__grid" data-grid="' + escapeText(group.id) + '" hidden></div>');
|
||||
(group.icons || []).forEach((name) => {
|
||||
if (group.type === 'fa') {
|
||||
const value = `fa ${group.id} fa-${name}`;
|
||||
$grid.append(itemButton(value, name, `<i class="fa ${group.id} fa-${escapeText(name)}" aria-hidden="true"></i>`));
|
||||
} else if (group.type === 'svg') {
|
||||
const value = `zibsvg-${name}`;
|
||||
const key = escapeText(name).replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
$grid.append(itemButton(value, name, `<svg class="icon" aria-hidden="true"><use xlink:href="#icon-${key}"></use></svg>`));
|
||||
}
|
||||
});
|
||||
$wrap.append($grid);
|
||||
state.grids[group.id] = $grid;
|
||||
|
||||
const $tab = $('<button type="button" class="button aksrt-icon-modal__tab" role="tab">').attr('data-tab', group.id).text(group.title || group.id);
|
||||
modal().find('.aksrt-icon-modal__tabs').append($tab);
|
||||
});
|
||||
}
|
||||
|
||||
function buildSessionGrid() {
|
||||
const $session = modal().find('.aksrt-icon-modal__grid--session');
|
||||
$session.empty();
|
||||
state.sessionCustom.forEach((svg) => {
|
||||
$session.append(itemButton('customsvg-' + svg, 'customsvg', cleanSvg(svg)));
|
||||
});
|
||||
}
|
||||
|
||||
function showTab(tabId) {
|
||||
state.activeTab = tabId;
|
||||
modal().find('.aksrt-icon-modal__tab').each(function () {
|
||||
this.classList.toggle('is-active', this.dataset.tab === tabId);
|
||||
});
|
||||
$.each(state.grids, (id, $grid) => { $grid[0].hidden = id !== tabId; });
|
||||
const isCustom = tabId === 'custom';
|
||||
modal().find('.aksrt-icon-modal__custom').each((_i, el) => { el.hidden = !isCustom; });
|
||||
modal().find('.aksrt-icon-modal__search').each((_i, el) => { el.hidden = isCustom; });
|
||||
applySearch();
|
||||
}
|
||||
|
||||
function applySearch() {
|
||||
if (state.activeTab === 'custom') return;
|
||||
const needle = String(modal().find('.aksrt-icon-modal__search').val() || '').trim().toLowerCase();
|
||||
const $grid = state.grids[state.activeTab];
|
||||
if (!$grid) return;
|
||||
let visible = 0;
|
||||
$grid.find('.aksrt-icon-picker__item').each(function () {
|
||||
const show = !needle || String(this.dataset.name || '').toLowerCase().indexOf(needle) !== -1;
|
||||
this.hidden = !show;
|
||||
if (show) visible++;
|
||||
});
|
||||
$grid.find('.aksrt-icon-picker__empty').remove();
|
||||
if (!visible) $grid.append('<div class="aksrt-icon-picker__empty">' + escapeText(i18n.empty || 'No icons found.') + '</div>');
|
||||
}
|
||||
|
||||
function refreshPicker($picker, value) {
|
||||
const $preview = $picker.find('.aksrt-icon-picker__preview').first();
|
||||
$preview.html(previewMarkup(value)).toggleClass('hidden', !value);
|
||||
$picker.find('.aksrt-icon-picker__remove').first().toggleClass('hidden', !value);
|
||||
}
|
||||
|
||||
function select(value) {
|
||||
modal().addClass('hidden');
|
||||
const $picker = state.$target;
|
||||
if (!$picker || !$picker.length) return;
|
||||
$picker.find('input.aksrt-icon-picker__value').val(value).trigger('change');
|
||||
refreshPicker($picker, value);
|
||||
}
|
||||
|
||||
function ensureCatalog() {
|
||||
if (state.loaded || state.loading) return;
|
||||
state.loading = true;
|
||||
setBusy(true);
|
||||
window.fetch(config.restUrl, { headers: { 'X-WP-Nonce': config.nonce } })
|
||||
.then((r) => r.json())
|
||||
.then((json) => {
|
||||
if (!json || !json.success || !Array.isArray(json.groups)) throw new Error('bad payload');
|
||||
buildGrids(json.groups);
|
||||
seedSessionCustom();
|
||||
buildSessionGrid();
|
||||
state.loaded = true;
|
||||
setBusy(false);
|
||||
showTab('fas');
|
||||
})
|
||||
.catch(() => {
|
||||
state.loading = false;
|
||||
setBusy(false);
|
||||
modal().find('.aksrt-icon-modal__error').each((_i, el) => {
|
||||
el.hidden = false;
|
||||
el.textContent = i18n.loadError || 'Failed to load icons.';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function open($picker) {
|
||||
state.$target = $picker;
|
||||
modal().removeClass('hidden');
|
||||
seedSessionCustom();
|
||||
buildSessionGrid();
|
||||
ensureCatalog();
|
||||
if (state.loaded) showTab(state.activeTab);
|
||||
modal().find('.aksrt-icon-modal__search').trigger('focus');
|
||||
}
|
||||
|
||||
$(document)
|
||||
.on('click', '.aksrt-icon-picker__choose', function () {
|
||||
open($(this).closest('[data-aksrt-icon-picker]'));
|
||||
})
|
||||
.on('click', '.aksrt-icon-picker__remove', function () {
|
||||
const $picker = $(this).closest('[data-aksrt-icon-picker]');
|
||||
$picker.find('input.aksrt-icon-picker__value').val('').trigger('change');
|
||||
refreshPicker($picker, '');
|
||||
})
|
||||
.on('change', 'input.aksrt-icon-picker__value', function () {
|
||||
refreshPicker($(this).closest('[data-aksrt-icon-picker]'), String(this.value || '').trim());
|
||||
})
|
||||
.on('click', '[data-aksrt-icon-close]', () => modal().addClass('hidden'))
|
||||
.on('keydown', (e) => { if (e.key === 'Escape') modal().addClass('hidden'); })
|
||||
.on('click', '.aksrt-icon-modal__tab', function () { showTab(this.dataset.tab); })
|
||||
.on('input', '.aksrt-icon-modal__search', applySearch)
|
||||
.on('click', '.aksrt-icon-picker__item', function () {
|
||||
const value = String(this.dataset.value || '');
|
||||
if (value) select(value);
|
||||
})
|
||||
.on('input', '.aksrt-icon-modal__custom textarea', function () {
|
||||
modal().find('.aksrt-icon-modal__custom-preview').html(cleanSvg(this.value));
|
||||
})
|
||||
.on('click', '.aksrt-icon-modal__custom-actions .button', function () {
|
||||
const $custom = modal().find('.aksrt-icon-modal__custom');
|
||||
const svg = cleanSvg($custom.find('textarea').val());
|
||||
if (!svg) {
|
||||
window.alert(i18n.customEmpty || 'Invalid SVG.');
|
||||
return;
|
||||
}
|
||||
if (state.sessionCustom.indexOf(svg) === -1) {
|
||||
state.sessionCustom.push(svg);
|
||||
buildSessionGrid();
|
||||
}
|
||||
select('customsvg-' + svg);
|
||||
$custom.find('textarea').val('');
|
||||
$custom.find('.aksrt-icon-modal__custom-preview').html('');
|
||||
});
|
||||
})(jQuery);
|
||||
@@ -14,6 +14,7 @@ defined('ABSPATH') || exit;
|
||||
// Stage 1: helpers
|
||||
require_once AKSRT_DIR . '/inc/aksrt-ajax-debug.php';
|
||||
require_once AKSRT_DIR . '/inc/helpers/aksrt-icons.php';
|
||||
require_once AKSRT_DIR . '/inc/helpers/aksrt-icon-picker.php';
|
||||
require_once AKSRT_DIR . '/inc/helpers/aksrt-avatar.php';
|
||||
require_once AKSRT_DIR . '/inc/helpers/aksrt-domain-model.php';
|
||||
require_once AKSRT_DIR . '/inc/helpers/aksrt-template-tags.php';
|
||||
|
||||
+1
-6
@@ -448,12 +448,7 @@ add_filter('manage_post_posts_columns', function ($columns) {
|
||||
|
||||
add_action('admin_enqueue_scripts', function ($hook) {
|
||||
if ($hook !== 'edit.php') return;
|
||||
wp_enqueue_style(
|
||||
'aksrt-font-awesome',
|
||||
get_template_directory_uri() . '/assets/fontawesome5/css/all.min.css',
|
||||
[],
|
||||
'5.15.4'
|
||||
);
|
||||
aksrt_enqueue_fontawesome();
|
||||
});
|
||||
|
||||
add_action('manage_post_posts_custom_column', function ($column, $post_id) {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
/**
|
||||
* AKSRT Theme — Universal icon picker (admin side)
|
||||
*
|
||||
* Server-side assets and modal markup for the shared FA5 + built-in SVG +
|
||||
* custom SVG picker. Field markup itself comes from aksrt_icon_picker_field().
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
if (!function_exists('aksrt_icon_picker_assets')) :
|
||||
/**
|
||||
* Enqueue the universal icon picker on any admin screen that needs it.
|
||||
*
|
||||
* Safe to call multiple times; assets and the footer modal are wired once.
|
||||
* FA5 CSS, the frontend SVG sprite (defines window.aksrt_svgs and injects the
|
||||
* symbol sheet) and the picker script/CSS are all included.
|
||||
*
|
||||
* @param array $deps Extra script dependencies for the picker script.
|
||||
*/
|
||||
function aksrt_icon_picker_assets(array $deps = []): void {
|
||||
static $done = false;
|
||||
if ($done) {
|
||||
return;
|
||||
}
|
||||
$done = true;
|
||||
|
||||
$css_mtime = (string) filemtime(AKSRT_DIR . '/assets/css/admin-icon-picker.css');
|
||||
$js_mtime = (string) filemtime(AKSRT_DIR . '/assets/js/admin-icon-picker.js');
|
||||
|
||||
aksrt_enqueue_fontawesome();
|
||||
wp_enqueue_style('aksrt-icon-picker', AKSRT_URI . '/assets/css/admin-icon-picker.css', ['aksrt-font-awesome'], $css_mtime);
|
||||
|
||||
wp_enqueue_script('aksrt-svg-sprite', AKSRT_URI . '/assets/js/runtime/svg-icon.js', ['jquery'], $js_mtime, true);
|
||||
wp_enqueue_script('aksrt-icon-picker', AKSRT_URI . '/assets/js/admin-icon-picker.js', array_merge(['jquery', 'aksrt-svg-sprite'], $deps), $js_mtime, true);
|
||||
|
||||
wp_localize_script('aksrt-icon-picker', 'aksrtIconPicker', [
|
||||
'restUrl' => esc_url_raw(rest_url('aksrt/v1/icons/catalog')),
|
||||
'nonce' => wp_create_nonce('wp_rest'),
|
||||
'i18n' => [
|
||||
'title' => __('选择图标', 'aksrt'),
|
||||
'search' => __('搜索图标…', 'aksrt'),
|
||||
'empty' => __('未找到匹配图标', 'aksrt'),
|
||||
'customTitle' => __('粘贴 SVG 图标代码', 'aksrt'),
|
||||
'customDesc' => __('粘贴 <svg viewBox="0 0 1024 1024">…</svg> 格式的代码,确认后以内联方式保存到当前字段。', 'aksrt'),
|
||||
'customSubmit' => __('确认插入', 'aksrt'),
|
||||
'customEmpty' => __('未检测到有效的 SVG 代码。', 'aksrt'),
|
||||
'loadError' => __('图标目录加载失败,请刷新重试。', 'aksrt'),
|
||||
'clear' => __('清除', 'aksrt'),
|
||||
],
|
||||
]);
|
||||
|
||||
add_action('admin_footer', 'aksrt_icon_picker_modal', 20);
|
||||
add_action('customize_controls_print_footer_scripts', 'aksrt_icon_picker_modal', 20);
|
||||
}
|
||||
endif;
|
||||
|
||||
if (!function_exists('aksrt_icon_picker_modal')) :
|
||||
/**
|
||||
* Print the shared picker modal once per admin page.
|
||||
*/
|
||||
function aksrt_icon_picker_modal(): void {
|
||||
static $printed = false;
|
||||
if ($printed) {
|
||||
return;
|
||||
}
|
||||
$printed = true;
|
||||
?>
|
||||
<div id="aksrt-modal-icon" class="aksrt-icon-modal hidden">
|
||||
<div class="aksrt-icon-modal__overlay" data-aksrt-icon-close></div>
|
||||
<div class="aksrt-icon-modal__inner" role="dialog" aria-modal="true" aria-label="<?php esc_attr_e('选择图标', 'aksrt'); ?>">
|
||||
<div class="aksrt-icon-modal__header">
|
||||
<strong><?php esc_html_e('选择图标', 'aksrt'); ?></strong>
|
||||
<button type="button" class="button-link aksrt-icon-modal__close" data-aksrt-icon-close aria-label="<?php esc_attr_e('关闭', 'aksrt'); ?>">×</button>
|
||||
</div>
|
||||
<div class="aksrt-icon-modal__toolbar">
|
||||
<input type="search" class="aksrt-icon-modal__search regular-text" placeholder="<?php esc_attr_e('搜索图标…', 'aksrt'); ?>">
|
||||
<div class="aksrt-icon-modal__tabs" role="tablist"></div>
|
||||
</div>
|
||||
<div class="aksrt-icon-modal__body">
|
||||
<div class="aksrt-icon-modal__loading" hidden><span class="spinner is-active"></span></div>
|
||||
<div class="aksrt-icon-modal__error" hidden></div>
|
||||
<div class="aksrt-icon-modal__grids"></div>
|
||||
<div class="aksrt-icon-modal__custom" hidden>
|
||||
<p class="description"><?php esc_html_e('粘贴 SVG 图标代码', 'aksrt'); ?></p>
|
||||
<textarea class="large-text code" rows="5" placeholder='<svg viewBox="0 0 1024 1024"><path d="…"></path></svg>'></textarea>
|
||||
<div class="aksrt-icon-modal__custom-actions">
|
||||
<span class="aksrt-icon-modal__custom-preview" aria-hidden="true"></span>
|
||||
<button type="button" class="button button-primary"><?php esc_html_e('确认插入', 'aksrt'); ?></button>
|
||||
</div>
|
||||
<div class="aksrt-icon-modal__grid aksrt-icon-modal__grid--session"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
endif;
|
||||
|
||||
if (!function_exists('aksrt_icon_picker_field')) :
|
||||
/**
|
||||
* Render a universal icon-picker field (preview + choose/clear + text input).
|
||||
*
|
||||
* The text input stays visible so raw class strings remain editable and
|
||||
* dependency evaluation (which reads the first input in the field) keeps
|
||||
* working; the picker simply fills it.
|
||||
*
|
||||
* @param string $name Input name.
|
||||
* @param string $id Input id.
|
||||
* @param string $value Current value.
|
||||
* @param array $args {placeholder?: string, class?: string}
|
||||
* @return string
|
||||
*/
|
||||
function aksrt_icon_picker_field(string $name, string $id, string $value, array $args = []): string {
|
||||
$value = (string) $value;
|
||||
$placeholder = (string) ($args['placeholder'] ?? 'fa fas fa-star');
|
||||
$wrap_class = trim('aksrt-icon-picker ' . (string) ($args['class'] ?? ''));
|
||||
$has_value = $value !== '';
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
<div class="<?php echo esc_attr($wrap_class); ?>" data-aksrt-icon-picker>
|
||||
<span class="aksrt-icon-picker__preview<?php echo $has_value ? '' : ' hidden'; ?>"><?php echo aksrt_icon_markup($value); // pre-escaped ?></span>
|
||||
<span class="aksrt-icon-picker__actions">
|
||||
<button type="button" class="button aksrt-icon-picker__choose"><?php esc_html_e('选择图标', 'aksrt'); ?></button>
|
||||
<button type="button" class="button-link-delete aksrt-icon-picker__remove<?php echo $has_value ? '' : ' hidden'; ?>"><?php esc_html_e('清除', 'aksrt'); ?></button>
|
||||
</span>
|
||||
<input type="text" class="widefat aksrt-icon-picker__value" id="<?php echo esc_attr($id); ?>" name="<?php echo esc_attr($name); ?>" value="<?php echo esc_attr($value); ?>" placeholder="<?php echo esc_attr($placeholder); ?>">
|
||||
</div>
|
||||
<?php
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
endif;
|
||||
@@ -239,6 +239,146 @@ function aksrt_contact_icon_options($selected = '') {
|
||||
}
|
||||
endif;
|
||||
|
||||
// ============================================================
|
||||
// 统一图标渲染(FA5 / 内置 SVG / 自定义 SVG)
|
||||
// ============================================================
|
||||
if (!function_exists('aksrt_enqueue_fontawesome')) :
|
||||
/**
|
||||
* Register (once) and enqueue the bundled Font Awesome 5 pack under the
|
||||
* single canonical handle. Every context — public, admin screens, widgets,
|
||||
* the icon picker — must go through this helper.
|
||||
*
|
||||
* @param array $deps Style dependencies for the first caller.
|
||||
* @return string The canonical handle.
|
||||
*/
|
||||
function aksrt_enqueue_fontawesome(array $deps = []): string {
|
||||
if (!wp_style_is('aksrt-font-awesome', 'registered')) {
|
||||
wp_register_style('aksrt-font-awesome', AKSRT_URI . '/assets/fontawesome5/css/all.min.css', $deps, '5.15.4');
|
||||
}
|
||||
wp_enqueue_style('aksrt-font-awesome');
|
||||
return 'aksrt-font-awesome';
|
||||
}
|
||||
endif;
|
||||
|
||||
if (!function_exists('aksrt_svg_kses_tags')) :
|
||||
/**
|
||||
* Allowed SVG tags/attributes for user-supplied custom SVG icon markup.
|
||||
*
|
||||
* @return array<string, array<string, bool>>
|
||||
*/
|
||||
function aksrt_svg_kses_tags(): array {
|
||||
static $tags = null;
|
||||
if ($tags !== null) {
|
||||
return $tags;
|
||||
}
|
||||
|
||||
$tags = [
|
||||
'svg' => ['class' => true, 'viewbox' => true, 'width' => true, 'height' => true, 'fill' => true, 'xmlns' => true, 'aria-hidden' => true],
|
||||
'use' => ['href' => true, 'xlink:href' => true],
|
||||
'path' => ['d' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true],
|
||||
'g' => ['fill' => true, 'transform' => true],
|
||||
];
|
||||
return $tags;
|
||||
}
|
||||
endif;
|
||||
|
||||
if (!function_exists('aksrt_icon_markup')) :
|
||||
/**
|
||||
* Render an icon-picker value of any supported format.
|
||||
*
|
||||
* Supported values:
|
||||
* - "fa fas fa-star" / "fa fab fa-weixin" — FA5 class string
|
||||
* - "fa-star" — bare glyph, rendered as solid FA5
|
||||
* - "zibsvg-hot" — theme built-in SVG sprite key
|
||||
* - "customsvg-<svg …>" — inline user SVG markup
|
||||
*
|
||||
* @param string $value Raw stored value.
|
||||
* @param array|string $extra_classes Extra classes for the rendered element.
|
||||
* @return string Safe HTML, empty string for empty values.
|
||||
*/
|
||||
function aksrt_icon_markup(string $value, $extra_classes = []): string {
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$extra = '';
|
||||
if (is_string($extra_classes) && $extra_classes !== '') {
|
||||
$extra_classes = preg_split('/\s+/', $extra_classes) ?: [];
|
||||
}
|
||||
if (is_array($extra_classes)) {
|
||||
$clean = array_filter(array_map('sanitize_html_class', $extra_classes));
|
||||
if ($clean !== []) {
|
||||
$extra = ' ' . implode(' ', $clean);
|
||||
}
|
||||
}
|
||||
|
||||
if (str_starts_with($value, 'customsvg-')) {
|
||||
$svg = wp_kses(substr($value, 10), aksrt_svg_kses_tags());
|
||||
if ($svg === '') {
|
||||
return '';
|
||||
}
|
||||
if ($extra !== '' && stripos($svg, '<svg') === 0 && stripos($svg, 'class=') === false) {
|
||||
$svg = preg_replace('/<svg\b/i', '<svg class="' . ltrim($extra) . '"', $svg, 1);
|
||||
}
|
||||
return $svg;
|
||||
}
|
||||
|
||||
if (str_starts_with($value, 'zibsvg-')) {
|
||||
$key = sanitize_key(substr($value, 7));
|
||||
if ($key === '') {
|
||||
return '';
|
||||
}
|
||||
$class = $extra !== '' ? ltrim($extra) : 'icon';
|
||||
return '<svg class="' . esc_attr($class) . '" aria-hidden="true"><use xlink:href="#icon-' . $key . '"></use></svg>';
|
||||
}
|
||||
|
||||
if (!str_contains($value, 'fa ') && str_contains($value, 'fa-')) {
|
||||
$value = 'fa ' . $value;
|
||||
}
|
||||
return '<i class="' . esc_attr($value . $extra) . '" aria-hidden="true"></i>';
|
||||
}
|
||||
endif;
|
||||
|
||||
if (!function_exists('aksrt_sanitize_icon_value')) :
|
||||
/**
|
||||
* Sanitize an icon-picker value. Keeps the SVG markup of customsvg- values
|
||||
* (plain sanitize_text_field would strip the tags), filters FA/zip keys.
|
||||
*
|
||||
* @param string $value Raw value from a form/REST payload.
|
||||
* @return string Sanitized value.
|
||||
*/
|
||||
function aksrt_sanitize_icon_value(string $value): string {
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (str_starts_with($value, 'customsvg-')) {
|
||||
$svg = wp_kses(substr($value, 10), aksrt_svg_kses_tags());
|
||||
return $svg === '' ? '' : 'customsvg-' . $svg;
|
||||
}
|
||||
|
||||
return sanitize_text_field($value);
|
||||
}
|
||||
endif;
|
||||
|
||||
if (!function_exists('aksrt_empty_state_svgs')) :
|
||||
/**
|
||||
* Shared empty-state SVG icons (24px stroke style, not sprite keys).
|
||||
*
|
||||
* @return array{post:string,comment:string,favorite:string,follow:string}
|
||||
*/
|
||||
function aksrt_empty_state_svgs(): array {
|
||||
return [
|
||||
'post' => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M9 13h6M9 17h6M9 9h1"/></svg>',
|
||||
'comment' => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
|
||||
'favorite' => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>',
|
||||
'follow' => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>',
|
||||
];
|
||||
}
|
||||
endif;
|
||||
|
||||
// ============================================================
|
||||
// Public social links (author hero / profile)
|
||||
// ============================================================
|
||||
|
||||
+11
-5
@@ -111,11 +111,16 @@ class Admin {
|
||||
}
|
||||
|
||||
// Use the same local FA5 pack as the public stack.
|
||||
wp_enqueue_style(
|
||||
'aksrt-font-awesome',
|
||||
get_template_directory_uri() . '/assets/fontawesome5/css/all.min.css',
|
||||
[],
|
||||
'5.15.4'
|
||||
aksrt_enqueue_fontawesome();
|
||||
|
||||
// Frontend SVG sprite (defines window.aksrt_svgs and injects the symbol
|
||||
// sheet) so schema icon fields can preview built-in zibsvg values.
|
||||
wp_enqueue_script(
|
||||
'aksrt-svg-sprite',
|
||||
get_template_directory_uri() . '/assets/js/runtime/svg-icon.js',
|
||||
['jquery'],
|
||||
(string) filemtime(get_template_directory() . '/assets/js/runtime/svg-icon.js'),
|
||||
true
|
||||
);
|
||||
|
||||
wp_enqueue_script(
|
||||
@@ -186,6 +191,7 @@ class Admin {
|
||||
CommentController::class,
|
||||
MessageController::class,
|
||||
AIController::class,
|
||||
\AKSRT\Icons\IconController::class,
|
||||
];
|
||||
|
||||
foreach ($controllers as $class_name) {
|
||||
|
||||
@@ -25,17 +25,12 @@ trait ScriptsPart1b {
|
||||
[],
|
||||
'3.4.1'
|
||||
);
|
||||
wp_enqueue_style(
|
||||
'fontawesome5',
|
||||
$base_uri . '/assets/fontawesome5/css/all.min.css',
|
||||
['bootstrap'],
|
||||
'5.15.4'
|
||||
);
|
||||
aksrt_enqueue_fontawesome(['bootstrap']);
|
||||
$main_css_path = get_template_directory() . '/assets/css/main.min.css';
|
||||
wp_enqueue_style(
|
||||
'aksrt-main',
|
||||
$base_uri . '/assets/css/main.min.css',
|
||||
['fontawesome5'],
|
||||
['aksrt-font-awesome'],
|
||||
file_exists($main_css_path) ? (string) filemtime($main_css_path) : $version
|
||||
);
|
||||
wp_enqueue_style(
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Icons;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Icon catalog for the universal icon picker.
|
||||
*
|
||||
* Two families:
|
||||
* - Font Awesome 5 Free (bundled, 5.15.4): static generated data in fa5-icons.php.
|
||||
* - Theme built-in SVG sprite: keys parsed from assets/js/runtime/svg-icon.js
|
||||
* (aksrt_svgs['key'] = '<path ...>') so the picker can never drift from the
|
||||
* frontend sprite. Parsed per file mtime and cached.
|
||||
*/
|
||||
final class IconCatalog {
|
||||
|
||||
/**
|
||||
* @return array<int, array{id:string,title:string,type:string,icons:string[]}>
|
||||
*/
|
||||
public static function groups(): array {
|
||||
$fa = include AKSRT_DIR . '/src/Icons/fa5-icons.php';
|
||||
if (!is_array($fa)) {
|
||||
$fa = ['fas' => [], 'far' => [], 'fab' => []];
|
||||
}
|
||||
|
||||
return [
|
||||
['id' => 'fas', 'title' => __('FA 实色', 'aksrt'), 'type' => 'fa', 'icons' => array_values((array) ($fa['fas'] ?? []))],
|
||||
['id' => 'far', 'title' => __('FA 常规', 'aksrt'), 'type' => 'fa', 'icons' => array_values((array) ($fa['far'] ?? []))],
|
||||
['id' => 'fab', 'title' => __('FA 品牌', 'aksrt'), 'type' => 'fa', 'icons' => array_values((array) ($fa['fab'] ?? []))],
|
||||
['id' => 'zibsvg', 'title' => __('主题内置 SVG', 'aksrt'), 'type' => 'svg', 'icons' => self::sprite_keys()],
|
||||
['id' => 'custom', 'title' => __('自定义 SVG', 'aksrt'), 'type' => 'custom', 'icons' => []],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprite symbol keys, e.g. ['like', 'view', ...] — derived from the
|
||||
* frontend sprite file, cached until the file changes.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function sprite_keys(): array {
|
||||
$file = AKSRT_DIR . '/assets/js/runtime/svg-icon.js';
|
||||
if (!is_readable($file)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$mtime = (string) filemtime($file);
|
||||
$cache = get_transient('aksrt_sprite_keys_v1');
|
||||
if (is_array($cache) && ($cache['mtime'] ?? '') === $mtime && isset($cache['keys'])) {
|
||||
return array_values((array) $cache['keys']);
|
||||
}
|
||||
|
||||
$src = (string) file_get_contents($file);
|
||||
$keys = [];
|
||||
if (preg_match_all("/aksrt_svgs\\['([A-Za-z0-9_]+)'\\]\\s*=/", $src, $m)) {
|
||||
$keys = array_values(array_unique($m[1]));
|
||||
}
|
||||
|
||||
set_transient('aksrt_sprite_keys_v1', ['mtime' => $mtime, 'keys' => $keys], MONTH_IN_SECONDS);
|
||||
return $keys;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Icons;
|
||||
|
||||
use AKSRT\Admin\ControllerBase;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* REST endpoint serving the icon picker catalog.
|
||||
*/
|
||||
class IconController extends ControllerBase {
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) self::$instance = new self();
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function register_routes(): void {
|
||||
register_rest_route(self::API_NAMESPACE, '/icons/catalog', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_catalog'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function get_catalog(): \WP_REST_Response {
|
||||
return $this->success([
|
||||
'groups' => IconCatalog::groups(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
/**
|
||||
* One-shot generator for src/Icons/fa5-icons.php.
|
||||
*
|
||||
* Sources (shipped with the theme):
|
||||
* - assets/fontawesome5/css/all.min.css → glyph class names + codepoints
|
||||
* - assets/fontawesome5/webfonts/fa-*.svg → per-style glyph codepoints
|
||||
*
|
||||
* Usage: php tools/generate-fa5-icons.php
|
||||
*/
|
||||
|
||||
defined('ABSPATH') || define('ABSPATH', __DIR__ . '/');
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$css_path = $root . '/assets/fontawesome5/css/all.min.css';
|
||||
$fonts_path = $root . '/assets/fontawesome5/webfonts';
|
||||
$out_path = $root . '/src/Icons/fa5-icons.php';
|
||||
|
||||
$css = (string) file_get_contents($css_path);
|
||||
|
||||
// class name → codepoint (e.g. 'home' => 0xf015)
|
||||
$name_to_code = [];
|
||||
if (!preg_match_all('/\.fa-([a-z0-9-]+):before\{content:"\\\\([0-9a-f]{4})"\}/i', $css, $m, PREG_SET_ORDER)) {
|
||||
fwrite(STDERR, "No glyph rules matched in all.min.css\n");
|
||||
exit(1);
|
||||
}
|
||||
foreach ($m as $hit) {
|
||||
$name_to_code[$hit[1]] = hexdec($hit[2]);
|
||||
}
|
||||
|
||||
// style → set of codepoints, parsed from the SVG font files
|
||||
$code_to_style = ['fas' => [], 'far' => [], 'fab' => []];
|
||||
foreach (array_keys($code_to_style) as $style) {
|
||||
$file = $fonts_path . '/fa-' . ($style === 'fab' ? 'brands-400' : ($style === 'far' ? 'regular-400' : 'solid-900')) . '.svg';
|
||||
$src = (string) file_get_contents($file);
|
||||
if (!preg_match_all('/<glyph[^>]*unicode="&#x([0-9a-f]+);"/i', $src, $g, PREG_SET_ORDER)) {
|
||||
fwrite(STDERR, "No glyphs matched in {$file}\n");
|
||||
exit(1);
|
||||
}
|
||||
foreach ($g as $hit) {
|
||||
$code_to_style[$style][hexdec($hit[1])] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$groups = ['fas' => [], 'far' => [], 'fab' => []];
|
||||
foreach ($name_to_code as $name => $code) {
|
||||
foreach ($groups as $style => $unused) {
|
||||
if (!empty($code_to_style[$style][$code])) {
|
||||
$groups[$style][] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($groups as $style => $names) {
|
||||
sort($groups[$style], SORT_NATURAL | SORT_FLAG_CASE);
|
||||
}
|
||||
|
||||
$export = var_export($groups, true);
|
||||
|
||||
$php = <<<PHP
|
||||
<?php
|
||||
/**
|
||||
* Font Awesome 5 Free (5.15.4) icon catalog, grouped by style.
|
||||
*
|
||||
* Generated by tools/generate-fa5-icons.php — do not edit by hand.
|
||||
* Bare glyph names; the picker composes "fa {fas|far|fab} fa-{name}".
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
return {$export}
|
||||
PHP;
|
||||
|
||||
file_put_contents($out_path, $php . ";\n");
|
||||
|
||||
printf(
|
||||
"fas=%d far=%d fab=%d (css glyph classes=%d)\nWrote %s\n",
|
||||
count($groups['fas']),
|
||||
count($groups['far']),
|
||||
count($groups['fab']),
|
||||
count($name_to_code),
|
||||
$out_path
|
||||
);
|
||||
Reference in New Issue
Block a user