mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
Merge pull request #30027 from overleaf/mg-svg-figure-modal
Support svg files in figure modal GitOrigin-RevId: 4f0e110dad3c06f5e5ac699e0bfcaf3cd6c784ff
This commit is contained in:
@@ -114,19 +114,20 @@ export function previewByPath(
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.pdf',
|
||||
'.svg',
|
||||
'.PNG',
|
||||
'.JPG',
|
||||
'.JPEG',
|
||||
'.PDF',
|
||||
'.SVG',
|
||||
]) {
|
||||
const result = findEntityByPath(folder, path + suffix)
|
||||
|
||||
if (result?.type === 'fileRef') {
|
||||
const { name, hash } = result.entity
|
||||
return {
|
||||
url: `/project/${projectId}/blob/${hash}`,
|
||||
extension: name.slice(name.lastIndexOf('.') + 1),
|
||||
}
|
||||
const extension = name.slice(name.lastIndexOf('.') + 1)
|
||||
const url = `/project/${projectId}/blob/${hash}`
|
||||
return { url, extension }
|
||||
}
|
||||
}
|
||||
return null
|
||||
|
||||
+105
-39
@@ -27,6 +27,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import useEventListener from '../../../../shared/hooks/use-event-listener'
|
||||
import { prepareLines } from '../../utils/prepare-lines'
|
||||
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
|
||||
import { isSvgFile } from '../../utils/file'
|
||||
|
||||
const FigureModalBody = lazy(() => import('./figure-modal-body'))
|
||||
|
||||
@@ -143,26 +144,63 @@ const FigureModalContent = () => {
|
||||
)
|
||||
|
||||
const insert = useCallback(async () => {
|
||||
const figure = view.state.field<FigureData>(editFigureData, false)
|
||||
|
||||
if (!getPath) {
|
||||
throw new Error('Cannot insert figure without a file path')
|
||||
const replaceGraphicsCommand = (
|
||||
figure: FigureData,
|
||||
changes: ChangeSpec[],
|
||||
isSvg: boolean,
|
||||
insertPath: string
|
||||
) => {
|
||||
// Replace the entire graphics command when switching between SVG and non-SVG
|
||||
const graphicsCommandName = isSvg ? 'includesvg' : 'includegraphics'
|
||||
let widthArgument = ''
|
||||
if (figure.unknownGraphicsArguments) {
|
||||
widthArgument = `[${figure.unknownGraphicsArguments}]`
|
||||
} else if (width) {
|
||||
widthArgument = `[width=${width}\\linewidth]`
|
||||
}
|
||||
changes.push({
|
||||
from: figure.graphicsCommand.from,
|
||||
to: figure.graphicsCommand.to,
|
||||
insert: `\\${graphicsCommandName}${widthArgument}{${insertPath}}`,
|
||||
})
|
||||
}
|
||||
let path: string
|
||||
try {
|
||||
path = await getPath()
|
||||
} catch (error) {
|
||||
dispatch({ error: String(error) })
|
||||
return
|
||||
}
|
||||
const labelCommand = includeLabel ? '\\label{fig:placeholder}' : ''
|
||||
const captionCommand = includeCaption ? '\\caption{Enter Caption}' : ''
|
||||
|
||||
if (figure) {
|
||||
// Updating existing figure
|
||||
const updateGraphicsArgs = (
|
||||
figure: FigureData,
|
||||
changes: ChangeSpec[],
|
||||
insertPath?: string
|
||||
) => {
|
||||
if (!figure.unknownGraphicsArguments && width) {
|
||||
// We understood the arguments, and should update the width
|
||||
if (figure.graphicsCommandArguments !== null) {
|
||||
changes.push({
|
||||
from: figure.graphicsCommandArguments.from,
|
||||
to: figure.graphicsCommandArguments.to,
|
||||
insert: `width=${width}\\linewidth`,
|
||||
})
|
||||
} else {
|
||||
// Insert new args
|
||||
changes.push({
|
||||
from: figure.file.from - 1,
|
||||
insert: `[width=${width}\\linewidth]`,
|
||||
})
|
||||
}
|
||||
}
|
||||
changes.push({
|
||||
from: figure.file.from,
|
||||
to: figure.file.to,
|
||||
insert: insertPath,
|
||||
})
|
||||
}
|
||||
|
||||
const updateCaptionAndLabel = (
|
||||
figure: FigureData,
|
||||
changes: ChangeSpec[]
|
||||
) => {
|
||||
const labelCommand = includeLabel ? '\\label{fig:placeholder}' : ''
|
||||
const captionCommand = includeCaption ? '\\caption{Enter Caption}' : ''
|
||||
const hadCaptionBefore = figure.caption !== null
|
||||
const hadLabelBefore = figure.label !== null
|
||||
const changes: ChangeSpec[] = []
|
||||
if (!hadCaptionBefore && includeCaption) {
|
||||
// We should insert a caption
|
||||
changes.push({
|
||||
@@ -198,28 +236,9 @@ const FigureModalContent = () => {
|
||||
insert: '',
|
||||
})
|
||||
}
|
||||
if (!figure.unknownGraphicsArguments && width) {
|
||||
// We understood the arguments, and should update the width
|
||||
if (figure.graphicsCommandArguments !== null) {
|
||||
changes.push({
|
||||
from: figure.graphicsCommandArguments.from,
|
||||
to: figure.graphicsCommandArguments.to,
|
||||
insert: `width=${width}\\linewidth`,
|
||||
})
|
||||
} else {
|
||||
// Insert new args
|
||||
changes.push({
|
||||
from: figure.file.from - 1,
|
||||
insert: `[width=${width}\\linewidth]`,
|
||||
})
|
||||
}
|
||||
}
|
||||
changes.push({ from: figure.file.from, to: figure.file.to, insert: path })
|
||||
view.dispatch({
|
||||
changes: view.state.changes(changes),
|
||||
effects: editFigureDataEffect.of(null),
|
||||
})
|
||||
} else {
|
||||
}
|
||||
|
||||
const insertNewFigure = (insertPath: string, isSvg: boolean) => {
|
||||
const { pos, suffix } = ensureEmptyLine(
|
||||
view.state,
|
||||
view.state.selection.main
|
||||
@@ -229,11 +248,12 @@ const FigureModalContent = () => {
|
||||
width !== undefined ? `[width=${width}\\linewidth]` : ''
|
||||
const caption = includeCaption ? `\n\t\\caption{\${Enter Caption}}` : ''
|
||||
const label = includeLabel ? `\n\t\\label{\${fig:placeholder}}` : ''
|
||||
const graphicsCommand = isSvg ? 'includesvg' : 'includegraphics'
|
||||
|
||||
snippet(
|
||||
`\\begin{figure}
|
||||
\t\\centering
|
||||
\t\\includegraphics${widthArgument}{${path}}${caption}${label}
|
||||
\t\\${graphicsCommand}${widthArgument}{${insertPath}}${caption}${label}
|
||||
\\end{figure}${suffix}\${}`
|
||||
)(
|
||||
{ state: view.state, dispatch: view.dispatch },
|
||||
@@ -242,6 +262,52 @@ const FigureModalContent = () => {
|
||||
pos
|
||||
)
|
||||
}
|
||||
|
||||
const figure = view.state.field<FigureData>(editFigureData, false)
|
||||
|
||||
if (!getPath) {
|
||||
throw new Error('Cannot insert figure without a file path')
|
||||
}
|
||||
let path: string
|
||||
try {
|
||||
path = await getPath()
|
||||
} catch (error) {
|
||||
dispatch({ error: String(error) })
|
||||
return
|
||||
}
|
||||
|
||||
const isSvg = isSvgFile(path)
|
||||
// For SVG files, strip the .svg extension as \includesvg expects it without
|
||||
const insertPath = isSvg ? path.replace(/\.svg$/i, '') : path
|
||||
|
||||
if (figure) {
|
||||
// Updating existing figure
|
||||
const changes: ChangeSpec[] = []
|
||||
|
||||
// Update caption and label as needed
|
||||
updateCaptionAndLabel(figure, changes)
|
||||
|
||||
// Check if we're switching to/from SVG, which requires changing the command
|
||||
const wasSvgBefore = isSvgFile(figure.file.path)
|
||||
const needsCommandChange = isSvg !== wasSvgBefore
|
||||
|
||||
if (needsCommandChange) {
|
||||
// Replace the entire graphics command when switching between SVG and non-SVG
|
||||
replaceGraphicsCommand(figure, changes, isSvg, insertPath)
|
||||
} else {
|
||||
// No command change needed, do surgical edits
|
||||
updateGraphicsArgs(figure, changes, insertPath)
|
||||
}
|
||||
|
||||
view.dispatch({
|
||||
changes: view.state.changes(changes),
|
||||
effects: editFigureDataEffect.of(null),
|
||||
})
|
||||
} else {
|
||||
// Inserting new figure
|
||||
insertNewFigure(insertPath, isSvg)
|
||||
}
|
||||
|
||||
hide()
|
||||
}, [getPath, view, hide, includeCaption, includeLabel, width, dispatch])
|
||||
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ export const FigureModalUploadFileSource: FC = () => {
|
||||
restrictions: {
|
||||
maxNumberOfFiles: 1,
|
||||
maxFileSize: getMeta('ol-ExposedSettings').maxUploadSize,
|
||||
allowedFileTypes: ['image/*', '.pdf', '.eps'],
|
||||
allowedFileTypes: ['image/*', '.pdf', '.eps', '.svg'],
|
||||
},
|
||||
autoProceed: false,
|
||||
})
|
||||
|
||||
+22
-3
@@ -881,11 +881,18 @@ export const atomicDecorations = (options: Options) => {
|
||||
)
|
||||
)
|
||||
}
|
||||
} else if (nodeRef.type.is('IncludeGraphics')) {
|
||||
// \includegraphics with a file path argument
|
||||
} else if (
|
||||
nodeRef.type.is('IncludeGraphics') ||
|
||||
nodeRef.type.is('IncludeSvg')
|
||||
) {
|
||||
// \includegraphics or \includesvg with a file path argument
|
||||
const isIncludeSvg = nodeRef.type.is('IncludeSvg')
|
||||
if (shouldDecorate(state, nodeRef)) {
|
||||
const argumentNodeName = isIncludeSvg
|
||||
? 'IncludeSvgArgument'
|
||||
: 'IncludeGraphicsArgument'
|
||||
const filePathArgument = nodeRef.node
|
||||
.getChild('IncludeGraphicsArgument')
|
||||
.getChild(argumentNodeName)
|
||||
?.getChild('FilePathArgument')
|
||||
?.getChild('LiteralArgContent')
|
||||
|
||||
@@ -895,6 +902,18 @@ export const atomicDecorations = (options: Options) => {
|
||||
filePathArgument.to
|
||||
)
|
||||
|
||||
// \includegraphics doesn't support SVG
|
||||
if (!isIncludeSvg && filePath.toLowerCase().endsWith('.svg')) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
isIncludeSvg &&
|
||||
previewByPath(filePath)?.extension !== 'svg'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filePath) {
|
||||
const environmentNode = ancestorNodeOfType(
|
||||
state,
|
||||
|
||||
+75
@@ -100,6 +100,7 @@ export class GraphicsWidget extends WidgetType {
|
||||
|
||||
switch (preview.extension) {
|
||||
case 'pdf':
|
||||
case 'PDF':
|
||||
{
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.classList.add('ol-cm-graphics')
|
||||
@@ -108,6 +109,11 @@ export class GraphicsWidget extends WidgetType {
|
||||
}
|
||||
break
|
||||
|
||||
case 'svg':
|
||||
case 'SVG':
|
||||
element.append(this.createSvgImage(view, preview.url))
|
||||
break
|
||||
|
||||
default:
|
||||
element.append(this.createImage(view, preview.url))
|
||||
break
|
||||
@@ -147,6 +153,75 @@ export class GraphicsWidget extends WidgetType {
|
||||
return wrapper
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an image element for SVG files by fetching the content and
|
||||
* creating a Blob URL with the correct MIME type. This is necessary because
|
||||
* the server serves blobs as application/octet-stream, which browsers won't
|
||||
* render as SVG.
|
||||
*/
|
||||
createSvgImage(view: EditorView, url: string) {
|
||||
const wrapper = document.createElement('div')
|
||||
const image = document.createElement('img')
|
||||
image.classList.add('ol-cm-graphics')
|
||||
image.classList.add('ol-cm-graphics-loading')
|
||||
const width = this.getFigureWidth()
|
||||
image.style.width = width
|
||||
image.style.maxWidth = width
|
||||
|
||||
// Fetch SVG and create a Blob URL with correct MIME type
|
||||
fetch(url)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch SVG: ${response.status}`)
|
||||
}
|
||||
return response.text()
|
||||
})
|
||||
.then(svgText => {
|
||||
if (this.destroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
const blob = new Blob([svgText], { type: 'image/svg+xml' })
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
|
||||
const showError = () => {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
const errorElement = this.createErrorElement(view)
|
||||
wrapper.replaceChildren(errorElement)
|
||||
this.height = wrapper.clientHeight
|
||||
view.requestMeasure()
|
||||
}
|
||||
|
||||
image.addEventListener(
|
||||
'load',
|
||||
() => {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
image.classList.remove('ol-cm-graphics-loading')
|
||||
this.height = image.height
|
||||
view.requestMeasure()
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
|
||||
image.addEventListener('error', showError, { once: true })
|
||||
|
||||
image.src = objectUrl
|
||||
})
|
||||
.catch(() => {
|
||||
if (this.destroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
const errorElement = this.createErrorElement(view)
|
||||
wrapper.replaceChildren(errorElement)
|
||||
this.height = wrapper.clientHeight
|
||||
view.requestMeasure()
|
||||
})
|
||||
|
||||
wrapper.appendChild(image)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
createErrorElement(view: EditorView): HTMLElement {
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.classList.add('ol-cm-graphics-loading-error')
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
VerbCtrlSeq,
|
||||
LstInlineCtrlSeq,
|
||||
IncludeGraphicsCtrlSeq,
|
||||
IncludeSvgCtrlSeq,
|
||||
CaptionCtrlSeq,
|
||||
DefCtrlSeq,
|
||||
LetCtrlSeq,
|
||||
@@ -297,6 +298,10 @@ KnownCommand<ArgumentType> {
|
||||
IncludeGraphicsCtrlSeq optionalWhitespace? OptionalArgument?
|
||||
IncludeGraphicsArgument { FilePathArgument }
|
||||
} |
|
||||
IncludeSvg {
|
||||
IncludeSvgCtrlSeq optionalWhitespace? OptionalArgument?
|
||||
IncludeSvgArgument { FilePathArgument }
|
||||
} |
|
||||
Caption {
|
||||
CaptionCtrlSeq "*"? optionalWhitespace? OptionalArgument? TextArgument
|
||||
} |
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
VerbCtrlSeq,
|
||||
LstInlineCtrlSeq,
|
||||
IncludeGraphicsCtrlSeq,
|
||||
IncludeSvgCtrlSeq,
|
||||
CaptionCtrlSeq,
|
||||
DefCtrlSeq,
|
||||
LetCtrlSeq,
|
||||
@@ -559,6 +560,7 @@ const otherKnowncommands = {
|
||||
'\\verb': VerbCtrlSeq,
|
||||
'\\lstinline': LstInlineCtrlSeq,
|
||||
'\\includegraphics': IncludeGraphicsCtrlSeq,
|
||||
'\\includesvg': IncludeSvgCtrlSeq,
|
||||
'\\caption': CaptionCtrlSeq,
|
||||
'\\def': DefCtrlSeq,
|
||||
'\\let': LetCtrlSeq,
|
||||
|
||||
@@ -46,15 +46,23 @@ function filterByType(type: 'file' | 'doc' | 'folder') {
|
||||
export const filterFiles = filterByType('file')
|
||||
export const filterFolders = filterByType('folder')
|
||||
|
||||
const IMAGE_FILE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'pdf', 'eps']
|
||||
const IMAGE_FILE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'pdf', 'eps', 'svg']
|
||||
|
||||
export const hasImageExtension = (filename: string) => {
|
||||
export const getFileExtension = (filename: string): string | null => {
|
||||
const parts = filename.split('.')
|
||||
if (parts.length < 2) {
|
||||
return false
|
||||
return null
|
||||
}
|
||||
const extension = parts[parts.length - 1].toLowerCase()
|
||||
return IMAGE_FILE_EXTENSIONS.includes(extension)
|
||||
return parts[parts.length - 1].toLowerCase()
|
||||
}
|
||||
|
||||
export const hasImageExtension = (filename: string) => {
|
||||
const extension = getFileExtension(filename)
|
||||
return extension !== null && IMAGE_FILE_EXTENSIONS.includes(extension)
|
||||
}
|
||||
|
||||
export const isSvgFile = (filename: string): boolean => {
|
||||
return getFileExtension(filename) === 'svg'
|
||||
}
|
||||
|
||||
export function isImageFile(file: File) {
|
||||
|
||||
+10
-3
@@ -227,7 +227,8 @@ export function parseFigureData(
|
||||
to: node.to,
|
||||
}
|
||||
}
|
||||
if (node.type.is('IncludeGraphics')) {
|
||||
if (node.type.is('IncludeGraphics') || node.type.is('IncludeSvg')) {
|
||||
const isIncludeSvg = node.type.is('IncludeSvg')
|
||||
if (file) {
|
||||
// Multiple figure
|
||||
error = true
|
||||
@@ -237,18 +238,24 @@ export function parseFigureData(
|
||||
from: node.from,
|
||||
to: node.to,
|
||||
}
|
||||
const argumentNodeName = isIncludeSvg
|
||||
? 'IncludeSvgArgument'
|
||||
: 'IncludeGraphicsArgument'
|
||||
const content = node.node
|
||||
.getChild('IncludeGraphicsArgument')
|
||||
.getChild(argumentNodeName)
|
||||
?.getChild('FilePathArgument')
|
||||
?.getChild('LiteralArgContent')
|
||||
if (!content) {
|
||||
error = true
|
||||
return false
|
||||
}
|
||||
// \includesvg stores path without .svg extension, but we add it for consistency
|
||||
file = {
|
||||
from: content.from,
|
||||
to: content.to,
|
||||
path: state.sliceDoc(content.from, content.to),
|
||||
path:
|
||||
state.sliceDoc(content.from, content.to) +
|
||||
(isIncludeSvg ? '.svg' : ''),
|
||||
}
|
||||
const optionalArgs = node.node
|
||||
.getChild('OptionalArgument')
|
||||
|
||||
@@ -70,6 +70,11 @@ describe('Path utils', function () {
|
||||
name: 'frog.JPG',
|
||||
hash: '22',
|
||||
},
|
||||
{
|
||||
_id: 'test-svg-file',
|
||||
name: 'diagram.svg',
|
||||
hash: '44',
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
@@ -158,5 +163,17 @@ describe('Path utils', function () {
|
||||
extension: 'png',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns SVG files with correct extension', function () {
|
||||
const preview = previewByPath(
|
||||
rootFolder,
|
||||
'test-project-id',
|
||||
'diagram.svg'
|
||||
)
|
||||
expect(preview).to.deep.equal({
|
||||
url: '/project/test-project-id/blob/44',
|
||||
extension: 'svg',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+170
-1
@@ -13,6 +13,9 @@ import getMeta from '@/utils/meta'
|
||||
import { mockProject } from '../helpers/mock-project'
|
||||
import { base64image } from '../fixtures/image'
|
||||
|
||||
const svgContent =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><circle cx="50" cy="50" r="40" fill="red"/></svg>'
|
||||
|
||||
const findInsertFigureToolbarButton = () => {
|
||||
return cy.findByRole('toolbar').within(() => {
|
||||
return cy
|
||||
@@ -83,7 +86,16 @@ describe('<FigureModal />', function () {
|
||||
previewByPath: cy
|
||||
.stub()
|
||||
.as('previewByPath')
|
||||
.returns({ url: base64image, extension: 'png' }),
|
||||
.callsFake(path => {
|
||||
if (path === 'diagram.svg' || path === 'diagram') {
|
||||
return {
|
||||
url: '/project/test-project/blob/abc123',
|
||||
extension: 'svg',
|
||||
}
|
||||
}
|
||||
// Default to PNG for any other path (for non-SVG tests)
|
||||
return { url: base64image, extension: 'png' }
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -674,5 +686,162 @@ text below`,
|
||||
|
||||
// TODO: Add tests for replacing image when we can match on image path
|
||||
// TODO: Add tests for changing image size when we can match on figure width
|
||||
|
||||
it('Switches from includegraphics to includesvg when replacing with SVG file', function () {
|
||||
cy.interceptLinkedFile()
|
||||
|
||||
cy.get('.cm-content').type(
|
||||
`\\begin{{}figure}
|
||||
\\centering
|
||||
\\includegraphics[width=0.75\\linewidth]{{}frog.jpg}
|
||||
\\caption{{}My caption}
|
||||
\\label{{}fig:my-label}
|
||||
\\end{{}figure}`,
|
||||
{ delay: 0 }
|
||||
)
|
||||
cy.get('[aria-label="Edit figure"]').click({ force: true })
|
||||
cy.findByRole('button', { name: 'Remove or replace figure' }).click()
|
||||
cy.findByText('Replace from URL').click()
|
||||
cy.findByLabelText('Image URL').type('https://example.com/diagram.svg')
|
||||
cy.findByText('Insert figure').click()
|
||||
|
||||
cy.get('@linked-file-request').should('have.been.calledWithMatch', {
|
||||
body: {
|
||||
provider: 'url',
|
||||
data: {
|
||||
url: 'https://example.com/diagram.svg',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Note: \caption and \label render as widgets in visual editor
|
||||
cy.get('.cm-content').should(
|
||||
'have.text',
|
||||
'\\begin{figure}\\centering\\includesvg[width=0.75\\linewidth]{diagram}My caption🏷fig:my-label\\end{figure}'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SVG file handling', function () {
|
||||
beforeEach(function () {
|
||||
// Intercept the fetch request for the SVG blob
|
||||
cy.intercept('GET', '/project/test-project/blob/abc123', {
|
||||
statusCode: 200,
|
||||
body: svgContent,
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
}).as('svgFetch')
|
||||
})
|
||||
|
||||
describe('inserting SVG from URL', function () {
|
||||
beforeEach(function () {
|
||||
cy.interceptLinkedFile()
|
||||
clickFigureToolbarButton()
|
||||
cy.findByRole('menu').within(() => {
|
||||
cy.findByText('From URL').click()
|
||||
})
|
||||
cy.findByLabelText('Image URL').as('image-url-input')
|
||||
findInsertFigureDialogButton()
|
||||
})
|
||||
|
||||
it('uses includesvg command for SVG files and displays the file', function () {
|
||||
cy.get('@image-url-input').type('https://example.com/diagram.svg')
|
||||
cy.get('@insertFigureDialogButton').click()
|
||||
|
||||
cy.get('@linked-file-request').should('have.been.calledWithMatch', {
|
||||
body: {
|
||||
provider: 'url',
|
||||
data: {
|
||||
url: 'https://example.com/diagram.svg',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cy.get('[aria-label="Edit figure"]').should('exist')
|
||||
cy.get('.ol-cm-environment-figure[data-filepath="diagram"]').should(
|
||||
'exist'
|
||||
)
|
||||
cy.get('.cm-content').should(
|
||||
'have.text',
|
||||
'\\begin{figure} \\centeringedit \\caption{Enter Caption} 🏷fig:placeholder\\end{figure}'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('editing existing SVG figure', function () {
|
||||
it('parses existing includesvg with width, label, and caption', function () {
|
||||
cy.get('.cm-content').type(
|
||||
`\\begin{{}figure}
|
||||
\\centering
|
||||
\\includesvg[width=0.75\\linewidth]{{}diagram}
|
||||
\\caption{{}My SVG caption}
|
||||
\\label{{}fig:svg-label}
|
||||
\\end{{}figure}`,
|
||||
{ delay: 0 }
|
||||
)
|
||||
cy.get('[aria-label="Edit figure"]').click({ force: true })
|
||||
cy.get('[value="0.75"]').should('be.checked')
|
||||
cy.findByRole('checkbox', { name: 'Include caption' }).should(
|
||||
'be.checked'
|
||||
)
|
||||
cy.findByRole('checkbox', { name: 'Include label' }).should(
|
||||
'be.checked'
|
||||
)
|
||||
})
|
||||
|
||||
it('removes existing label from includesvg figure when unchecked', function () {
|
||||
cy.get('.cm-content').type(
|
||||
`\\begin{{}figure}
|
||||
\\centering
|
||||
\\includesvg[width=0.75\\linewidth]{{}diagram}
|
||||
\\label{{}fig:my-label}
|
||||
\\end{{}figure}`,
|
||||
{ delay: 0 }
|
||||
)
|
||||
cy.get('[aria-label="Edit figure"]').click({ force: true })
|
||||
cy.findByRole('checkbox', { name: 'Include label' }).click()
|
||||
cy.findByRole('checkbox', { name: 'Include label' }).should(
|
||||
'not.be.checked'
|
||||
)
|
||||
cy.findByText('Done').click()
|
||||
cy.get('.cm-content').should(
|
||||
'have.text',
|
||||
'\\begin{figure}\\centering\\includesvg[width=0.75\\linewidth]{diagram}\\end{figure}'
|
||||
)
|
||||
})
|
||||
|
||||
it('switches from includesvg to includegraphics when replacing with non-SVG file', function () {
|
||||
cy.interceptLinkedFile()
|
||||
|
||||
cy.get('.cm-content').type(
|
||||
`\\begin{{}figure}
|
||||
\\centering
|
||||
\\includesvg[width=0.75\\linewidth]{{}diagram}
|
||||
\\caption{{}My caption}
|
||||
\\label{{}fig:my-label}
|
||||
\\end{{}figure}`,
|
||||
{ delay: 0 }
|
||||
)
|
||||
cy.get('[aria-label="Edit figure"]').click({ force: true })
|
||||
cy.findByRole('button', { name: 'Remove or replace figure' }).click()
|
||||
cy.findByText('Replace from URL').click()
|
||||
cy.findByLabelText('Image URL').type('https://example.com/photo.jpg')
|
||||
cy.findByText('Insert figure').click()
|
||||
|
||||
cy.get('@linked-file-request').should('have.been.calledWithMatch', {
|
||||
body: {
|
||||
provider: 'url',
|
||||
data: {
|
||||
url: 'https://example.com/photo.jpg',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Should switch to includegraphics for non-SVG files
|
||||
cy.get('.cm-content').should('contain.text', '\\includegraphics')
|
||||
cy.get('.cm-content').should('not.contain.text', '\\includesvg')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+66
-10
@@ -12,6 +12,9 @@ import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-pat
|
||||
import { TestContainer } from '../helpers/test-container'
|
||||
import { base64image } from '../fixtures/image'
|
||||
|
||||
const svgContent =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><circle cx="50" cy="50" r="40" fill="red"/></svg>'
|
||||
|
||||
describe('<CodeMirrorEditor/> in Visual mode', function () {
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
|
||||
@@ -35,16 +38,24 @@ describe('<CodeMirrorEditor/> in Visual mode', function () {
|
||||
previewByPath: cy
|
||||
.stub()
|
||||
.as('previewByPath')
|
||||
.callsFake(path =>
|
||||
path === 'valid.png'
|
||||
? { url: base64image, extension: 'png' }
|
||||
: path === 'graphic.eps'
|
||||
? {
|
||||
url: 'data:application/postscript,0 0 moveto (hello) show',
|
||||
extension: 'eps',
|
||||
}
|
||||
: null
|
||||
),
|
||||
.callsFake(path => {
|
||||
if (path === 'valid.png') {
|
||||
return { url: base64image, extension: 'png' }
|
||||
}
|
||||
if (path === 'graphic.eps') {
|
||||
return {
|
||||
url: 'data:application/postscript,0 0 moveto (hello) show',
|
||||
extension: 'eps',
|
||||
}
|
||||
}
|
||||
if (path === 'diagram.svg' || path === 'diagram') {
|
||||
return {
|
||||
url: '/project/test-project/blob/abc123',
|
||||
extension: 'svg',
|
||||
}
|
||||
}
|
||||
return null
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -259,6 +270,51 @@ describe('<CodeMirrorEditor/> in Visual mode', function () {
|
||||
cy.get('@first-line').should('have.text', '\\foo[bar]{baz} ')
|
||||
})
|
||||
|
||||
it('renders \\includesvg command and fetches SVG with blob URL', function () {
|
||||
// Intercept the fetch request for the SVG blob
|
||||
cy.intercept('GET', '/project/test-project/blob/abc123', {
|
||||
statusCode: 200,
|
||||
body: svgContent,
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
}).as('svgFetch')
|
||||
|
||||
// Type the includesvg command directly (no figure environment needed)
|
||||
cy.get('@first-line').type('\\includesvg[width=0.5\\linewidth]{{}diagram}')
|
||||
|
||||
// Move cursor out to trigger widget rendering
|
||||
cy.get('@second-line').click()
|
||||
|
||||
// Wait for the fetch to complete
|
||||
cy.wait('@svgFetch')
|
||||
|
||||
// Should show the image with a blob URL (widget renders the SVG)
|
||||
cy.get('img.ol-cm-graphics')
|
||||
.should('exist')
|
||||
.and('have.attr', 'src')
|
||||
.and('match', /^blob:/)
|
||||
})
|
||||
|
||||
it('does not render \\includegraphics with SVG file (invalid LaTeX)', function () {
|
||||
// \includegraphics{file.svg} is not valid LaTeX - SVG files require \includesvg
|
||||
cy.get('@first-line').type(
|
||||
'\\includegraphics[width=0.5\\linewidth]{{}invalid.svg}'
|
||||
)
|
||||
|
||||
// Move cursor out to trigger any potential widget rendering
|
||||
cy.get('@second-line').click()
|
||||
|
||||
// Should NOT create a preview widget - raw LaTeX should be visible
|
||||
cy.get('.cm-content').should(
|
||||
'contain.text',
|
||||
'\\includegraphics[width=0.5\\linewidth]{invalid.svg}'
|
||||
)
|
||||
|
||||
// No graphics widget should be created
|
||||
cy.get('img.ol-cm-graphics').should('not.exist')
|
||||
})
|
||||
|
||||
describe('Figure environments', function () {
|
||||
beforeEach(function () {
|
||||
cy.get('@first-line').type('\\begin{{}figure')
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { expect } from 'chai'
|
||||
import {
|
||||
getFileExtension,
|
||||
hasImageExtension,
|
||||
isSvgFile,
|
||||
} from '../../../../../frontend/js/features/source-editor/utils/file'
|
||||
|
||||
describe('file utilities', function () {
|
||||
describe('getFileExtension', function () {
|
||||
;[
|
||||
['image.png', 'png'],
|
||||
['image.PNG', 'png'],
|
||||
['folder/subfolder/image.SVG', 'svg'],
|
||||
['my.file.name.jpg', 'jpg'],
|
||||
['filename', null],
|
||||
['.gitignore.txt', 'txt'],
|
||||
].forEach(([input, expected]) => {
|
||||
it(`returns correct extension for "${input}"`, function () {
|
||||
expect(getFileExtension(input as string)).to.equal(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasImageExtension', function () {
|
||||
;['png', 'jpg', 'jpeg', 'pdf', 'eps', 'svg'].forEach(ext => {
|
||||
it(`returns true for .${ext} files (including nested and uppercase)`, function () {
|
||||
expect(hasImageExtension(`image.${ext}`)).to.equal(true)
|
||||
expect(hasImageExtension(`image.${ext.toUpperCase()}`)).to.equal(true)
|
||||
expect(hasImageExtension(`folder/image.${ext}`)).to.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('returns false for non-image files', function () {
|
||||
expect(hasImageExtension('document.tex')).to.equal(false)
|
||||
expect(hasImageExtension('README')).to.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSvgFile', function () {
|
||||
it('returns true for svg files (including nested and uppercase)', function () {
|
||||
expect(isSvgFile('diagram.svg')).to.equal(true)
|
||||
expect(isSvgFile('diagram.SVG')).to.equal(true)
|
||||
expect(isSvgFile('figures/diagram.svg')).to.equal(true)
|
||||
})
|
||||
|
||||
it('returns false for non-svg files', function () {
|
||||
expect(isSvgFile('image.png')).to.equal(false)
|
||||
expect(isSvgFile('svgfile')).to.equal(false)
|
||||
expect(isSvgFile('svg-diagram.png')).to.equal(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user