diff --git a/backend/src/baserow/contrib/builder/elements/element_types.py b/backend/src/baserow/contrib/builder/elements/element_types.py index 505afadca..1064141ef 100644 --- a/backend/src/baserow/contrib/builder/elements/element_types.py +++ b/backend/src/baserow/contrib/builder/elements/element_types.py @@ -451,12 +451,13 @@ class TextElementType(ElementType): type = "text" model_class = TextElement - serializer_field_names = ["value", "alignment"] - allowed_fields = ["value", "alignment"] + serializer_field_names = ["value", "alignment", "format"] + allowed_fields = ["value", "alignment", "format"] class SerializedDict(ElementDict): value: BaserowFormula alignment: str + format: str def get_pytest_params(self, pytest_data_fixture): return { @@ -466,6 +467,7 @@ class TextElementType(ElementType): "Asperiores corporis perspiciatis nam harum veritatis. " "Impedit qui maxime aut illo quod ea molestias.'", "alignment": "left", + "format": TextElement.TEXT_FORMATS.PLAIN, } @property @@ -479,6 +481,11 @@ class TextElementType(ElementType): allow_blank=True, default="", ), + "format": serializers.ChoiceField( + choices=TextElement.TEXT_FORMATS.choices, + default=TextElement.TEXT_FORMATS.PLAIN, + help_text=TextElement._meta.get_field("format").help_text, + ), } def import_serialized(self, page, serialized_values, id_mapping): diff --git a/backend/src/baserow/contrib/builder/elements/models.py b/backend/src/baserow/contrib/builder/elements/models.py index 0c5a4d1a5..c32456e9a 100644 --- a/backend/src/baserow/contrib/builder/elements/models.py +++ b/backend/src/baserow/contrib/builder/elements/models.py @@ -355,12 +355,22 @@ class TextElement(Element): A simple blob of text. """ + class TEXT_FORMATS(models.TextChoices): + PLAIN = "plain" + MARKDOWN = "markdown" + value = FormulaField(default="") alignment = models.CharField( choices=HorizontalAlignments.choices, max_length=10, default=HorizontalAlignments.LEFT, ) + format = models.CharField( + choices=TEXT_FORMATS.choices, + help_text="The format of the text", + max_length=10, + default=TEXT_FORMATS.PLAIN, + ) class LinkElement(Element): diff --git a/backend/src/baserow/contrib/builder/migrations/0005_textelement_format.py b/backend/src/baserow/contrib/builder/migrations/0005_textelement_format.py new file mode 100644 index 000000000..5c1a615ce --- /dev/null +++ b/backend/src/baserow/contrib/builder/migrations/0005_textelement_format.py @@ -0,0 +1,22 @@ +# Generated by Django 4.0.10 on 2024-01-17 14:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("builder", "0004_image_element_sizing_styles"), + ] + + operations = [ + migrations.AddField( + model_name="textelement", + name="format", + field=models.CharField( + choices=[("plain", "Plain"), ("markdown", "Markdown")], + default="plain", + help_text="The format of the text", + max_length=10, + ), + ), + ] diff --git a/backend/tests/baserow/contrib/builder/test_builder_application_type.py b/backend/tests/baserow/contrib/builder/test_builder_application_type.py index 5ddaa4757..7fdf451f9 100644 --- a/backend/tests/baserow/contrib/builder/test_builder_application_type.py +++ b/backend/tests/baserow/contrib/builder/test_builder_application_type.py @@ -228,6 +228,7 @@ def test_builder_application_export(data_fixture): "style_background": "none", "value": element2.value, "alignment": "left", + "format": TextElement.TEXT_FORMATS.PLAIN, }, { "id": element_container.id, @@ -277,6 +278,7 @@ def test_builder_application_export(data_fixture): "order": str(element_inside_container.order), "value": element_inside_container.value, "alignment": "left", + "format": TextElement.TEXT_FORMATS.PLAIN, }, ], }, diff --git a/web-frontend/modules/builder/components/elements/components/HeadingElement.vue b/web-frontend/modules/builder/components/elements/components/HeadingElement.vue index 20c1c5d0b..3c65ac617 100644 --- a/web-frontend/modules/builder/components/elements/components/HeadingElement.vue +++ b/web-frontend/modules/builder/components/elements/components/HeadingElement.vue @@ -8,11 +8,9 @@ <component :is="`h${element.level}`" class="heading-element__heading" + :class="{ [`ab-heading--h${element.level}`]: true }" :style="{ '--color': resolveColor(element.font_color, headingColorVariables), - '--font-size': `${ - builder.theme[`heading_${element.level}_font_size`] - }px`, }" > {{ resolvedValue || $t('headingElement.noValue') }} diff --git a/web-frontend/modules/builder/components/elements/components/TextElement.vue b/web-frontend/modules/builder/components/elements/components/TextElement.vue index 1c085c3b9..0ef0a9052 100644 --- a/web-frontend/modules/builder/components/elements/components/TextElement.vue +++ b/web-frontend/modules/builder/components/elements/components/TextElement.vue @@ -6,12 +6,28 @@ [`element--alignment-horizontal-${element.alignment}`]: true, }" > - <p v-for="paragraph in paragraphs" :key="paragraph.id" class="ab-paragraph"> - {{ paragraph.content }} - </p> - <p v-if="!paragraphs.length" class="ab-paragraph"> - {{ $t('textElement.noValue') }} - </p> + <template v-if="element.format === TEXT_FORMAT_TYPES.MARKDOWN"> + <MarkdownIt + v-if="resolvedValue" + class="ab-paragraph" + :content="resolvedValue" + :rules="rules" + @click.native="onClick" + ></MarkdownIt> + <p v-else class="ab-paragraph">{{ $t('textElement.noValue') }}</p> + </template> + <template v-else> + <p + v-for="paragraph in paragraphs" + :key="paragraph.id" + class="ab-paragraph" + > + {{ paragraph.content }} + </p> + <p v-if="!paragraphs.length" class="ab-paragraph"> + {{ $t('textElement.noValue') }} + </p> + </template> </div> </template> @@ -19,6 +35,8 @@ import element from '@baserow/modules/builder/mixins/element' import { generateHash } from '@baserow/modules/core/utils/hashing' import { ensureString } from '@baserow/modules/core/utils/validator' +import { TEXT_FORMAT_TYPES } from '@baserow/modules/builder/enums' +import { prefixInternalResolvedUrl } from '@baserow/modules/builder/utils/urlResolution' /** * @typedef Text @@ -34,6 +52,7 @@ export default { * @type {Object} * @property {Array.<Text>} value - A list of paragraphs * @property {string} alignment - The alignment of the element on the page + * @property {string} format - The format of the text */ element: { type: Object, @@ -58,6 +77,77 @@ export default { id: generateHash(line + index), })) }, + TEXT_FORMAT_TYPES() { + return TEXT_FORMAT_TYPES + }, + // Custom rules to pass down to `MarkdownIt` element. + // The goal is to make the styling of the rendered markdown content + // consistent with the rest of the application builder CSS classes + rules() { + return { + heading_open: (tokens, idx, options, env, renderer) => { + const level = tokens[idx].markup.length + tokens[idx].attrJoin('class', `ab-heading--h${level}`) + return renderer.renderToken(tokens, idx, options) + }, + link_open: (tokens, idx, options, env, renderer) => { + const url = prefixInternalResolvedUrl( + tokens[idx].attrGet('href'), + this.builder, + 'custom', + this.mode + ) + tokens[idx].attrSet('href', url) + tokens[idx].attrJoin('class', 'link-element__link') + return renderer.renderToken(tokens, idx, options) + }, + image: (tokens, idx, options, env, renderer) => { + tokens[idx].attrJoin('class', 'image_element__img') + return renderer.renderToken(tokens, idx, options) + }, + paragraph_open: (tokens, idx, options, env, renderer) => { + tokens[idx].attrJoin('class', 'ab-paragraph') + return renderer.renderToken(tokens, idx, options) + }, + table_open: (tokens, idx, options, env, renderer) => { + tokens[idx].attrJoin('class', 'baserow-table') + return renderer.renderToken(tokens, idx, options) + }, + tr_open: (tokens, idx, options, env, renderer) => { + // Only apply this styling to the first row present in table header. + if (idx > 0 && tokens[idx - 1].type === 'thead_open') { + tokens[idx].attrJoin('class', 'baserow-table__header-row') + } else { + tokens[idx].attrJoin('class', 'baserow-table__row') + } + return renderer.renderToken(tokens, idx, options) + }, + th_open: (tokens, idx, options, env, renderer) => { + tokens[idx].attrJoin('class', 'baserow-table__header-cell') + return renderer.renderToken(tokens, idx, options) + }, + td_open: (tokens, idx, options, env, renderer) => { + tokens[idx].attrJoin('class', 'baserow-table__cell') + return renderer.renderToken(tokens, idx, options) + }, + } + }, + }, + methods: { + onClick(event) { + if (this.mode === 'editing') { + event.preventDefault() + return + } + if (event.target.classList.contains('link-element__link')) { + const url = event.target.getAttribute('href') + + if (url.startsWith('/')) { + event.preventDefault() + this.$router.push(url) + } + } + }, }, } </script> diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/TextElementForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/TextElementForm.vue index f714718ac..05eb34744 100644 --- a/web-frontend/modules/builder/components/elements/components/forms/general/TextElementForm.vue +++ b/web-frontend/modules/builder/components/elements/components/forms/general/TextElementForm.vue @@ -9,13 +9,24 @@ <FormElement class="control"> <HorizontalAlignmentsSelector v-model="values.alignment" /> </FormElement> + <FormGroup :label="$t('textElementForm.textFormatTypeLabel')"> + <RadioButton v-model="values.format" :value="TEXT_FORMAT_TYPES.PLAIN"> + {{ $t('textElementForm.textFormatTypePlain') }} + </RadioButton> + <RadioButton v-model="values.format" :value="TEXT_FORMAT_TYPES.MARKDOWN"> + {{ $t('textElementForm.textFormatTypeMarkdown') }} + </RadioButton> + </FormGroup> </form> </template> <script> import ApplicationBuilderFormulaInputGroup from '@baserow/modules/builder/components/ApplicationBuilderFormulaInputGroup' import elementForm from '@baserow/modules/builder/mixins/elementForm' -import { HORIZONTAL_ALIGNMENTS } from '@baserow/modules/builder/enums' +import { + HORIZONTAL_ALIGNMENTS, + TEXT_FORMAT_TYPES, +} from '@baserow/modules/builder/enums' import HorizontalAlignmentsSelector from '@baserow/modules/builder/components/elements/components/forms/general/settings/HorizontalAlignmentsSelector.vue' export default { @@ -30,8 +41,14 @@ export default { values: { value: '', alignment: HORIZONTAL_ALIGNMENTS.LEFT.value, + format: TEXT_FORMAT_TYPES.PLAIN, }, } }, + computed: { + TEXT_FORMAT_TYPES() { + return TEXT_FORMAT_TYPES + }, + }, } </script> diff --git a/web-frontend/modules/builder/components/page/PageContent.vue b/web-frontend/modules/builder/components/page/PageContent.vue index 36da4f235..df22a2f88 100644 --- a/web-frontend/modules/builder/components/page/PageContent.vue +++ b/web-frontend/modules/builder/components/page/PageContent.vue @@ -1,19 +1,20 @@ <template> - <div> + <ThemeProvider> <PageElement v-for="element in elements" :key="element.id" :element="element" :mode="mode" /> - </div> + </ThemeProvider> </template> <script> import PageElement from '@baserow/modules/builder/components/page/PageElement' +import ThemeProvider from '@baserow/modules/builder/components/theme/ThemeProvider.vue' export default { - components: { PageElement }, + components: { ThemeProvider, PageElement }, inject: ['builder', 'mode'], props: { page: { diff --git a/web-frontend/modules/builder/components/page/PagePreview.vue b/web-frontend/modules/builder/components/page/PagePreview.vue index cceeabd9e..94d079825 100644 --- a/web-frontend/modules/builder/components/page/PagePreview.vue +++ b/web-frontend/modules/builder/components/page/PagePreview.vue @@ -1,5 +1,5 @@ <template> - <div + <ThemeProvider class="page-preview__wrapper" @click.self="actionSelectElement({ element: null })" > @@ -32,7 +32,7 @@ /> </div> </div> - </div> + </ThemeProvider> </template> <script> @@ -42,10 +42,12 @@ import { notifyIf } from '@baserow/modules/core/utils/error' import PreviewNavigationBar from '@baserow/modules/builder/components/page/PreviewNavigationBar' import { PLACEMENTS } from '@baserow/modules/builder/enums' import AddElementModal from '@baserow/modules/builder/components/elements/AddElementModal.vue' +import ThemeProvider from '@baserow/modules/builder/components/theme/ThemeProvider.vue' export default { name: 'PagePreview', components: { + ThemeProvider, AddElementModal, ElementPreview, PreviewNavigationBar, diff --git a/web-frontend/modules/builder/components/theme/ThemeProvider.vue b/web-frontend/modules/builder/components/theme/ThemeProvider.vue new file mode 100644 index 000000000..903b87836 --- /dev/null +++ b/web-frontend/modules/builder/components/theme/ThemeProvider.vue @@ -0,0 +1,32 @@ +<template> + <div :style="style"> + <slot></slot> + </div> +</template> + +<script> +export default { + name: 'ThemeProvider', + inject: ['builder'], + computed: { + style() { + const colors = { + '--primary-color': this.builder.theme.primary_color, + '--secondary-color': this.builder.theme.secondary_color, + } + const headings = Array.from([1, 2, 3, 4, 5, 6]).reduce( + (headings, level) => ({ + [`--heading-h${level}--font-size`]: `${ + this.builder.theme[`heading_${level}_font_size`] + }px`, + [`--heading-h${level}--color`]: + this.builder.theme[`heading_${level}_color`], + ...headings, + }), + {} + ) + return { ...colors, ...headings } + }, + }, +} +</script> diff --git a/web-frontend/modules/builder/enums.js b/web-frontend/modules/builder/enums.js index 8c985d520..8e076d335 100644 --- a/web-frontend/modules/builder/enums.js +++ b/web-frontend/modules/builder/enums.js @@ -20,6 +20,11 @@ export const PAGE_PARAM_TYPE_VALIDATION_FUNCTIONS = { text: ensureNonEmptyString, } +export const TEXT_FORMAT_TYPES = { + PLAIN: 'plain', + MARKDOWN: 'markdown', +} + export const IMAGE_SOURCE_TYPES = { UPLOAD: 'upload', URL: 'url', diff --git a/web-frontend/modules/builder/locales/en.json b/web-frontend/modules/builder/locales/en.json index 3be5a9e00..1d26c8c97 100644 --- a/web-frontend/modules/builder/locales/en.json +++ b/web-frontend/modules/builder/locales/en.json @@ -143,7 +143,10 @@ "textElementForm": { "textTitle": "Text", "textPlaceholder": "Enter text...", - "textError": "The value is invalid." + "textError": "The value is invalid.", + "textFormatTypeLabel": "Format", + "textFormatTypePlain": "Plain text", + "textFormatTypeMarkdown": "Markdown" }, "imageElement": { "emptyState": "No alt text defined..." diff --git a/web-frontend/modules/core/assets/scss/components/all.scss b/web-frontend/modules/core/assets/scss/components/all.scss index 18fcbf26b..a00836251 100644 --- a/web-frontend/modules/core/assets/scss/components/all.scss +++ b/web-frontend/modules/core/assets/scss/components/all.scss @@ -19,7 +19,6 @@ @import 'dropdown'; @import 'field_form_context'; @import 'tooltip'; -@import 'markdown'; @import 'rating'; @import 'progress'; @import 'fields/boolean'; diff --git a/web-frontend/modules/core/assets/scss/components/api_docs.scss b/web-frontend/modules/core/assets/scss/components/api_docs.scss index 1ea1d2688..7559f4c65 100644 --- a/web-frontend/modules/core/assets/scss/components/api_docs.scss +++ b/web-frontend/modules/core/assets/scss/components/api_docs.scss @@ -131,6 +131,25 @@ .api-docs__content { color: $color-neutral-900; + + &.markdown code { + display: inline-block; + font-size: 14px; + padding: 3px 6px; + background-color: $color-neutral-100; + @include rounded($rounded); + } + + &.markdown p { + color: $color-neutral-900; + margin-bottom: 20px; + font-size: 13px; + line-height: 140%; + } + + &.markdown ul { + padding-left: 1em; + } } .api-docs__heading-wrapper { diff --git a/web-frontend/modules/core/assets/scss/components/builder/elements/heading_element.scss b/web-frontend/modules/core/assets/scss/components/builder/elements/heading_element.scss index b48501565..cdbed112a 100644 --- a/web-frontend/modules/core/assets/scss/components/builder/elements/heading_element.scss +++ b/web-frontend/modules/core/assets/scss/components/builder/elements/heading_element.scss @@ -2,7 +2,6 @@ margin: 0; text-align: left; color: var(--color, $black); - font-size: var(--font-size, 30px); .element--alignment-horizontal-center & { text-align: center; @@ -13,29 +12,33 @@ } } -// These tag specific selectors will be removed once the heading element has it's own -// custom styling. -h1.heading-element__heading { - font-size: var(--font-size, 30px); +.ab-heading--h1 { + color: var(--heading-h1--color, $black); + font-size: var(--heading-h1--font-size, 30px); } -h2.heading-element__heading { - font-size: var(--font-size, 26px); +.ab-heading--h2 { + color: var(--heading-h2--color, $black); + font-size: var(--heading-h2--font-size, 26px); } -h3.heading-element__heading { - font-size: var(--font-size, 22px); +.ab-heading--h3 { + color: var(--heading-h3--color, $black); + font-size: var(--heading-h3--font-size, 22px); } -h4.heading-element__heading { - font-size: var(--font-size, 18px); +.ab-heading--h4 { + color: var(--heading-h4--color, $black); + font-size: var(--heading-h4--font-size, 18px); } -h5.heading-element__heading { - font-size: var(--font-size, 14px); +.ab-heading--h5 { + color: var(--heading-h5--color, $black); + font-size: var(--heading-h5--font-size, 14px); } -h6.heading-element__heading { - font-size: var(--font-size, 14px); +.ab-heading--h6 { + color: var(--heading-h6--color, $black); + font-size: var(--heading-h6--font-size, 14px); font-style: italic; } diff --git a/web-frontend/modules/core/assets/scss/components/markdown.scss b/web-frontend/modules/core/assets/scss/components/markdown.scss deleted file mode 100644 index bf7a55fc5..000000000 --- a/web-frontend/modules/core/assets/scss/components/markdown.scss +++ /dev/null @@ -1,20 +0,0 @@ -.markdown { - & code { - display: inline-block; - font-size: 14px; - padding: 3px 6px; - background-color: $color-neutral-100; - @include rounded($rounded); - } - - & p { - color: $color-neutral-900; - margin-bottom: 20px; - font-size: 13px; - line-height: 140%; - } - - & ul { - padding-left: 1em; - } -} diff --git a/web-frontend/modules/core/components/MarkdownIt.vue b/web-frontend/modules/core/components/MarkdownIt.vue index 68710fdba..274208c30 100644 --- a/web-frontend/modules/core/components/MarkdownIt.vue +++ b/web-frontend/modules/core/components/MarkdownIt.vue @@ -16,6 +16,11 @@ export default { type: String, default: 'div', }, + rules: { + required: false, + type: Object, + default: () => {}, + }, }, data() { return { @@ -38,6 +43,7 @@ export default { async created() { const Markdown = (await import('markdown-it')).default this.md = new Markdown() + this.md.renderer.rules = { ...this.md.renderer.rules, ...this.rules } this.htmlContent = this.md.render(this.content) }, }