diff --git a/src/app.vue b/src/app.vue index 6e507252..b892b25f 100644 --- a/src/app.vue +++ b/src/app.vue @@ -26,8 +26,6 @@ export default class App extends Vue { } } } - - diff --git a/src/components/slide-toc.vue b/src/components/slide-toc.vue index d2c5bcda..f5c6c1b5 100644 --- a/src/components/slide-toc.vue +++ b/src/components/slide-toc.vue @@ -100,130 +100,27 @@ - -
+
- - - - - -
- - -
- + @clear="$vfm.open(`delete-slide-${index}-en-config`)" + @createConfig="createNewConfig(index, 'en')" + />
- - - - - -
- - -
- + @clear="$vfm.open(`delete-slide-${index}-fr-config`)" + @createConfig="createNewConfig(index, 'fr')" + /> import ActionModal from '@/components/helpers/action-modal.vue'; -import TocOptions from '@/components/helpers/toc-options.vue'; +import SlideTocButton from '@/components/helpers/slide-toc-button.vue'; import { Options, Prop, Vue } from 'vue-property-decorator'; import { BasePanel, @@ -542,12 +338,12 @@ import ConfirmationModalV from './helpers/confirmation-modal.vue'; @Options({ components: { - TocOptions, ActionModal, 'slide-editor': SlideEditorV, 'confirmation-modal': ConfirmationModalV, 'vue-final-modal': VueFinalModal, - draggable + draggable, + SlideTocButton } }) export default class SlideTocV extends Vue { @@ -801,21 +597,6 @@ window.addEventListener('resize', () => { margin: 10px 0px 0px 0px !important; } -.slide-toc-button { - border-radius: 3px; - padding: 2px; -} -.slide-toc-button:hover { - background-color: rgb(209, 213, 219); -} - -.line-clamp-2 { - overflow: hidden; - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; -} - /* Hard coded height :( TODO: Change positioning of app components so we don't need to hardcode TODO: Change height here when any new changes cause overshoot @@ -836,4 +617,12 @@ window.addEventListener('resize', () => { .selected-toc-config-item { background-color: rgb(225, 225, 225); } + +.slide-toc-button { + border-radius: 3px; + padding: 2px; +} +.slide-toc-button:hover { + background-color: rgb(209, 213, 219); +} diff --git a/src/directives/truncate/truncate.ts b/src/directives/truncate/truncate.ts new file mode 100644 index 00000000..c91f9f0a --- /dev/null +++ b/src/directives/truncate/truncate.ts @@ -0,0 +1,108 @@ +import { useTippy } from 'vue-tippy'; +import type { TippyContent } from 'vue-tippy'; +import linkifyHtml from 'linkify-html'; +import type { Directive, DirectiveBinding } from 'vue'; + +const TRUNCATE_ATTR = 'truncate-text'; +const TRIGGER_ATTR = 'truncate-trigger'; + +/** + * The Truncate Directive + * + * It makes the text truncate as needed and adds a tooltip that shows IFF the text is actually truncated. + * + * The binding value looks like: + * ``` + * { + * externalTrigger: boolean, + * options: tippyOptions + * } + * ``` + * if externalTrigger is present you must put the attribute `truncate-trigger` on the element you wish to be the tooltip trigger (this element must be an ancestor of the element with v-truncate) + * if noTruncateClass is present it will prevent the 'truncate' class from being added (which can break some elements) + */ +export const Truncate: Directive = { + beforeMount(el: HTMLElement, binding: DirectiveBinding) { + if (!el.classList.contains('truncate-multiline') && !binding.value?.noTruncateClass) { + el.classList.add('truncate-multiline'); + } + + el.toggleAttribute(TRUNCATE_ATTR, true); + }, + mounted(el: HTMLElement, binding: DirectiveBinding) { + let triggerElement; + if (binding.value && binding.value.externalTrigger) { + // el.closest gets closest ancestor that matches the selector (moves up the parent chain) + triggerElement = el.closest(`[${TRIGGER_ATTR}]`); + } + + useTippy(el, { + content: linkifyContent(el.textContent), + onShow: onShow, + allowHTML: true, + placement: 'bottom-start', + flip: false, // can't find a replacement for Vue3 + boundary: 'window', + triggerTarget: triggerElement, + size: 'large', + ...(binding.value?.options || {}) + }); + }, + updated(el: HTMLElement, binding: DirectiveBinding) { + // update content and options + if ((el as any)._tippy) { + (el as any)._tippy.setContent(linkifyContent(el.textContent)); + if (binding.value && binding.value.options) { + (el as any)._tippy.setProps(binding.value.options); + } + } + }, + unmounted(el: HTMLElement) { + // destroy tippy instance + if ((el as any)._tippy) { + (el as any)._tippy.destroy(); + } + } +}; + +/** + * The callback for the onShow lifecycle hook of tooltips + * + * @param instance tippy instance, automatically given to this on callback + * @returns false IFF the text is not being truncated and the tooltip should not be shown + */ +function onShow(instance: any) { + // cancel showing the tooltip if the text isn't truncated + // clientWidth is the visible width of the element, scrollWidth is the width of the content + // clientHeight is the visible height of the element, scrollHeight is the height of the content + const isTruncated = + instance.reference.clientWidth < instance.reference.scrollWidth || + instance.reference.clientHeight < instance.reference.scrollHeight; + + if (!isTruncated) { + // returning false tells tippy to cancel + return false; + } +} + +/** + * Applies hyperlinks to any URLs in the provided content. + * + * @param the text content + * @returns a string with any URLs hyperlinked. + */ +function linkifyContent(content: string | null): TippyContent { + if (content === null) { + return ''; + } + + let res = linkifyHtml(content, { + target: '_blank', + validate: { + url: (value: string) => /^https?:\/\//.test(value) // only links that begin with a protocol will be hyperlinked + } + }); + res = `
${res}
`; + + return res; +} diff --git a/src/lang/lang.csv b/src/lang/lang.csv index d3af0f78..b2361917 100644 --- a/src/lang/lang.csv +++ b/src/lang/lang.csv @@ -210,7 +210,7 @@ editor.slides.toc.copySlide,Copy Slide,1,Copier la diapositive,0 editor.slides.toc.deleteSlide,Delete Slide,1,Supprimer la diapositive,0 editor.slides.toc.newENGSlideText,New EN Slide*,1,Nouvelle diapositive AN*,0 editor.slides.toc.newFRSlideText,New FR Slide*,1,Nouvelle diapositive FR*,0 -editor.slides.toc.noENGslide,(No English Config),1,(Pas de configuration Anglais),0 +editor.slide.toc.noENGslide,(No English Config),1,(Pas de configuration Anglais),0 editor.slide.toc.noFRSlide,(No French Config),1,(Pas de configuration française),0 editor.slide.toc.untitledENG,(Untitled English slide),1,(Diapositive anglaise sans titre),0 editor.slide.toc.untitledFR,(Untitled French slide),1,(Diapositive française sans titre),0 diff --git a/src/main.ts b/src/main.ts index f907123e..9cfc7de5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -36,6 +36,7 @@ import StorylinesViewer from 'ramp-storylines_demo-scenarios-pcar'; import 'ramp-storylines_demo-scenarios-pcar/dist/style.css'; import { FocusContainer, FocusItem, FocusList } from '@/directives/focus-list'; +import { Truncate } from '@/directives/truncate/truncate'; const app = createApp(App); const pinia = createPinia(); @@ -56,4 +57,5 @@ app.use(pinia) app.directive('focus-container', FocusContainer); app.directive('focus-list', FocusList); app.directive('focus-item', FocusItem); +app.directive('truncate', Truncate); app.mount('#app');