pietrop / slate-transcript-editor

A React component to make correcting automated transcriptions of audio and video easier and faster. Using the SlateJs editor.
https://pietrop.github.io/slate-transcript-editor
Other
77 stars 33 forks source link

Adding highlights as custom elements #75

Open BasilPH opened 3 years ago

BasilPH commented 3 years ago

First of all, thank you very much for working on this and open-sourcing it! I am working on a tool for podcasters to edit their show transcripts. A core feature is that they should be able to create highlights. A highlight is basically a start- and end-timestamp that can cross over multiple speakers.

Browsing the code, I think I would need to add some logic to TimedTextElement that checks if a given word is in the interval of a highlight and changes its styling accordingly. Does this mean rewriting TimedTextElement, or is there any way I can hook the editor more smartly? Happy for any other input you might have.

pietrop commented 3 years ago

Hi @BasilPH , Yes, I think there's a few things to do to enable a feature like that. But I think you'd have to tweak TimedTextElement in the current setup.

For selection, some of the logic in slateJs hovering-toolbar example might be useful.

Fo displaying the selection, yes, probably something with ranges of selection, and then adding some markup like underlying highlight the text. That can be done at word level or paragraph level depending on how you do the selection.

In autoEdit.io I deliberately separate the correction stage from the markup/hilighting / selection stage (see the user manual, or download the app for more info) to keep the slateJs editor as simple as possible. And then have a separate view that allows to do selection, and display text that has already been selected/tagged/annotated with labels.

Hope this helps. Let me know how you get on. Would be interesting to see what approach you take.

pietrop commented 3 years ago

oh, also see this comment https://github.com/pietrop/slate-transcript-editor/issues/21#issuecomment-788937090 might be relevant for what you are trying to do

BasilPH commented 3 years ago

Thank you for your response @pietrop. I didn't have as much time as I hoped to really dig into this, I'll give a more detailed updated once I'm further along. So far though:

pietrop commented 3 years ago

Hi @BasilPH One distinction I did in the past, was single click and double click. Eg single click to edit, and double click to jump to that point. (but it's an "hidden interaction" so you'd have to let your users know in some other way, via text or onboarding info etc...)

Would you be up for sharing a PR with the hilighting? would love to see your approach, and test it out on longer transcript (eg 5 hours from the storybook demo) to see how it performs.

BasilPH commented 3 years ago

I'm definitely up for sharing the PR if something usable comes out of my experiment. I'm splitting the editing and highlighting, as you also do in autoEdit.io. I'm still using SlateTranscriptEditor for the highlighting view, but with isEditable=false: This allows me to reuse the logic and styling you already implemented.

I'm thinking of Highlights as simple time ranges that have a start and end. They can span across multiple speakers, so we have to model them at the word level.

The approach for "Custom formatting" in the Slate.js documentation could be the way to go. If you look at the last code box at the very end of the linked page, you see that the leaf node decides if it should render itself bold or not.

  // Define a leaf rendering function that is memoized with `useCallback`.
  const renderLeaf = useCallback(props => {
    return <Leaf {...props} />
  }, [])

const Leaf = props => {
  return (
    <span
      {...props.attributes}
      style={{ fontWeight: props.leaf.bold ? 'bold' : 'normal' }}
    >
      {props.children}
    </span>
  )
}

If the leaf is bold or not is set with Transforms, but I assume we could also pass this information in the initial data or just compute it by checking if the leaf timestamp intersects with a highlight start and end timestamp.

 Transforms.setNodes(
                editor,
                { bold: true },
                { match: n => Text.isText(n), split: true }
              )

I've tried to implement this in the SlateTranscriptEditor as following:


  const TimedTextElement = (props) => {
  ...
  return (...
       {/* Unchanged until here */}
       {/* Moved this up from your original `renderLeaf` function */}
        <Grid item xs={12} sm={12} md={12} lg={textLg} xl={textXl} className={'p-b-1 mx-auto'}>
          <span
            onDoubleClick={handleTimedTextClick}
            className={classNames('timecode', 'text')}
            data-start={props.children.props.node.start}
            data-previous-timings={props.children.props.node.previousTimings}
            {...props.attributes}
          >
            {props.children}
          </span>
        </Grid>
      </Grid>
    );
  };

  const DefaultElement = (props) => {
    return <p {...props.attributes}>{props.children}</p>;
  };

  const renderElement = (props) => {
    switch (props.element.type) {
      case 'timedText':
        return <TimedTextElement {...props} />;
      default:
        return <DefaultElement {...props} />;
    }
  };

  const HIGHLIGHTS = [{ start: 1, end: 20 }]; // Static dummy data with one single highlight

  const Leaf = ({ attributes, children, leaf }) => {
    // Check if the leaf intersects with a highlight
    const start = children.props.parent.start;
    const isInQuote = HIGHLIGHTS.filter((quote) => quote.start < start && quote.end > start).length > 0;

    return (
      //The "inQuote" class could have a different color background to make the quote stand out.
      <span className={classNames({ inQuote: isInQuote })} {...attributes}>
        {children}
      </span>
    );
  };

  const renderLeaf = (props) => {
    return <Leaf {...props} />;
  };

  /*Notes:
  - I've removed `useCallback` for the moment to make debugging easier
  */

I hoped TimedTextElement would render the rows in the grid, and then hand over the rendering of the leaves (i.e. words) to renderLeaf. What is happening instead, is that renderLeaf receives only TimedTextElement objects and nothing with a smaller granularity. It's as if the TimeTextElement was the smallest granularity supported. Do you have an idea why this is the case? I wonder if I don't have to change the data model we pass to Slate to support one more level of nesting.

allisonking commented 2 years ago

Hi @BasilPH , any chance you've tried out decorations for this use case? Like what is done in this slate example: https://github.com/ianstormtaylor/slate/blob/main/site/examples/search-highlighting.tsx

From what I understand, you write a decorate function which returns a list of ranges where your criteria (i.e. words that are part of a highlight) is met. Then renderLeaf looks for the intersection between what it is rendering and the range that your decorate function returned.

I used a decorate function to highlight words as they are spoken, though I'm not sure about its performance. I think for just a list of highlights it may work okay though. Also curious how you gave each word an id when you implemented highlight words as they're spoken 😄