PluginProbe ʕ •ᴥ•ʔ
VK Blocks / 1.123.0
VK Blocks v1.123.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 2 months ago format-link-url.js 1 month ago index.js 1 month ago
index.js
471 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 } = props;
108 const [isOpen, setIsOpen] = useState(false);
109 const [linkTitle, setLinkTitle] = useState('');
110 const [icon, setIcon] = useState(null);
111 const [isSnackbarVisible, setSnackbarVisible] = useState(false);
112 const [isSubmitDisabled, setSubmitDisabled] = useState(true);
113 const [ariaMessage, setAriaMessage] = useState('');
114
115 useEffect(() => {
116 if (linkUrl) {
117 const formattedUrl = formatLinkUrl(linkUrl);
118 const isExternalLink =
119 !formattedUrl.startsWith(window.location.origin) &&
120 !formattedUrl.startsWith('#'); // 外部リンクかどうか判定
121
122 // 外部リンクの場合はプレビュー(タイトル取得)をスキップする
123 if (!isExternalLink) {
124 const fetchTitle = function (url) {
125 if (url.startsWith('#')) {
126 return Promise.resolve(url); // アンカーリンクの場合はそのまま返す
127 }
128 return fetch(url, { method: 'GET' })
129 .then((response) => response.text())
130 .then((text) => {
131 const titleMatch = text.match(
132 /<title>(.*?)<\/title>/i
133 );
134 return titleMatch ? titleMatch[1] : url;
135 })
136 .catch(() => {
137 return url;
138 });
139 };
140
141 fetchTitle(formattedUrl).then((title) => {
142 setLinkTitle(title);
143 });
144 } else {
145 // 外部リンクの場合はそのままリンクURLをタイトルとして設定する
146 setLinkTitle(formattedUrl);
147 }
148
149 // アイコン設定
150 if (isExternalLink) {
151 setIcon(globe); // 外部リンクの場合は地球アイコン
152 } else if (formattedUrl.startsWith('#')) {
153 setIcon(globe); // アンカーリンクにも地球アイコンを使用
154 } else {
155 try {
156 const domain = new URL(formattedUrl).origin;
157 const faviconUrl = `${domain}/favicon.ico`;
158 setIcon(<img src={faviconUrl} alt="" />);
159 } catch {
160 setIcon(link); // URLが無効な場合はリンクアイコンを使用
161 }
162 }
163 }
164 }, [linkUrl]);
165
166 useEffect(() => {
167 setSubmitDisabled(!linkUrl || linkUrl.trim() === '');
168 }, [linkUrl]);
169
170 const handleToggle = () => {
171 if (!isOpen) {
172 setIsOpen(true);
173 } else if (linkUrl === '') {
174 setIsOpen(false);
175 }
176 };
177
178 const handleRemove = () => {
179 setLinkUrl('');
180 setLinkTarget('');
181 if (typeof setLinkToPost === 'function') {
182 setLinkToPost(false);
183 }
184 setIsOpen(false);
185 };
186
187 // クエリループ�
188 で「投稿へのリンク」か「URLを指定」のどちらか(2択)
189 const showLinkDestinationChoice =
190 isDescendentOfQueryLoop &&
191 linkToPost !== undefined &&
192 typeof setLinkToPost === 'function';
193 const isLinkToPostMode = !!linkToPost;
194
195 // ブロックがクエリループ外に移ったら「投稿へのリンク」を解除する
196 useEffect(() => {
197 if (
198 !showLinkDestinationChoice &&
199 linkToPost &&
200 typeof setLinkToPost === 'function'
201 ) {
202 setLinkToPost(false);
203 }
204 }, [showLinkDestinationChoice, linkToPost, setLinkToPost]);
205
206 // リンクが設定されているか(ツールバーアイコンの反転・is-pressed に使用)
207 const hasLink =
208 !!linkToPost ||
209 !!(linkUrl && typeof linkUrl === 'string' && linkUrl.trim() !== '');
210
211 const handleCopy = function (url) {
212 // `#` や `/` などの素通し条件は formatLinkUrl �
213 で一�
214 �管理する
215 const formattedUrl = formatLinkUrl(url);
216 if (typeof window !== 'undefined' && window.navigator.clipboard) {
217 window.navigator.clipboard
218 .writeText(formattedUrl)
219 .then(() => {
220 setAriaMessage(
221 __('Link copied to clipboard.', 'vk-blocks')
222 );
223 setSnackbarVisible(true);
224 setTimeout(() => setSnackbarVisible(false), 3000);
225 })
226 .catch(() => {
227 // console.error('Failed to copy: ', error);
228 });
229 } else {
230 // Clipboard API がサポートされていない場合のフォールバック
231 const textArea = document.createElement('textarea');
232 textArea.value = formattedUrl;
233 document.body.appendChild(textArea);
234 textArea.focus();
235 textArea.select();
236 document.execCommand('copy');
237 document.body.removeChild(textArea);
238 setAriaMessage(__('Link copied to clipboard.', 'vk-blocks'));
239 setSnackbarVisible(true);
240 setTimeout(() => setSnackbarVisible(false), 3000);
241 }
242 };
243
244 const handleSubmit = () => {
245 if (linkUrl) {
246 setLinkUrl(formatLinkUrl(linkUrl));
247 }
248 };
249
250 const handleRelChange = (type, checked) => {
251 const rel = relAttribute ? relAttribute.split(' ') : [];
252 if (checked) {
253 rel.push(type);
254 } else {
255 const index = rel.indexOf(type);
256 if (index !== -1) {
257 rel.splice(index, 1);
258 }
259 }
260 setRelAttribute(rel.join(' '));
261 };
262
263 return (
264 <>
265 <Dropdown
266 popoverProps={{ placement: 'bottom-start' }}
267 renderToggle={({ isOpen, onToggle }) => {
268 const setLink = () => {
269 if (isOpen) {
270 // 開いている状�
271 �でクリック=「Unlink」→ リンクを解除して閉じる
272 handleRemove();
273 onToggle();
274 } else {
275 handleToggle();
276 onToggle();
277 }
278 };
279 return (
280 <ToolbarButton
281 aria-expanded={isOpen}
282 icon={isOpen ? linkOff : link}
283 isActive={hasLink}
284 label={
285 isOpen
286 ? __('Unlink', 'vk-blocks')
287 : __('Input Link URL', 'vk-blocks')
288 }
289 onClick={setLink}
290 className={`vk-block-editor-link-toolbar-button ${hasLink ? 'is-pressed' : ''}`}
291 />
292 );
293 }}
294 renderContent={({ onClose }) => (
295 <div className="vk-block-editor-link-toolbar-popover block-editor-link-control">
296 {showLinkDestinationChoice && (
297 <div className="vk-block-editor-link-destination-choice">
298 <RadioControl
299 label={__('Link destination', 'vk-blocks')}
300 selected={isLinkToPostMode ? 'post' : 'url'}
301 options={[
302 {
303 label: __(
304 'Link to post',
305 'vk-blocks'
306 ),
307 value: 'post',
308 },
309 {
310 label: __(
311 'Specify URL',
312 'vk-blocks'
313 ),
314 value: 'url',
315 },
316 ]}
317 onChange={(value) =>
318 setLinkToPost(value === 'post')
319 }
320 />
321 </div>
322 )}
323 {!isLinkToPostMode && linkUrl && (
324 // LinkPreview には生の linkUrl を渡し、表示用 URL の整形は LinkPreview �
325 部の formatLinkUrl に一任する
326 // (二重呼び出しを避け、プレゼンテーション層に責務を統一する)
327 <LinkPreview
328 linkUrl={linkUrl}
329 linkTitle={linkTitle}
330 icon={icon}
331 linkTarget={linkTarget}
332 onRemove={handleRemove}
333 onCopy={handleCopy}
334 />
335 )}
336 <form
337 className="vk-block-editor-link-toolbar-form"
338 onSubmit={(e) => {
339 e.preventDefault();
340 handleSubmit();
341 onClose();
342 }}
343 >
344 {(!showLinkDestinationChoice ||
345 !isLinkToPostMode) && (
346 <div className="vk-block-editor-link-toolbar-section vk-block-editor-link-toolbar-section-url">
347 <div className="vk-block-editor-url-input-wrapper">
348 <URLInput
349 __nextHasNoMarginBottom
350 value={linkUrl}
351 onChange={(value) =>
352 setLinkUrl(value)
353 }
354 />
355 <Button
356 icon={keyboardReturn}
357 label={__('Submit', 'vk-blocks')}
358 type="submit"
359 disabled={isSubmitDisabled}
360 />
361 </div>
362 </div>
363 )}
364 <div className="vk-block-editor-link-toolbar-section vk-block-editor-link-toolbar-section-options">
365 <CheckboxControl
366 label={__('Open link new tab', 'vk-blocks')}
367 checked={linkTarget === '_blank'}
368 onChange={(checked) =>
369 setLinkTarget(checked ? '_blank' : '')
370 }
371 />
372 {relAttribute !== undefined &&
373 typeof setRelAttribute === 'function' && (
374 <>
375 <CheckboxControl
376 label={__(
377 'Add noreferrer',
378 'vk-blocks'
379 )}
380 checked={
381 relAttribute.includes(
382 'noreferrer'
383 ) || false
384 }
385 onChange={(checked) =>
386 handleRelChange(
387 'noreferrer',
388 checked
389 )
390 }
391 />
392 <CheckboxControl
393 label={__(
394 'Add nofollow',
395 'vk-blocks'
396 )}
397 checked={
398 relAttribute.includes(
399 'nofollow'
400 ) || false
401 }
402 onChange={(checked) =>
403 handleRelChange(
404 'nofollow',
405 checked
406 )
407 }
408 />
409 </>
410 )}
411 </div>
412 {!isLinkToPostMode &&
413 linkDescription !== undefined &&
414 typeof setLinkDescription === 'function' && (
415 <div className="vk-block-editor-link-toolbar-section vk-block-editor-link-toolbar-section-description">
416 <TextControl
417 label={__(
418 'Accessibility link description',
419 'vk-blocks'
420 )}
421 value={linkDescription}
422 onChange={(value) =>
423 setLinkDescription(value)
424 }
425 />
426 </div>
427 )}
428 </form>
429 </div>
430 )}
431 />
432 {isSnackbarVisible && (
433 <div
434 aria-live="polite"
435 style={{
436 position: 'fixed',
437 bottom: '-3.5rem',
438 right: '0',
439 zIndex: 9999,
440 background: '#000',
441 color: '#fff',
442 padding: '10px',
443 borderRadius: '4px',
444 fontSize: '12px',
445 lineHeight: '1.2',
446 }}
447 >
448 {__('Link copied to clipboard.', 'vk-blocks')}
449 </div>
450 )}
451 <div
452 aria-live="polite"
453 style={{
454 position: 'absolute',
455 width: '1px',
456 height: '1px',
457 margin: '-1px',
458 padding: '0',
459 overflow: 'hidden',
460 clip: 'rect(0,0,0,0)',
461 border: '0',
462 }}
463 >
464 {ariaMessage}
465 </div>
466 </>
467 );
468 };
469
470 export default LinkToolbar;
471