feat(react/settings): port code block settings

This commit is contained in:
Elian Doran 2025-08-18 11:21:09 +03:00
parent 3ba0bcea4e
commit 234d3997b1
No known key found for this signature in database
5 changed files with 146 additions and 169 deletions

View File

@ -14,10 +14,12 @@ export default function FormGroup({ label, title, className, children, descripti
return (
<div className={`form-group ${className} ${disabled ? "disabled" : ""}`} title={title}
style={{ "margin-bottom": "15px" }}>
<label style={{ width: "100%" }} ref={labelRef}>
{ label
? <label style={{ width: "100%" }} ref={labelRef}>
{label && <div style={{ "margin-bottom": "10px" }}>{label}</div> }
{children}
</label>
: children}
{description && <small className="form-text">{description}</small>}
</div>

View File

@ -2,7 +2,7 @@ import type { ComponentChildren } from "preact";
type OnChangeListener = (newValue: string) => void;
interface FormSelectGroup<T> {
export interface FormSelectGroup<T> {
title: string;
items: T[];
}

View File

@ -24,7 +24,7 @@ function getProps({ className, html, style }: RawHtmlProps) {
}
}
function getHtml(html: string | HTMLElement | JQuery<HTMLElement>) {
export function getHtml(html: string | HTMLElement | JQuery<HTMLElement>) {
if (typeof html === "object" && "length" in html) {
html = html[0];
}

View File

@ -1,10 +1,20 @@
import { useEffect } from "preact/hooks";
import { useContext, useEffect, useMemo, useState } from "preact/hooks";
import { t } from "../../../services/i18n";
import FormCheckbox from "../../react/FormCheckbox";
import FormRadioGroup from "../../react/FormRadioGroup";
import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
import OptionsSection from "./components/OptionsSection";
import { toggleBodyClass } from "../../../services/utils";
import FormGroup from "../../react/FormGroup";
import Column from "../../react/Column";
import { FormSelectGroup, FormSelectWithGroups } from "../../react/FormSelect";
import { Themes, type Theme } from "@triliumnext/highlightjs";
import { ensureMimeTypesForHighlighting, loadHighlightingTheme } from "../../../services/syntax_highlight";
import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons";
import { ComponentChildren } from "preact";
import RawHtml, { getHtml } from "../../react/RawHtml";
import { ParentComponent } from "../../react/ReactBasicWidget";
import { CSSProperties } from "preact/compat";
export default function TextNoteSettings() {
return (
@ -12,6 +22,7 @@ export default function TextNoteSettings() {
<FormattingToolbar />
<EditorFeatures />
<HeadingStyle />
<CodeBlockStyle />
</>
)
}
@ -91,3 +102,132 @@ function HeadingStyle() {
</OptionsSection>
);
}
function CodeBlockStyle() {
const themes = useMemo(() => groupThemesByLightOrDark(), []);
const [ codeBlockTheme, setCodeBlockTheme ] = useTriliumOption("codeBlockTheme");
const [ codeBlockWordWrap, setCodeBlockWordWrap ] = useTriliumOptionBool("codeBlockWordWrap");
return (
<OptionsSection title={t("highlighting.title")}>
<FormGroup className="row">
<Column md={6}>
<label>{t("highlighting.color-scheme")}</label>
<FormSelectWithGroups
values={themes}
keyProperty="val" titleProperty="title"
currentValue={codeBlockTheme} onChange={(newTheme) => {
loadHighlightingTheme(newTheme);
setCodeBlockTheme(newTheme);
}}
/>
</Column>
<Column md={6} className="side-checkbox">
<FormCheckbox
name="word-wrap"
label={t("code_block.word_wrapping")}
currentValue={codeBlockWordWrap} onChange={setCodeBlockWordWrap}
/>
</Column>
</FormGroup>
<CodeBlockPreview theme={codeBlockTheme} wordWrap={codeBlockWordWrap} />
</OptionsSection>
)
}
const SAMPLE_LANGUAGE = normalizeMimeTypeForCKEditor("application/javascript;env=frontend");
const SAMPLE_CODE = `\
const n = 10;
greet(n); // Print "Hello World" for n times
/**
* Displays a "Hello World!" message for a given amount of times, on the standard console. The "Hello World!" text will be displayed once per line.
*
* @param {number} times The number of times to print the \`Hello World!\` message.
*/
function greet(times) {
for (let i = 0; i++; i < times) {
console.log("Hello World!");
}
}
`;
function CodeBlockPreview({ theme, wordWrap }: { theme: string, wordWrap: boolean }) {
const [ code, setCode ] = useState<string>(SAMPLE_CODE);
useEffect(() => {
if (theme !== "none") {
import("@triliumnext/highlightjs").then(async (hljs) => {
await ensureMimeTypesForHighlighting();
const highlightedText = hljs.highlight(SAMPLE_CODE, {
language: SAMPLE_LANGUAGE
});
if (highlightedText) {
setCode(highlightedText.value);
}
});
} else {
setCode(SAMPLE_CODE);
}
}, [theme]);
const codeStyle = useMemo<CSSProperties>(() => {
if (wordWrap) {
return { whiteSpace: "pre-wrap" };
} else {
return { whiteSpace: "pre"};
}
}, [ wordWrap ]);
return (
<div className="note-detail-readonly-text-content ck-content code-sample-wrapper">
<pre className="hljs" style={{ marginBottom: 0 }}>
<code className="code-sample" style={codeStyle} dangerouslySetInnerHTML={getHtml(code)} />
</pre>
</div>
)
}
interface ThemeData {
val: string;
title: string;
}
function groupThemesByLightOrDark() {
const darkThemes: ThemeData[] = [];
const lightThemes: ThemeData[] = [];
for (const [ id, theme ] of Object.entries(Themes)) {
const data: ThemeData = {
val: "default:" + id,
title: theme.name
};
if (theme.name.includes("Dark")) {
darkThemes.push(data);
} else {
lightThemes.push(data);
}
}
const output: FormSelectGroup<ThemeData>[] = [
{
title: "",
items: [{
val: "none",
title: t("code_block.theme_none")
}]
},
{
title: t("code_block.theme_group_light"),
items: lightThemes
},
{
title: t("code_block.theme_group_dark"),
items: darkThemes
}
];
return output;
}

View File

@ -1,165 +0,0 @@
import { normalizeMimeTypeForCKEditor, type OptionMap } from "@triliumnext/commons";
import { t } from "../../../../services/i18n.js";
import server from "../../../../services/server.js";
import OptionsWidget from "../options_widget.js";
import { ensureMimeTypesForHighlighting, loadHighlightingTheme } from "../../../../services/syntax_highlight.js";
import { Themes, type Theme } from "@triliumnext/highlightjs";
const SAMPLE_LANGUAGE = normalizeMimeTypeForCKEditor("application/javascript;env=frontend");
const SAMPLE_CODE = `\
const n = 10;
greet(n); // Print "Hello World" for n times
/**
* Displays a "Hello World!" message for a given amount of times, on the standard console. The "Hello World!" text will be displayed once per line.
*
* @param {number} times The number of times to print the \`Hello World!\` message.
*/
function greet(times) {
for (let i = 0; i++; i < times) {
console.log("Hello World!");
}
}
`;
const TPL = /*html*/`
<div class="options-section">
<h4>${t("highlighting.title")}</h4>
<div class="form-group row">
<div class="col-md-6">
<label for="highlighting-color-scheme-select">${t("highlighting.color-scheme")}</label>
<select id="highlighting-color-scheme-select" class="theme-select form-select"></select>
</div>
<div class="col-md-6 side-checkbox">
<label class="form-check tn-checkbox">
<input type="checkbox" class="word-wrap form-check-input" />
${t("code_block.word_wrapping")}
</label>
</div>
</div>
<div class="note-detail-readonly-text-content ck-content code-sample-wrapper">
<pre class="hljs"><code class="code-sample">${SAMPLE_CODE}</code></pre>
</div>
<style>
.code-sample-wrapper {
margin-top: 1em;
}
.code-sample-wrapper pre {
margin-bottom: 0;
}
</style>
</div>
`;
/**
* Contains appearance settings for code blocks within text notes, such as the theme for the syntax highlighter.
*/
export default class CodeBlockOptions extends OptionsWidget {
private $themeSelect!: JQuery<HTMLElement>;
private $wordWrap!: JQuery<HTMLElement>;
private $sampleEl!: JQuery<HTMLElement>;
doRender() {
this.$widget = $(TPL);
this.$themeSelect = this.$widget.find(".theme-select");
// Populate the list of themes.
const themeGroups = groupThemesByLightOrDark();
for (const [key, themes] of Object.entries(themeGroups)) {
const $group = key ? $("<optgroup>").attr("label", key) : null;
for (const theme of themes) {
const option = $("<option>")
.attr("value", theme.val)
.text(theme.title);
if ($group) {
$group.append(option);
} else {
this.$themeSelect.append(option);
}
}
if ($group) {
this.$themeSelect.append($group);
}
}
this.$themeSelect.on("change", async () => {
const newTheme = String(this.$themeSelect.val());
loadHighlightingTheme(newTheme);
await server.put(`options/codeBlockTheme/${newTheme}`);
});
this.$wordWrap = this.$widget.find("input.word-wrap");
this.$wordWrap.on("change", () => this.updateCheckboxOption("codeBlockWordWrap", this.$wordWrap));
// Set up preview
this.$sampleEl = this.$widget.find(".code-sample");
}
#setupPreview(shouldEnableSyntaxHighlight: boolean) {
const text = SAMPLE_CODE;
if (shouldEnableSyntaxHighlight) {
import("@triliumnext/highlightjs").then(async (hljs) => {
await ensureMimeTypesForHighlighting();
const highlightedText = hljs.highlight(text, {
language: SAMPLE_LANGUAGE
});
if (highlightedText) {
this.$sampleEl.html(highlightedText.value);
}
});
} else {
this.$sampleEl.text(text);
}
}
async optionsLoaded(options: OptionMap) {
this.$themeSelect.val(options.codeBlockTheme);
this.setCheckboxState(this.$wordWrap, options.codeBlockWordWrap);
this.$widget.closest(".note-detail-printable").toggleClass("word-wrap", options.codeBlockWordWrap === "true");
this.#setupPreview(options.codeBlockTheme !== "none");
}
}
interface ThemeData {
val: string;
title: string;
}
function groupThemesByLightOrDark() {
const darkThemes: ThemeData[] = [];
const lightThemes: ThemeData[] = [];
for (const [ id, theme ] of Object.entries(Themes)) {
const data: ThemeData = {
val: "default:" + id,
title: theme.name
};
if (theme.name.includes("Dark")) {
darkThemes.push(data);
} else {
lightThemes.push(data);
}
}
const output: Record<string, ThemeData[]> = {
"": [
{
val: "none",
title: t("code_block.theme_none")
}
]
};
output[t("code_block.theme_group_light")] = lightThemes;
output[t("code_block.theme_group_dark")] = darkThemes;
return output;
}