PluginProbe ʕ •ᴥ•ʔ
VK Blocks / 1.126.3
VK Blocks v1.126.3
1.126.4 1.126.3 1.126.1 1.126.2 1.126.0 1.125.0 1.124.0 1.123.0 1.122.0 1.121.0 1.120.0 1.119.2 1.119.1 1.119.0 1.118.7 1.82.0.0 1.82.0.1 1.83.0.0 1.83.0.1 1.84.0.0 1.84.0.1 1.85.0.2 1.85.0.3 1.85.1.0 1.85.1.1 1.86.0.0 1.86.0.1 1.86.1.0 1.87.0.0 1.87.0.1 1.88.0.1 1.88.0.2 1.89.0.0 1.9.2 1.90.0.0 1.90.0.1 1.90.1.1 1.91.0.0 1.91.1.1 1.92.0.0 1.92.0.1 1.92.1.0 1.92.1.1 1.93.0.0 trunk 1.93.0.1 0.1.1 1.93.1.0 0.10.3 1.93.1.1 0.11.0 1.94.0.0 0.12.6 1.94.0.1 0.15.1 1.94.1.0 0.16.2 1.94.2.1 0.17.6 1.94.2.2 0.2.2 1.95.0.3 0.22.4 1.95.0.4 0.26.7 1.96.2.0 0.3.2 1.96.2.1 0.31.0 1.97.0.1 0.35.5 1.97.0.2 0.37.5 1.99.0.0 0.38.8 1.99.0.1 0.39.4 0.4.7 0.41.0 0.42.0 0.44.13 0.45.2 0.46.1 0.47.0 0.48.0 0.49.8 0.5.2 0.50.3 0.51.0 0.52.2 0.53.2 0.54.0 0.55.0 0.56.2 0.56.3 0.57.6 0.58.10 0.59.0 0.6.0 0.60.1 0.61.2 0.7.1 0.8.2 1.0.16 1.10.0 1.100.0.0 1.100.0.1 1.102.0.0 1.102.0.1 1.103.0.0 1.104.0.0 1.104.0.1 1.105.1.0 1.105.1.1 1.106.0.0 1.106.0.1 1.107.0.0 1.107.0.1 1.107.0.2 1.108.0.0 1.108.0.1 1.109.0.0 1.109.0.1 1.11.4 1.110.0.0 1.110.0.1 1.111.0.0 1.111.0.1 1.111.0.2 1.112.0.0 1.112.0.1 1.113.0.0 1.113.0.1 1.114.0.0 1.114.0.1 1.114.2.0 1.114.2.1 1.115.0.0 1.115.0.1 1.115.1.0 1.115.1.1 1.115.2.0 1.115.2.1 1.116.0.0 1.116.0.1 1.116.1.0 1.116.1.1 1.116.2.0 1.117.0.0 1.117.0.1 1.117.1.0 1.118.2 1.118.5 1.12.0 1.13.2 1.14.1 1.15.1 1.16.11 1.17.0 1.18.6 1.19.0 1.19.1 1.2.3 1.20.7 1.21.0 1.22.4 1.23.0 1.24.5 1.25.1 1.26.2 1.27.7.2 1.28.0.1 1.29.2.0 1.3.9 1.30.0.1 1.31.0.1 1.32.0.2 1.33.2.1 1.36.1.5 1.37.0.0 1.39.2.1 1.4.6 1.40.0.1 1.40.1.0 1.40.1.1 1.41.0.0 1.41.0.1 1.41.2.2 1.41.2.3 1.43.0.0 1.43.0.1 1.43.0.2 1.44.0.0 1.44.0.1 1.45.0.0 1.45.0.1 1.46.0.0 1.46.0.1 1.47.0.0 1.47.0.1 1.47.1.0 1.48.0.0 1.48.0.1 1.48.0.2 1.48.1.0 1.48.1.1 1.5.0 1.50.0.0 1.50.0.1 1.51.0.0 1.51.0.1 1.52.0.0 1.52.0.1 1.53.0.0 1.53.0.1 1.54.0.0 1.54.0.1 1.55.0.0 1.55.0.1 1.56.0.0 1.56.0.1 1.57.0.0 1.57.0.2 1.57.0.3 1.57.0.4 1.57.0.5 1.57.1.0 1.57.1.1 1.57.1.2 1.58.0.0 1.58.0.1 1.59.0.0 1.59.0.1 1.6.0 1.60.0.0 1.60.0.1 1.63.0.0 1.63.0.1 1.64.0.0 1.64.0.1 1.64.1.0 1.64.1.2 1.67.0.0 1.67.0.1 1.68.0.0 1.68.0.1 1.69.0.0 1.69.0.1 1.69.1.1 1.69.1.2 1.7.1 1.70.0.0 1.70.0.1 1.71.0.0 1.71.0.1 1.72.0.0 1.72.1.0 1.72.1.1 1.73.0.0 1.73.0.1 1.74.0.0 1.74.0.1 1.75.0.0 1.75.1.0 1.75.1.1 1.76.0.0 1.76.0.1 1.76.1.0 1.76.1.1 1.76.2.0 1.76.2.1 1.77.0.0 1.77.0.1 1.78.0.0 1.78.0.1 1.79.0.0 1.79.0.1 1.79.0.2 1.79.0.3 1.79.1.0 1.79.1.1 1.8.2 1.80.1.0 1.80.1.1 1.80.1.2 1.81.0.1 1.81.0.2
vk-blocks / src / components / link-toolbar / index.js
vk-blocks / src / components / link-toolbar Last commit date
styles 3 months ago format-link-url.js 3 months ago index.js 2 weeks ago
index.js
560 lines
1 import { useState, useEffect } from 'react';
2 import {
3 ToolbarButton,
4 Dropdown,
5 CheckboxControl,
6 Button,
7 Tooltip,
8 TextControl,
9 RadioControl,
10 } from '@wordpress/components';
11 import { URLInput } from '@wordpress/block-editor';
12 import { __, sprintf } from '@wordpress/i18n';
13 import { link, linkOff, keyboardReturn, globe, copy } from '@wordpress/icons';
14
15 import { formatLinkUrl } from './format-link-url';
16
17 // コアの LinkControl と同じ __preview 系クラス・構造に合わせる(WP仕様に準拠)
18 const LinkPreview = ({
19 linkUrl,
20 linkTitle,
21 icon,
22 linkTarget,
23 onRemove,
24 onCopy,
25 relAttribute,
26 linkDescription,
27 }) => {
28 // LinkToolbar 側の formatUrl と同じ判定で URL を整形する。
29 // (`/foo` や `#section` などの相対パス・アンカーをそのまま素通しする)
30 const displayURL = formatLinkUrl(linkUrl);
31 const isUrlTitle = linkTitle === linkUrl;
32
33 return (
34 <div
35 role="group"
36 aria-label={__('Manage link', 'vk-blocks')}
37 className={`block-editor-link-control__preview is-current is-rich is-preview${isUrlTitle ? ' is-url-title' : ''}`}
38 >
39 <div
40 className="block-editor-link-control__link-information"
41 role="figure"
42 aria-label={__('Link information', 'vk-blocks')}
43 >
44 <div className="block-editor-link-control__preview-icon">
45 {icon}
46 </div>
47 <div className="block-editor-link-control__preview-details">
48 <a
49 className="components-external-link block-editor-link-control__preview-title"
50 href={displayURL}
51 target={linkTarget}
52 rel={relAttribute}
53 {...(linkDescription
54 ? { 'aria-label': linkDescription }
55 : {})}
56 >
57 {linkTitle}
58 </a>
59 <span className="block-editor-link-control__preview-info">
60 {linkUrl}
61 </span>
62 </div>
63 </div>
64 <Tooltip text={__('Deleting Link', 'vk-blocks')}>
65 <Button
66 icon={linkOff}
67 label={__('Deleting Link', 'vk-blocks')}
68 onClick={onRemove}
69 size="compact"
70 className="has-icon"
71 />
72 </Tooltip>
73 <Tooltip
74 text={sprintf(
75 // translators: %s is the link URL
76 __('Copy link: %s', 'vk-blocks'),
77 linkUrl
78 )}
79 >
80 <Button
81 icon={copy}
82 label={__('Copy link', 'vk-blocks')}
83 onClick={() => onCopy(linkUrl)}
84 size="compact"
85 className="has-icon"
86 />
87 </Tooltip>
88 </div>
89 );
90 };
91
92 const LinkToolbar = (props) => {
93 const {
94 linkUrl,
95 setLinkUrl,
96 linkTarget,
97 setLinkTarget,
98 linkDescription,
99 setLinkDescription,
100 relAttribute,
101 setRelAttribute,
102 // クエリループ�
103 での「投稿へのリンク」用(任意)
104 isDescendentOfQueryLoop,
105 linkToPost,
106 setLinkToPost,
107 // クエリループ�
108 での「カスタムフィールドURL」用(任意・button のみが渡す)
109 linkToCustomField,
110 setLinkToCustomField,
111 customFieldName,
112 setCustomFieldName,
113 } = props;
114 const [isOpen, setIsOpen] = useState(false);
115 const [linkTitle, setLinkTitle] = useState('');
116 const [icon, setIcon] = useState(null);
117 const [isSnackbarVisible, setSnackbarVisible] = useState(false);
118 const [isSubmitDisabled, setSubmitDisabled] = useState(true);
119 const [ariaMessage, setAriaMessage] = useState('');
120
121 useEffect(() => {
122 if (linkUrl) {
123 const formattedUrl = formatLinkUrl(linkUrl);
124 const isExternalLink =
125 !formattedUrl.startsWith(window.location.origin) &&
126 !formattedUrl.startsWith('#'); // 外部リンクかどうか判定
127
128 // 外部リンクの場合はプレビュー(タイトル取得)をスキップする
129 if (!isExternalLink) {
130 const fetchTitle = function (url) {
131 if (url.startsWith('#')) {
132 return Promise.resolve(url); // アンカーリンクの場合はそのまま返す
133 }
134 return fetch(url, { method: 'GET' })
135 .then((response) => response.text())
136 .then((text) => {
137 const titleMatch = text.match(
138 /<title>(.*?)<\/title>/i
139 );
140 return titleMatch ? titleMatch[1] : url;
141 })
142 .catch(() => {
143 return url;
144 });
145 };
146
147 fetchTitle(formattedUrl).then((title) => {
148 setLinkTitle(title);
149 });
150 } else {
151 // 外部リンクの場合はそのままリンクURLをタイトルとして設定する
152 setLinkTitle(formattedUrl);
153 }
154
155 // アイコン設定
156 if (isExternalLink) {
157 setIcon(globe); // 外部リンクの場合は地球アイコン
158 } else if (formattedUrl.startsWith('#')) {
159 setIcon(globe); // アンカーリンクにも地球アイコンを使用
160 } else {
161 try {
162 const domain = new URL(formattedUrl).origin;
163 const faviconUrl = `${domain}/favicon.ico`;
164 setIcon(<img src={faviconUrl} alt="" />);
165 } catch {
166 setIcon(link); // URLが無効な場合はリンクアイコンを使用
167 }
168 }
169 }
170 }, [linkUrl]);
171
172 useEffect(() => {
173 setSubmitDisabled(!linkUrl || linkUrl.trim() === '');
174 }, [linkUrl]);
175
176 const handleToggle = () => {
177 if (!isOpen) {
178 setIsOpen(true);
179 } else if (linkUrl === '') {
180 setIsOpen(false);
181 }
182 };
183
184 const handleRemove = () => {
185 setLinkUrl('');
186 setLinkTarget('');
187 if (typeof setLinkToPost === 'function') {
188 setLinkToPost(false);
189 }
190 if (typeof setLinkToCustomField === 'function') {
191 setLinkToCustomField(false);
192 }
193 setIsOpen(false);
194 };
195
196 // クエリループ�
197 で「投稿へのリンク」を出せるか(既存)
198 const showLinkToPostChoice =
199 isDescendentOfQueryLoop &&
200 linkToPost !== undefined &&
201 typeof setLinkToPost === 'function';
202 // button だけが渡す「カスタムフィールドURL」を出せるか。
203 // MVP は linkToPost と同じくクエリループ�
204 に限定する。他ブロックは prop 未指定で false
205 const showCustomFieldChoice =
206 isDescendentOfQueryLoop &&
207 linkToCustomField !== undefined &&
208 typeof setLinkToCustomField === 'function';
209 // 「リンク�
210 �の選択」ラジオ自体を出すか(post か customField のどちらかが出せるなら出す)。
211 const showLinkDestinationChoice =
212 showLinkToPostChoice || showCustomFieldChoice;
213 const isLinkToPostMode = !!linkToPost;
214 const isCustomFieldMode = !!linkToCustomField;
215 // 動的リンク(投稿リンク or カスタムフィールドURL)モードでは生URL�
216 �力・プレビューを出さない。
217 const isDynamicMode = isLinkToPostMode || isCustomFieldMode;
218 // ラジオの現在値(排他)。customField を最優�
219 �で判定する。
220 let currentDestination = 'url';
221 if (isCustomFieldMode) {
222 currentDestination = 'customField';
223 } else if (isLinkToPostMode) {
224 currentDestination = 'post';
225 }
226
227 // リンク�
228 �を排他的に切り替える。2つの排他 bool が同時に true にならないよう、
229 // 常に「選ばれたモードだけ true・他は false」で両方の setter を呼ぶ。
230 const applyLinkDestination = (value) => {
231 if (typeof setLinkToPost === 'function') {
232 setLinkToPost(value === 'post');
233 }
234 if (typeof setLinkToCustomField === 'function') {
235 setLinkToCustomField(value === 'customField');
236 }
237 };
238
239 // ブロックがクエリループ外に移ったら「投稿へのリンク」を解除する
240 useEffect(() => {
241 if (
242 !showLinkToPostChoice &&
243 linkToPost &&
244 typeof setLinkToPost === 'function'
245 ) {
246 setLinkToPost(false);
247 }
248 }, [showLinkToPostChoice, linkToPost, setLinkToPost]);
249
250 // ブロックがクエリループ外に移ったら「カスタムフィールドURL」を解除する
251 useEffect(() => {
252 if (
253 !showCustomFieldChoice &&
254 linkToCustomField &&
255 typeof setLinkToCustomField === 'function'
256 ) {
257 setLinkToCustomField(false);
258 }
259 }, [showCustomFieldChoice, linkToCustomField, setLinkToCustomField]);
260
261 // リンクが設定されているか(ツールバーアイコンの反転・is-pressed に使用)
262 const hasLink =
263 !!linkToPost ||
264 !!linkToCustomField ||
265 !!(linkUrl && typeof linkUrl === 'string' && linkUrl.trim() !== '');
266
267 const handleCopy = function (url) {
268 // `#` や `/` などの素通し条件は formatLinkUrl �
269 で一�
270 �管理する
271 const formattedUrl = formatLinkUrl(url);
272 if (typeof window !== 'undefined' && window.navigator.clipboard) {
273 window.navigator.clipboard
274 .writeText(formattedUrl)
275 .then(() => {
276 setAriaMessage(
277 __('Link copied to clipboard.', 'vk-blocks')
278 );
279 setSnackbarVisible(true);
280 setTimeout(() => setSnackbarVisible(false), 3000);
281 })
282 .catch(() => {
283 // console.error('Failed to copy: ', error);
284 });
285 } else {
286 // Clipboard API がサポートされていない場合のフォールバック
287 const textArea = document.createElement('textarea');
288 textArea.value = formattedUrl;
289 document.body.appendChild(textArea);
290 textArea.focus();
291 textArea.select();
292 document.execCommand('copy');
293 document.body.removeChild(textArea);
294 setAriaMessage(__('Link copied to clipboard.', 'vk-blocks'));
295 setSnackbarVisible(true);
296 setTimeout(() => setSnackbarVisible(false), 3000);
297 }
298 };
299
300 const handleSubmit = () => {
301 if (linkUrl) {
302 setLinkUrl(formatLinkUrl(linkUrl));
303 }
304 };
305
306 const handleRelChange = (type, checked) => {
307 const rel = relAttribute ? relAttribute.split(' ') : [];
308 if (checked) {
309 rel.push(type);
310 } else {
311 const index = rel.indexOf(type);
312 if (index !== -1) {
313 rel.splice(index, 1);
314 }
315 }
316 setRelAttribute(rel.join(' '));
317 };
318
319 return (
320 <>
321 <Dropdown
322 popoverProps={{ placement: 'bottom-start' }}
323 renderToggle={({ isOpen, onToggle }) => {
324 const setLink = () => {
325 if (isOpen) {
326 // 開いている状�
327 �でクリック=「Unlink」→ リンクを解除して閉じる
328 handleRemove();
329 onToggle();
330 } else {
331 handleToggle();
332 onToggle();
333 }
334 };
335 return (
336 <ToolbarButton
337 aria-expanded={isOpen}
338 icon={isOpen ? linkOff : link}
339 isActive={hasLink}
340 label={
341 isOpen
342 ? __('Unlink', 'vk-blocks')
343 : __('Input Link URL', 'vk-blocks')
344 }
345 onClick={setLink}
346 className={`vk-block-editor-link-toolbar-button ${hasLink ? 'is-pressed' : ''}`}
347 />
348 );
349 }}
350 renderContent={({ onClose }) => (
351 <div className="vk-block-editor-link-toolbar-popover block-editor-link-control">
352 {showLinkDestinationChoice && (
353 <div className="vk-block-editor-link-destination-choice">
354 <RadioControl
355 label={__('Link destination', 'vk-blocks')}
356 selected={currentDestination}
357 options={[
358 ...(showLinkToPostChoice
359 ? [
360 {
361 label: __(
362 'Link to post',
363 'vk-blocks'
364 ),
365 value: 'post',
366 },
367 ]
368 : []),
369 ...(showCustomFieldChoice
370 ? [
371 {
372 label: __(
373 'Custom field URL',
374 'vk-blocks'
375 ),
376 value: 'customField',
377 },
378 ]
379 : []),
380 {
381 label: __(
382 'Specify URL',
383 'vk-blocks'
384 ),
385 value: 'url',
386 },
387 ]}
388 onChange={(value) =>
389 applyLinkDestination(value)
390 }
391 />
392 </div>
393 )}
394 {isCustomFieldMode &&
395 typeof setCustomFieldName === 'function' && (
396 <div className="vk-block-editor-link-destination-custom-field">
397 <TextControl
398 label={__(
399 'Custom Field Name',
400 'vk-blocks'
401 )}
402 value={customFieldName || ''}
403 onChange={(value) =>
404 setCustomFieldName(value)
405 }
406 help={__(
407 'The link points to the URL stored in this custom field of each post.',
408 'vk-blocks'
409 )}
410 />
411 </div>
412 )}
413 {!isDynamicMode && linkUrl && (
414 // LinkPreview には生の linkUrl を渡し、表示用 URL の整形は LinkPreview �
415 部の formatLinkUrl に一任する
416 // (二重呼び出しを避け、プレゼンテーション層に責務を統一する)
417 <LinkPreview
418 linkUrl={linkUrl}
419 linkTitle={linkTitle}
420 icon={icon}
421 linkTarget={linkTarget}
422 onRemove={handleRemove}
423 onCopy={handleCopy}
424 />
425 )}
426 <form
427 className="vk-block-editor-link-toolbar-form"
428 onSubmit={(e) => {
429 e.preventDefault();
430 handleSubmit();
431 onClose();
432 }}
433 >
434 {(!showLinkDestinationChoice || !isDynamicMode) && (
435 <div className="vk-block-editor-link-toolbar-section vk-block-editor-link-toolbar-section-url">
436 <div className="vk-block-editor-url-input-wrapper">
437 <URLInput
438 __nextHasNoMarginBottom
439 value={linkUrl}
440 onChange={(value) =>
441 setLinkUrl(value)
442 }
443 />
444 <Button
445 icon={keyboardReturn}
446 label={__('Submit', 'vk-blocks')}
447 type="submit"
448 disabled={isSubmitDisabled}
449 />
450 </div>
451 </div>
452 )}
453 <div className="vk-block-editor-link-toolbar-section vk-block-editor-link-toolbar-section-options">
454 <CheckboxControl
455 label={__('Open link new tab', 'vk-blocks')}
456 checked={linkTarget === '_blank'}
457 onChange={(checked) =>
458 setLinkTarget(checked ? '_blank' : '')
459 }
460 />
461 {relAttribute !== undefined &&
462 typeof setRelAttribute === 'function' && (
463 <>
464 <CheckboxControl
465 label={__(
466 'Add noreferrer',
467 'vk-blocks'
468 )}
469 checked={
470 relAttribute.includes(
471 'noreferrer'
472 ) || false
473 }
474 onChange={(checked) =>
475 handleRelChange(
476 'noreferrer',
477 checked
478 )
479 }
480 />
481 <CheckboxControl
482 label={__(
483 'Add nofollow',
484 'vk-blocks'
485 )}
486 checked={
487 relAttribute.includes(
488 'nofollow'
489 ) || false
490 }
491 onChange={(checked) =>
492 handleRelChange(
493 'nofollow',
494 checked
495 )
496 }
497 />
498 </>
499 )}
500 </div>
501 {!isDynamicMode &&
502 linkDescription !== undefined &&
503 typeof setLinkDescription === 'function' && (
504 <div className="vk-block-editor-link-toolbar-section vk-block-editor-link-toolbar-section-description">
505 <TextControl
506 label={__(
507 'Accessibility link description',
508 'vk-blocks'
509 )}
510 value={linkDescription}
511 onChange={(value) =>
512 setLinkDescription(value)
513 }
514 />
515 </div>
516 )}
517 </form>
518 </div>
519 )}
520 />
521 {isSnackbarVisible && (
522 <div
523 aria-live="polite"
524 style={{
525 position: 'fixed',
526 bottom: '-3.5rem',
527 right: '0',
528 zIndex: 9999,
529 background: '#000',
530 color: '#fff',
531 padding: '10px',
532 borderRadius: '4px',
533 fontSize: '12px',
534 lineHeight: '1.2',
535 }}
536 >
537 {__('Link copied to clipboard.', 'vk-blocks')}
538 </div>
539 )}
540 <div
541 aria-live="polite"
542 style={{
543 position: 'absolute',
544 width: '1px',
545 height: '1px',
546 margin: '-1px',
547 padding: '0',
548 overflow: 'hidden',
549 clip: 'rect(0,0,0,0)',
550 border: '0',
551 }}
552 >
553 {ariaMessage}
554 </div>
555 </>
556 );
557 };
558
559 export default LinkToolbar;
560