PluginProbe
Code Snippets / 4.0.0-beta.2
Code Snippets v4.0.0-beta.2
4.0.0-beta.2 3.10.2 3.10.1 3.10.0 3.10.0-beta.2 3.10.0-beta.1 4.0.0-beta.1 3.9.6 trunk 2.10.0 2.10.1 2.12.0 2.12.1 2.13.0 2.13.1 2.13.2 2.13.3 2.14.0 2.14.1 2.14.2 2.14.3 2.14.4 2.14.5 2.14.6 3.0.0 All 65 releases
code-snippets / js / components / common / snippets / SnippetPreviewModal.tsx

SnippetPreviewModal.tsx in Code Snippets 4.0.0-beta.2, at js/components/common/snippets/SnippetPreviewModal.tsx

310 lines 8.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { Modal } from '@wordpress/components'
2 import { __ } from '@wordpress/i18n'
3 import React, { useEffect, useRef, useState } from 'react'
4 import { useSnippetsAPI } from '../../../hooks/useSnippetsAPI'
5 import { useSnippetsList } from '../../../hooks/useSnippetsList'
6 import { handleUnknownError } from '../../../utils/errors'
7 import { downloadSnippetExportFile } from '../../../utils/files'
8 import { canModifySnippet, cloneSnippetObject, getSnippetDisplayName, getSnippetEditUrl, getSnippetType } from '../../../utils/snippets/snippets'
9 import { Badge } from '../Badge'
10 import { Button } from '../Button'
11 import { CloudSnippetDownloadButton } from '../cloud/CloudSnippetDownloadButton'
12 import { ConfirmDeleteDialog, useDeleteSnippet } from './ConfirmDeleteDialog'
13 import type { CloudSnippetSchema } from '../../../types/schema/CloudSnippetSchema'
14 import type { EditorConfiguration, EditorFromTextArea } from 'codemirror'
15 import type { ReactNode } from 'react'
16 import type { Snippet, SnippetType } from '../../../types/Snippet'
17
18 const EDITOR_MODES: Record<string, string> = {
19 css: 'text/css',
20 js: 'javascript',
21 php: 'text/x-php',
22 html: 'application/x-httpd-php'
23 }
24
25 const getClipboard = (): Clipboard | undefined =>
26 window.isSecureContext ? navigator.clipboard as Clipboard | undefined : undefined
27
28 const getPreviewEditorSettings = (type: string): EditorConfiguration => ({
29 extraKeys: {
30 'Tab': false,
31 'Shift-Tab': false
32 },
33 readOnly: true,
34 lineNumbers: true,
35 theme: window.CODE_SNIPPETS_MANAGE?.editorTheme ?? 'default',
36 mode: EDITOR_MODES[type] ?? EDITOR_MODES.php,
37 screenReaderLabel: __('Snippet code preview', 'code-snippets')
38 })
39
40 /**
41 * Tracks whether a footer action is in flight. The ref mirrors the state so
42 * `beginWorking` can reject re-entry within the same tick, before React
43 * re-renders with the disabled buttons.
44 */
45 export interface PreviewWorkingState {
46 isWorking: boolean
47 beginWorking: () => boolean
48 finishWorking: () => void
49 setIsWorking: (isWorking: boolean) => void
50 }
51
52 const useWorkingState = (): PreviewWorkingState => {
53 const [isWorking, setIsWorking] = useState(false)
54 const isWorkingRef = useRef(false)
55 const updateWorking = (value: boolean) => {
56 isWorkingRef.current = value
57 setIsWorking(value)
58 }
59
60 return {
61 isWorking,
62 beginWorking: () => {
63 if (isWorkingRef.current) {
64 return false
65 }
66
67 updateWorking(true)
68 return true
69 },
70 finishWorking: () => updateWorking(false),
71 setIsWorking: updateWorking
72 }
73 }
74
75 enum CopyStatus { Ready, Copied, Failed}
76
77 const CopyCodeButton: React.FC<{ code: string }> = ({ code }) => {
78 const [copyStatus, setCopyStatus] = useState(CopyStatus.Ready)
79
80 const handleCopy = () => {
81 const clipboard = getClipboard()
82
83 if (!clipboard) {
84 setCopyStatus(CopyStatus.Failed)
85 return
86 }
87
88 void clipboard.writeText(code)
89 .then(() => setCopyStatus(CopyStatus.Copied))
90 .catch(() => setCopyStatus(CopyStatus.Failed))
91 }
92
93 return (
94 <Button secondary onClick={handleCopy}>
95 {(() => {
96 switch (copyStatus) {
97 case CopyStatus.Copied:
98 return __('Copied', 'code-snippets')
99 case CopyStatus.Failed:
100 return __('Copy unavailable', 'code-snippets')
101 case CopyStatus.Ready:
102 return __('Copy code', 'code-snippets')
103 }
104 })()}
105 </Button>
106 )
107 }
108
109 export interface PreviewModalProps {
110 onRequestClose: VoidFunction
111 title: string
112 type: SnippetType
113 code: string
114 children?: ReactNode
115 }
116
117 export const PreviewModal: React.FC<PreviewModalProps> = ({ onRequestClose, title, type, code, children }) => {
118 const textareaRef = useRef<HTMLTextAreaElement>(null)
119
120 useEffect(() => {
121 if (!textareaRef.current || !window.wp.codeEditor) {
122 return undefined
123 }
124
125 const instance = window.wp.codeEditor.initialize(
126 textareaRef.current,
127 { codemirror: getPreviewEditorSettings(type) }
128 )
129
130 // CodeMirror hides the labeled source textarea and creates an unlabelled
131 // internal input. The screenReaderLabel option only exists from CodeMirror
132 // 5.59, while WordPress 5.5 ships 5.29, so label the input directly.
133 instance.codemirror.getInputField().setAttribute('aria-label', __('Snippet code preview', 'code-snippets'))
134
135 return () => {
136 (instance.codemirror as EditorFromTextArea).toTextArea()
137 }
138 }, [type])
139
140 return (
141 <Modal
142 className="code-snippets-preview-modal"
143 onRequestClose={onRequestClose}
144 title={title}
145 headerActions={
146 <div className="code-snippets-preview-modal__badge">
147 <Badge name={type} />
148 </div>
149 }
150 >
151 <div className="code-snippets-preview-modal__editor">
152 <textarea
153 ref={textareaRef}
154 readOnly
155 aria-label={__('Snippet code preview', 'code-snippets')}
156 defaultValue={`${'php' === type ? '<?php\n\n' : ''}${code}`}
157 />
158 </div>
159 {children}
160 </Modal>
161 )
162 }
163
164 export interface SnippetCodePreviewModalProps {
165 snippet: CloudSnippetSchema
166 setIsOpen: (isOpen: boolean) => void
167 onDownloaded?: VoidFunction
168 }
169
170 export const CloudSnippetPreviewModal: React.FC<SnippetCodePreviewModalProps> = ({
171 snippet,
172 setIsOpen,
173 onDownloaded
174 }) => {
175 return (
176 <PreviewModal
177 code={snippet.code}
178 type={getSnippetType(snippet)}
179 title={snippet.name}
180 onRequestClose={() => setIsOpen(false)}
181 >
182 <div className="code-snippets-preview-modal__footer">
183 <div className="code-snippets-preview-modal__buttons">
184 <CloudSnippetDownloadButton snippet={snippet} onDownloaded={onDownloaded} />
185 {getClipboard() && <CopyCodeButton code={snippet.code} />}
186 </div>
187 </div>
188 </PreviewModal>
189 )
190 }
191
192 interface ActionButtonProps {
193 snippet: Snippet
194 isWorking: boolean
195 setIsWorking: (isWorking: boolean) => void
196 }
197
198 interface CloneButtonProps extends ActionButtonProps {
199 setIsOpen: (isOpen: boolean) => void
200 }
201
202 const CloneButton: React.FC<CloneButtonProps> = ({ snippet, isWorking, setIsWorking, setIsOpen }) => {
203 const api = useSnippetsAPI()
204 const { refreshSnippetsList } = useSnippetsList()
205
206 const handleClone = () => {
207 setIsWorking(true)
208
209 api.create(cloneSnippetObject(snippet))
210 .then(refreshSnippetsList)
211 .then(() => setIsOpen(false))
212 .catch(handleUnknownError)
213 .finally(() => setIsWorking(false))
214 }
215
216 return (
217 <Button secondary disabled={isWorking} onClick={handleClone}>
218 {__('Clone', 'code-snippets')}
219 </Button>
220 )
221 }
222
223 const ExportButton: React.FC<ActionButtonProps> = ({ snippet, isWorking, setIsWorking }) => {
224 const api = useSnippetsAPI()
225
226 const handleExport = () => {
227 setIsWorking(true)
228
229 api.export(snippet)
230 .then(response => downloadSnippetExportFile(response, snippet))
231 .catch(handleUnknownError)
232 .finally(() => setIsWorking(false))
233 }
234
235 return (
236 <Button
237 secondary
238 disabled={isWorking}
239 onClick={handleExport}
240 >
241 {__('Export', 'code-snippets')}
242 </Button>
243 )
244 }
245
246 export interface SnippetPreviewModalProps {
247 snippet: Snippet
248 setIsOpen: (open: boolean) => void
249 extraActions?: (working: boolean) => ReactNode
250 }
251
252 export const SnippetPreviewModal: React.FC<SnippetPreviewModalProps> = ({ snippet, setIsOpen, extraActions }) => {
253 const { refreshSnippetsList } = useSnippetsList()
254 const { isWorking, setIsWorking } = useWorkingState()
255
256 const { requestDelete, deleteDialogProps } = useDeleteSnippet({
257 snippet,
258 setIsWorking,
259 onSuccess: () => {
260 setIsOpen(false)
261 return refreshSnippetsList()
262 },
263 onError: handleUnknownError
264 })
265
266 const canModify = canModifySnippet(snippet)
267
268 return (
269 <PreviewModal
270 code={snippet.code}
271 type={getSnippetType(snippet)}
272 title={getSnippetDisplayName(snippet)}
273 onRequestClose={() => setIsOpen(false)}
274 >
275 <div className="code-snippets-preview-modal__footer">
276 <div className="code-snippets-preview-modal__buttons">
277 <a className="button button-primary" href={getSnippetEditUrl(snippet)}>
278 {snippet.locked || !canModify
279 ? __('View', 'code-snippets')
280 : __('Edit', 'code-snippets')}
281 </a>
282
283 {canModify && <CloneButton snippet={snippet} isWorking={isWorking} setIsWorking={setIsWorking} setIsOpen={setIsOpen} />}
284
285 <ExportButton snippet={snippet} isWorking={isWorking} setIsWorking={setIsWorking} />
286 <CopyCodeButton code={snippet.code} />
287
288 {!snippet.locked && canModify && (
289 <Button
290 link
291 className="code-snippets-preview-modal__trash"
292 disabled={isWorking}
293 onClick={() => void requestDelete()}
294 >
295 {__('Trash', 'code-snippets')}
296 </Button>)}
297 </div>
298
299 <div className="code-snippets-preview-modal__priority">
300 <span>{__('Priority', 'code-snippets')}</span>
301 <span className="code-snippets-preview-modal__priority-value">{snippet.priority}</span>
302 </div>
303
304 {extraActions?.(isWorking)}
305 <ConfirmDeleteDialog {...deleteDialogProps} />
306 </div>
307 </PreviewModal>
308 )
309 }
310