From 5cffc4d933e611587b00c25861c911d5f734fa22 Mon Sep 17 00:00:00 2001 From: Ian Tenney Date: Mon, 12 Feb 2024 15:54:57 -0800 Subject: [PATCH] Misc frontend changes to support sequence salience. - Updates to - accepts labels via slots, so don't need to be just text - Specify default left/right split for three-panel layouts PiperOrigin-RevId: 606392017 --- lit_nlp/api/layout.py | 1 + lit_nlp/client/core/modules.ts | 14 +++- lit_nlp/client/elements/switch.ts | 9 ++- lit_nlp/client/elements/token_chips.css | 47 ++++++++++++ lit_nlp/client/elements/token_chips.ts | 85 ++++++++++++++++++--- lit_nlp/client/elements/token_chips_test.ts | 68 ++++++++++++++++- lit_nlp/client/lib/types.ts | 4 +- 7 files changed, 212 insertions(+), 16 deletions(-) diff --git a/lit_nlp/api/layout.py b/lit_nlp/api/layout.py index fe65d412..637b3f19 100644 --- a/lit_nlp/api/layout.py +++ b/lit_nlp/api/layout.py @@ -91,6 +91,7 @@ class ModuleConfig(dtypes.DataTuple): class LayoutSettings(dtypes.DataTuple): hideToolbar: bool = False mainHeight: int = 45 + leftWidth: int = 50 centerPage: bool = False diff --git a/lit_nlp/client/core/modules.ts b/lit_nlp/client/core/modules.ts index d4503776..ffb53b85 100644 --- a/lit_nlp/client/core/modules.ts +++ b/lit_nlp/client/core/modules.ts @@ -151,6 +151,12 @@ export class LitModules extends ReactiveElement { (mainHeight) => { if (mainHeight != null) {this.upperHeight = `${mainHeight}%`;} }); + this.reactImmediately( + () => this.modulesService.getSetting('leftWidth'), (leftWidth) => { + if (leftWidth != null) { + this.leftColumnWidth = `${leftWidth}%`; + } + }); document.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Escape') { @@ -422,7 +428,13 @@ export class LitModules extends ReactiveElement { const columnSeparatorDoubleClick = (event: DragEvent) => { event.stopPropagation(); event.preventDefault(); - this.leftColumnWidth = LEFT_COLUMN_DEFAULT_WIDTH; + const layoutDefaultLeftWidth = + this.modulesService.getSetting('leftWidth'); + if (layoutDefaultLeftWidth != null) { + this.leftColumnWidth = `${layoutDefaultLeftWidth}%`; + } else { + this.leftColumnWidth = LEFT_COLUMN_DEFAULT_WIDTH; + } }; const leftColumnStyles = styleMap({ diff --git a/lit_nlp/client/elements/switch.ts b/lit_nlp/client/elements/switch.ts index 7bd383e4..74118e4f 100644 --- a/lit_nlp/client/elements/switch.ts +++ b/lit_nlp/client/elements/switch.ts @@ -85,12 +85,17 @@ export class LitSwitch extends LitElement { 'selected': this.selected }); + // prettier-ignore return html`
-
${this.labelLeft}
+
+ ${this.labelLeft} +
-
${this.labelRight}
+
+ ${this.labelRight} +
`; } diff --git a/lit_nlp/client/elements/token_chips.css b/lit_nlp/client/elements/token_chips.css index 8cc33112..bea68608 100644 --- a/lit_nlp/client/elements/token_chips.css +++ b/lit_nlp/client/elements/token_chips.css @@ -52,4 +52,51 @@ .pre-wrap { white-space: pre-wrap; +} + +.row-break { + flex-basis: 100%; + height: 0; +} + +.word-spacer { + width: 1em; +} + +.tokens-holder-dense .word-spacer { + width: 0.5em; +} + +/* block mode */ +.tokens-holder-display-block { + display: block; + font-size: 0; /* hack to get zero spacing between elements */ + line-height: 22px; +} + +.tokens-holder-display-block > * { + /* TODO: set this for all modes? */ + font-size: 13px; /* restore standard font size */ +} + +.tokens-holder-display-block .salient-token { + display: inline; + min-height: 1lh; + vertical-align: baseline; +} + +.tokens-holder-display-block.tokens-holder-dense .salient-token span { + /* hack to remove extra whitespace. ugh. */ + margin-right: -0.445ch; +} + +.tokens-holder-display-block .word-spacer { + display: inline; + vertical-align: baseline; + white-space: pre-wrap; +} + +.tokens-holder-display-block lit-tooltip { + --anchor-display-mode: 'inline'; + --tooltip-position-left: 0; } \ No newline at end of file diff --git a/lit_nlp/client/elements/token_chips.ts b/lit_nlp/client/elements/token_chips.ts index bafff398..2e14ffdc 100644 --- a/lit_nlp/client/elements/token_chips.ts +++ b/lit_nlp/client/elements/token_chips.ts @@ -35,9 +35,9 @@ export interface TokenWithWeight { weight: number; selected?: boolean; pinned?: boolean; - onClick?: (e: Event) => void; - onMouseover?: (e: Event) => void; - onMouseout?: (e: Event) => void; + onClick?: (e: MouseEvent) => void; + onMouseover?: (e: MouseEvent) => void; + onMouseout?: (e: MouseEvent) => void; disableHover?: boolean; forceShowTooltip?: boolean; } @@ -50,9 +50,33 @@ export class TokenChips extends LitElement { // List of tokens to display @property({type: Array}) tokensWithWeights: TokenWithWeight[] = []; @property({type: Object}) cmap: SalienceCmap = new UnsignedSalienceCmap(); - @property({type: String}) - tokenGroupTitle?: string; // can be used for gradKey + // Group title, such as the name of the active salience method. + @property({type: String}) tokenGroupTitle?: string; + /** + * Dense mode, for less padding and smaller margins around each chip. + */ @property({type: Boolean}) dense = false; + /** + * Block mode uses display: block and inline elements for chips, instead of + * a flex-row layout. This allows chips to flow across line breaks, behaving + * more like elements and giving a much better experience for larger + * segments like sentences. However, this comes at the cost of more spacing + * artifacts and occasional issues with tooltip positioning. + */ + @property({type: Boolean}) displayBlock = false; + /** + * breakNewlines removes \n at the beginning or end of a segment and inserts + * explicit row break elements instead. Improves readability in many settings, + * at the cost of "faithfulness" to the original token text. + */ + @property({type: Boolean}) breakNewlines = false; + /** + * preSpace removes a leading space from a token and inserts an explicit + * spacer element instead. Improves readability in many settings by giving + * natural space between the highlight area for adjacent words, albeit at the + * cost of hiding where the actual spaces are in the tokenization. + */ + @property({type: Boolean}) preSpace = false; static override get styles() { return [sharedStyles, styles]; @@ -71,17 +95,56 @@ export class TokenChips extends LitElement { 'color': this.cmap.textCmap(tokenInfo.weight), }); - // clang-format off + let tokenText = tokenInfo.token; + + let preSpace = false; + if (this.preSpace && tokenText.startsWith(' ')) { + preSpace = true; + tokenText = tokenText.slice(1); + } + + // TODO(b/324955623): render a gray '⏎' for newlines? + // Maybe make this a toggleable option, as it can be distracting. + // TODO(b/324955623): better rendering for multiple newlines, like \n\n\n ? + // Consider adding an extra ' ' on each line. + + let preBreak = false; + let postBreak = false; + if (this.breakNewlines) { + // Logic: + // - \n : post-break, so blank space goes on previous line + // - foo\n : post-break + // - \nfoo : pre-break + // - \n\n : pre- and post-break, shows a space on its own line + // - \n\n\n : pre- and post-break, two lines with only spaces + if (tokenText.endsWith('\n')) { + // Prefer post-break because this puts the blank space on the end of the + // previous line, rather than creating an awkward indent on the next + // one. + tokenText = tokenText.slice(0, -1) + ' '; + postBreak = true; + } + if (tokenText.startsWith('\n')) { + // Pre-break only if \n precedes some other text. + preBreak = true; + tokenText = ' ' + tokenText.slice(1); + } + } + + // prettier-ignore return html` + ${preBreak ? html`
` : null} + ${preSpace ? html`
` : null}
- ${tokenInfo.token} + ${tokenText} -
`; - // clang-format on + + ${postBreak ? html`
` : null} + `; } override render() { @@ -92,9 +155,10 @@ export class TokenChips extends LitElement { const holderClass = classMap({ 'tokens-holder': true, 'tokens-holder-dense': this.dense, + 'tokens-holder-display-block': this.displayBlock, }); - // clang-format off + // prettier-ignore return html`
${this.tokenGroupTitle ? this.tokenGroupTitle : ''} @@ -102,7 +166,6 @@ export class TokenChips extends LitElement { ${tokensDOM}
`; - // clang-format on } } diff --git a/lit_nlp/client/elements/token_chips_test.ts b/lit_nlp/client/elements/token_chips_test.ts index 03c36598..1cdb8a87 100644 --- a/lit_nlp/client/elements/token_chips_test.ts +++ b/lit_nlp/client/elements/token_chips_test.ts @@ -31,7 +31,21 @@ const TESTDATA: Array<{tokensWithWeights: TokenWithWeight[]}> = [ {token: 'hello', weight: 0.7, selected: true, pinned: true}, {token: 'world', weight: 0.3} ], - } + }, + { + // for testing preSpace mode + tokensWithWeights: [ + {token: 'foo', weight: 0.7, selected: true, pinned: true}, + {token: ' bar', weight: 0.3}, {token: 'baz', weight: 0.5} + ], + }, + { + // for testing breakNewlines mode + tokensWithWeights: [ + {token: 'foo', weight: 0.7}, {token: '\nbar', weight: 0.3}, + {token: '\n\n', weight: 0.1}, {token: 'baz\n', weight: 0.5} + ], + }, ]; describe('token chips test', () => { @@ -60,6 +74,58 @@ describe('token chips test', () => { expect(tokenElements[0].children[0]).toBeInstanceOf(LitTooltip); }); + it('should break spaces in preSpace mode', async () => { + tokenChips.preSpace = true; + await tokenChips.updateComplete; + + const tokenElements = + tokenChips.renderRoot.querySelectorAll( + 'div.salient-token'); + expect(tokenElements.length).toEqual(tokensWithWeights.length); + for (let i = 0; i < tokenElements.length; i++) { + const elem = tokenElements[i]; + const expectedToken = tokensWithWeights[i].token; + if (expectedToken.startsWith(' ')) { + // Space moved to a word spacer. + expect(elem.innerText).toEqual(expectedToken.slice(1)); + expect(elem.previousElementSibling?.classList ?? []) + .toContain('word-spacer'); + } else { + // Space intact, no word spacer. + expect(elem.innerText).toEqual(expectedToken); + if (i > 0) { + expect(elem.previousElementSibling?.classList ?? []) + .toContain('salient-token'); + } + } + } + }); + + it('should break newlines in breakNewlines mode', async () => { + tokenChips.breakNewlines = true; + await tokenChips.updateComplete; + + const tokenElements = + tokenChips.renderRoot.querySelectorAll( + 'div.salient-token'); + expect(tokenElements.length).toEqual(tokensWithWeights.length); + for (let i = 0; i < tokenElements.length; i++) { + const elem = tokenElements[i]; + let expectedToken = tokensWithWeights[i].token; + if (expectedToken.endsWith('\n')) { + expectedToken = expectedToken.slice(0, -1) + ' '; + expect(elem.nextElementSibling?.classList ?? []) + .toContain('row-break'); + } + if (expectedToken.startsWith('\n')) { + expectedToken = ' ' + expectedToken.slice(1); + expect(elem.previousElementSibling?.classList ?? []) + .toContain('row-break'); + } + expect(elem.innerText).toEqual(expectedToken); + } + }); + it('should mark a selected token', async () => { const tokenElements = tokenChips.renderRoot.querySelectorAll( diff --git a/lit_nlp/client/lib/types.ts b/lit_nlp/client/lib/types.ts index 5f60fff0..c1bb8794 100644 --- a/lit_nlp/client/lib/types.ts +++ b/lit_nlp/client/lib/types.ts @@ -410,8 +410,10 @@ export declare interface LitCanonicalLayout { */ export declare interface LayoutSettings { hideToolbar?: boolean; - /** The default height of #upper-right, as a percentage of the parent. */ + /** The default height of the 'upper' section, as a percentage. */ mainHeight?: number; + /** The default width of the 'left' section, as a percentage. */ + leftWidth?: number; centerPage?: boolean; }