feat(creator-studio): multi-select category picker and creatable tag input
分类从平铺复选标签改为带搜索的多选下拉(触发器内是已选令牌), 标签改为令牌输入框:回车 / 逗号 / 顿号 / 分号 / 换行切分,输入时列出 站点已有标签作为建议,面板内可一键新建。 - 抽出 useAnchoredPanel,工具条下拉与新的选择器共用同一套 portal 定位 - bootstrap 的 tags 增加 suggestions(按使用量取前 200 个已有标签) - 清理 .jio-creator-inline 与 chip 内的死样式 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
8bb4b98fc9
commit
330df53195
10 files changed
+888
-170
No files matched your search
@@ -32,7 +32,7 @@ const mockBootstrap = {
|
||||
],
|
||||
selected: [1],
|
||||
},
|
||||
tags: { items: ['写作', '产品'], enabled: true },
|
||||
tags: { items: ['写作', '产品'], enabled: true, suggestions: ['写作', '产品', '前端', 'WordPress', '设计'] },
|
||||
custom: {},
|
||||
customDefs: [],
|
||||
featured: { type: '', data: {}, canImage: true, canSlide: false, canVideo: false },
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 分类多选下拉。
|
||||
*
|
||||
* 触发器本身就是「已选令牌 + 占位文字」的输入框外观,点开后是带搜索的复选列表:
|
||||
* 作者不用在一屏几十个分类里横向找,输入关键词就能定位。面板 portal 到 body,
|
||||
* 避免被发布设置卡片或页面容器裁剪。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
import { createPortal, useEffect, useMemo, useState } from '@wordpress/element';
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
|
||||
import { useAnchoredPanel } from '../hooks/use-anchored-panel';
|
||||
|
||||
export type CategoryOption = { id: number; name: string };
|
||||
|
||||
type Props = {
|
||||
options: CategoryOption[];
|
||||
value: number[];
|
||||
onChange: (ids: number[]) => void;
|
||||
};
|
||||
|
||||
export function CategorySelect({ options, value, onChange }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const { triggerRef, panelRef, position } = useAnchoredPanel<HTMLDivElement>({
|
||||
open,
|
||||
width: 320,
|
||||
align: 'start',
|
||||
followScroll: true,
|
||||
onDismiss: () => setOpen(false),
|
||||
});
|
||||
|
||||
const selected = useMemo(() => new Set(value), [value]);
|
||||
const chosen = useMemo(
|
||||
() => options.filter((option) => selected.has(option.id)),
|
||||
[options, selected]
|
||||
);
|
||||
const filtered = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase();
|
||||
if (keyword === '') {
|
||||
return options;
|
||||
}
|
||||
return options.filter((option) => option.name.toLowerCase().includes(keyword));
|
||||
}, [options, query]);
|
||||
|
||||
// 每次打开都从干净的搜索词开始,避免上次的过滤条件把列表藏起来。
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const toggle = (id: number) => {
|
||||
onChange(
|
||||
selected.has(id) ? value.filter((item) => item !== id) : [...value, id]
|
||||
);
|
||||
};
|
||||
|
||||
const panel = open
|
||||
? createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`jio-creator-picker is-${position.placement}`}
|
||||
style={{ left: `${position.left}px`, top: `${position.top}px`, width: `${position.width}px` }}
|
||||
>
|
||||
<div className="jio-creator-picker__search">
|
||||
<i className="fa fas fa-search" aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder={__('搜索分类', 'jio')}
|
||||
aria-label={__('搜索分类', 'jio')}
|
||||
// 打开即可输入:分类列表通常很长,键盘操作比翻找更快。
|
||||
autoFocus
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="jio-creator-picker__list" aria-label={__('分类', 'jio')}>
|
||||
{filtered.length === 0 && (
|
||||
<p className="jio-creator-picker__empty">{__('没有匹配的分类', 'jio')}</p>
|
||||
)}
|
||||
{filtered.map((option) => {
|
||||
const active = selected.has(option.id);
|
||||
return (
|
||||
<label
|
||||
key={option.id}
|
||||
className={`jio-creator-picker__item${active ? ' is-active' : ''}`}
|
||||
>
|
||||
<input type="checkbox" checked={active} onChange={() => toggle(option.id)} />
|
||||
<span className="jio-creator-picker__name">{option.name}</span>
|
||||
<i className="fa fas fa-check jio-creator-picker__check" aria-hidden="true" />
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="jio-creator-picker__foot">
|
||||
<span className="jio-creator-picker__count">
|
||||
{sprintf(
|
||||
/* translators: %d: number of selected categories */
|
||||
__('已选 %d 个', 'jio'),
|
||||
value.length
|
||||
)}
|
||||
</span>
|
||||
<button type="button" className="jio-creator-picker__done" onClick={() => setOpen(false)}>
|
||||
{__('完成', 'jio')}
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="jio-creator-select" ref={triggerRef}>
|
||||
<div
|
||||
className={`jio-creator-select__control${open ? ' is-open' : ''}`}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open ? 'true' : 'false'}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
setOpen((value) => !value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="jio-creator-select__tokens">
|
||||
{chosen.length === 0 ? (
|
||||
<span className="jio-creator-select__placeholder">{__('选择分类(可多选)', 'jio')}</span>
|
||||
) : (
|
||||
chosen.map((option) => (
|
||||
<span key={option.id} className="jio-creator-select__token">
|
||||
<span className="jio-creator-select__token-label">{option.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="jio-creator-select__token-x"
|
||||
aria-label={sprintf(__('移除分类 %s', 'jio'), option.name)}
|
||||
onClick={(event) => {
|
||||
// 令牌上的叉号只移除这一项,不要顺带展开面板。
|
||||
event.stopPropagation();
|
||||
toggle(option.id);
|
||||
}}
|
||||
>
|
||||
<i className="fa fas fa-times" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</span>
|
||||
<i className="fa fas fa-caret-down jio-creator-select__caret" aria-hidden="true" />
|
||||
</div>
|
||||
{panel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,13 +11,16 @@
|
||||
import { useMemo, useState } from '@wordpress/element';
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
|
||||
import { CategorySelect, type CategoryOption } from './category-select';
|
||||
import { TagInput } from './tag-input';
|
||||
import type { PayState } from '../draft';
|
||||
|
||||
type Featured = { type: string; data: Record<string, any>; mediaId?: number };
|
||||
|
||||
export type TaxonomyDef = {
|
||||
categories: Array<{ id: number; name: string }>;
|
||||
tags: boolean;
|
||||
categories: CategoryOption[];
|
||||
/** 标签是否开放;开放时附带站点已有标签,用于输入建议。 */
|
||||
tags: { enabled: boolean; suggestions: string[] };
|
||||
customDefs: Array<{ key: string; name: string; values: Array<{ key: string; name: string }> }>;
|
||||
};
|
||||
|
||||
@@ -44,7 +47,6 @@ type Props = {
|
||||
};
|
||||
|
||||
const bootstrap: Record<string, any> = window['jioCreatorStudio'] || {};
|
||||
const nonce: string = bootstrap.nonce || '';
|
||||
|
||||
const pad = (value: number): string => String(value).padStart(2, '0');
|
||||
|
||||
@@ -77,30 +79,10 @@ export function PublishSettings({
|
||||
onCustomChange,
|
||||
onOpenPaySettings,
|
||||
}: Props) {
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const minSchedule = useMemo(() => toLocalInput(new Date(Date.now() + 5 * 60 * 1000)), []);
|
||||
const coverUrl = typeof featured?.data?.url === 'string' ? featured.data.url : '';
|
||||
|
||||
const toggleCategory = (id: number) => {
|
||||
onCategoriesChange(
|
||||
categories.includes(id) ? categories.filter((value) => value !== id) : [...categories, id]
|
||||
);
|
||||
};
|
||||
|
||||
const addTag = () => {
|
||||
const value = tagInput.trim();
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
const next = value
|
||||
.split(/[,,\n]+/u)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
onTagsChange(Array.from(new Set([...tags, ...next])));
|
||||
setTagInput('');
|
||||
};
|
||||
|
||||
const toggleCustom = (key: string, value: string) => {
|
||||
const current = custom[key] || [];
|
||||
onCustomChange({
|
||||
@@ -150,67 +132,24 @@ export function PublishSettings({
|
||||
<div className="jio-creator__setting-row jio-creator__setting-row--stack">
|
||||
<div className="jio-creator__setting-copy">
|
||||
<span className="jio-creator__setting-label">{__('分类', 'jio')}</span>
|
||||
<p className="jio-creator__setting-help">{__('必选,决定文章出现在哪些栏目。', 'jio')}</p>
|
||||
</div>
|
||||
<div className="jio-creator-chips">
|
||||
{taxonomy.categories.length === 0 && (
|
||||
<span className="jio-creator-muted">{__('暂无可选分类', 'jio')}</span>
|
||||
)}
|
||||
{taxonomy.categories.map((category) => (
|
||||
<label
|
||||
key={category.id}
|
||||
className={`jio-creator-chip${categories.includes(category.id) ? ' is-active' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={categories.includes(category.id)}
|
||||
onChange={() => toggleCategory(category.id)}
|
||||
/>
|
||||
<span>{category.name}</span>
|
||||
</label>
|
||||
))}
|
||||
<p className="jio-creator__setting-help">{__('必选,可多选,决定文章出现在哪些栏目。', 'jio')}</p>
|
||||
</div>
|
||||
{taxonomy.categories.length === 0 ? (
|
||||
<p className="jio-creator-muted">{__('暂无可选分类', 'jio')}</p>
|
||||
) : (
|
||||
<CategorySelect options={taxonomy.categories} value={categories} onChange={onCategoriesChange} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{taxonomy.tags && (
|
||||
{taxonomy.tags.enabled && (
|
||||
<div className="jio-creator__setting-row jio-creator__setting-row--stack">
|
||||
<div className="jio-creator__setting-copy">
|
||||
<span className="jio-creator__setting-label">{__('标签', 'jio')}</span>
|
||||
<p className="jio-creator__setting-help">{__('多个标签用逗号分隔,回车即可添加。', 'jio')}</p>
|
||||
</div>
|
||||
<div>
|
||||
{tags.length > 0 && (
|
||||
<div className="jio-creator-chips">
|
||||
{tags.map((tag) => (
|
||||
<span key={tag} className="jio-creator-chip is-active">
|
||||
<span>{tag}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTagsChange(tags.filter((value) => value !== tag))}
|
||||
aria-label={sprintf(__('移除标签 %s', 'jio'), tag)}
|
||||
>
|
||||
<i className="fa fas fa-times" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="jio-creator-inline">
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
placeholder={__('输入标签,逗号分隔', 'jio')}
|
||||
onChange={(event) => setTagInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
addTag();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="button" onClick={addTag}>{__('添加', 'jio')}</button>
|
||||
</div>
|
||||
<p className="jio-creator__setting-help">
|
||||
{__('可直接输入新标签,回车或逗号分隔;也可从站点已有标签中选择。', 'jio')}
|
||||
</p>
|
||||
</div>
|
||||
<TagInput value={tags} suggestions={taxonomy.tags.suggestions} onChange={onTagsChange} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 标签输入框(可创建 + 站点标签建议)。
|
||||
*
|
||||
* 标签在 WordPress 里本来就是"没有就建"的自由词条,所以这里的主交互是输入框:
|
||||
* - 回车 / 逗号 / 顿号 / 分号 / 换行都算一个标签的结束;
|
||||
* - 输入时在下方列出站点已有标签作为建议,点选即可复用,避免同义标签散落;
|
||||
* - 面板里没有匹配项时,回车直接把输入内容建成新标签。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
import { createPortal, useEffect, useMemo, useState } from '@wordpress/element';
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
|
||||
import { useAnchoredPanel } from '../hooks/use-anchored-panel';
|
||||
|
||||
type Props = {
|
||||
value: string[];
|
||||
suggestions: string[];
|
||||
onChange: (tags: string[]) => void;
|
||||
};
|
||||
|
||||
const MAX_SUGGESTIONS = 8;
|
||||
/** 一个标签的结束符:中英文逗号、顿号、分号、换行。 */
|
||||
const SEPARATORS = /[,,、;;\n]+/u;
|
||||
|
||||
const normalize = (value: string): string => value.trim().replace(/\s+/g, ' ');
|
||||
|
||||
export function TagInput({ value, suggestions, onChange }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [text, setText] = useState('');
|
||||
const { triggerRef, panelRef, position } = useAnchoredPanel<HTMLDivElement>({
|
||||
open,
|
||||
width: 320,
|
||||
align: 'start',
|
||||
followScroll: true,
|
||||
onDismiss: () => setOpen(false),
|
||||
});
|
||||
|
||||
const lower = useMemo(() => new Set(value.map((tag) => tag.toLowerCase())), [value]);
|
||||
const keyword = text.trim().toLowerCase();
|
||||
const matches = useMemo(() => {
|
||||
const pool = suggestions.filter(
|
||||
(tag) => !lower.has(tag.toLowerCase()) && (keyword === '' || tag.toLowerCase().includes(keyword))
|
||||
);
|
||||
return pool.slice(0, MAX_SUGGESTIONS);
|
||||
}, [suggestions, lower, keyword]);
|
||||
|
||||
// 已经有完全相同的标签时不再提示「创建」,避免产生重复词条。
|
||||
const canCreate =
|
||||
keyword !== '' && !suggestions.some((tag) => tag.toLowerCase() === keyword) && !lower.has(keyword);
|
||||
|
||||
/** 输入框里的文本按分隔符切成标签,与前台投稿的 `tags` 文本框同一口径。 */
|
||||
const commit = (raw: string) => {
|
||||
// 只和已有标签比对,避免同一批输入里的重复项漏网。
|
||||
const taken = new Set(value.map((tag) => tag.toLowerCase()));
|
||||
const parts: string[] = [];
|
||||
raw.split(SEPARATORS).forEach((piece) => {
|
||||
const tag = normalize(piece);
|
||||
const key = tag.toLowerCase();
|
||||
if (tag === '' || taken.has(key)) {
|
||||
return;
|
||||
}
|
||||
taken.add(key);
|
||||
parts.push(tag);
|
||||
});
|
||||
setText('');
|
||||
if (parts.length > 0) {
|
||||
onChange([...value, ...parts]);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = (tag: string) => {
|
||||
onChange(value.filter((item) => item !== tag));
|
||||
};
|
||||
|
||||
// 输入框获得焦点即展开建议面板;面板里的按钮用 mousedown 阻止默认行为,
|
||||
// 不会让输入框先失焦,作者可以连续点选多个标签。
|
||||
const focusInput = () => setOpen(true);
|
||||
|
||||
const panel = open
|
||||
? createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`jio-creator-picker is-${position.placement}`}
|
||||
style={{ left: `${position.left}px`, top: `${position.top}px`, width: `${position.width}px` }}
|
||||
>
|
||||
{matches.length > 0 && (
|
||||
<>
|
||||
<p className="jio-creator-picker__title">{__('站点已有标签', 'jio')}</p>
|
||||
<div className="jio-creator-picker__list" role="group" aria-label={__('标签建议', 'jio')}>
|
||||
{matches.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
className="jio-creator-picker__item"
|
||||
// 按钮获得焦点会抢走输入框光标,用 mousedown 阻止默认行为。
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
onChange([...value, tag]);
|
||||
setText('');
|
||||
}}
|
||||
>
|
||||
<i className="fa fas fa-hashtag jio-creator-picker__icon" aria-hidden="true" />
|
||||
<span className="jio-creator-picker__name">{tag}</span>
|
||||
<i className="fa fas fa-plus jio-creator-picker__check" aria-hidden="true" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{canCreate && (
|
||||
<button
|
||||
type="button"
|
||||
className="jio-creator-picker__item jio-creator-picker__item--create"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => commit(text)}
|
||||
>
|
||||
<i className="fa fas fa-plus jio-creator-picker__icon" aria-hidden="true" />
|
||||
<span className="jio-creator-picker__name">
|
||||
{sprintf(
|
||||
/* translators: %s: tag name typed by the author */
|
||||
__('新建标签「%s」', 'jio'),
|
||||
text.trim()
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{matches.length === 0 && !canCreate && (
|
||||
<p className="jio-creator-picker__empty">
|
||||
{keyword === '' ? __('还没有可用的站点标签', 'jio') : __('没有匹配的标签,回车即可新建', 'jio')}
|
||||
</p>
|
||||
)}
|
||||
<p className="jio-creator-picker__foot-hint">
|
||||
{__('回车或逗号分隔多个标签', 'jio')}
|
||||
</p>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div ref={triggerRef} className="jio-creator-tagbox">
|
||||
<div className={`jio-creator-tagbox__control${open ? ' is-open' : ''}`}>
|
||||
{value.map((tag) => (
|
||||
<span key={tag} className="jio-creator-select__token">
|
||||
<span className="jio-creator-select__token-label">{tag}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="jio-creator-select__token-x"
|
||||
aria-label={sprintf(__('移除标签 %s', 'jio'), tag)}
|
||||
// 保持输入框焦点,删完一个标签可以继续输入。
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => remove(tag)}
|
||||
>
|
||||
<i className="fa fas fa-times" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
type="text"
|
||||
className="jio-creator-tagbox__input"
|
||||
value={text}
|
||||
placeholder={value.length === 0 ? __('输入标签,回车添加', 'jio') : __('继续添加', 'jio')}
|
||||
onFocus={focusInput}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
// 粘贴 / 输入了分隔符:立即切分成多个标签。
|
||||
if (SEPARATORS.test(next)) {
|
||||
setOpen(true);
|
||||
commit(next);
|
||||
return;
|
||||
}
|
||||
setText(next);
|
||||
setOpen(true);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
commit(text);
|
||||
return;
|
||||
}
|
||||
// 输入框为空时退格删掉最后一个标签,符合令牌输入框的惯例。
|
||||
if (event.key === 'Backspace' && text === '' && value.length > 0) {
|
||||
event.preventDefault();
|
||||
remove(value[value.length - 1]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{panel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,9 @@
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
import { createPortal, useEffect, useLayoutEffect, useRef, useState } from '@wordpress/element';
|
||||
import { createPortal, useEffect, useState } from '@wordpress/element';
|
||||
|
||||
import { useAnchoredPanel } from '../hooks/use-anchored-panel';
|
||||
|
||||
export type ToolMenuItem = {
|
||||
key: string;
|
||||
@@ -38,8 +40,6 @@ type Props = {
|
||||
onRememberSelection?: () => void;
|
||||
};
|
||||
|
||||
type Position = { left: number; top: number; placement: 'bottom' | 'top' };
|
||||
|
||||
const MENU_GAP = 8;
|
||||
|
||||
export function ToolMenu({
|
||||
@@ -53,72 +53,29 @@ export function ToolMenu({
|
||||
onRememberSelection,
|
||||
}: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [position, setPosition] = useState<Position>({ left: 0, top: 0, placement: 'bottom' });
|
||||
|
||||
// 打开时按触发器矩形定位;优先向下展开,下方放不下时翻到上方。
|
||||
useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const update = () => {
|
||||
const trigger = triggerRef.current;
|
||||
if (!trigger) {
|
||||
return;
|
||||
}
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const menuHeight = menuRef.current?.offsetHeight || 240;
|
||||
const placement: Position['placement'] =
|
||||
rect.bottom + MENU_GAP + menuHeight > window.innerHeight && rect.top > menuHeight
|
||||
? 'top'
|
||||
: 'bottom';
|
||||
const left = Math.min(
|
||||
Math.max(MENU_GAP, rect.left + rect.width / 2 - width / 2),
|
||||
Math.max(MENU_GAP, window.innerWidth - width - MENU_GAP)
|
||||
);
|
||||
const top = placement === 'bottom' ? rect.bottom + MENU_GAP : rect.top - menuHeight - MENU_GAP;
|
||||
setPosition({ left, top, placement });
|
||||
};
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, [open, width]);
|
||||
const { triggerRef, panelRef, position } = useAnchoredPanel<HTMLButtonElement>({
|
||||
open,
|
||||
width,
|
||||
gap: MENU_GAP,
|
||||
onDismiss: () => setOpen(false),
|
||||
});
|
||||
|
||||
// 工具条下拉在页面滚动时直接收起:菜单是 fixed 定位,滚动后与触发器脱节。
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return undefined;
|
||||
}
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (triggerRef.current?.contains(target) || menuRef.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
};
|
||||
const onScroll = () => setOpen(false);
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('scroll', onScroll, true);
|
||||
};
|
||||
return () => window.removeEventListener('scroll', onScroll, true);
|
||||
}, [open]);
|
||||
|
||||
const menu = open
|
||||
? createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
ref={panelRef}
|
||||
className={`jio-creator__menu is-${position.placement}`}
|
||||
style={{ left: `${position.left}px`, top: `${position.top}px`, width: `${width}px` }}
|
||||
style={{ left: `${position.left}px`, top: `${position.top}px`, width: `${position.width}px` }}
|
||||
role="menu"
|
||||
aria-label={title || label}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 浮层定位:把下拉菜单 / 选择面板固定在触发器的旁边。
|
||||
*
|
||||
* 工具条是横向滚动容器,发布设置卡片又可能出现在长页面的任意位置,浮层如果
|
||||
* 直接绝对定位在触发器的 DOM 里,就会被祖先的 `overflow` 裁掉。这里统一把
|
||||
* 浮层 portal 到 body,再用触发器的 `getBoundingClientRect()` 计算 fixed 坐标:
|
||||
* 优先向下展开,下方空间不足时翻到上方,左右不越出视口。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from '@wordpress/element';
|
||||
|
||||
export type AnchoredPosition = {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
placement: 'bottom' | 'top';
|
||||
};
|
||||
|
||||
type Options = {
|
||||
open: boolean;
|
||||
/** 面板固定宽度(px);不传时与触发器同宽。 */
|
||||
width?: number;
|
||||
/** 面板最小宽度(px)。 */
|
||||
minWidth?: number;
|
||||
/** 面板与触发器的间距(px)。 */
|
||||
gap?: number;
|
||||
/** 面板相对触发器的水平对齐方式。 */
|
||||
align?: 'center' | 'start';
|
||||
/** 滚动时跟随重新定位。工具条下拉是滚动即收起,保持默认关闭。 */
|
||||
followScroll?: boolean;
|
||||
/** 点击浮层外部或按 Esc 时调用。 */
|
||||
onDismiss?: () => void;
|
||||
};
|
||||
|
||||
const VIEWPORT_GAP = 8;
|
||||
|
||||
export function useAnchoredPanel<T extends HTMLElement = HTMLElement>({
|
||||
open,
|
||||
width,
|
||||
minWidth = 0,
|
||||
gap = 8,
|
||||
align = 'center',
|
||||
followScroll = false,
|
||||
onDismiss,
|
||||
}: Options) {
|
||||
const triggerRef = useRef<T | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const [position, setPosition] = useState<AnchoredPosition>({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: width || minWidth,
|
||||
placement: 'bottom',
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
return undefined;
|
||||
}
|
||||
const update = () => {
|
||||
const trigger = triggerRef.current;
|
||||
if (!trigger) {
|
||||
return;
|
||||
}
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const panelWidth = Math.max(minWidth, width || rect.width);
|
||||
// 先按目标宽度写回面板再量高度:否则首帧宽度为 0,文字全部换行,
|
||||
// 量出来的高度会把「向下展开」误判成「向上翻」。
|
||||
const panel = panelRef.current;
|
||||
if (panel) {
|
||||
panel.style.width = `${panelWidth}px`;
|
||||
}
|
||||
const panelHeight = panel?.offsetHeight || 240;
|
||||
const placement: AnchoredPosition['placement'] =
|
||||
rect.bottom + gap + panelHeight > window.innerHeight && rect.top > panelHeight
|
||||
? 'top'
|
||||
: 'bottom';
|
||||
const preferred = align === 'start' ? rect.left : rect.left + rect.width / 2 - panelWidth / 2;
|
||||
const left = Math.min(
|
||||
Math.max(VIEWPORT_GAP, preferred),
|
||||
Math.max(VIEWPORT_GAP, window.innerWidth - panelWidth - VIEWPORT_GAP)
|
||||
);
|
||||
const top = placement === 'bottom' ? rect.bottom + gap : rect.top - panelHeight - gap;
|
||||
setPosition({ left, top, width: panelWidth, placement });
|
||||
};
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
if (followScroll) {
|
||||
window.addEventListener('scroll', update, true);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('resize', update);
|
||||
if (followScroll) {
|
||||
window.removeEventListener('scroll', update, true);
|
||||
}
|
||||
};
|
||||
}, [open, width, minWidth, gap, align, followScroll]);
|
||||
|
||||
// 点击外部 / Esc 收起。与定位逻辑分开:定位只在打开时算一次,
|
||||
// 关闭时机则依赖文档级事件,两者互不干扰。
|
||||
// 回调存进 ref:调用方通常传内联箭头函数,直接进依赖数组会让监听器
|
||||
// 每次渲染都重新挂载。
|
||||
const dismissRef = useRef(onDismiss);
|
||||
dismissRef.current = onDismiss;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return undefined;
|
||||
}
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (triggerRef.current?.contains(target) || panelRef.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
dismissRef.current?.();
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
dismissRef.current?.();
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return { triggerRef, panelRef, position };
|
||||
}
|
||||
@@ -772,7 +772,10 @@ function Studio(): JSX.Element {
|
||||
canPay={!!capabilities.canPay}
|
||||
taxonomy={{
|
||||
categories: (bootstrap.categories && bootstrap.categories.items) || [],
|
||||
tags: !!bootstrap.tags?.enabled,
|
||||
tags: {
|
||||
enabled: !!bootstrap.tags?.enabled,
|
||||
suggestions: (bootstrap.tags && bootstrap.tags.suggestions) || [],
|
||||
},
|
||||
customDefs: bootstrap.customDefs || [],
|
||||
}}
|
||||
categories={draft.categories}
|
||||
|
||||
@@ -162,6 +162,7 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 自定义分类法仍是少量固定选项的复选标签;分类 / 标签改用下面的选择器。 */
|
||||
.jio-creator-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -186,40 +187,6 @@
|
||||
background: #eef5f2;
|
||||
color: $accent-strong;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-inline {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
|
||||
input[type="text"],
|
||||
input:not([type]) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input:not([type]) {
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid $line;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-switch {
|
||||
@@ -507,3 +474,322 @@
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ---------- 发布设置:分类多选下拉 / 标签令牌输入 ---------- */
|
||||
|
||||
/* 触发器外观与卡片里的输入框一致:白底、细边、单行最小 40px 高。 */
|
||||
.jio-creator-select,
|
||||
.jio-creator-tagbox {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.jio-creator-select__control,
|
||||
.jio-creator-tagbox__control {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid $line;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: $ink;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.16s ease, box-shadow 0.16s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #d3dbd7;
|
||||
}
|
||||
|
||||
&.is-open,
|
||||
&:focus-within {
|
||||
border-color: $accent;
|
||||
box-shadow: 0 0 0 3px rgba(38, 118, 101, 0.1);
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 触发器是容器(内含令牌叉号按钮),键盘聚焦时给出与输入框一致的焦点环。 */
|
||||
.jio-creator-select__control:focus-visible {
|
||||
border-color: $accent;
|
||||
box-shadow: 0 0 0 3px rgba(38, 118, 101, 0.1);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.jio-creator-select__tokens,
|
||||
.jio-creator-tagbox__control {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.jio-creator-select__tokens {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.jio-creator-select__placeholder {
|
||||
color: #b0b8b5;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 已选令牌:分类与标签共用,保持同一视觉重量。 */
|
||||
.jio-creator-select__token {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 6px 3px 10px;
|
||||
border-radius: 6px;
|
||||
background: #eef5f2;
|
||||
color: $accent-strong;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.jio-creator-select__token-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.jio-creator-select__token-x {
|
||||
display: inline-flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 9px;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: rgba(29, 95, 82, 0.14);
|
||||
outline: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-select__caret {
|
||||
flex: 0 0 auto;
|
||||
color: #a9b2ae;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.jio-creator-tagbox__input {
|
||||
flex: 1 1 120px;
|
||||
min-width: 120px;
|
||||
height: 26px;
|
||||
padding: 0 2px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #b0b8b5;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 浮层面板(portal 到 body,与工具条菜单同一套语言) ---------- */
|
||||
|
||||
.jio-creator-picker {
|
||||
position: fixed;
|
||||
z-index: 620;
|
||||
display: flex;
|
||||
max-height: min(320px, 60vh);
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(24, 39, 34, 0.06);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
box-shadow: 0 14px 40px rgba(20, 32, 28, 0.16), 0 2px 6px rgba(20, 32, 28, 0.06);
|
||||
animation: jio-menu-in 0.12s ease-out;
|
||||
}
|
||||
|
||||
.jio-creator-picker.is-top {
|
||||
animation-name: jio-menu-in-up;
|
||||
}
|
||||
|
||||
.jio-creator-picker__search {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #f0f2f1;
|
||||
color: #9aa2a0;
|
||||
font-size: 12px;
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $ink;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-picker__title {
|
||||
margin: 0;
|
||||
padding: 10px 12px 4px;
|
||||
color: #9aa2a0;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.jio-creator-picker__list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.jio-creator-picker__item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #4f5957;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: #f2f5f3;
|
||||
color: $accent-strong;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
color: $accent-strong;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-picker__icon {
|
||||
flex: 0 0 12px;
|
||||
color: #a9b2ae;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.jio-creator-picker__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.jio-creator-picker__check {
|
||||
flex: 0 0 auto;
|
||||
color: $accent;
|
||||
font-size: 10px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.jio-creator-picker__item.is-active .jio-creator-picker__check,
|
||||
.jio-creator-picker__item--create .jio-creator-picker__check {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.jio-creator-picker__item--create {
|
||||
color: $accent-strong;
|
||||
|
||||
.jio-creator-picker__name {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-picker__empty {
|
||||
margin: 0;
|
||||
padding: 14px 12px;
|
||||
color: #9aa2a0;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.jio-creator-picker__foot {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid #f0f2f1;
|
||||
}
|
||||
|
||||
.jio-creator-picker__count {
|
||||
color: #9aa2a0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.jio-creator-picker__foot-hint {
|
||||
margin: 0;
|
||||
padding: 0 12px 10px;
|
||||
color: #9aa2a0;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.jio-creator-picker__done {
|
||||
padding: 4px 12px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: #eef5f2;
|
||||
color: $accent-strong;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: #e2efea;
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.jio-creator-picker {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user