diff --git a/config/hyde.php b/config/hyde.php index 0f83b8031a4..b05f8683950 100644 --- a/config/hyde.php +++ b/config/hyde.php @@ -418,6 +418,9 @@ // Should preview pages be saved to the output directory? 'save_preview' => true, + // Should the live edit feature be enabled? + 'live_edit' => env('SERVER_LIVE_EDIT', true), + // Configure the realtime compiler dashboard 'dashboard' => [ // Should the realtime compiler dashboard be enabled? diff --git a/packages/framework/config/hyde.php b/packages/framework/config/hyde.php index 0f83b8031a4..b05f8683950 100644 --- a/packages/framework/config/hyde.php +++ b/packages/framework/config/hyde.php @@ -418,6 +418,9 @@ // Should preview pages be saved to the output directory? 'save_preview' => true, + // Should the live edit feature be enabled? + 'live_edit' => env('SERVER_LIVE_EDIT', true), + // Configure the realtime compiler dashboard 'dashboard' => [ // Should the realtime compiler dashboard be enabled? diff --git a/packages/realtime-compiler/resources/live-edit.blade.php b/packages/realtime-compiler/resources/live-edit.blade.php new file mode 100644 index 00000000000..f75810cc616 --- /dev/null +++ b/packages/realtime-compiler/resources/live-edit.blade.php @@ -0,0 +1,31 @@ +
+ + @php + /** @var \Hyde\Pages\Concerns\BaseMarkdownPage $page */ + $markdown = $page->markdown()->body(); + @endphp + + + + +
diff --git a/packages/realtime-compiler/resources/live-edit.css b/packages/realtime-compiler/resources/live-edit.css new file mode 100644 index 00000000000..400c8de0ef5 --- /dev/null +++ b/packages/realtime-compiler/resources/live-edit.css @@ -0,0 +1,63 @@ +#live-editor { + width: 100%; + height: 100%; + min-height: 300px; + border: none; + outline: none; + font-family: 'Source Code Pro', monospace; + padding: 1rem; + white-space: pre-line; +} + +#live-editor:focus { + outline: 2px solid transparent; + outline-offset: 2px; + box-shadow: 0 0 0 0 rgba(255, 255, 255, 0), 0 0 0 calc(1px + 0px) rgba(37, 99, 235, 1), 0 0 #0000; + border-color: #2563eb; +} + +#live-edit-container header { + width: 100%; + max-width: 100%; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; +} + +#live-edit-container menu button { + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; + font-weight: 500; + font-size: 0.75rem; + line-height: 1.25rem; + text-transform: uppercase; + letter-spacing: 0.05em; + cursor: pointer; + transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out, color 0.2s ease-in-out; +} + +#liveEditCancel { + background-color: #e5e7eb; + border-color: #e5e7eb; + color: #1f2937; + margin-right: 0.35rem; +} + +#liveEditCancel:hover { + background-color: #d1d5db; + border-color: #d1d5db; + color: #1f2937; +} + +#liveEditSubmit { + background-color: #2563eb; + border-color: #2563eb; + color: #fff; +} + +#liveEditSubmit:hover { + background-color: #1d4ed8; + border-color: #1d4ed8; + color: #fff; +} diff --git a/packages/realtime-compiler/resources/live-edit.js b/packages/realtime-compiler/resources/live-edit.js new file mode 100644 index 00000000000..d58a91a10fe --- /dev/null +++ b/packages/realtime-compiler/resources/live-edit.js @@ -0,0 +1,105 @@ +function initLiveEdit() { + function getArticle() { + let article = document.querySelector('#content > article'); + + if (article === null) { + // If no article element is found the user may have a custom template, so we cannot know which element to edit. + throw new Error('No article element found, cannot live edit. If you are using a custom template, please make sure to include an article element in the #content container.'); + } + + return article; + } + + function getLiveEditor() { + return document.querySelector('#live-edit-container'); + } + + function showEditor() { + article.style.display = 'none'; + getLiveEditor().style.display = ''; + focusOnTextarea(); + } + + function hideEditor() { + article.style.display = ''; + getLiveEditor().style.display = 'none'; + } + + function focusOnTextarea() { + const textarea = getLiveEditor().querySelector('textarea'); + + textarea.selectionStart = textarea.value.length; + textarea.focus(); + } + + function switchToEditor() { + + function hasEditorBeenSetUp() { + return getLiveEditor() !== null; + } + + function setupEditor() { + const template = document.getElementById('live-edit-template'); + const article = getArticle(); + let editor = document.importNode(template.content, true); + article.parentNode.insertBefore(editor, article.nextSibling); + editor = getLiveEditor(); + + // Apply CSS classes from article to editor to match layout + editor.classList.add(...article.classList); + + showEditor(); + + document.getElementById('liveEditCancel').addEventListener('click', hideEditor); + } + + if (hasEditorBeenSetUp()) { + showEditor(); + } else { + setupEditor(); + } + } + + function handleShortcut(event) { + let isEditorHidden = getLiveEditor() === null || getLiveEditor().style.display === 'none'; + let isEditorVisible = getLiveEditor() !== null && getLiveEditor().style.display !== 'none'; + + if (event.ctrlKey && event.key === 'e') { + event.preventDefault(); + + if (isEditorHidden) { + switchToEditor(); + } else { + hideEditor(); + } + } + + if (event.ctrlKey && event.key === 's') { + if (isEditorVisible) { + event.preventDefault(); + + document.getElementById('liveEditSubmit').click(); + } + } + + if (event.key === 'Escape') { + if (isEditorVisible) { + event.preventDefault(); + + hideEditor(); + } + } + } + + function shortcutsEnabled() { + return localStorage.getItem('hydephp.live-edit.shortcuts') !== 'false'; + } + + const article = getArticle(); + + article.addEventListener('dblclick', switchToEditor); + + if (shortcutsEnabled()) { + document.addEventListener('keydown', handleShortcut); + } +} diff --git a/packages/realtime-compiler/src/Http/LiveEditController.php b/packages/realtime-compiler/src/Http/LiveEditController.php new file mode 100644 index 00000000000..a4de9543f51 --- /dev/null +++ b/packages/realtime-compiler/src/Http/LiveEditController.php @@ -0,0 +1,75 @@ +authorizePostRequest(); + + return $this->handleRequest(); + } + + protected function handleRequest(): HtmlResponse + { + $pagePath = $this->request->data['page'] ?? $this->abort(400, 'Must provide page path'); + $content = $this->request->data['markdown'] ?? $this->abort(400, 'Must provide content'); + + $page = Hyde::pages()->getPage($pagePath); + + if (! $page instanceof BaseMarkdownPage) { + $this->abort(400, 'Page is not a markdown page'); + } + + $page->markdown = new Markdown($content); + $page->save(); + + $this->writeToConsole("Updated file '$pagePath'", 'hyde@live-edit'); + + return $this->redirectToPage($page->getRoute()); + } + + public static function enabled(): bool + { + return config('hyde.server.live_edit', true); + } + + public static function injectLiveEditScript(string $html): string + { + session_start(); + + return str_replace('', sprintf('%s', Blade::render(file_get_contents(__DIR__.'/../../resources/live-edit.blade.php'), [ + 'styles' => file_get_contents(__DIR__.'/../../resources/live-edit.css'), + 'scripts' => file_get_contents(__DIR__.'/../../resources/live-edit.js'), + 'csrfToken' => self::generateCSRFToken(), + ])), $html); + } + + protected function redirectToPage(Route $route): HtmlResponse + { + $redirectPage = new Redirect($this->request->path, "../$route"); + Hyde::shareViewData($redirectPage); + + return (new HtmlResponse(303, 'See Other', [ + 'body' => $redirectPage->compile(), + ]))->withHeaders([ + 'Location' => $route, + ]); + } +} diff --git a/packages/realtime-compiler/src/Routing/PageRouter.php b/packages/realtime-compiler/src/Routing/PageRouter.php index 9981e9a6651..1e41b531bd4 100644 --- a/packages/realtime-compiler/src/Routing/PageRouter.php +++ b/packages/realtime-compiler/src/Routing/PageRouter.php @@ -5,7 +5,9 @@ use Desilva\Microserve\Request; use Desilva\Microserve\Response; use Hyde\Foundation\Facades\Routes; +use Hyde\Pages\Concerns\BaseMarkdownPage; use Hyde\Framework\Actions\StaticPageBuilder; +use Hyde\RealtimeCompiler\Http\LiveEditController; use Hyde\Framework\Features\Documentation\DocumentationSearchPage; use Hyde\Pages\Concerns\HydePage; use Hyde\RealtimeCompiler\Concerns\InteractsWithLaravel; @@ -36,6 +38,10 @@ protected function handlePageRequest(): Response return (new DashboardController($this->request))->handle(); } + if ($this->request->path === '/_hyde/live-edit' && LiveEditController::enabled()) { + return (new LiveEditController($this->request))->handle(); + } + return new HtmlResponse(200, 'OK', [ 'body' => $this->getHtml($this->getPageFromRoute()), ]); @@ -70,6 +76,10 @@ protected function getHtml(HydePage $page): string $contents = $page->compile(); } + if ($page instanceof BaseMarkdownPage && LiveEditController::enabled()) { + $contents = LiveEditController::injectLiveEditScript($contents); + } + return $contents; } diff --git a/packages/realtime-compiler/tests/RealtimeCompilerTest.php b/packages/realtime-compiler/tests/RealtimeCompilerTest.php index 981f4b53df5..9f48e9467a9 100644 --- a/packages/realtime-compiler/tests/RealtimeCompilerTest.php +++ b/packages/realtime-compiler/tests/RealtimeCompilerTest.php @@ -17,6 +17,10 @@ ob_start(); +beforeEach(function () { + putenv('SERVER_LIVE_EDIT=false'); +}); + test('handle routes index page', function () { putenv('SERVER_DASHBOARD=false'); mockRoute('');