diff --git a/components/text.js b/components/text.js
index f4e7397b2c..3fe686d287 100644
--- a/components/text.js
+++ b/components/text.js
@@ -21,6 +21,7 @@ import remarkUnicode from '@/lib/remark-unicode'
import Embed from './embed'
import remarkMath from 'remark-math'
import remarkToc from '@/lib/remark-toc'
+import { SNReader } from './editor'
const rehypeSNStyled = () => rehypeSN({
stylers: [{
@@ -52,8 +53,89 @@ export function SearchText ({ text }) {
)
}
+export function useOverflow ({ element, truncated = false }) {
+ // would the text overflow on the current screen size?
+ const [overflowing, setOverflowing] = useState(false)
+ // should we show the full text?
+ const [show, setShow] = useState(false)
+ const showOverflow = useCallback(() => setShow(true), [setShow])
+
+ // clip item and give it a`show full text` button if we are overflowing
+ useEffect(() => {
+ if (!element) return
+
+ const node = 'current' in element ? element.current : element
+ if (!node || !(node instanceof window.Element)) return
+
+ function checkOverflow () {
+ setOverflowing(
+ truncated
+ ? node.scrollHeight > window.innerHeight * 0.5
+ : node.scrollHeight > window.innerHeight * 2
+ )
+ }
+
+ let resizeObserver
+ if ('ResizeObserver' in window) {
+ resizeObserver = new window.ResizeObserver(checkOverflow)
+ resizeObserver.observe(node)
+ }
+
+ window.addEventListener('resize', checkOverflow)
+ checkOverflow()
+ return () => {
+ window.removeEventListener('resize', checkOverflow)
+ resizeObserver?.disconnect()
+ }
+ }, [element, setOverflowing])
+
+ const Overflow = useMemo(() => {
+ if (overflowing && !show) {
+ return (
+
+ )
+ }
+ return null
+ }, [showOverflow, overflowing, show, setShow])
+
+ return { overflowing, show, setShow, Overflow }
+}
+
+// TODO: revisit
+export default function Text ({ topLevel, children, ...props }) {
+ const [element, setElement] = useState(null)
+ const { overflowing, show, Overflow } = useOverflow({ element, truncated: !!children })
+
+ const textClassNames = useMemo(() => {
+ return classNames(
+ 'sn-text',
+ topLevel && 'sn-text--top-level',
+ show ? 'sn-text--uncontained' : overflowing && 'sn-text--contained'
+ )
+ }, [topLevel, show, overflowing])
+
+ return (
+
+ {Overflow}
+
+ )
+}
+
// this is one of the slowest components to render
-export default memo(function Text ({ rel = UNKNOWN_LINK_REL, imgproxyUrls, children, tab, itemId, outlawed, topLevel }) {
+export const LegacyText = memo(function LegacyText ({ rel = UNKNOWN_LINK_REL, imgproxyUrls, children, tab, itemId, outlawed, topLevel }) {
// include remarkToc if topLevel
const remarkPlugins = topLevel ? [...baseRemarkPlugins, remarkToc] : baseRemarkPlugins
diff --git a/fragments/comments.js b/fragments/comments.js
index 72f19a9ce4..135e222192 100644
--- a/fragments/comments.js
+++ b/fragments/comments.js
@@ -20,6 +20,8 @@ export const COMMENT_FIELDS = gql`
createdAt
deletedAt
text
+ lexicalState
+ html
user {
id
name
@@ -73,6 +75,8 @@ export const COMMENT_FIELDS_NO_CHILD_COMMENTS = gql`
createdAt
deletedAt
text
+ lexicalState
+ html
user {
id
name
@@ -119,6 +123,8 @@ export const COMMENTS_ITEM_EXT_FIELDS = gql`
${STREAK_FIELDS}
fragment CommentItemExtFields on Item {
text
+ lexicalState
+ html
root {
id
title
diff --git a/fragments/items.js b/fragments/items.js
index a687ee119a..7eebec64ff 100644
--- a/fragments/items.js
+++ b/fragments/items.js
@@ -94,6 +94,8 @@ export const ITEM_FULL_FIELDS = gql`
fragment ItemFullFields on Item {
...ItemFields
text
+ lexicalState
+ html
root {
id
createdAt
diff --git a/lib/dompurify.js b/lib/dompurify.js
new file mode 100644
index 0000000000..6fa7242c75
--- /dev/null
+++ b/lib/dompurify.js
@@ -0,0 +1,69 @@
+/**
+ * creates a fake DOM using LinkeDOM for server-side rendering
+ * @param {string} html - HTML content to parse
+ * @returns {Object} parsed HTML object with window and document
+ */
+export function createLinkeDOM (html) {
+ const { parseHTML } = require('linkedom')
+ return parseHTML(html || '')
+}
+
+/**
+ * returns DOMPurify instance for either browser or server environment
+ * @param {Object} [domWindow] - optional DOM window object (for server-side)
+ * @returns {Object} DOMPurify instance
+ */
+export function getDOMPurify (domWindow) {
+ const DOMPurify = require('dompurify')
+
+ if (typeof window === 'undefined') {
+ if (domWindow) {
+ return DOMPurify(domWindow)
+ }
+ const { window } = createLinkeDOM()
+ return DOMPurify(window)
+ }
+
+ return DOMPurify
+}
+
+/**
+ * sanitizes HTML using DOMPurify with optional custom DOM window
+ * @param {string} html - HTML content to sanitize
+ * @param {Object} [domWindow] - optional DOM window object
+ * @returns {string} sanitized HTML string
+ */
+export function sanitizeHTML (html, domWindow) {
+ return getDOMPurify(domWindow).sanitize(html)
+}
+
+/**
+ * parses and sanitizes HTML, returning a document object
+ * @param {string} html - HTML content to parse and sanitize
+ * @returns {Document} parsed and sanitized document object
+ */
+export function getParsedHTML (html) {
+ if (typeof window === 'undefined') {
+ const normalizedHTML = !html.toLowerCase().startsWith('')
+ ? `
+
+
+ ${html}
+
+ `
+ : html
+ const parsed = createLinkeDOM(normalizedHTML)
+ const domWindow = parsed.window
+
+ // sanitize
+ const sanitizedHTML = getDOMPurify(domWindow).sanitize(html)
+
+ // update the body with sanitized content
+ parsed.document.body.innerHTML = sanitizedHTML
+ return parsed.document
+ }
+
+ // client-side
+ const sanitizedHTML = sanitizeHTML(html)
+ return new window.DOMParser().parseFromString(sanitizedHTML, 'text/html')
+}
diff --git a/lib/lexical/exts/md-commands.js b/lib/lexical/exts/md-commands.js
new file mode 100644
index 0000000000..158ba47dd1
--- /dev/null
+++ b/lib/lexical/exts/md-commands.js
@@ -0,0 +1,68 @@
+import { createCommand, defineExtension, COMMAND_PRIORITY_HIGH, $getSelection, $isRangeSelection } from 'lexical'
+import { mergeRegister } from '@lexical/utils'
+
+export const MD_INSERT_LINK_COMMAND = createCommand('MD_INSERT_LINK_COMMAND')
+export const MD_INSERT_BOLD_COMMAND = createCommand('MD_INSERT_BOLD_COMMAND')
+export const MD_INSERT_ITALIC_COMMAND = createCommand('MD_INSERT_ITALIC_COMMAND')
+
+function wrapMarkdownSelection (editor, prefix, suffix = prefix, cursorOffset = suffix.length) {
+ editor.update(() => {
+ const selection = $getSelection()
+ if (!$isRangeSelection(selection)) return
+
+ const selectedText = selection.getTextContent()
+
+ // wrap the selected text with the prefix and suffix
+ selection.insertText(`${prefix}${selectedText}${suffix}`)
+
+ // position the cursor using the specified offset
+ const newSelection = $getSelection()
+ if ($isRangeSelection(newSelection)) {
+ const anchor = newSelection.anchor
+ const newOffset = anchor.offset - cursorOffset
+ anchor.set(anchor.key, newOffset, anchor.type)
+ newSelection.focus.set(anchor.key, newOffset, anchor.type)
+ }
+ })
+}
+
+// bold
+function handleMDInsertBoldCommand (editor) {
+ wrapMarkdownSelection(editor, '**')
+ return true
+}
+
+// italic
+function handleMDInsertItalicCommand (editor) {
+ wrapMarkdownSelection(editor, '*')
+ return true
+}
+
+// link
+function handleMDInsertLinkCommand (editor) {
+ wrapMarkdownSelection(editor, '[', ']()', 1)
+ return true
+}
+
+export const MDCommandsExtension = defineExtension({
+ name: 'MDCommandsExtension',
+ register: (editor) => {
+ return mergeRegister(
+ editor.registerCommand(
+ MD_INSERT_LINK_COMMAND,
+ handleMDInsertLinkCommand,
+ COMMAND_PRIORITY_HIGH
+ ),
+ editor.registerCommand(
+ MD_INSERT_BOLD_COMMAND,
+ handleMDInsertBoldCommand,
+ COMMAND_PRIORITY_HIGH
+ ),
+ editor.registerCommand(
+ MD_INSERT_ITALIC_COMMAND,
+ handleMDInsertItalicCommand,
+ COMMAND_PRIORITY_HIGH
+ )
+ )
+ }
+})
diff --git a/lib/lexical/exts/media-check.js b/lib/lexical/exts/media-check.js
new file mode 100644
index 0000000000..c758074296
--- /dev/null
+++ b/lib/lexical/exts/media-check.js
@@ -0,0 +1,163 @@
+import { createCommand, defineExtension, COMMAND_PRIORITY_EDITOR, $getNodeByKey, $createTextNode } from 'lexical'
+import { $createLinkNode } from '@lexical/link'
+import { mergeRegister } from '@lexical/utils'
+import { MediaNode } from '@/lib/lexical/nodes/content/media'
+import { PUBLIC_MEDIA_CHECK_URL, UNKNOWN_LINK_REL } from '@/lib/constants'
+import { fetchWithTimeout } from '@/lib/fetch'
+
+export const MEDIA_CHECK_COMMAND = createCommand('MEDIA_CHECK_COMMAND')
+
+export const MediaCheckExtension = defineExtension({
+ name: 'MediaCheckExtension',
+ register: (editor) => {
+ const aborters = new Map()
+ const tokens = new Map()
+ const promises = new Map()
+
+ // replaces a media node with a link node
+ const replaceMediaWithLink = (node) => {
+ const url = node.getSrc()
+ const link = $createLinkNode(url, { target: '_blank', rel: UNKNOWN_LINK_REL })
+ link.append($createTextNode(url))
+ node.replace(link)
+ }
+
+ // checks media type and updates node accordingly
+ const checkMediaNode = (nodeKey, url) => {
+ if (promises.has(nodeKey)) {
+ return promises.get(nodeKey)
+ }
+
+ const prev = aborters.get(nodeKey)
+ if (prev) prev.abort()
+
+ const token = (tokens.get(nodeKey) ?? 0) + 1
+ tokens.set(nodeKey, token)
+
+ // set node status to pending while checking
+ editor.update(() => {
+ const node = $getNodeByKey(nodeKey)
+ if (node instanceof MediaNode) node.setStatus('pending')
+ }, { tag: 'history-merge' })
+
+ // create new abort controller for this request
+ const controller = new AbortController()
+ aborters.set(nodeKey, controller)
+
+ const promise = checkMedia(PUBLIC_MEDIA_CHECK_URL, url, { signal: controller.signal })
+ .then((result) => {
+ if (tokens.get(nodeKey) !== token) return
+
+ editor.update(() => {
+ const node = $getNodeByKey(nodeKey)
+ if (!(node instanceof MediaNode)) return
+
+ if (result.type === 'unknown') {
+ replaceMediaWithLink(node)
+ } else {
+ node.applyCheckResult(result.type)
+ }
+ }, { tag: 'history-merge' })
+ return result
+ })
+ .catch((error) => {
+ console.error('media check failed:', error)
+ if (tokens.get(nodeKey) !== token) throw error
+
+ editor.update(() => {
+ const node = $getNodeByKey(nodeKey)
+ if (node instanceof MediaNode) {
+ node.setStatus('error')
+ replaceMediaWithLink(node)
+ }
+ }, { tag: 'history-merge' })
+ })
+ .finally(() => {
+ if (aborters.get(nodeKey) === controller) aborters.delete(nodeKey)
+ promises.delete(nodeKey)
+ })
+
+ promises.set(nodeKey, promise)
+ return promise
+ }
+
+ const unregisterTransforms = mergeRegister(
+ // register command to check media type for a given node
+ editor.registerCommand(MEDIA_CHECK_COMMAND, ({ nodeKey, url }) => {
+ checkMediaNode(nodeKey, url)
+ return true
+ }, COMMAND_PRIORITY_EDITOR),
+ // register transform to automatically check unknown media nodes
+ editor.registerNodeTransform(MediaNode, (node) => {
+ // trigger media check for unknown media nodes that are idle and have a source
+ if (node.getKind() === 'unknown' && node.getStatus() === 'idle' && node.getSrc()) {
+ editor.dispatchCommand(MEDIA_CHECK_COMMAND, { nodeKey: node.getKey(), url: node.getSrc() })
+ }
+ })
+ )
+
+ return () => {
+ unregisterTransforms()
+ aborters.forEach((controller) => controller.abort())
+ aborters.clear()
+ tokens.clear()
+ promises.clear()
+ }
+ }
+})
+
+/**
+ * checks if a URL points to video or image by calling media check endpoint
+ * @param {string} endpoint - media check endpoint URL
+ * @param {string} url - URL to check
+ * @param {Object} [options] - options object
+ * @param {AbortSignal} [options.signal] - abort signal for request cancellation
+ * @returns {Promise
>
diff --git a/prisma/migrations/20251120022830_editor/migration.sql b/prisma/migrations/20251120022830_editor/migration.sql
new file mode 100644
index 0000000000..227347fce0
--- /dev/null
+++ b/prisma/migrations/20251120022830_editor/migration.sql
@@ -0,0 +1,50 @@
+-- AlterTable - Lexical Editor support
+-- lexicalState is the raw JSON state of the editor
+-- html is the sanitized HTML result of the editor
+ALTER TABLE "Item" ADD COLUMN "lexicalState" JSONB, ADD COLUMN "html" TEXT;
+
+-- CreateEnum
+CREATE TYPE "LexicalMigrationType" AS ENUM ('LEXICAL_CONVERSION', 'HTML_GENERATION', 'UNEXPECTED');
+
+-- CreateTable
+CREATE TABLE "LexicalMigrationLog" (
+ "id" SERIAL NOT NULL,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "itemId" INTEGER NOT NULL,
+ "type" "LexicalMigrationType" NOT NULL,
+ "retryCount" INTEGER NOT NULL DEFAULT 0,
+ "message" TEXT NOT NULL,
+
+ CONSTRAINT "LexicalMigrationLog_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "LexicalBatchMigrationLog" (
+ "id" SERIAL NOT NULL,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "durationMs" INTEGER NOT NULL,
+ "successCount" INTEGER NOT NULL,
+ "failureCount" INTEGER NOT NULL,
+ "summary" JSONB,
+
+ CONSTRAINT "LexicalBatchMigrationLog_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "LexicalMigrationLog_type_idx" ON "LexicalMigrationLog"("type");
+
+-- CreateIndex
+CREATE INDEX "LexicalMigrationLog_retryCount_idx" ON "LexicalMigrationLog"("retryCount");
+
+-- CreateIndex
+CREATE INDEX "LexicalMigrationLog_itemId_idx" ON "LexicalMigrationLog"("itemId");
+
+-- CreateIndex
+CREATE INDEX "LexicalBatchMigrationLog_created_at_idx" ON "LexicalBatchMigrationLog"("created_at");
+
+-- AddForeignKey
+ALTER TABLE "LexicalMigrationLog" ADD CONSTRAINT "LexicalMigrationLog_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- CreateIndex
+CREATE UNIQUE INDEX "LexicalMigrationLog_itemId_key" ON "LexicalMigrationLog"("itemId");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 6d080d45f4..c41b3908bb 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -466,6 +466,8 @@ model Item {
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
title String?
text String?
+ lexicalState Json? @db.JsonB
+ html String?
url String?
userId Int
parentId Int?
@@ -546,6 +548,7 @@ model Item {
randPollOptions Boolean @default(false)
itemPayIns ItemPayIn[]
CommentsViewAt CommentsViewAt[]
+ LexicalMigrationLog LexicalMigrationLog[]
@@index([uploadId])
@@index([lastZapAt])
@@ -1986,3 +1989,36 @@ model AggRewards {
@@unique([granularity, timeBucket, payInType], map: "AggRewards_unique_key")
}
+
+model LexicalMigrationLog {
+ id Int @id @default(autoincrement())
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
+ itemId Int @unique
+ type LexicalMigrationType
+ retryCount Int @default(0)
+ message String
+
+ item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
+
+ @@index([type])
+ @@index([retryCount])
+ @@index([itemId])
+}
+
+enum LexicalMigrationType {
+ LEXICAL_CONVERSION
+ HTML_GENERATION
+ UNEXPECTED
+}
+
+model LexicalBatchMigrationLog {
+ id Int @id @default(autoincrement())
+ createdAt DateTime @default(now()) @map("created_at")
+ durationMs Int
+ successCount Int
+ failureCount Int
+ summary Json? @db.JsonB
+
+ @@index([createdAt])
+}
diff --git a/styles/globals.scss b/styles/globals.scss
index 8d2d174a49..cd3529535d 100644
--- a/styles/globals.scss
+++ b/styles/globals.scss
@@ -142,6 +142,7 @@ $zindex-sticky: 900;
--theme-dropdownItemColor: rgba(0, 0, 0, 0.7);
--theme-dropdownItemColorHover: rgba(0, 0, 0, 0.9);
--theme-commentBg: rgba(0, 0, 0, 0.03);
+ --theme-forceCommentBg: rgb(244, 244, 247);
--theme-clickToContextColor: rgba(0, 0, 0, 0.07);
--theme-brandColor: rgba(0, 0, 0, 0.9);
--theme-grey: #707070;
@@ -166,6 +167,7 @@ $zindex-sticky: 900;
--theme-dropdownItemColor: rgba(255, 255, 255, 0.7);
--theme-dropdownItemColorHover: rgba(255, 255, 255, 0.9);
--theme-commentBg: rgba(255, 255, 255, 0.025);
+ --theme-forceCommentBg: rgb(24, 24, 26);
--theme-clickToContextColor: rgba(255, 255, 255, 0.1);
--theme-brandColor: var(--bs-primary);
--theme-grey: #969696;
@@ -448,7 +450,7 @@ a:hover {
}
select,
-div[contenteditable],
+div[contenteditable]:not(.sn-text),
.form-control {
background-color: var(--theme-inputBg);
color: var(--bs-body-color);
@@ -490,7 +492,7 @@ select:focus {
}
-div[contenteditable]:focus,
+div[contenteditable]:not(.sn-text):focus,
.form-control:focus {
background-color: var(--theme-inputBg);
color: var(--bs-body-color);
@@ -498,7 +500,7 @@ div[contenteditable]:focus,
box-shadow: 0 0 0 0.2rem rgb(250 218 94 / 25%);
}
-div[contenteditable]:disabled,
+div[contenteditable]:not(.sn-text):disabled,
.form-control:disabled,
.form-control[readonly] {
background-color: var(--theme-inputDisabledBg);
@@ -632,8 +634,8 @@ footer {
textarea,
.form-control,
.form-control:focus,
- div[contenteditable],
- div[contenteditable]:focus,
+ div[contenteditable]:not(.sn-text),
+ div[contenteditable]:not(.sn-text):focus,
.input-group-text {
font-size: 1rem !important;
}
@@ -646,7 +648,7 @@ footer {
}
textarea.form-control,
-div[contenteditable] {
+div[contenteditable]:not(.sn-text) {
line-height: 1rem;
}
@@ -747,7 +749,7 @@ header .navbar:not(:first-child) {
text-shadow: 0 0 10px var(--bs-primary);
}
-div[contenteditable]:focus,
+div[contenteditable]:not(.sn-text):focus,
.form-control:focus {
border-color: var(--bs-primary);
}
@@ -988,6 +990,22 @@ div[contenteditable]:focus,
color: #fff;
}
+.fade.tooltip {
+ opacity: 1;
+ visibility: visible;
+ animation: fadeIn 0.15s ease-in;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+
.popover {
.popover-header {
background-color: var(--bs-body-bg);
diff --git a/styles/text.scss b/styles/text.scss
new file mode 100644
index 0000000000..36109be57a
--- /dev/null
+++ b/styles/text.scss
@@ -0,0 +1,681 @@
+@import 'katex/dist/katex.min.css';
+
+.sn-text {
+ font-size: 94%;
+ font-family: inherit;
+ word-break: break-word;
+ overflow-y: hidden;
+ overflow-x: hidden;
+ position: relative;
+ max-height: 200vh;
+ --grid-gap: 0.5rem;
+ line-height: 140%;
+
+ // reset bootstrap
+ p, pre, ul, ol, blockquote, table {
+ margin-bottom: 0;
+ }
+}
+
+.sn-text>*:not(.sn-heading, .sn-toc, .sn-spoiler, .sn-code-block) {
+ padding-top: calc(var(--grid-gap) * 0.5);
+ padding-bottom: calc(var(--grid-gap) * 0.5);
+}
+
+.sn-text pre, .sn-text blockquote {
+ margin-top: calc(var(--grid-gap) * 0.5);
+ margin-bottom: calc(var(--grid-gap) * 0.5);
+}
+
+.sn-text>*:last-child:not(.sn-text__show-full, .sn-code-block) {
+ padding-bottom: 0 !important;
+ margin-bottom: 0 !important;
+}
+
+.sn-text>*:first-child:not(.sn-code-block) {
+ padding-top: 0 !important;
+ margin-top: 0 !important;
+}
+
+.sn-text blockquote {
+ padding-top: 0 !important;
+ padding-bottom: 0 !important;
+}
+
+.sn-text blockquote *:first-child {
+ padding-top: 0;
+}
+
+.sn-text blockquote *:last-child {
+ padding-bottom: 0;
+}
+
+.sn-text--truncated {
+ max-height: 50vh;
+}
+
+.sn-text[contenteditable="false"] {
+ background-color: var(--theme-body) !important;
+}
+
+.sn-text.sn-text--top-level {
+ max-height: 200vh;
+ --grid-gap: 0.75rem;
+}
+
+.sn-text.sn-text--uncontained {
+ max-height: none;
+}
+
+.sn-text--contained::before {
+ content: "";
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 50vh;
+ pointer-events: none;
+ z-index: 1;
+ background: linear-gradient(rgba(255, 255, 255, 0), var(--bs-body-bg) 200%);
+}
+
+.sn-text__show-full {
+ position: absolute;
+ bottom: 0;
+ z-index: 2;
+ border-radius: 0;
+}
+
+.sn-paragraph {
+ display: block;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.sn-paragraph mark {
+ background-color: unset;
+}
+
+@media screen and (min-width: 767px) {
+ .sn-text {
+ line-height: 130%;
+ }
+}
+
+.sn-hr {
+ border-top: 3px solid var(--theme-quoteBar);
+ caret-color: transparent;
+}
+
+[contenteditable="true"] .sn-hr.selected {
+ opacity: 1 !important;
+}
+
+.sn-heading {
+ margin-top: 0.75rem;
+ margin-bottom: 0.5rem;
+ font-size: 1rem;
+ position: relative;
+}
+
+.sn-text--top-level h1.sn-heading {
+ font-size: 1.6rem;
+}
+
+.sn-text--top-level h2.sn-heading {
+ font-size: 1.45rem;
+}
+
+.sn-text--top-level h3.sn-heading {
+ font-size: 1.3rem;
+}
+
+.sn-text--top-level h4.sn-heading {
+ font-size: 1.15rem;
+}
+
+/* blocks */
+.sn-quote {
+ border-left: 3px solid var(--theme-quoteBar);
+ padding-left: .75rem;
+ // nested elements don't need extra spacing (override the :where default)
+ > *:first-child {
+ margin-top: 0;
+ }
+ > *:last-child {
+ margin-bottom: 0;
+ }
+}
+
+span.sn-media-container {
+ cursor: default;
+ display: inline-block;
+ position: relative;
+ user-select: none;
+ --max-width: 500px;
+}
+
+span.sn-media-container .outlawed {
+ user-select: text;
+ cursor: text;
+}
+
+.sn-media-container img,
+.sn-media-container video {
+ max-width: var(--max-width);
+ max-height: var(--max-width);
+ display: block;
+ cursor: default;
+ aspect-ratio: var(--aspect-ratio);
+}
+
+[contenteditable="false"] .sn-media-container img {
+ cursor: zoom-in;
+ min-width: 30%;
+ object-position: left top;
+}
+
+[contenteditable="true"] .sn-media-container .focused img, [contenteditable="true"] .sn-media-container .focused video {
+ outline: 2px solid rgb(60, 132, 244);
+ user-select: none;
+}
+
+[contenteditable="true"] .sn-media-container .focused.draggable img, [contenteditable="true"] .sn-media-container .focused.draggable video {
+ cursor: grab;
+}
+
+[contenteditable="true"] .sn-media-container .focused.draggable:active img, [contenteditable="true"] .sn-media-container .focused.draggable:active video {
+ cursor: grabbing;
+}
+
+.sn-image {
+ cursor: zoom-in;
+ min-width: 30%;
+ max-width: 100%;
+ object-position: left top;
+}
+
+.sn-link {
+ color: var(--theme-link);
+ text-decoration: none;
+}
+
+span + .sn-link {
+ margin-left: 0px;
+}
+
+.sn-user-mention,
+.sn-territory-mention,
+.sn-item-mention {
+ color: var(--theme-link);
+ font-weight: bold;
+}
+
+.sn-block-cursor {
+ display: block;
+ pointer-events: none;
+ position: absolute;
+}
+.sn-block-cursor:after {
+ content: '';
+ display: block;
+ position: absolute;
+ top: -2px;
+ width: 20px;
+ border-top: 1px solid var(--bs-body-color);
+ animation: CursorBlink 1.1s steps(2, start) infinite;
+}
+@keyframes CursorBlink {
+ to {
+ visibility: hidden;
+ }
+}
+
+.sn-twitter-container,
+.sn-nostr-container,
+.sn-video-wrapper,
+.sn-wavlake-wrapper,
+.sn-spotify-wrapper {
+ background-color: var(--theme-body);
+}
+
+.sn-embed-wrapper {
+ user-select: none;
+ pointer-events: auto;
+}
+
+[contenteditable="true"] .sn-embed-wrapper--focus {
+ outline: 2px solid rgb(60, 132, 244);
+}
+
+.sn-embed-wrapper__loading {
+ display: flex;
+ flex-flow: row wrap;
+ max-width: 350px;
+ width: 100%;
+ padding-right: 12px;
+}
+
+.sn-embed-wrapper__loading-message {
+ justify-content: center;
+ display: flex;
+ flex-direction: column;
+ border-radius: 0.4rem;
+ height: 150px;
+ width: 100%;
+ padding: 1.5rem;
+ background-color: var(--theme-commentBg);
+}
+
+.sn-embed-wrapper__loading-message svg {
+ width: 24px;
+ height: 24px;
+ margin-bottom: 1rem;
+ margin-left: -0.15rem;
+}
+
+.sn-list-ol, .sn-list-ul {
+ padding-left: 2rem;
+ max-width: calc(100% - 1rem);
+}
+
+/* list item padding, lookbehind for nested list items */
+.sn-list__item:has(> span) {
+ margin-top: .25rem;
+
+ .sn-list__nested-item & {
+ padding-top: 0;
+ padding-bottom: 0;
+ }
+}
+
+.sn-list__item {
+ margin-top: .25rem;
+}
+
+.sn-list__item--checked, .sn-list__item--unchecked {
+ position: relative;
+ margin-left: 0.5em;
+ margin-right: 0.5em;
+ padding-left: 1.5em;
+ padding-right: 1.5em;
+ list-style-type: none;
+ outline: none;
+ display: block;
+ min-height: 1.5em;
+}
+
+.sn-list__item--checked > *, .sn-list__item--unchecked > * {
+ margin-left: 0.01em;
+}
+
+.sn-list__item--checked:before, .sn-list__item--unchecked:before {
+ content: '\200B';
+ width: 0.9em;
+ height: 0.9em;
+ top: 45%;
+ left: 0;
+ cursor: pointer;
+ display: block;
+ background-size: cover;
+ position: absolute;
+ transform: translateY(-50%);
+}
+
+.sn-list__item--checked {
+ text-decoration: line-through;
+}
+
+.sn-list__item--checked:focus:before, .sn-list__item--unchecked:focus:before {
+ box-shadow: 0 0 0 2px #a6cdfe;
+ border-radius: 2px;
+}
+
+.sn-list__item--unchecked:before {
+ border: 1px solid #999;
+ border-radius: 2px;
+}
+
+.sn-list__item--checked:before {
+ border: 1px solid var(--bs-primary);
+ border-radius: 2px;
+ background-color: var(--bs-primary);
+ background-repeat: no-repeat;
+}
+
+.sn-list__item--checked:after {
+ content: '';
+ cursor: pointer;
+ border-color: #000;
+ border-style: solid;
+ position: absolute;
+ display: block;
+ top: 45%;
+ width: 0.2em;
+ left: 0.35em;
+ height: 0.4em;
+ transform: translateY(-50%) rotate(45deg);
+ border-width: 0 0.1em 0.1em 0;
+}
+
+[contenteditable="false"] .sn-list__item--checked:before,
+[contenteditable="false"] .sn-list__item--unchecked:before {
+ cursor: default;
+ pointer-events: none;
+ opacity: 0.5;
+ filter: grayscale(0.3);
+}
+
+[contenteditable="false"] .sn-list__item--checked:after {
+ cursor: default;
+ pointer-events: none;
+ opacity: 0.5;
+}
+
+[contenteditable="false"] .sn-list__item--checked:focus:before,
+[contenteditable="false"] .sn-list__item--unchecked:focus:before {
+ box-shadow: 0 0 0 0;
+ border-radius: 2px;
+}
+
+/* shared collapsible for ToC and spoilers */
+.sn-collapsible {
+ background-color: var(--theme-commentBg);
+ border: 1px solid var(--theme-borderColor);
+ border-radius: 0.4rem;
+ margin-top: calc(var(--grid-gap) * 0.5);
+ margin-bottom: calc(var(--grid-gap) * 0.5);
+ max-width: fit-content;
+}
+
+.sn-collapsible__header {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ padding: 0.5rem 0.5rem 0.5rem 0.25rem;
+ cursor: pointer;
+ color: var(--bs-body-color);
+ transition: background-color 0.15s ease-in-out;
+}
+
+.sn-collapsible__header:hover {
+ background-color: var(--theme-clickToContextColor);
+}
+
+/* svg is imported like this for 1:1 html-lexical compatibility */
+.sn-collapsible__header::before {
+ content: '';
+ width: 20px;
+ height: 20px;
+ display: inline-block;
+ background-color: var(--bs-body-color);
+ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M11.9999 13.1714L16.9497 8.22168L18.3639 9.63589L11.9999 15.9999L5.63599 9.63589L7.0502 8.22168L11.9999 13.1714Z'/%3E%3C/svg%3E") no-repeat center / contain;
+ transform: rotate(-90deg);
+ flex-shrink: 0;
+}
+
+.sn-collapsible[open] > .sn-collapsible__header::before {
+ transform: rotate(0deg);
+}
+
+/* table of contents specific */
+.sn-toc {
+ padding: 0;
+}
+
+.sn-toc > summary {
+ font-weight: bold;
+ font-size: 0.875rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ list-style: none;
+}
+
+.sn-toc ul {
+ margin-left: 2rem;
+ margin-right: 1rem;
+ margin-bottom: 1rem;
+ padding-left: 0;
+ line-height: 1.6;
+ list-style-type: disc;
+}
+
+.sn-toc > ul {
+ padding-left: 0;
+ margin-top: 0.5rem;
+}
+
+.sn-toc ul ul {
+ margin-top: 0.25rem;
+ margin-bottom: 0.25rem;
+ list-style-type: circle;
+}
+
+.sn-toc li {
+ margin-top: 0.25rem;
+ margin-bottom: 0.25rem;
+}
+
+/* can select the entire ToC node if we're inside an editor */
+[contenteditable="true"] [data-lexical-toc].selected .sn-toc {
+ background-color: var(--theme-clickToContextColor);
+ outline: 1px solid var(--bs-primary);
+}
+
+/* spoiler specific */
+.sn-spoiler__title {
+ padding: 0rem 0.5rem 0rem 0.25rem;
+}
+
+.sn-spoiler__content {
+ padding-left: 0.4rem;
+ padding-right: 0.4rem;
+ padding-bottom: 0.15rem;
+}
+
+.sn-spoiler--collapsed .sn-spoiler__content {
+ display: none;
+ user-select: none;
+}
+
+.sn-text__bold {
+ font-weight: bold;
+}
+
+.sn-text__italic {
+ font-style: italic;
+}
+
+.sn-text__underline {
+ text-decoration: underline;
+}
+
+.sn-text__strikethrough {
+ text-decoration: line-through;
+}
+
+.sn-text__underline-strikethrough {
+ text-decoration: underline line-through;
+}
+
+.sn-text sup,
+.sn-text sub {
+ vertical-align: baseline;
+ font-size: inherit;
+ position: static;
+}
+
+.sn-text__superscript {
+ vertical-align: super;
+ font-size: smaller;
+}
+
+.sn-text__subscript {
+ vertical-align: sub;
+ font-size: smaller;
+}
+
+.sn-text__highlight {
+ background-color: #fada5e5e;
+ padding: 0 0.2rem;
+ color: var(--bs-body-color);
+}
+
+/* inline */
+
+.sn-code-block {
+ position: relative;
+ background-color: var(--theme-commentBg);
+ font-family: Menlo, Consolas, Monaco, monospace;
+ display: block;
+ line-height: 1.53;
+ font-size: 13px;
+ padding: 0.5em;
+ overflow-y: hidden;
+ overflow-x: auto;
+ tab-size: 2;
+ white-space: pre;
+ scrollbar-width: thin;
+ scrollbar-color: var(--theme-borderColor) var(--theme-commentBg);
+ border-radius: 0.3rem;
+}
+
+.sn-math {
+ cursor: default;
+ user-select: none;
+}
+
+.sn-math.focused {
+ outline: 2px solid rgb(60, 132, 244);
+}
+
+.sn-table {
+ border-collapse: separate;
+ border-spacing: 0;
+ overflow-y: scroll;
+ overflow-x: scroll;
+ table-layout: fixed;
+ width: auto;
+ margin-top: 10px;
+ margin-bottom: 10px;
+}
+
+.sn-table--selection *::selection {
+ background-color: transparent;
+}
+.sn-table--selected {
+ outline: 2px solid rgb(60, 132, 244);
+}
+.sn-table__cell {
+ border: 1px solid var(--theme-borderColor);
+ min-width: 75px;
+ vertical-align: top;
+ text-align: start;
+ padding: .3rem .75rem;
+ position: relative;
+ outline: none;
+ overflow: auto;
+ line-height: 1.2;
+}
+
+/*
+ lexical dev notes:
+ A firefox workaround to allow scrolling of overflowing table cell
+ ref: https://bugzilla.mozilla.org/show_bug.cgi?id=1904159
+*/
+.sn-table__cell > * {
+ overflow: inherit;
+}
+.sn-table__cell-resizer {
+ position: absolute;
+ right: -4px;
+ height: 100%;
+ width: 8px;
+ cursor: ew-resize;
+ z-index: 10;
+ top: 0;
+}
+.sn-table__cell--header {
+ background-color: var(--theme-commentBg);
+ text-align: start;
+}
+.sn-table__cell--selected {
+ caret-color: transparent;
+}
+.sn-table__cell--selected::after {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ top: 0;
+ background-color: highlight;
+ mix-blend-mode: multiply;
+ content: '';
+ pointer-events: none;
+}
+
+.sn-table__cell-resize-ruler {
+ display: block;
+ position: absolute;
+ width: 1px;
+ background-color: rgb(60, 132, 244);
+ height: 100%;
+ top: 0;
+}
+.sn-table__action-button-container {
+ display: block;
+ right: 5px;
+ top: 6px;
+ position: absolute;
+ z-index: 4;
+ width: 20px;
+ height: 20px;
+}
+.sn-table__action-button {
+ background-color: #eee;
+ display: block;
+ border: 0;
+ border-radius: 20px;
+ width: 20px;
+ height: 20px;
+ color: #222;
+ cursor: pointer;
+}
+.sn-table__action-button:hover {
+ background-color: #ddd;
+}
+.sn-table__scrollable-wrapper {
+ overflow-x: auto;
+ overflow-y: hidden;
+ scrollbar-width: thin;
+ scrollbar-color: var(--theme-borderColor) transparent;
+}
+
+.sn-table__scrollable-wrapper::-webkit-scrollbar {
+ height: 8px;
+}
+
+.sn-table__scrollable-wrapper::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.sn-table__scrollable-wrapper::-webkit-scrollbar-thumb {
+ background-color: var(--theme-borderColor);
+ border-radius: 4px;
+}
+
+.sn-table__scrollable-wrapper::-webkit-scrollbar-thumb:hover {
+ background-color: var(--theme-navLink);
+}
+
+.sn-table__scrollable-wrapper > .sn-table {
+ margin-top: 0;
+ margin-bottom: 0;
+}
+.sn-table--align-center {
+ margin-left: auto;
+ margin-right: auto;
+}
+.sn-table--align-right {
+ margin-left: auto;
+}
diff --git a/svgs/editor/toolbar/inserts/upload-paperclip.svg b/svgs/editor/toolbar/inserts/upload-paperclip.svg
new file mode 100644
index 0000000000..74d8cab2af
--- /dev/null
+++ b/svgs/editor/toolbar/inserts/upload-paperclip.svg
@@ -0,0 +1 @@
+