0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2026-06-22 04:58:40 +00:00

Merge pull request #4770 from 5e-Cleric/add-image-preview

Add image preview
This commit is contained in:
Víctor Losada Hernández
2026-05-23 11:41:21 +02:00
committed by GitHub
8 changed files with 211 additions and 77 deletions
+16 -71
View File
@@ -1,4 +1,4 @@
/* eslint max-lines: ["error", { "max": 400 }] */ /* eslint max-lines: ["error", { "max": 405 }] */
import './codeEditor.less'; import './codeEditor.less';
import React, { useEffect, useRef, forwardRef, useImperativeHandle } from 'react'; import React, { useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
@@ -10,20 +10,26 @@ import {
highlightActiveLine, highlightActiveLine,
scrollPastEnd, scrollPastEnd,
Decoration, Decoration,
ViewPlugin,
drawSelection, drawSelection,
dropCursor, dropCursor,
rectangularSelection, rectangularSelection,
crosshairCursor, crosshairCursor,
} from '@codemirror/view'; } from '@codemirror/view';
import { EditorState, Compartment, StateEffect, StateField } from '@codemirror/state'; import { EditorState, Compartment, StateEffect, StateField } from '@codemirror/state';
import { foldAll as foldAllCmd, unfoldAll as unfoldAllCmd, foldGutter, foldKeymap, foldEffect, foldState, syntaxHighlighting } from '@codemirror/language'; import {
unfoldAll as unfoldAllCmd,
foldGutter,
foldKeymap,
foldEffect,
foldState,
syntaxHighlighting,
} from '@codemirror/language';
import { defaultKeymap, history, undo, redo, undoDepth, redoDepth } from '@codemirror/commands'; import { defaultKeymap, history, undo, redo, undoDepth, redoDepth } from '@codemirror/commands';
import { languages } from '@codemirror/language-data'; import { languages } from '@codemirror/language-data';
import { css } from '@codemirror/lang-css'; import { css } from '@codemirror/lang-css';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { html } from '@codemirror/lang-html'; import { html } from '@codemirror/lang-html';
import { autocompleteEmoji } from './autocompleteEmoji.js'; import { autocompleteEmoji } from './extensions/autocompleteEmoji.js';
import { searchKeymap, search } from '@codemirror/search'; import { searchKeymap, search } from '@codemirror/search';
import { closeBrackets } from '@codemirror/autocomplete'; import { closeBrackets } from '@codemirror/autocomplete';
@@ -37,69 +43,13 @@ const themes = { default: defaultCM5Theme, ...cm5Themes, darkbrewery };
const themeCompartment = new Compartment(); const themeCompartment = new Compartment();
const highlightCompartment = new Compartment(); const highlightCompartment = new Compartment();
import { generalKeymap, markdownKeymap } from './customKeyMaps.js'; import { generalKeymap, markdownKeymap } from './extensions/customKeyMaps.js';
import foldOnPages from './customFolding.js'; import foldOnPages from './extensions/customFolding.js';
import { customHighlightStyle, tokenizeCustomMarkdown, tokenizeCustomCSS } from './customHighlight.js'; import { customHighlightPlugin, customHighlightStyle } from './extensions/customHighlight.js';
import { legacyCustomHighlightStyle, legacyTokenizeCustomMarkdown } from './legacyCustomHighlight.js'; import { legacyCustomHighlightStyle } from './extensions/legacyCustomHighlight.js';
const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m; const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
const createHighlightPlugin = (renderer, tab)=>{
//this function takes the custom tokens created in the tokenize function in customhighlight files
//takes the tokens defined by that function and assigns classes to them
//it also creates page number and snippet number widgets
let tokenize;
if(tab === 'brewStyles') {
tokenize = tokenizeCustomCSS;
} else {
tokenize = renderer === 'V3' ? tokenizeCustomMarkdown : legacyTokenizeCustomMarkdown;
}
return ViewPlugin.fromClass(
class {
constructor(view) {
this.decorations = this.buildDecorations(view);
}
update(update) {
if(update.docChanged) {
this.decorations = this.buildDecorations(update.view);
}
}
buildDecorations(view) {
const decos = [];
const tokens = tokenize(view.state.doc.toString());
let pageCount = 1;
let snippetCount = 0;
tokens.forEach((tok)=>{
const line = view.state.doc.line(tok.line + 1);
if(tok.from != null && tok.to != null && tok.from < tok.to) {
decos.push(Decoration.mark({ class: `cm-${tok.type}` }).range(line.from + tok.from, line.from + tok.to));
} else {
decos.push(Decoration.line({ class: `cm-${tok.type}` }).range(line.from));
if(tok.type === 'pageLine' && tab === 'brewText') {
pageCount++;
line.from === 0 && pageCount--;
decos.push(Decoration.line({ attributes: { 'data-page-number': pageCount } }).range(line.from));
}
if(tok.type === 'snippetLine' && tab === 'brewSnippets') {
snippetCount++;
decos.push(Decoration.line({ attributes: { 'data-page-number': snippetCount } }).range(line.from));
}
}
});
decos.sort((a, b)=>a.from - b.from || a.to - b.to);
return Decoration.set(decos);
}
},
{ decorations: (v)=>v.decorations }
);
};
const setProgrammaticCursorLine = StateEffect.define(); const setProgrammaticCursorLine = StateEffect.define();
const programmaticCursorLineField = StateField.define({ const programmaticCursorLineField = StateField.define({
@@ -206,8 +156,6 @@ const CodeEditor = forwardRef(
? syntaxHighlighting(customHighlightStyle) ? syntaxHighlighting(customHighlightStyle)
: syntaxHighlighting(legacyCustomHighlightStyle); : syntaxHighlighting(legacyCustomHighlightStyle);
const customHighlightPlugin = createHighlightPlugin(renderer, tab);
const languageExtension = language === 'css' ? css() : [markdown({ base: markdownLanguage, codeLanguages: languages }), html({ autoCloseTags: true })]; const languageExtension = language === 'css' ? css() : [markdown({ base: markdownLanguage, codeLanguages: languages }), html({ autoCloseTags: true })];
const themeExtension = Array.isArray(themes[editorTheme]) ? themes[editorTheme] : themes[editorTheme] || themes['default']; const themeExtension = Array.isArray(themes[editorTheme]) ? themes[editorTheme] : themes[editorTheme] || themes['default'];
@@ -230,7 +178,7 @@ const CodeEditor = forwardRef(
}), }),
//highlights //highlights
highlightCompartment.of([customHighlightPlugin, highlightExtension]), highlightCompartment.of([customHighlightPlugin(renderer, tab), highlightExtension]),
themeCompartment.of(themeExtension), themeCompartment.of(themeExtension),
highlightActiveLine(), highlightActiveLine(),
highlightActiveLineGutter(), highlightActiveLineGutter(),
@@ -371,10 +319,8 @@ const CodeEditor = forwardRef(
? syntaxHighlighting(customHighlightStyle) ? syntaxHighlighting(customHighlightStyle)
: syntaxHighlighting(legacyCustomHighlightStyle); : syntaxHighlighting(legacyCustomHighlightStyle);
const customHighlightPlugin = createHighlightPlugin(renderer, tab);
view.dispatch({ view.dispatch({
effects : highlightCompartment.reconfigure([customHighlightPlugin, highlightExtension]), effects : highlightCompartment.reconfigure([customHighlightPlugin(renderer, tab), highlightExtension]),
}); });
}, [renderer, tab]); }, [renderer, tab]);
@@ -383,7 +329,6 @@ const CodeEditor = forwardRef(
injectText : (text)=>{ injectText : (text)=>{
const view = viewRef.current; const view = viewRef.current;
view.dispatch( view.dispatch(
view.state.replaceSelection(text) view.state.replaceSelection(text)
); );
+29 -1
View File
@@ -63,7 +63,8 @@
&.term { color : rgb(96, 117, 143); } &.term { color : rgb(96, 117, 143); }
&.definition { color : rgb(97, 57, 178); } &.definition { color : rgb(97, 57, 178); }
} }
.cm-block:not(.cm-comment) { .cm-block:not(.cm-comment),
.cm-block:not(.cm-comment) * {
font-weight : bold; font-weight : bold;
color : purple; color : purple;
} }
@@ -161,6 +162,33 @@
outline : 1px inset #00000055 !important; outline : 1px inset #00000055 !important;
} }
.cm-image {
position:relative;
.cm-preview {
object-fit: contain;
position:absolute;
bottom:0;
left:0;
height:200px;
width:200px;
height:200px;
padding:5px;
background-color: #fff;
border-radius:10px;
border:3px solid grey;
pointer-events: none;
opacity:0;
transition:0.2s opacity 0.5s;
translate:0 100%;
z-index:1000;
}
&:hover .cm-preview {
opacity:1;
}
}
/* Tab character visualization (optional) */ /* Tab character visualization (optional) */
//.cm-tab { //.cm-tab {
// background: url(...) no-repeat right; // background: url(...) no-repeat right;
@@ -1,5 +1,15 @@
/* eslint max-lines: ["error", { "max": 500 }] */
import { HighlightStyle } from '@codemirror/language'; import { HighlightStyle } from '@codemirror/language';
import { tags } from '@lezer/highlight'; import { tags } from '@lezer/highlight';
import { legacyTokenizeCustomMarkdown } from './legacyCustomHighlight';
import {
Decoration,
ViewPlugin,
} from '@codemirror/view';
import {
syntaxTree,
ensureSyntaxTree
} from '@codemirror/language';
// Making the tokens // Making the tokens
const customTags = { const customTags = {
@@ -23,7 +33,7 @@ const customTags = {
variable : 'variable', variable : 'variable',
}; };
export function tokenizeCustomMarkdown(text) { function tokenizeCustomMarkdown(text) {
const tokens = []; const tokens = [];
const lines = text.split('\n'); const lines = text.split('\n');
@@ -149,7 +159,6 @@ export function tokenizeCustomMarkdown(text) {
if(!/^::/.test(lines[lineNumber]) && lineNumber + 1 < lines.length && /^::/.test(lines[lineNumber + 1])) { if(!/^::/.test(lines[lineNumber]) && lineNumber + 1 < lines.length && /^::/.test(lines[lineNumber + 1])) {
const startLine = lineNumber; const startLine = lineNumber;
const defs = []; const defs = [];
let endLine = startLine;
// collect all following :: definitions // collect all following :: definitions
for (let i = lineNumber + 1; i < lines.length; i++) { for (let i = lineNumber + 1; i < lines.length; i++) {
@@ -158,7 +167,6 @@ export function tokenizeCustomMarkdown(text) {
const defMatch = /^(::)(.+)$/.exec(nextLine); const defMatch = /^(::)(.+)$/.exec(nextLine);
if(!onlyColonsMatch && defMatch) { if(!onlyColonsMatch && defMatch) {
defs.push({ colons: defMatch[1], desc: defMatch[2], line: i }); defs.push({ colons: defMatch[1], desc: defMatch[2], line: i });
endLine = i;
} else break; } else break;
} }
@@ -202,6 +210,7 @@ export function tokenizeCustomMarkdown(text) {
if(lineText.includes('{') && lineText.includes('}')) { if(lineText.includes('{') && lineText.includes('}')) {
const injectionRegex = /(?:^|[^{\n])({(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\2})/gmd; const injectionRegex = /(?:^|[^{\n])({(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\2})/gmd;
let match; let match;
while ((match = injectionRegex.exec(lineText)) !== null) { while ((match = injectionRegex.exec(lineText)) !== null) {
tokens.push({ tokens.push({
line : lineNumber, line : lineNumber,
@@ -241,14 +250,21 @@ export function tokenizeCustomMarkdown(text) {
/^ *{{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1 *$|^ *}}$/, /^ *{{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1 *$|^ *}}$/,
); );
if(match) endCh = match.index + match[0].length; if(match) endCh = match.index + match[0].length;
tokens.push({ line: lineNumber, type: customTags.block }); const closingMatch = lineText.match(/ *(}})/d);
if(closingMatch) {
tokens.push({ line: lineNumber, from: closingMatch.indices[1][0], to: closingMatch.indices[1][1], type: customTags.block });
} else {
tokens.push({ line: lineNumber, type: customTags.block });
}
} }
}); });
return tokens; return tokens;
} }
export function tokenizeCustomCSS(text) { function tokenizeCustomCSS(text) {
const tokens = []; const tokens = [];
const lines = text.split('\n'); const lines = text.split('\n');
@@ -307,5 +323,150 @@ export const customHighlightStyle = HighlightStyle.define([
]); ]);
function getUrl(node, doc) {
let url = null;
const cursor = node.node.cursor();
if(cursor.firstChild()) {
do {
if(cursor.name === 'URL') {
url = doc.sliceString(cursor.from, cursor.to);
break;
}
} while (cursor.nextSibling());
}
return url;
}
import { WidgetType } from '@codemirror/view';
class ImageWidget extends WidgetType {
constructor(url) {
super();
this.url = url;
}
toDOM() {
const img = document.createElement('img');
img.loading = "lazy";
img.className = 'cm-preview';
img.src = this.url;
img.onerror = ()=>{
img.src = 'client/icons/broken-image.jpg';
};
return img;
}
eq(other) {
return other.url === this.url;
}
}
export function customHighlightPlugin(renderer, tab) {
//this function takes the custom tokens created in the tokenize function in customhighlight files
//takes the tokens defined by that function and assigns classes to them
//it also creates page number and snippet number widgets
let tokenize;
if(tab === 'brewStyles') {
tokenize = tokenizeCustomCSS;
} else {
tokenize = renderer === 'V3' ? tokenizeCustomMarkdown : legacyTokenizeCustomMarkdown;
}
return ViewPlugin.fromClass(
class {
constructor(view) {
this.decorations = this.buildDecorations(view);
}
update(update) {
if(update.docChanged) {
this.decorations = this.buildDecorations(update.view);
}
}
buildDecorations(view) {
const decos = [];
const tokens = tokenize(view.state.doc.toString());
let pageCount = 1;
let snippetCount = 0;
const tree = ensureSyntaxTree(view.state, view.state.doc.length, 50) || syntaxTree(view.state);
tree.iterate({
enter : (node)=>{
if(node.name === 'Image') {
const url = getUrl(node, view.state.doc);
const widgetPosition = node.node.lastChild.from;
//this is not exactly standard, but should hold,
//and is the shortest way i could find of positioning
//the image inside the cm-image node
if(!url) return;
decos.push(
Decoration.mark({
class : 'cm-image'
}).range(node.from, node.to)
);
decos.push(
Decoration.widget({
widget : new ImageWidget(url),
side : 1
}).range(widgetPosition)
);
}
}
});
tokens.forEach((token)=>{
const line = view.state.doc.line(token.line + 1);
if(token.from != null && token.to != null && token.from < token.to) {
const from = line.from + token.from;
const to = line.from + token.to;
const attrs = {};
if(token.type === 'Image' && token.url) {
attrs['data-url'] = token.url;
}
decos.push(
Decoration.mark({
class : `cm-${token.type}`,
...(Object.keys(attrs).length
? { attributes: attrs }
: {})
}).range(from, to)
);
} else {
decos.push(
Decoration.line({
class : `cm-${token.type}`
}).range(line.from)
);
if(token.type === 'pageLine' && tab === 'brewText') {
pageCount++;
if(line.from === 0) pageCount--;
decos.push(Decoration.line({ attributes: { 'data-page-number': pageCount } }).range(line.from));
}
if(token.type === 'snippetLine' && tab === 'brewSnippets') {
snippetCount++;
decos.push(Decoration.line({ attributes: { 'data-page-number': snippetCount } }).range(line.from));
}
}
});
decos.sort((a, b)=>a.from - b.from || a.to - b.to);
return Decoration.set(decos);
}
},
{ decorations: (v)=>v.decorations }
);
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB