diff --git a/CHANGELOG.md b/CHANGELOG.md index 32a203fc..17fa1662 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `GIT_SYNC_*` environment variables, including `GIT_SYNC_ENABLED`, `GIT_SYNC_SERVICE_USER_ID`, and `GIT_SYNC_HTTP_ENABLED` (see `.env.example`). (#119) +- **Editable captions for images.** Images gain an optional caption shown + below them, edited inline from the image bubble menu and stored as a `caption` attribute. Captions round-trip + losslessly through markdown as a `data-caption` attribute on the image, so + they survive export/import unchanged. (#221) + - **Quick-create regular and temporary notes from the Home and Space screens.** The Home screen now shows a second action next to "New note" that creates a *temporary* note (one that auto-moves to Trash after the workspace lifetime), @@ -81,6 +86,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `nosniff` + restrictive CSP + attachment disposition for non-image mimes) and are RAM-only, bound to the instance that created them. Tunable via five `SANDBOX_*` env vars (see `.env.example`). (#243) +- **Inline spoiler mark — hide text behind click-to-reveal blur.** Selected text + can be marked as a spoiler from a new bubble-menu toggle, or typed Discord-style + with the `||text||` input rule; the rendered span blurs until clicked to reveal. + The mark is preserved losslessly through Markdown export/import (as a raw + ``) and on public shares. (#259) ### Changed @@ -138,6 +148,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 "This address is in use. Saving will move it to this page." — and keeps Save enabled, so the existing reassign-confirm flow (`409 ALIAS_REASSIGN_REQUIRED` → "Move custom address?") is discoverable instead of reading as terminal. (#227) +- **A non-empty page can no longer be silently lost to a momentarily-empty live + document.** The server's persistence guard now refuses to overwrite non-empty + persisted content with an empty live Y.Doc — a transient emptiness from a + glitch, a bad merge, or an emptying transclusion no longer wipes the saved + page. A *deliberate* clear still works: a select-all + Delete in the editor + emits a single-use "intentional clear" signal that lets exactly that one empty + write through the guard, so genuinely emptying a page is persisted while + accidental empties are blocked. (#248, #251) ### Security diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index d749b77a..7be60aed 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -286,6 +286,9 @@ "Alt text": "Alt text", "Describe this for accessibility.": "Describe this for accessibility.", "Add a description": "Add a description", + "Caption": "Caption", + "Add a caption": "Add a caption", + "Shown below the image.": "Shown below the image.", "Justify": "Justify", "Merge cells": "Merge cells", "Split cell": "Split cell", @@ -352,6 +355,7 @@ "Underline": "Underline", "Strike": "Strike", "Code": "Code", + "Spoiler": "Spoiler", "Comment": "Comment", "Text": "Text", "Heading 1": "Heading 1", diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index efdf28ce..88629662 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -351,6 +351,7 @@ "Underline": "Подчёркнутый", "Strike": "Перечёркнутый", "Code": "Код", + "Spoiler": "Спойлер", "Comment": "Комментарий", "Text": "Текст", "Heading 1": "Заголовок 1", diff --git a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx index 5c590487..651cb2f6 100644 --- a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx @@ -9,6 +9,7 @@ import { IconStrikethrough, IconUnderline, IconMessage, + IconEyeOff, } from "@tabler/icons-react"; import clsx from "clsx"; import classes from "./bubble-menu.module.css"; @@ -74,6 +75,7 @@ export const EditorBubbleMenu: FC = (props) => { isStrike: ctx.editor.isActive("strike"), isCode: ctx.editor.isActive("code"), isComment: ctx.editor.isActive("comment"), + isSpoiler: ctx.editor.isActive("spoiler"), }; }, }); @@ -109,6 +111,12 @@ export const EditorBubbleMenu: FC = (props) => { command: () => props.editor.chain().focus().toggleCode().run(), icon: IconCode, }, + { + name: "Spoiler", + isActive: () => editorState?.isSpoiler, + command: () => props.editor.chain().focus().toggleSpoiler().run(), + icon: IconEyeOff, + }, ]; const commentItem: BubbleMenuItem = { diff --git a/apps/client/src/features/editor/components/common/use-alt-text-control.tsx b/apps/client/src/features/editor/components/common/use-alt-text-control.tsx index 1a43f9d7..2f1e8eb5 100644 --- a/apps/client/src/features/editor/components/common/use-alt-text-control.tsx +++ b/apps/client/src/features/editor/components/common/use-alt-text-control.tsx @@ -1,16 +1,7 @@ -import React, { useCallback, useEffect, useState } from "react"; import { Editor } from "@tiptap/react"; -import { - ActionIcon, - Button, - Group, - Paper, - Text, - Textarea, - Tooltip, -} from "@mantine/core"; import { IconAlt } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; +import { useImageTextFieldControl } from "@/features/editor/components/common/use-image-text-field-control.tsx"; const ALT_MAX_LENGTH = 300; @@ -27,113 +18,25 @@ type UseAltTextControlArgs = { currentAlt: string; }; +// Thin wrapper over the shared image text-field popover; see +// useImageTextFieldControl. The t("...") literals stay here so they remain +// statically extractable for i18n. export function useAltTextControl({ editor, nodeName, currentAlt, }: UseAltTextControlArgs) { const { t } = useTranslation(); - const [showInput, setShowInput] = useState(false); - const [draft, setDraft] = useState(""); - - const open = useCallback(() => { - setDraft(currentAlt || ""); - setShowInput(true); - }, [currentAlt]); - - useEffect(() => { - const handler = () => { - if (!editor.isActive(nodeName)) { - setShowInput(false); - } - }; - editor.on("selectionUpdate", handler); - return () => { - editor.off("selectionUpdate", handler); - }; - }, [editor, nodeName]); - - const cancel = useCallback(() => { - setShowInput(false); - }, []); - - const save = useCallback(() => { - editor - .chain() - .focus(undefined, { scrollIntoView: false }) - .updateAttributes(nodeName, { alt: sanitizeAlt(draft) || undefined }) - .run(); - setShowInput(false); - }, [editor, nodeName, draft]); - - const onKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - save(); - } else if (e.key === "Escape") { - e.preventDefault(); - cancel(); - } - }, - [save, cancel], - ); - - const button = ( - - - - - - ); - - const panel = showInput ? ( - - - {t("Alt text")} - - - {t("Describe this for accessibility.")} - -