标签: resize

  • 给 Bootstrap Modal 增加缩放功能

    给 Bootstrap Modal 增加缩放功能

    需求

    Bootstrap 应该还是目前最流行的前端基础框架之一。因为架构方面的优势,它的侵入性很低,可以以各种方式集成到其它项目当中。在我厂的各种产品里,都有它的用武之地。

    前两天,老板抱怨,说 Modal(弹窗)在他的屏幕上太小,浪费他的 5K 显示器。

    我看了一下,按照 Bootstrap 的设计,超过 1200px 就算是 XL,此时 .modal-lg 的宽度固定在 1140px。其实 Bootstrap 这么设计也有它的道理,因为人眼聚焦后宽度有限,如果弹窗太宽的话,内容一眼看不全,也不好。不过在我厂的产品里,弹窗要呈现火焰图,所以宽一些也有好处。

    技术方案

    那么,综合来看,最合适的做法,就给 Modal 添加一个拖拽的功能:用户觉得够大了,就这么着;用户想看大一点,就自己拉大一些,然后我记录用户的选择,以便复用。

    看过我《用 `resize` 和 MutationObserver 实现缩放 DOM 并记录尺寸》的同学,应该知道 resize 这个 CSS 属性,使用它可以很方便的给元素添加缩放功能。参考 caniuse 上面的普及度,大部分新版本的浏览器都已经支持,可以放心使用。

    使用它的时候要注意两点:

    首先,我们在缩放元素的同时,也会对它的子元素、父元素同时造成影响。因为在静态文档流当中,块级元素的宽度默认是父元素 content-box 的 100%,而高度由子元素决定。所以,对一个块级元素的缩放,不可能宽过它的父元素(如果限制了宽度的话),也不可能矮于它的子元素。

    其次,拖拽手柄的显示优先级很低,会被子元素盖住,哪怕子元素没有填充任何内容。换言之,一定要有 padding 的元素才适合添加 resize 缩放。

    实施方案

    总而言之,把这个属性加在哪个元素上面,很有讲究。具体到本次需求,Bootstrap Modal,最合适添加 resize 属性的的是 modal-content,因为它有 1rem 的内边距。

    但是限制宽度的是父元素,也就是 modal-dialog,它是响应式的,会根据显示器的宽度设置一个最大宽度。如果不修改它的 max-widthmodal-content 的最大宽度就无法超过它,达不到预期效果。但是也不能改成 width,这样的话,弹窗会失去弹性,分辨率低的时候表现不好。

    所以还是要在 max-width 上做文章。如果直接去掉它,modal-dialog 的宽度就会是 100%,失去弹窗效果,所以也不能这样做。最终,我的方案是:

    1. 窗口完全展开后,获取宽高,赋给 modal-content
    2. 去掉 modal-dialogmax-width
    3. 用 MutationObserver 监测 modal-content 的宽高,保存 localStorage,以便接下来使用

    完整代码展示

    我厂的产品基于 Vue 开发,所以部分逻辑用 Vue 组件实现。

    效果演示

    为方便在 Codepen 里呈现,有部分修改。

    代码及解释

    <template lang="pug">
    .modal.simple-modal(
      :style="{display: visibility ? 'block' : 'none'}",
      @click="doCloseFromBackdrop",
    )
      .modal-dialog.modal-dialog-scrollable(
        ref="dialog",
        :class="dialogClass",
      )
        .modal-content(ref="content", :style="contentStyle")
          .modal-header.p-2
            slot(name="header")
              h4 {{title}}
            span.close(v-if="canClose", @click="doClose") ×
          .modal-body
            slot(name="body")
    </template>
    
    <script>
    import debounce from 'lodash/debounce';
    
    const RESIZED_SIZE = 'resized_width_key';
    let sharedSize = null;
    
    export default {
      props: {
        canClose: {
          type: Boolean,
          default: true,
        },
        size: {
          type: String,
          default: null,
          validator: function(value) {
            return ['sm', 'lg', 'xl'].indexOf(value) !== -1;
          },
        },
        resizable: {
          type: Boolean,
          default: false,
        },
        backdrop: {
          type: Boolean,
          default: true,
        },
        title: {
          type: String,
          default: 'Modal title',
        },
      },
    
      computed: {
        dialogClass() {
          const classes = [];
          if (this.size) {
            classes.push(`modal-${this.size}`);
          }
          if (this.resizable) {
            classes.push('modal-dialog-resizable');
          }
          if (this.resizedSize) {
            classes.push('ready');
          }
          return classes.join(' ');
        },
        contentStyle() {
          if (!this.resizable || !this.resizedSize) {
            return null;
          }
          const {width, height} = this.resizedSize;
          return {
            width: `${width}px`,
            height: `${height}px`,
          };
        },
      },
    
      data() {
        return {
          visibility: false,
          resizedSize: null,
        };
      },
    
      methods: {
        async doOpen() {
          this.visibility = true;
          this.$emit('open');
          if (this.resizable) {
            // 通过 debounce 节流可以降低函数运行次数
            const onResize = debounce(this.onEditorResize, 100);
            // 这里用 MutationObserver 监测元素尺寸
            const observer = this.observer = new MutationObserver(onResize);
            observer.observe(this.$refs.content, {
              attributes: true,
            });
    
            if (sharedSize) {
              this.resizedSize = sharedSize;
            } 
            // 第一次运行的时候,记录 Modal 尺寸,避免太大
            if (!this.resizedSize) {
              await this.$nextTick();
              // 按照张鑫旭的说法,这里用 `clientWidth` 有性能问题,不过暂时还没有更好的解决方案
              // https://weibo.com/1263362863/ImwIOmamC
              const width = this.$refs.dialog.clientWidth;
              this.resizedSize = {width};
              // 这里产生纪录之后,上面的 computed 属性就会把 `max-width` 去掉了
            }
          }
        },
        doClose() {
          this.visibility = false;
          this.$emit('close');
        },
        doCloseFromBackdrop({target}) {
          if (!this.backdrop || target !== this.$el) {
            return;
          }
          this.doClose();
        },
    
        onEditorResize([{target}]) {
          const width = target.clientWidth;
          const height = target.clientHeight;
          if (width < 320 || height < 160) {
            return;
          }
          sharedSize = {width, height};
          localStorage.setItem(RESIZED_SIZE, JSON.stringify(sharedSize));
        },
      },
    
      beforeMount() {
        const size = localStorage.getItem(RESIZED_SIZE);
        if (size) {
          this.resizedSize = JSON.parse(size);
        }
      },
    
      beforeDestroy() {
        if (this.observer) {
          this.observer.disconnect();
          this.observer = null;
        }
      },
    };
    </script>
    
    <style lang="stylus">
    .simple-modal
      background-color: rgba(0, 0, 0, 0.5)
      .modal-content
        padding 1em
    
        .close
          cursor pointer
    
    .modal-dialog-resizable
      &.ready
        max-width unset !important
    
      .modal-content
        resize both
        margin 0 auto
    </style>

    注意

    因为浏览器的异步加载机制,有可能在 modal 打开并完成布局后,高度和宽度被内容撑开导致记录不准,或者内容被异常遮盖。请读者自己想办法处理,就当练习题吧。

    总结

    本次组件开发非常符合我理想的组件模式:

    1. 充分利用浏览器原生机制
    2. 配合尽量少的 JS
    3. 需要什么功能就加什么功能,不需要大而全

    在 MVVM 框架的配合下,这样的方案很容易实现。另一方面,每个项目都有独特的使用场景,通过长期在特定场景下工作,我们可以逐步整理出适用于这个场景的组件库,不断改进该项目的开发效率。我认为这才是组件化的正道。

  • 用 `resize` 和 MutationObserver 实现缩放 DOM 并记录尺寸

    用 `resize` 和 MutationObserver 实现缩放 DOM 并记录尺寸

    我厂既然号称“机器编程”,DSL 便属于基础配置。于是,各个产品几乎都需要用到 CodeMirror,用于在线写代码。大家的屏幕分辨率不同,对编辑器的大小要求也不同,最简单的办法就是让大家自己调整,并且将尺寸保存在本地。

    以前的做法会复杂一些:

    1. 拿一个元素作为 handle 放在角落里
    2. mousedown 时开始侦听 mousemove,动态调整文本框大小
    3. mouseup 时保存尺寸

    如今大家应该都有注意到,<textarea> 右下角多了一个缩放用的 handle。这个功能自然不是 <textarea> 独享,而是受 CSS 样式 resize 控制,任何块级元素都适用。MDN 文档在此,建议大家先好好看一遍。

    (更多…)