Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
504 changes: 503 additions & 1 deletion frontend/package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
"@atlaskit/pragmatic-drag-and-drop": "^2.0.1",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^3.0.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^2.0.0",
"@blocknote/code-block": "^0.55.0",
"@blocknote/core": "^0.55.0",
"@blocknote/mantine": "^0.55.0",
"@blocknote/math-block": "^0.55.0",
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/react/components/OpBlockNoteEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,15 @@
//++

import { BlockNoteEditorOptions, BlockNoteSchema, combineByGroup, User } from '@blocknote/core';
import { createCodeBlockSpec } from '../extensions/code-block';
import { ExternalLinkA11yExtension } from '../extensions/external-link-a11y';
import { ExternalLinkCaptureExtension } from '../extensions/external-link-capture';
import { CollaborationCursorLabelFitExtension } from '../extensions/collaboration-cursor-label-fit';
import { HeadingLinkExtension } from '../extensions/heading-link';
import { filterSuggestionItems } from '@blocknote/core/extensions';
import { withCollaboration } from '@blocknote/core/yjs';
import { BlockNoteView } from '@blocknote/mantine';
import { syntaxHighlighter } from '@blocknote/code-block';
import { createReactInlineMathSpec, createReactMathBlockSpec, getMathSlashMenuItems } from '@blocknote/math-block';
import { getDefaultReactSlashMenuItems, SuggestionMenuController, useCreateBlockNote } from '@blocknote/react';
import { HocuspocusProvider } from '@hocuspocus/provider';
Expand Down Expand Up @@ -64,6 +66,7 @@ export interface OpBlockNoteEditorProps {

const schema = BlockNoteSchema.create().extend({
blockSpecs: {
codeBlock: createCodeBlockSpec(),
openProjectWorkPackageBlock: openProjectWorkPackageBlockSpec(),
mathBlock: createReactMathBlockSpec(),
},
Expand Down Expand Up @@ -105,6 +108,7 @@ export function OpBlockNoteEditor({
HeadingLinkExtension,
...(captureExternalLinks ? [ExternalLinkCaptureExtension] : []),
...(hocuspocusProvider ? [CollaborationCursorLabelFitExtension] : []),
syntaxHighlighter,
],
};

Expand Down
69 changes: 69 additions & 0 deletions frontend/src/react/extensions/code-block.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// See COPYRIGHT and LICENSE files for more details.
//++

import { BlockNoteEditor, BlockNoteSchema, PartialBlock } from '@blocknote/core';
import { createCodeBlockSpec } from './code-block';

const schema = BlockNoteSchema.create().extend({ blockSpecs: { codeBlock: createCodeBlockSpec() } });

function mountCodeBlock(props:{ language?:string }) {
const element = document.createElement('div');
document.body.appendChild(element);
const initialContent = [{ type: 'codeBlock', props, content: 'puts 1' }] as PartialBlock<typeof schema.blockSchema>[];
const editor = BlockNoteEditor.create({ schema, initialContent });
editor.mount(element);

return { editor, select: element.querySelector<HTMLSelectElement>('[data-content-type="codeBlock"] select')! };
}

describe('createCodeBlockSpec', () => {
it('offers the supported languages', () => {
const { select } = mountCodeBlock({ language: 'ruby' });

expect(select.value).toBe('ruby');
});

it('shows a language stored under an alias as its supported language', () => {
const { editor, select } = mountCodeBlock({ language: 'js' });

expect(select.value).toBe('javascript');
expect(editor.document[0].props).toEqual(expect.objectContaining({ language: 'js' }));
});

it('shows a language it does not support as plain text, keeping the stored one', () => {
const { editor, select } = mountCodeBlock({ language: 'go' });

expect(select.value).toBe('text');
expect(editor.document[0].props).toEqual(expect.objectContaining({ language: 'go' }));
});

it('starts new code blocks as plain text', () => {
const { select } = mountCodeBlock({});

expect(select.value).toBe('text');
});
});
55 changes: 55 additions & 0 deletions frontend/src/react/extensions/code-block.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// See COPYRIGHT and LICENSE files for more details.
//++

import { createCodeBlockSpec as createBlockNoteCodeBlockSpec } from '@blocknote/core';
import { codeBlockOptions } from '@blocknote/code-block';

const { supportedLanguages } = codeBlockOptions;

function supportedLanguageId(language:string):string {
const normalized = language.trim().toLowerCase();
const match = Object.entries(supportedLanguages)
.find(([id, { aliases }]) => id === normalized || aliases?.includes(normalized));

return match?.[0] ?? 'text';
}

export function createCodeBlockSpec() {
const spec = createBlockNoteCodeBlockSpec({ ...codeBlockOptions, defaultLanguage: 'text' });
const { render } = spec.implementation;

return {
...spec,
implementation: {
...spec.implementation,
render(this:ThisParameterType<typeof render>, ...[block, editor]:Parameters<typeof render>) {
const language = supportedLanguageId(block.props.language);
return render.call(this, { ...block, props: { ...block.props, language } }, editor);
},
},
};
}
11 changes: 11 additions & 0 deletions modules/documents/app/assets/stylesheets/_index.sass
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,17 @@ $blocknote-side-menu-gutter: 48px // Left lane for BlockNote's side menu (drag h

[data-content-type="heading"]:hover .op-heading-link
opacity: 1

// The global `select` rule (full width, page background) turns BlockNote's language picker
// into a band across the top of every code block.
.bn-block-content[data-content-type="codeBlock"] > div > select
width: auto
height: auto
padding: 0
background: transparent !important

> option
color: var(--fgColor-default)
> .bn-default-styles
font-family: var(--body-font-family)

Expand Down
11 changes: 11 additions & 0 deletions modules/documents/spec/features/block_note_editor_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,17 @@
end
end

it "highlights the syntax of a code block in the chosen language and stores the language" do
visit document_path(document)
expect(page).to have_test_selector("blocknote-document-description")

editor.insert_code_block("puts 'hello'", language: "Ruby")

expect(editor.element).to have_css("[data-content-type='codeBlock'] code span[style*='--shiki']")
expect(editor.code_block_language_picker_background).to start_with("rgba(0, 0, 0, 0) none")
editor.wait_for_autosave { document.reload.description&.include?("```ruby\nputs 'hello'\n```") }
end

context "when running with a relative_url_root and non-proxied frontend assets",
js: false,
with_env: { "OPENPROJECT_DISABLE_DEV_ASSET_PROXY" => "1" } do
Expand Down
15 changes: 15 additions & 0 deletions spec/support/form_fields/primerized/block_note_editor_input.rb
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ def insert_inline_equation(latex)
insert_equation("/inline equation", latex)
end

def insert_code_block(code, language:)
send_keys_to_editor("/code block")
send_keys(:enter)
send_keys(code)
code_block = shadow_root.find("[data-content-type='codeBlock']")
code_block.hover
code_block.find("select").find("option", exact_text: language).select_option
end

def click_formatting_toolbar_button(label)
shadow_root.find("button[aria-label='#{label}']").click
end
Expand Down Expand Up @@ -212,6 +221,12 @@ def katex_stylesheet_applied?
JS
end

def code_block_language_picker_background
page.evaluate_script(<<~JS)
getComputedStyle(#{shadow_root_query}.querySelector('[data-content-type="codeBlock"] select')).background
JS
end

def heading_to_paragraph_font_size_ratio
heading_font_size, paragraph_font_size = page.evaluate_script(<<~JS)
[
Expand Down
Loading