diff --git a/lib/bds/desktop/shell_live/post_editor.ex b/lib/bds/desktop/shell_live/post_editor.ex index 531234a..a3c31ae 100644 --- a/lib/bds/desktop/shell_live/post_editor.ex +++ b/lib/bds/desktop/shell_live/post_editor.ex @@ -303,7 +303,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do draft = current_draft(socket.assigns, post, metadata, active_language) normalized = normalize_list_entry(value) - if normalized in [nil, ""] do + if normalized == "" do socket else ensure_list_value(post.project_id, kind, normalized) @@ -414,8 +414,8 @@ defmodule BDS.Desktop.ShellLive.PostEditor do def build(_assigns), do: nil - def normalize_mode(mode) when mode in [:visual, :markdown, :preview], do: mode - def normalize_mode("visual"), do: :visual + def normalize_mode(mode) when mode in [:markdown, :preview], do: mode + def normalize_mode("visual"), do: :markdown def normalize_mode("preview"), do: :preview def normalize_mode(_mode), do: :markdown @@ -507,7 +507,6 @@ defmodule BDS.Desktop.ShellLive.PostEditor do def post_editor_save_state_label(:discarded), do: translated("Reverted") def post_editor_save_state_label(_state), do: translated("Idle") - def post_editor_mode_label(:visual), do: translated("Visual") def post_editor_mode_label(:markdown), do: translated("Markdown") def post_editor_mode_label(:preview), do: translated("Preview") @@ -687,7 +686,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do %{ "title" => post.title || "", "excerpt" => post.excerpt || "", - "content" => post.content || "", + "content" => Posts.editor_body(post), "tags" => Enum.join(post.tags || [], ", "), "categories" => Enum.join(post.categories || [], ", "), "author" => post.author || metadata.default_author || "", @@ -699,7 +698,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do %{ "title" => translation && translation.title || "", "excerpt" => translation && translation.excerpt || "", - "content" => translation && translation.content || "", + "content" => if(translation, do: Posts.editor_body(translation), else: ""), "tags" => Enum.join(post.tags || [], ", "), "categories" => Enum.join(post.categories || [], ", "), "author" => post.author || metadata.default_author || "", diff --git a/lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex b/lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex index 80fb259..898e115 100644 --- a/lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex +++ b/lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex @@ -327,7 +327,7 @@
- <%= for mode <- [:visual, :markdown, :preview] do %> + <%= for mode <- [:markdown, :preview] do %>
<% else %> - +
+ + +
<% end %>
diff --git a/lib/bds/posts.ex b/lib/bds/posts.ex index 64f8b34..a978090 100644 --- a/lib/bds/posts.ex +++ b/lib/bds/posts.ex @@ -215,6 +215,30 @@ defmodule BDS.Posts do end end + def editor_body(%Post{content: content}) when is_binary(content), do: content + + def editor_body(%Post{project_id: project_id, file_path: file_path}) + when is_binary(file_path) and file_path != "" do + project_id + |> Projects.get_project!() + |> Projects.project_data_dir() + |> Path.join(file_path) + |> read_markdown_body() + end + + def editor_body(%Translation{content: content}) when is_binary(content), do: content + + def editor_body(%Translation{project_id: project_id, file_path: file_path}) + when is_binary(file_path) and file_path != "" do + project_id + |> Projects.get_project!() + |> Projects.project_data_dir() + |> Path.join(file_path) + |> read_markdown_body() + end + + def editor_body(_record), do: "" + def delete_post(post_id) do case Repo.get(Post, post_id) do nil -> @@ -664,8 +688,10 @@ defmodule BDS.Posts do defp published_post_body(%Post{content: content}, _full_path) when is_binary(content), do: content - defp published_post_body(_post, full_path) do - case File.read(full_path) do + defp published_post_body(_post, full_path), do: read_markdown_body(full_path) + + defp read_markdown_body(path) do + case File.read(path) do {:ok, contents} -> case String.split(contents, "\n---\n", parts: 2) do [_frontmatter, body] -> String.trim_trailing(body, "\n") diff --git a/priv/ui/app.css b/priv/ui/app.css index 81a42dc..a00df03 100644 --- a/priv/ui/app.css +++ b/priv/ui/app.css @@ -1606,12 +1606,100 @@ button svg * { background: #ffffff; } -.post-editor .post-editor-content { +.post-editor .post-editor-markdown-surface { + position: relative; flex: 1; min-height: 380px; - resize: none; + border: 1px solid var(--vscode-input-border, var(--vscode-panel-border)); + border-radius: 4px; + background: var(--vscode-input-background); + overflow: hidden; +} + +.post-editor .post-editor-markdown-highlight, +.post-editor .post-editor-content { font-family: var(--vscode-editor-font-family, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: var(--vscode-editor-font-size, 13px); + line-height: 1.6; + tab-size: 2; +} + +.post-editor .post-editor-markdown-highlight { + display: none; + position: absolute; + inset: 0; + margin: 0; + padding: 10px 12px; + overflow: hidden; + pointer-events: none; + white-space: pre-wrap; + word-break: break-word; + color: var(--vscode-editor-foreground); +} + +.post-editor .post-editor-markdown-surface.is-enhanced .post-editor-markdown-highlight { + display: block; +} + +.post-editor .post-editor-content { + position: relative; + z-index: 1; + flex: 1; + min-height: 380px; + margin: 0; + padding: 10px 12px; + border: none; + border-radius: 0; + background: transparent; + resize: none; +} + +.post-editor .post-editor-markdown-surface.is-enhanced .post-editor-content { + color: transparent; + caret-color: var(--vscode-editor-foreground); + -webkit-text-fill-color: transparent; +} + +.post-editor .post-editor-markdown-surface.is-enhanced .post-editor-content::selection { + background: rgba(97, 175, 239, 0.35); +} + +.post-editor .md-heading-marker, +.post-editor .md-list-marker, +.post-editor .md-quote-marker, +.post-editor .md-fence, +.post-editor .token-keyword { + color: #c678dd; +} + +.post-editor .md-heading-text, +.post-editor .token-module { + color: #61afef; +} + +.post-editor .md-inline-code, +.post-editor .token-string { + color: #98c379; +} + +.post-editor .md-link, +.post-editor .token-atom { + color: #56b6c2; +} + +.post-editor .md-emphasis, +.post-editor .md-strong, +.post-editor .token-number { + color: #e5c07b; +} + +.post-editor .md-quote-text, +.post-editor .token-comment { + color: var(--vscode-descriptionForeground); +} + +.post-editor .md-code-line { + color: var(--vscode-editor-foreground); } .post-editor .editor-footer { diff --git a/priv/ui/live.js b/priv/ui/live.js index 7dcfd17..2d0f2bc 100644 --- a/priv/ui/live.js +++ b/priv/ui/live.js @@ -155,6 +155,92 @@ document.addEventListener("DOMContentLoaded", () => { }; }; + const escapeHtml = (value) => + String(value || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + + const stashTokens = (source, pattern, className, tokens) => + source.replace(pattern, (match) => { + const marker = `@@token_${tokens.length}@@`; + tokens.push(`${match}`); + return marker; + }); + + const restoreTokens = (source, tokens) => + tokens.reduce((html, token, index) => html.replaceAll(`@@token_${index}@@`, token), source); + + const highlightCodeLine = (line, language) => { + const tokens = []; + let html = escapeHtml(line); + + html = stashTokens(html, /#.*/g, "token-comment", tokens); + html = stashTokens(html, /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, "token-string", tokens); + html = html.replace(/\b\d+(?:\.\d+)?\b/g, '$&'); + + if (language === "elixir") { + html = html.replace(/:\w+[!?]?/g, '$&'); + html = html.replace( + /\b(?:defp?|do|end|fn|case|cond|if|else|with|when|receive|after|rescue|catch|try|use|alias|import|require|quote|unquote|for|in)\b/g, + '$&' + ); + html = html.replace(/\b[A-Z][A-Za-z0-9_.]*\b/g, '$&'); + } + + return `${restoreTokens(html, tokens)}`; + }; + + const highlightMarkdownLine = (line) => { + let html = escapeHtml(line); + + if (/^\s{0,3}#{1,6}\s/.test(line)) { + html = html.replace(/^((?:\s{0,3}#{1,6}))(\s+)(.*)$/, '$1$2$3'); + } else if (/^\s*>\s?/.test(html)) { + html = html.replace(/^(\s*>)(\s?)(.*)$/, '$1$2$3'); + } else if (/^\s*(?:[-*+]|\d+\.)\s+/.test(line)) { + html = html.replace(/^(\s*(?:[-*+]|\d+\.))(\s+)(.*)$/, '$1$2$3'); + } + + html = html.replace(/(`[^`]+`)/g, '$1'); + html = html.replace(/(\[[^\]]+\]\([^\)]+\))/g, '$1'); + html = html.replace(/(\*\*[^*]+\*\*)/g, '$1'); + html = html.replace(/(^|[^*])(\*[^*\n]+\*)/g, '$1$2'); + + return html; + }; + + const highlightMarkdownSource = (source) => { + const lines = String(source || "").split("\n"); + let inFence = false; + let fenceLanguage = ""; + + return lines + .map((line) => { + const trimmed = line.trimStart(); + + if (trimmed.startsWith("```")) { + const nextLanguage = trimmed.slice(3).trim().toLowerCase(); + const highlightedFence = `${escapeHtml(line)}`; + + if (!inFence) { + inFence = true; + fenceLanguage = nextLanguage; + } else { + inFence = false; + fenceLanguage = ""; + } + + return highlightedFence; + } + + return inFence ? highlightCodeLine(line, fenceLanguage) : highlightMarkdownLine(line); + }) + .join("\n"); + }; + const Hooks = { AppShell: { mounted() { @@ -375,30 +461,68 @@ document.addEventListener("DOMContentLoaded", () => { PostEditorContent: { mounted() { - this.handleInsert = ({ id, content }) => { - if (!content || String(id) !== String(this.el.dataset.postEditorId)) { + this.textarea = this.el.querySelector("textarea"); + this.highlight = this.el.querySelector(".post-editor-markdown-highlight"); + + this.renderHighlight = () => { + if (!this.textarea || !this.highlight) { return; } - const start = this.el.selectionStart ?? this.el.value.length; - const end = this.el.selectionEnd ?? start; - const before = this.el.value.slice(0, start); - const after = this.el.value.slice(end); + this.highlight.innerHTML = `${highlightMarkdownSource(this.textarea.value)}\n`; + this.highlight.scrollTop = this.textarea.scrollTop; + this.highlight.scrollLeft = this.textarea.scrollLeft; + }; + + this.handleInput = () => this.renderHighlight(); + this.handleScroll = () => { + if (!this.textarea || !this.highlight) { + return; + } + + this.highlight.scrollTop = this.textarea.scrollTop; + this.highlight.scrollLeft = this.textarea.scrollLeft; + }; + + this.handleInsert = ({ id, content }) => { + if (!this.textarea || !content || String(id) !== String(this.el.dataset.postEditorId)) { + return; + } + + const start = this.textarea.selectionStart ?? this.textarea.value.length; + const end = this.textarea.selectionEnd ?? start; + const before = this.textarea.value.slice(0, start); + const after = this.textarea.value.slice(end); const separator = before !== "" && !before.endsWith("\n") ? "\n" : ""; const suffix = after !== "" && !content.endsWith("\n") ? "\n" : ""; const inserted = `${separator}${content}${suffix}`; const nextValue = `${before}${inserted}${after}`; - this.el.focus(); - this.el.value = nextValue; + this.textarea.focus(); + this.textarea.value = nextValue; const caret = before.length + inserted.length; - this.el.setSelectionRange(caret, caret); - this.el.dispatchEvent(new Event("input", { bubbles: true })); - this.el.dispatchEvent(new Event("change", { bubbles: true })); + this.textarea.setSelectionRange(caret, caret); + this.textarea.dispatchEvent(new Event("input", { bubbles: true })); + this.textarea.dispatchEvent(new Event("change", { bubbles: true })); }; + this.el.classList.add("is-enhanced"); + this.textarea?.addEventListener("input", this.handleInput); + this.textarea?.addEventListener("scroll", this.handleScroll); this.handleEvent("post-editor-insert-content", this.handleInsert); + this.renderHighlight(); + }, + + updated() { + this.textarea = this.el.querySelector("textarea"); + this.highlight = this.el.querySelector(".post-editor-markdown-highlight"); + this.renderHighlight(); + }, + + destroyed() { + this.textarea?.removeEventListener("input", this.handleInput); + this.textarea?.removeEventListener("scroll", this.handleScroll); } } }; diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs index b8a5382..7078182 100644 --- a/test/bds/desktop/shell_live_test.exs +++ b/test/bds/desktop/shell_live_test.exs @@ -699,6 +699,34 @@ defmodule BDS.Desktop.ShellLiveTest do assert discarded_post.title == "Updated Shell Post" end + test "published post editor loads body from file and renders markdown-only editor", %{project: project} do + {:ok, post} = + Posts.create_post(%{ + project_id: project.id, + title: "Published Editor Post", + content: "# Heading\n\n```elixir\nIO.puts(:ok)\n```\n", + excerpt: "Published excerpt" + }) + + assert {:ok, _published} = Posts.publish_post(post.id) + published = Posts.get_post!(post.id) + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + + html = + render_click(view, "pin_sidebar_item", %{ + "route" => "post", + "id" => published.id, + "title" => published.title, + "subtitle" => "published" + }) + + assert html =~ ~s(data-testid="post-editor-content") + assert Regex.match?(~r/name="post_editor\[content\]"[^>]*># Heading\s+```elixir\s+IO\.puts\(:ok\)\s+```/s, html) + assert html =~ "post-editor-markdown-surface" + refute html =~ ~s(phx-value-mode="visual") + end + defp seed_sidebar_posts(project_id) do now = Persistence.now_ms()