diff --git a/entry_types/scrolled/config/locales/new/hotspots.de.yml b/entry_types/scrolled/config/locales/new/hotspots.de.yml index 0b2003950..b0d80a9cb 100644 --- a/entry_types/scrolled/config/locales/new/hotspots.de.yml +++ b/entry_types/scrolled/config/locales/new/hotspots.de.yml @@ -31,6 +31,19 @@ de: values: indicator: Am Indikator area: Am Bereich + tooltipMaxWidth: + label: Tooltip-Maximalbreite + values: + medium: "Mittel" + veryNarrow: Sehr schmal + narrow: Schmal + wide: Breit + tooltipTextAlign: + label: Textausrichtung in Tooltip + values: + left: Links + center: Zentriert + right: Rechts color: label: Farbe activeImage: @@ -51,6 +64,13 @@ de: values: indicator: Am Indikator area: Am Bereich + portraitTooltipMaxWidth: + label: Tooltip-Maximalbreite (Hochkant) + values: + medium: "Mittel" + narrow: Schmal + veryNarrow: Sehr schmal + wide: Breit portraitColor: label: Farbe (Hochkant) portraitActiveImage: diff --git a/entry_types/scrolled/config/locales/new/hotspots.en.yml b/entry_types/scrolled/config/locales/new/hotspots.en.yml index db1a4f835..5f758d802 100644 --- a/entry_types/scrolled/config/locales/new/hotspots.en.yml +++ b/entry_types/scrolled/config/locales/new/hotspots.en.yml @@ -31,6 +31,19 @@ en: values: indicator: At indicator area: At area + tooltipMaxWidth: + label: Tooltip maximum width + values: + medium: Medium + narrow: Narrow + veryNarrow: Very Narrow + wide: Wide + tooltipTextAlign: + label: Text alignment in tooltip + values: + left: Left + center: Center + right: Right color: label: Color activeImage: @@ -51,6 +64,13 @@ en: values: indicator: At indicator area: At area + portraitTooltipMaxWidth: + label: Tooltip maximum width (Portrait) + values: + medium: Medium + narrow: Narrow + veryNarrow: Very Narrow + wide: Wide portraitColor: label: Color (Portrait) portraitActiveImage: diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js index abdf2b479..b1e0b74ae 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/Hotspots-spec.js @@ -34,6 +34,7 @@ describe('Hotspots', () => { this.callback = callback; this.observe = observeResizeMock; this.unobserve = function(element) {}; + this.disconnect = function() {}; }; }); @@ -356,7 +357,7 @@ describe('Hotspots', () => { const seed = { imageFileUrlTemplates: { large: 'large/:id_partition/image.webp', - linkThumbnailLarge: 'linkThumbnailLarge/:id_partition/image.webp' + medium: 'medium/:id_partition/image.webp' }, imageFiles: [{id: 1, permaId: 100}, {id: 2, permaId: 101}] }; @@ -395,7 +396,7 @@ describe('Hotspots', () => { expect(queryByText('Some link')).not.toBeNull(); expect(getByRole('link')).toHaveAttribute('href', 'https://example.com'); expect(getByRole('link')).toHaveAttribute('target', '_blank'); - expect(getByRole('img')).toHaveAttribute('src', 'linkThumbnailLarge/000/000/002/image.webp'); + expect(getByRole('img')).toHaveAttribute('src', 'medium/000/000/002/image.webp'); }); it('does not render tooltip link if link text is blank', async () => { @@ -424,14 +425,184 @@ describe('Hotspots', () => { }; const user = userEvent.setup(); - const {container, queryByRole} = renderInContentElement( + const {container, queryByRole, simulateScrollPosition} = renderInContentElement( , {seed} ); + simulateScrollPosition('near viewport'); await user.click(container.querySelector(`.${areaStyles.clip}`)) expect(queryByRole('link')).toBeNull(); }); + it('does not apply min width to tooltip without link', async () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}] + }; + const configuration = { + image: 100, + areas: [ + { + id: 1, + indicatorPosition: [10, 20], + } + ], + tooltipTexts: { + 1: { + title: [{type: 'heading', children: [{text: 'Some title'}]}], + description: [{type: 'paragraph', children: [{text: 'Some description'}]}], + link: [{type: 'paragraph', children: [{text: ''}]}] + } + }, + tooltipLinks: { + 1: {href: 'https://example.com', openInNewTab: true} + } + }; + + const user = userEvent.setup(); + const {container, simulateScrollPosition} = renderInContentElement( + , {seed} + ); + simulateScrollPosition('near viewport'); + await user.click(container.querySelector(`.${areaStyles.clip}`)) + + expect(container.querySelector(`.${tooltipStyles.box}`)).not.toHaveClass(tooltipStyles.minWidth); + }); + + it('applies min width to tooltip with link', async () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}] + }; + const configuration = { + image: 100, + areas: [ + { + id: 1, + indicatorPosition: [10, 20], + } + ], + tooltipTexts: { + 1: { + title: [{type: 'heading', children: [{text: 'Some title'}]}], + description: [{type: 'paragraph', children: [{text: 'Some description'}]}], + link: [{type: 'paragraph', children: [{text: 'Some link'}]}] + } + }, + tooltipLinks: { + 1: {href: 'https://example.com', openInNewTab: true} + } + }; + + const user = userEvent.setup(); + const {container, simulateScrollPosition} = renderInContentElement( + , {seed} + ); + simulateScrollPosition('near viewport'); + await user.click(container.querySelector(`.${areaStyles.clip}`)) + + expect(container.querySelector(`.${tooltipStyles.box}`)).toHaveClass(tooltipStyles.minWidth); + }); + + it('applies max width to tooltip', async () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}] + }; + const configuration = { + image: 100, + areas: [ + { + id: 1, + indicatorPosition: [10, 20], + tooltipMaxWidth: 'narrow' + } + ], + tooltipTexts: { + 1: { + title: [{type: 'heading', children: [{text: 'Some title'}]}], + description: [{type: 'paragraph', children: [{text: 'Some description'}]}] + } + } + }; + + const user = userEvent.setup(); + const {container, simulateScrollPosition} = renderInContentElement( + , {seed} + ); + simulateScrollPosition('near viewport'); + await user.click(container.querySelector(`.${areaStyles.clip}`)) + + expect(container.querySelector(`.${tooltipStyles.box}`)).toHaveClass(tooltipStyles['maxWidth-narrow']); + }); + + it('supports separate max width for portrait mode', async () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}] + }; + const configuration = { + image: 100, + portraitImage: 100, + areas: [ + { + id: 1, + indicatorPosition: [10, 20], + tooltipMaxWidth: 'narrow', + portraitTooltipMaxWidth: 'veryNarrow' + } + ], + tooltipTexts: { + 1: { + title: [{type: 'heading', children: [{text: 'Some title'}]}], + description: [{type: 'paragraph', children: [{text: 'Some description'}]}] + } + } + }; + + const user = userEvent.setup(); + window.matchMedia.mockPortrait(); + const {container, simulateScrollPosition} = renderInContentElement( + , {seed} + ); + simulateScrollPosition('near viewport'); + await user.click(container.querySelector(`.${areaStyles.clip}`)) + + expect(container.querySelector(`.${tooltipStyles.box}`)).toHaveClass(tooltipStyles['maxWidth-veryNarrow']); + }); + + it('supports setting tooltip text align', async () => { + const seed = { + imageFileUrlTemplates: {large: ':id_partition/image.webp'}, + imageFiles: [{id: 1, permaId: 100}] + }; + const configuration = { + image: 100, + areas: [ + { + id: 1, + indicatorPosition: [10, 20], + tooltipTextAlign: 'center' + } + ], + tooltipTexts: { + 1: { + title: [{type: 'heading', children: [{text: 'Some title'}]}], + description: [{type: 'paragraph', children: [{text: 'Some description'}]}] + } + } + }; + + const user = userEvent.setup(); + const {container, simulateScrollPosition} = renderInContentElement( + , {seed} + ); + simulateScrollPosition('near viewport'); + await user.click(container.querySelector(`.${areaStyles.clip}`)) + + expect(container.querySelector(`.${tooltipStyles.box}`)).toHaveClass(tooltipStyles['align-center']); + }); + it('does not observe resize by default', () => { const seed = { imageFileUrlTemplates: {large: ':id_partition/image.webp'}, @@ -570,7 +741,7 @@ describe('Hotspots', () => { image: 100, areas: [ { - indicatorPosition: [10, 20], + indicatorPosition: [15, 20], outline: [[10, 20], [10, 30], [40, 30], [40, 15]], tooltipReference: 'area' } @@ -584,9 +755,8 @@ describe('Hotspots', () => { simulateScrollPosition('near viewport'); expect(container.querySelector(`.${tooltipStyles.reference}`)).toHaveStyle({ - left: '10px', + left: '15px', top: '15px', - width: '30px', height: '15px' }); }); @@ -618,9 +788,8 @@ describe('Hotspots', () => { simulateScrollPosition('near viewport'); expect(container.querySelector(`.${tooltipStyles.reference}`)).toHaveStyle({ - left: '10px', + left: '20px', top: '15px', - width: '30px', height: '15px' }); }); @@ -654,7 +823,6 @@ describe('Hotspots', () => { expect(container.querySelector(`.${tooltipStyles.reference}`)).toHaveStyle({ left: '10px', top: '15px', - width: '30px', height: '15px' }); }); diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js index 9972bc575..5e3a22140 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.js @@ -36,6 +36,7 @@ export function Tooltip({ panZoomEnabled, imageFile, containerRect, keepInViewport, floatingStrategy, onMouseEnter, onMouseLeave, onClick, onDismiss, }) { + const {t: translateWithEntryLocale} = useI18n(); const {t} = useI18n({locale: 'ui'}); const updateConfiguration = useContentElementConfigurationUpdate(); const {isEditable} = useContentElementEditorState(); @@ -55,6 +56,7 @@ export function Tooltip({ const referenceType = portraitMode ? area.portraitTooltipReference : area.tooltipReference; const position = portraitMode ? area.portraitTooltipPosition : area.tooltipPosition; + const maxWidth = portraitMode ? area.portraitTooltipMaxWidth : area.tooltipMaxWidth; const arrowRef = useRef(); const {refs, floatingStyles, context} = useFloating({ @@ -64,18 +66,14 @@ export function Tooltip({ placement: position === 'above' ? 'top' : 'bottom', middleware: [ offset(referenceType === 'area' ? 7 : 20), - shift({crossAxis: keepInViewport}), + shift({crossAxis: keepInViewport, padding: {left: 16, right: 16}}), keepInViewport && flip(), arrow({ - element: arrowRef + element: arrowRef, + padding: 5 }) ], - whileElementsMounted(referenceEl, floatingEl, update) { - return autoUpdate(referenceEl, floatingEl, update, { - elementResize: false, - layoutShift: false, - }); - } + whileElementsMounted: autoUpdate }); const role = useRole(context, {role: 'label'}); @@ -107,7 +105,7 @@ export function Tooltip({ if (utils.isBlankEditableTextValue(tooltipTexts[area.id]?.link)) { handleTextChange('link', [{ type: 'heading', - children: [{text: t('pageflow_scrolled.public.more')}] + children: [{text: translateWithEntryLocale('pageflow_scrolled.public.more')}] }]); } @@ -143,17 +141,20 @@ export function Tooltip({
{presentOrEditing('title') &&

diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.module.css b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.module.css index 28e423acc..58e91218e 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.module.css +++ b/entry_types/scrolled/package/src/contentElements/hotspots/Tooltip.module.css @@ -24,11 +24,35 @@ background-color: #fff; color: #000; box-sizing: border-box; - padding: 1rem; + padding: 0.75rem; box-shadow: 0px 3px 3px -2px rgba(0,0,0,0.2), 0px 3px 4px 0px rgba(0,0,0,0.14), 0px 1px 8px 0px rgba(0,0,0,0.12); border-radius: 5px; - width: calc(100% - 2rem); - max-width: 400px; + --max-width: 400px; + max-width: min(100% - 2rem, var(--max-width)); +} + +.maxWidth-wide { + --max-width: 600px; +} + +.maxWidth-narrow { + --max-width: 300px; +} + +.maxWidth-veryNarrow { + --max-width: 200px; +} + +.align-center { + text-align: center; +} + +.align-right { + text-align: right; +} + +.minWidth { + min-width: 200px; } .box svg { @@ -37,6 +61,7 @@ .box img { width: 100%; + height: auto; margin-bottom: 1rem; } @@ -47,7 +72,7 @@ .box > h3, .box > div { - margin-bottom: 0.5em; + margin-bottom: 0.5rem; } .box > :last-child { @@ -60,10 +85,9 @@ gap: 0.5em; border-radius: 5px; text-decoration: none; - padding: 0.75rem; + padding: 0.5rem; background-color: var(--theme-widget-primary-color); color: var(--theme-widget-on-primary-color); - font-size: 18px; margin-top: 1rem; font-weight: bold; } diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarEditAreaView.js b/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarEditAreaView.js index 8042d471d..aa1e316bf 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarEditAreaView.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarEditAreaView.js @@ -77,6 +77,10 @@ export const SidebarEditAreaView = Marionette.Layout.extend({ this.input('tooltipPosition', SelectInputView, { values: ['below', 'above'] }); + this.input('tooltipMaxWidth', SelectInputView, { + defaultValue: 'medium', + values: ['wide', 'medium', 'narrow', 'veryNarrow'] + }); this.view(SeparatorView); this.input('tooltipImage', FileInputView, { collection: 'image_files', @@ -87,6 +91,9 @@ export const SidebarEditAreaView = Marionette.Layout.extend({ }, positioning: false }); + this.input('tooltipTextAlign', SelectInputView, { + values: ['left', 'right', 'center'] + }); }); if (portraitFile) { @@ -124,6 +131,10 @@ export const SidebarEditAreaView = Marionette.Layout.extend({ this.input('portraitTooltipPosition', SelectInputView, { values: ['below', 'above'] }); + this.input('portraitTooltipMaxWidth', SelectInputView, { + defaultValue: 'medium', + values: ['wide', 'medium', 'narrow', 'veryNarrow'] + }); }); } diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js b/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js index e73cd7ec7..207e1a173 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js @@ -17,7 +17,7 @@ editor.registerSideBarRouting({ editor.contentElementTypes.register('hotspots', { pictogram, - category: 'links', + category: 'interactive', featureName: 'hotspots_content_element', supportedPositions: ['inline', 'sticky', 'standAlone', 'left', 'right', 'backdrop'], supportedWidthRange: ['xxs', 'full'], diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/getTooltipReferencePosition.js b/entry_types/scrolled/package/src/contentElements/hotspots/getTooltipReferencePosition.js index cb28633d0..247ebee15 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/getTooltipReferencePosition.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/getTooltipReferencePosition.js @@ -6,38 +6,49 @@ export function getTooltipReferencePosition({ portraitMode, panZoomEnabled, imageFile, containerRect }) { - const referenceType = portraitMode ? area.portraitTooltipReference : area.tooltipReference; - - const referencePositionInPercent = - referenceType === 'area' ? - getBoundingRect(portraitMode ? area.portraitOutline : area.outline) : - getIndicatorRect(portraitMode ? area.portraitIndicatorPosition : area.indicatorPosition) + const referencePositionInPercent = getReferencePositionInPercent({area, portraitMode}); const transform = panZoomEnabled ? getPanZoomStepTransform({ - areaOutline: portraitMode ? area.portraitOutline : area.outline, - areaZoom: (portraitMode ? area.portraitZoom : area.zoom) || 0, - imageFileWidth: imageFile?.width, - imageFileHeight: imageFile?.height, - containerWidth: containerRect.width, - containerHeight: containerRect.height - }) : - {x: 0, y: 0, scale: 1}; + areaOutline: portraitMode ? area.portraitOutline : area.outline, + areaZoom: (portraitMode ? area.portraitZoom : area.zoom) || 0, + imageFileWidth: imageFile?.width, + imageFileHeight: imageFile?.height, + containerWidth: containerRect.width, + containerHeight: containerRect.height + }) : + {x: 0, y: 0, scale: 1}; return { left: containerRect.width * transform.scale * referencePositionInPercent.left / 100 + transform.x, top: containerRect.height * transform.scale * referencePositionInPercent.top / 100 + transform.y, - width: containerRect.width * transform.scale * referencePositionInPercent.width / 100, height: containerRect.height * transform.scale * referencePositionInPercent.height / 100 }; } +function getReferencePositionInPercent({area, portraitMode}) { + const referenceType = portraitMode ? area.portraitTooltipReference : area.tooltipReference; + const indicatorRect = getIndicatorRect(portraitMode ? area.portraitIndicatorPosition : area.indicatorPosition); + + if (referenceType === 'area') { + const boundingRect = getBoundingRect(portraitMode ? area.portraitOutline : area.outline); + + return { + top: boundingRect.top, + height: boundingRect.height, + left: indicatorRect.left + } + } + else { + return indicatorRect; + } +} + function getIndicatorRect(indicatorPosition = [50, 50]) { return { left: indicatorPosition[0], top: indicatorPosition[1], - width: 0, height: 0 }; } diff --git a/entry_types/scrolled/package/src/frontend/Text.module.css b/entry_types/scrolled/package/src/frontend/Text.module.css index e2cf7cfe6..7a1c15ab2 100644 --- a/entry_types/scrolled/package/src/frontend/Text.module.css +++ b/entry_types/scrolled/package/src/frontend/Text.module.css @@ -1,3 +1,4 @@ +@value text-2xs: 16px; @value text-xs: 18px; @value text-s: 20px; @value text-base: 22px; @@ -188,20 +189,20 @@ .hotspotsTooltipTitle { composes: typography-hotspotTooltipTitle from global; - font-size: text-s; + font-size: text-xs; line-height: 1.4; font-weight: bold; } .hotspotsTooltipDescription { composes: typography-hotspotTooltipDescription from global; - font-size: text-s; + font-size: text-xs; line-height: 1.4; } .hotspotsTooltipLink { composes: typography-hotspotTooltipLink from global; - font-size: text-xs; + font-size: text-2xs; line-height: 1.4; font-weight: bold; } diff --git a/package/spec/ui/views/inputs/SelectInputView-spec.js b/package/spec/ui/views/inputs/SelectInputView-spec.js index 4f300bc9e..1f70cb535 100644 --- a/package/spec/ui/views/inputs/SelectInputView-spec.js +++ b/package/spec/ui/views/inputs/SelectInputView-spec.js @@ -23,6 +23,21 @@ describe('pageflow.SelectInputView', () => { expect($('select', selectInputView.el).val()).toEqual('second'); }); + it('saves value on change', () => { + var model = new Model(); + var selectInputView = new SelectInputView({ + model: model, + propertyName: 'value', + values: ['first', 'second'] + }); + + selectInputView.render(); + selectInputView.ui.select.val('second'); + selectInputView.ui.select.trigger('change'); + + expect(model.get('value')).toEqual('second'); + }); + it('selects first option if value is not among values', () => { var model = new Model({value: 'not there'}); var selectInputView = new SelectInputView({ @@ -240,6 +255,68 @@ describe('pageflow.SelectInputView', () => { }); }); + describe('with defaultValue', () => { + it('selects default value if property is not set', () => { + var model = new Model(); + var selectInputView = new SelectInputView({ + model: model, + propertyName: 'value', + values: ['small', 'medium', 'large'], + defaultValue: 'medium' + }); + + selectInputView.render(); + + expect($('select', selectInputView.el).val()).toEqual('medium'); + }); + + it('selects current value if property is set', () => { + var model = new Model({value: 'small'}); + var selectInputView = new SelectInputView({ + model: model, + propertyName: 'value', + values: ['small', 'medium', 'large'], + defaultValue: 'medium' + }); + + selectInputView.render(); + + expect($('select', selectInputView.el).val()).toEqual('small'); + }); + + it('does not save default value', () => { + var model = new Model({value: 'small'}); + var selectInputView = new SelectInputView({ + model: model, + propertyName: 'value', + values: ['small', 'medium', 'large'], + defaultValue: 'medium' + }); + + selectInputView.render(); + selectInputView.ui.select.val('medium'); + selectInputView.ui.select.trigger('change'); + + expect(model.has('value')).toEqual(false); + }); + + it('saves other values on change', () => { + var model = new Model({value: 'small'}); + var selectInputView = new SelectInputView({ + model: model, + propertyName: 'value', + values: ['small', 'medium', 'large'], + defaultValue: 'medium' + }); + + selectInputView.render(); + selectInputView.ui.select.val('large'); + selectInputView.ui.select.trigger('change'); + + expect(model.get('value')).toEqual('large'); + }); + }); + function optionTexts(view) { view.render(); diff --git a/package/src/ui/views/inputs/SelectInputView.js b/package/src/ui/views/inputs/SelectInputView.js index b26dd3575..e7cdcf021 100644 --- a/package/src/ui/views/inputs/SelectInputView.js +++ b/package/src/ui/views/inputs/SelectInputView.js @@ -17,6 +17,9 @@ import template from '../../templates/inputs/selectInput.jst'; * @param {string[]} [options.values] * List of possible values to persist in the attribute. * + * @param {number} [options.defaultValue] + * Default value to display if property is not set. + * * @param {string[]} [options.texts] * List of display texts for drop down items. * @@ -226,7 +229,14 @@ export const SelectInputView = Marionette.ItemView.extend({ }, save: function() { - this.model.set(this.options.propertyName, this.ui.select.val()); + const value = this.ui.select.val(); + + if ('defaultValue' in this.options && value === this.options.defaultValue) { + this.model.unset(this.options.propertyName); + } + else { + this.model.set(this.options.propertyName, value); + } }, load: function() { @@ -236,7 +246,9 @@ export const SelectInputView = Marionette.ItemView.extend({ if (this.model.has(this.options.propertyName) && this.ui.select.find('option[value="' + value +'"]:not([disabled])').length) { this.ui.select.val(value); - + } + else if ('defaultValue' in this.options) { + this.ui.select.val(this.options.defaultValue); } else { this.ui.select.val(this.ui.select.find('option:not([disabled]):first').val()); diff --git a/package/src/ui/views/inputs/SliderInputView.js b/package/src/ui/views/inputs/SliderInputView.js index 50b161950..21ca0c2e8 100644 --- a/package/src/ui/views/inputs/SliderInputView.js +++ b/package/src/ui/views/inputs/SliderInputView.js @@ -12,7 +12,7 @@ import template from '../../templates/inputs/sliderInput.jst'; * @param {Object} [options] * * @param {number} [options.defaultValue] - * Defaults value to display if property is not set. + * Default value to display if property is not set. * * @param {number} [options.minValue=0] * Value when dragging slider to the very left.